Skill: aggie-security/ustack (version 0.1.0)

--- name: ustack description: > Universal agent workspace compatibility and update engine. Track upstream agent frameworks (like gstack), analyze every change, classify portability, and generate adapted output for your runtime. Use when you want to import, analyze, adapt, or publish updates from upstream agent-native repos across Claude Code, Codex, OpenClaw, Gemini CLI, Cursor, and more. --- # uStack — One Workspace. Any Agent. uStack watches frontier agent-native workspaces, analyzes each upstream change, and produces a portable version that works for your agent/runtime. ## Commands ### Import an upstream framework ```bash node ustack/bin/ustack.js import https://github.com/garrytan/gstack --name gstack ``` Clones the upstream repo, detects key files (skills, control docs, config, tooling), identifies structure (skill count, host conventions, category), and writes a manifest. ### Analyze changes ```bash node ustack/bin/ustack.js analyze gstack ``` Diffs HEAD against previous snapshot. Classifies impact across 8 areas: skills, install, browser, workflow, safety, tooling, docs, config. Assesses portability: portable / needs-adaptation / host-specific. ### Publish update page ```bash node ustack/bin/ustack.js publish gstack ``` Generates a website-ready markdown page with YAML frontmatter. Includes: summary, impact table, portability breakdown, commit list, usage instructions, and trust notes. Output lands in `.ustack/site/updates/<id>/` — ready for blog/feed integration. ### Update (all-in-one) ```bash node ustack/bin/ustack.js update gstack ``` Combines import → analyze → publish. Cron-friendly — only proceeds with analyze+publish if upstream actually changed. ### Doctor ```bash node ustack/bin/ustack.js doctor ``` Shows configured upstreams, last import/analysis dates, skill counts, host conventions, and flags upstreams with unanalyzed changes pending. ## Architecture ``` Upstream repo → uStack IR → Target runtime ``` No pairwise adapters. One normalized intermediate representation, one renderer per target. Adding a new upstream or target is independent. ## Run Artifacts ``` .ustack/ upstreams/<id>/manifest.json — upstream metadata + revision tracking runs/<id>/<timestamp>/ — analysis.json, analysis.md, publish.md site/updates/<id>/<date>.md — website-ready update pages ``` ## Supported Upstreams - `garrytan/gstack` — workflow-skill framework (36 skills, 8 agent hosts) - More upstreams can be added with `ustack import <url> --name <id>` ## Requirements - Node.js >= 20 - Git

path: CHANGELOG.md
content: # Changelog

## [0.1.0] — 2026-04-07

### Added — Phase 1: Upstream Watcher + Analysis Pipeline

**`ustack import <github-url> [--name <id>]`**
- Clones or pulls upstream repo
- Detects key files: control docs, config, skills, tooling
- Detects structure: skill count, format, host conventions, category
- Writes manifest.json with current HEAD sha
- Preserves previous sha for change detection
- Saves README snapshot for fast inspection

**`ustack analyze <upstream-id>`**
- Compares HEAD to previous sha via git diff
- Extracts commit log and changed file list
- Classifies impact by 8 areas: skills, install, browser, workflow, safety, tooling, docs, config
- Assesses portability: portable / needs-adaptation / host-specific
- Produces structured JSON artifact + human-readable markdown analysis

**`ustack publish <upstream-id>`**
- Reads latest analysis run
- Generates website-ready markdown page with YAML frontmatter
- Includes: summary, impact table, portability breakdown, commit list, usage instructions, trust note
- Writes to both run directory and `.ustack/site/updates/<id>/`
- Designed to drop directly into AGI.security blog/update feed

**`ustack update <upstream-id>`**
- Combines import → analyze → publish in one command
- Only proceeds with analyze+publish if upstream actually changed
- The canonical cron-friendly command

**`ustack doctor`**
- Shows configured upstreams with health status
- Shows last import date, last analysis date, skill count, host conventions
- Flags upstreams with unanalyzed changes pending
- Lists recent run artifacts


path: README.md
content: # uStack

> **One workspace. Any agent.**

uStack is the open compatibility layer that watches frontier agent-native workspaces, analyzes each upstream change, and produces a portable version that works for your agent/runtime.

## What it does

Paste a GitHub URL in. uStack:

1. Imports and snapshots the upstream agent framework
2. Detects every upstream change
3. Analyzes impact by area (skills, workflow, browser, safety, install, docs)
4. Classifies what's portable vs what needs adaptation vs what's host-specific
5. Generates a website-ready update page for your blog/feed
6. Keeps your local fork compatible as upstream evolves

## Quick start

```bash
# Import an upstream agent framework
node bin/ustack.js import https://github.com/garrytan/gstack --name gstack

# Analyze what changed
node bin/ustack.js analyze gstack

# Generate website-ready update page
node bin/ustack.js publish gstack

# Or do all three in one go
node bin/ustack.js update gstack

# Check workspace health
node bin/ustack.js doctor
```

## Why this exists

The agent ecosystem is fragmenting. Every stack — Claude Code, Codex, Gemini CLI, Cursor, OpenClaw — is creating its own:

- skill format
- memory conventions
- workflow assumptions
- browser / tool integrations
- safety patterns

This means useful upstream frameworks are trapped inside one host/runtime. Every team repeats the same adaptation work. uStack solves this by becoming the compatibility and update engine.

## Architecture

```
Upstream repo → uStack IR → target runtime
```

Not pairwise adapters. One normalized intermediate representation, one renderer per target.

## First beachhead

Tracking `garrytan/gstack` — a fast-moving workflow-skill framework for coding agents. It already supports Claude Code, Codex, Gemini CLI, Cursor, and Factory, making it the ideal first upstream to track.

## Run artifacts

```
.ustack/
  upstreams/<id>/
    manifest.json        — upstream metadata + current revision
    snapshot/            — key file snapshots for fast inspection
  runs/<id>/<timestamp>/
    analysis.json        — structured change analysis
    analysis.md          — human-readable analysis
    publish.md           — website-ready update page
  site/updates/<id>/
    <date>-<sha>.md      — blog/update feed pages
```

## Status

v0.1.0 — Phase 1 complete:
- [x] Upstream watcher + import
- [x] Structured change analyzer
- [x] Impact classification
- [x] Portability assessment
- [x] Website-ready publisher
- [x] `ustack update` (all three in one)
- [ ] IR normalization (Phase 2)
- [ ] Target adapters: openclaw, codex (Phase 3)
- [ ] Automated cron update loop (Phase 4)

## License

MIT — [AGI.security](https://agi.security)


path: bin/ustack.js
content: #!/usr/bin/env node
import { run } from '../src/index.js';
await run(process.argv.slice(2));


path: package.json
content: {
  "name": "ustack",
  "version": "0.1.0",
  "description": "uStack — universal agent workspace compatibility and update engine. One workspace. Any agent.",
  "type": "module",
  "bin": {
    "ustack": "bin/ustack.js"
  },
  "engines": {
    "node": ">=20"
  },
  "files": [
    "bin/",
    "src/",
    "SKILL.md",
    "README.md",
    "CHANGELOG.md"
  ],
  "keywords": [
    "agent",
    "workspace",
    "gstack",
    "claude-code",
    "codex",
    "openclaw",
    "compatibility",
    "ustack",
    "agisecurity"
  ],
  "license": "MIT",
  "homepage": "https://agi.security",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/aggie-security/ustack.git"
  },
  "scripts": {
    "import": "node bin/ustack.js import",
    "analyze": "node bin/ustack.js analyze",
    "publish": "node bin/ustack.js publish",
    "update": "node bin/ustack.js update",
    "doctor": "node bin/ustack.js doctor",
    "test": "node bin/ustack.js doctor"
  },
  "dependencies": {}
}


path: src/commands/analyze.js
content: import fs from 'node:fs';
import path from 'node:path';
import { getCommitLog, getChangedFiles } from '../lib/git.js';
import { writeJson, writeText, fileExists, readJson, getUpstreamDir, getRunDir } from '../lib/fs.js';

/**
 * ustack analyze <upstream-id>
 *
 * Analyzes changes between previousSha and headSha.
 * Produces a structured analysis artifact at:
 *   .ustack/runs/<id>/<timestamp>/analysis.json
 *   .ustack/runs/<id>/<timestamp>/analysis.md
 */
export async function runAnalyze({ cwd, args = [] }) {
  const upstreamId = args[0];

  if (!upstreamId) {
    console.error('Usage: ustack analyze <upstream-id>');
    process.exitCode = 1;
    return;
  }

  const upstreamDir = getUpstreamDir(cwd, upstreamId);
  const manifestPath = path.join(upstreamDir, 'manifest.json');

  if (!fileExists(manifestPath)) {
    console.error(`Upstream "${upstreamId}" not found. Run: ustack import <url> --name ${upstreamId}`);
    process.exitCode = 1;
    return;
  }

  const manifest = readJson(manifestPath);
  const { repoDir, headSha, previousSha, repoUrl } = manifest;

  console.log(`Analyzing: ${upstreamId}`);
  console.log(`HEAD: ${headSha.slice(0, 12)}`);
  console.log(`Previous: ${previousSha ? previousSha.slice(0, 12) : 'none (first run)'}`);

  // Get commits in range
  const commits = getCommitLog(repoDir, previousSha, headSha);
  const changedFiles = getChangedFiles(repoDir, previousSha, headSha);

  console.log(`Commits: ${commits.length}`);
  console.log(`Changed files: ${changedFiles.length}`);

  // Classify changes by impact area
  const impact = classifyImpact(changedFiles, manifest.structure);

  // Classify portability of changed files
  const portabilityAnalysis = classifyPortability(changedFiles, manifest.keyFiles);

  // Read key file snippets for the analysis
  const keySnippets = {};
  const keyFilesToRead = ['README.md', 'CHANGELOG.md', 'ARCHITECTURE.md'].filter(
    f => changedFiles.some(c => c.path === f)
  );
  for (const kf of keyFilesToRead.slice(0, 3)) {
    const fullPath = path.join(repoDir, kf);
    if (fileExists(fullPath)) {
      keySnippets[kf] = fs.readFileSync(fullPath, 'utf8').split('\n').slice(0, 80).join('\n');
    }
  }

  // Build structured analysis
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  const runDir = getRunDir(cwd, upstreamId, timestamp);

  const analysis = {
    upstreamId,
    repoUrl,
    analyzedAt: new Date().toISOString(),
    fromSha: previousSha,
    toSha: headSha,
    fromShaShort: previousSha ? previousSha.slice(0, 12) : null,
    toShaShort: headSha.slice(0, 12),
    commitCount: commits.length,
    changedFileCount: changedFiles.length,
    commits: commits.slice(0, 30),
    changedFiles,
    impact,
    portability: portabilityAnalysis,
    summary: buildSummary(commits, changedFiles, impact, manifest),
  };

  writeJson(path.join(runDir, 'analysis.json'), analysis);
  const mdReport = renderAnalysisMarkdown(analysis, keySnippets, manifest);
  writeText(path.join(runDir, 'analysis.md'), mdReport);

  // Update manifest with run reference
  manifest.lastAnalyzedAt = new Date().toISOString();
  manifest.lastRunDir = runDir;
  manifest.lastRunTimestamp = timestamp;
  writeJson(manifestPath, manifest);

  console.log(`\nAnalysis complete.`);
  console.log(`Run dir: .ustack/runs/${upstreamId}/${timestamp}/`);
  console.log(`\nImpact summary:`);
  for (const [area, level] of Object.entries(impact)) {
    if (level !== 'none') console.log(`  ${area}: ${level}`);
  }
  console.log(`\nPortability:`);
  console.log(`  portable: ${portabilityAnalysis.portable.length} files`);
  console.log(`  needs-adaptation: ${portabilityAnalysis.needsAdaptation.length} files`);
  console.log(`  host-specific: ${portabilityAnalysis.hostSpecific.length} files`);
  console.log(`\nNext: ustack publish ${upstreamId}`);
}

const IMPACT_CATEGORIES = {
  skills: {
    patterns: [/\/SKILL\.md$/, /\/SKILL\.md\.tmpl$/, /^[a-z-]+\/SKILL\./],
    description: 'Skill definitions changed',
  },
  install: {
    patterns: [/^setup/, /^bin\/dev-/, /CONTRIBUTING\.md$/, /README\.md$/],
    description: 'Install or setup flow changed',
  },
  browser: {
    patterns: [/^extension\//, /^browse\//, /^bin\/chrome/, /^bin\/gstack-open/, /^connect-chrome\//],
    description: 'Browser tooling changed',
  },
  workflow: {
    patterns: [/^review\//, /^ship\//, /^qa\//, /^plan-/, /^autoplan\//, /^retro\//],
    description: 'Core workflow skills changed',
  },
  safety: {
    patterns: [/^careful\//, /^freeze\//, /^guard\//, /^unfreeze\//],
    description: 'Safety/guardrail skills changed',
  },
  tooling: {
    patterns: [/^bin\/gstack-/, /^scripts\//, /^lib\//],
    description: 'Internal tooling changed',
  },
  docs: {
    patterns: [/^docs\//, /README\.md$/, /ARCHITECTURE\.md$/, /DESIGN\.md$/, /CHANGELOG\.md$/],
    description: 'Documentation changed',
  },
  config: {
    patterns: [/^package\.json$/, /^conductor\.json$/, /^\.github\//],
    description: 'Config or CI changed',
  },
};

function classifyImpact(changedFiles, structure) {
  const impact = {};

  for (const [area, { patterns }] of Object.entries(IMPACT_CATEGORIES)) {
    const matched = changedFiles.filter(({ path: p }) =>
      patterns.some(pattern => pattern.test(p))
    );
    impact[area] = matched.length === 0 ? 'none'
      : matched.length <= 2 ? 'low'
      : matched.length <= 6 ? 'medium'
      : 'high';
  }

  return impact;
}

const HOST_SPECIFIC_PATTERNS = [
  /CLAUDE\.md$/,
  /^\.claude\//,
  /^agents\/openai/,
  /^agents\/claude/,
  /hook.*claude/i,
];

const PORTABILITY_CONCERN_PATTERNS = [
  /SKILL\.md\.tmpl$/,    // template files need target-specific rendering
  /^bin\/gstack-/,        // binary tools — need rebuild for other hosts
  /^extension\//,         // Chrome extension — platform-specific
  /^lib\//,               // library code — may have host assumptions
];

function classifyPortability(changedFiles, keyFiles) {
  const portable = [];
  const needsAdaptation = [];
  const hostSpecific = [];

  for (const f of changedFiles) {
    const p = f.path;
    if (HOST_SPECIFIC_PATTERNS.some(pat => pat.test(p))) {
      hostSpecific.push(p);
    } else if (PORTABILITY_CONCERN_PATTERNS.some(pat => pat.test(p))) {
      needsAdaptation.push(p);
    } else {
      portable.push(p);
    }
  }

  return { portable, needsAdaptation, hostSpecific };
}

function buildSummary(commits, changedFiles, impact, manifest) {
  const highImpactAreas = Object.entries(impact)
    .filter(([, level]) => level === 'high')
    .map(([area]) => area);
  const mediumImpactAreas = Object.entries(impact)
    .filter(([, level]) => level === 'medium')
    .map(([area]) => area);

  const lines = [];

  if (commits.length === 0 && changedFiles.length === 0) {
    lines.push('No changes detected since last analysis.');
    return lines.join(' ');
  }

  lines.push(`${commits.length} commit(s), ${changedFiles.length} file(s) changed.`);

  if (highImpactAreas.length) {
    lines.push(`High-impact areas: ${highImpactAreas.join(', ')}.`);
  }
  if (mediumImpactAreas.length) {
    lines.push(`Medium-impact areas: ${mediumImpactAreas.join(', ')}.`);
  }

  // Notable commits
  if (commits.length > 0) {
    const notable = commits.slice(0, 3).map(c => c.subject).join('; ');
    lines.push(`Recent commits: ${notable}.`);
  }

  return lines.join(' ');
}

function renderAnalysisMarkdown(analysis, keySnippets, manifest) {
  const lines = [];

  lines.push(`# uStack Update Analysis — ${analysis.upstreamId}`);
  lines.push('');
  lines.push(`- upstream: [${analysis.repoUrl}](${analysis.repoUrl})`);
  lines.push(`- analyzed-at: ${analysis.analyzedAt}`);
  lines.push(`- from: \`${analysis.fromShaShort || 'initial'}\``);
  lines.push(`- to: \`${analysis.toShaShort}\``);
  lines.push(`- commits: ${analysis.commitCount}`);
  lines.push(`- files changed: ${analysis.changedFileCount}`);
  lines.push('');

  lines.push('## Summary');
  lines.push('');
  lines.push(analysis.summary || 'No changes detected.');
  lines.push('');

  lines.push('## Impact by Area');
  lines.push('');
  for (const [area, level] of Object.entries(analysis.impact)) {
    const icon = level === 'high' ? '🔴' : level === 'medium' ? '🟡' : level === 'low' ? '🟢' : '⚪';
    lines.push(`- ${icon} **${area}**: ${level}`);
  }
  lines.push('');

  lines.push('## Portability Assessment');
  lines.push('');
  lines.push(`- ✅ **Portable as-is**: ${analysis.portability.portable.length} files`);
  if (analysis.portability.portable.length > 0) {
    analysis.portability.portable.slice(0, 10).forEach(f => lines.push(`  - ${f}`));
  }
  lines.push(`- 🔧 **Needs adaptation**: ${analysis.portability.needsAdaptation.length} files`);
  if (analysis.portability.needsAdaptation.length > 0) {
    analysis.portability.needsAdaptation.slice(0, 10).forEach(f => lines.push(`  - ${f}`));
  }
  lines.push(`- 🔒 **Host-specific (Claude Code)**: ${analysis.portability.hostSpecific.length} files`);
  if (analysis.portability.hostSpecific.length > 0) {
    analysis.portability.hostSpecific.slice(0, 10).forEach(f => lines.push(`  - ${f}`));
  }
  lines.push('');

  if (analysis.commits.length > 0) {
    lines.push('## Commits');
    lines.push('');
    for (const c of analysis.commits.slice(0, 15)) {
      lines.push(`- \`${c.shortSha}\` ${c.subject} _(${c.date.split('T')[0]})_`);
    }
    lines.push('');
  }

  if (analysis.changedFiles.length > 0) {
    lines.push('## Changed Files');
    lines.push('');
    const byStatus = {};
    for (const f of analysis.changedFiles) {
      (byStatus[f.status] = byStatus[f.status] || []).push(f.path);
    }
    for (const [status, files] of Object.entries(byStatus)) {
      const label = status === 'A' ? 'Added' : status === 'M' ? 'Modified' : status === 'D' ? 'Deleted' : status;
      lines.push(`**${label}** (${files.length}):`);
      files.slice(0, 20).forEach(f => lines.push(`- ${f}`));
      if (files.length > 20) lines.push(`- ...and ${files.length - 20} more`);
      lines.push('');
    }
  }

  if (Object.keys(keySnippets).length > 0) {
    lines.push('## Key File Snapshots');
    lines.push('');
    for (const [filename, content] of Object.entries(keySnippets)) {
      lines.push(`### ${filename} (first 80 lines)`);
      lines.push('');
      lines.push('```markdown');
      lines.push(content.trim());
      lines.push('```');
      lines.push('');
    }
  }

  lines.push('## Next Steps');
  lines.push('');
  lines.push('1. Review portability assessment above.');
  lines.push('2. Run `ustack publish <id>` to generate a website-ready update page.');
  lines.push('3. Review and merge generated adapter output for supported targets.');
  lines.push('');
  lines.push('---');
  lines.push(`_Generated by uStack v0.1.0_`);

  return lines.join('\n') + '\n';
}


path: src/commands/doctor.js
content: import fs from 'node:fs';
import path from 'node:path';
import { fileExists, getUstackDir, readJson } from '../lib/fs.js';

/**
 * ustack doctor
 *
 * Checks workspace health: configured upstreams, last analysis dates, git availability.
 */
export async function runDoctor({ cwd, args = [] }) {
  console.log('uStack Doctor\n');

  // Check git
  try {
    const { execSync } = await import('node:child_process');
    const gitVersion = execSync('git --version', { encoding: 'utf8' }).trim();
    console.log(`✅ git: ${gitVersion}`);
  } catch {
    console.log('❌ git: not found — required for ustack import/update');
  }

  // Check .ustack dir
  const ustackDir = getUstackDir(cwd);
  if (!fileExists(ustackDir)) {
    console.log('\n⚪ No .ustack workspace found in current directory.');
    console.log('   Run: ustack import <github-url> --name <id>');
    return;
  }

  console.log(`✅ .ustack workspace: ${ustackDir}`);

  // List configured upstreams
  const upstreamsDir = path.join(ustackDir, 'upstreams');
  if (!fileExists(upstreamsDir)) {
    console.log('\n⚪ No upstreams configured yet.');
    console.log('   Run: ustack import https://github.com/garrytan/gstack --name gstack');
    return;
  }

  const upstreamIds = fs.readdirSync(upstreamsDir).filter(
    d => fileExists(path.join(upstreamsDir, d, 'manifest.json'))
  );

  if (!upstreamIds.length) {
    console.log('\n⚪ No upstreams configured yet.');
    return;
  }

  console.log(`\nConfigured upstreams (${upstreamIds.length}):\n`);

  for (const id of upstreamIds) {
    const manifest = readJson(path.join(upstreamsDir, id, 'manifest.json'));
    const head = manifest.headSha ? manifest.headSha.slice(0, 12) : 'unknown';
    const lastAnalyzed = manifest.lastAnalyzedAt
      ? manifest.lastAnalyzedAt.split('T')[0]
      : 'never';
    const lastUpdated = manifest.updatedAt
      ? manifest.updatedAt.split('T')[0]
      : 'unknown';

    const changed = manifest.headSha !== manifest.previousSha && manifest.previousSha
      ? ' ⚠️  (unanalyzed changes pending)'
      : '';

    console.log(`  ${id}`);
    console.log(`    source:        ${manifest.repoUrl}`);
    console.log(`    head:          ${head}`);
    console.log(`    last imported: ${lastUpdated}`);
    console.log(`    last analyzed: ${lastAnalyzed}${changed}`);
    console.log(`    skills:        ${manifest.structure?.skillCount ?? '?'}`);
    console.log(`    host conventions: ${manifest.structure?.hostConventions ?? 'unknown'}`);
    console.log('');
  }

  // List runs
  const runsDir = path.join(ustackDir, 'runs');
  if (fileExists(runsDir)) {
    console.log('Recent runs:');
    for (const id of upstreamIds) {
      const idRunsDir = path.join(runsDir, id);
      if (!fileExists(idRunsDir)) continue;
      const runs = fs.readdirSync(idRunsDir).sort().reverse().slice(0, 3);
      for (const run of runs) {
        const hasAnalysis = fileExists(path.join(idRunsDir, run, 'analysis.json'));
        const hasPublish = fileExists(path.join(idRunsDir, run, 'publish.md'));
        const status = [hasAnalysis ? '📊 analysis' : '', hasPublish ? '📄 publish' : '']
          .filter(Boolean).join(', ');
        console.log(`  ${id}/${run}  [${status || 'empty'}]`);
      }
    }
  }

  console.log('\nAll checks complete.');
}


path: src/commands/import.js
content: import fs from 'node:fs';
import path from 'node:path';
import { syncUpstream, getTags } from '../lib/git.js';
import { writeJson, writeText, fileExists, getUpstreamDir, ensureDir } from '../lib/fs.js';

/**
 * ustack import <github-url> [--name <id>]
 *
 * Clones (or pulls) the upstream repo and records baseline metadata.
 * Creates:
 *   .ustack/upstreams/<id>/
 *     manifest.json       — upstream metadata + current revision
 *     snapshot/           — shallow copy of key files for fast inspection
 */
export async function runImport({ cwd, args = [] }) {
  let repoUrl = null;
  let upstreamId = null;

  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--name' && args[i + 1]) {
      upstreamId = args[i + 1];
      i++;
    } else if (!repoUrl && !args[i].startsWith('--')) {
      repoUrl = args[i];
    }
  }

  if (!repoUrl) {
    console.error('Usage: ustack import <github-url> [--name <id>]');
    console.error('Example: ustack import https://github.com/garrytan/gstack --name gstack');
    process.exitCode = 1;
    return;
  }

  if (!upstreamId) {
    upstreamId = repoUrl.split('/').pop().replace(/\.git$/, '');
  }

  const upstreamDir = getUpstreamDir(cwd, upstreamId);
  const repoDir = path.join(upstreamDir, 'repo');

  console.log(`Importing upstream: ${upstreamId}`);
  console.log(`Source: ${repoUrl}`);

  console.log('Cloning/pulling upstream...');
  let headSha;
  try {
    headSha = syncUpstream({ repoUrl, targetDir: repoDir });
  } catch (err) {
    console.error(`Failed to sync upstream: ${err.message}`);
    process.exitCode = 1;
    return;
  }

  console.log(`HEAD: ${headSha}`);

  const keyFiles = detectKeyFiles(repoDir);
  const tags = getTags(repoDir);
  const latestTag = tags[0] || null;
  const structure = detectStructure(repoDir, keyFiles);

  // Load existing manifest to preserve previousSha
  const manifestPath = path.join(upstreamDir, 'manifest.json');
  let previousSha = null;
  if (fileExists(manifestPath)) {
    try {
      const existing = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
      previousSha = existing.headSha || null;
    } catch { /* ignore */ }
  }

  const manifest = {
    id: upstreamId,
    repoUrl,
    repoDir,
    importedAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    headSha,
    previousSha,
    latestTag,
    keyFiles,
    structure,
  };

  writeJson(manifestPath, manifest);

  // Save README snapshot
  const readmePath = path.join(repoDir, 'README.md');
  if (fileExists(readmePath)) {
    ensureDir(path.join(upstreamDir, 'snapshot'));
    const readmeHead = fs.readFileSync(readmePath, 'utf8').split('\n').slice(0, 60).join('\n');
    writeText(path.join(upstreamDir, 'snapshot', 'README-head.md'), readmeHead);
  }

  console.log(`\nImport complete.`);
  console.log(`Manifest: .ustack/upstreams/${upstreamId}/manifest.json`);
  console.log(`\nKey files:`);
  for (const [category, files] of Object.entries(keyFiles)) {
    if (files.length) console.log(`  ${category}: ${files.slice(0, 8).join(', ')}`);
  }
  console.log(`\nStructure:`);
  for (const [key, val] of Object.entries(structure)) {
    console.log(`  ${key}: ${val}`);
  }
  if (previousSha && previousSha !== headSha) {
    console.log(`\nChanges detected since last import:`);
    console.log(`  from: ${previousSha.slice(0, 12)}`);
    console.log(`  to:   ${headSha.slice(0, 12)}`);
    console.log(`  Run: ustack analyze ${upstreamId}`);
  } else if (!previousSha) {
    console.log(`\nFirst import. Run: ustack analyze ${upstreamId}`);
  } else {
    console.log(`\nNo changes since last import (${headSha.slice(0, 12)}).`);
  }
}

function detectKeyFiles(repoDir) {
  const check = (f) => fileExists(path.join(repoDir, f));

  const controlDocs = [];
  const config = [];
  const skills = [];
  const tooling = [];

  for (const f of ['README.md', 'ARCHITECTURE.md', 'AGENTS.md', 'CLAUDE.md', 'DESIGN.md', 'ETHOS.md', 'CONTRIBUTING.md', 'TODOS.md', 'CHANGELOG.md']) {
    if (check(f)) controlDocs.push(f);
  }

  for (const f of ['package.json', 'conductor.json', '.env.example', 'bun.lock', 'package-lock.json', 'VERSION']) {
    if (check(f)) config.push(f);
  }

  try {
    const entries = fs.readdirSync(repoDir, { withFileTypes: true });
    for (const entry of entries) {
      if (!entry.isDirectory()) continue;
      if (fileExists(path.join(repoDir, entry.name, 'SKILL.md'))) {
        skills.push(entry.name + '/');
      }
      if (['bin', 'scripts', 'lib'].includes(entry.name)) {
        tooling.push(entry.name + '/');
      }
    }
  } catch { /* ignore */ }

  return { controlDocs, config, skills, tooling };
}

function detectStructure(repoDir, keyFiles) {
  const skillCount = keyFiles.skills.length;
  const hasClaude = keyFiles.controlDocs.includes('CLAUDE.md');
  const hasArchitecture = keyFiles.controlDocs.includes('ARCHITECTURE.md');
  const hasBun = keyFiles.config.includes('bun.lock');
  const hasConductor = keyFiles.config.includes('conductor.json');

  const hostConventions = [];
  if (hasClaude) hostConventions.push('claude-code');
  if (fileExists(path.join(repoDir, 'codex'))) hostConventions.push('codex');
  try {
    const agentFiles = fs.readdirSync(path.join(repoDir, 'agents'));
    if (agentFiles.some(f => f.includes('openai') || f.includes('codex'))) {
      if (!hostConventions.includes('codex')) hostConventions.push('codex');
    }
  } catch { /* no agents dir */ }

  const category = skillCount > 10 ? 'workflow-skill-framework'
    : skillCount > 0 ? 'skill-library'
    : 'agent-workspace';

  return {
    skillCount,
    skillFormat: skillCount > 0 ? 'SKILL.md' : 'none',
    hostConventions: hostConventions.join(', ') || 'unknown',
    hasArchitecture,
    hasBun,
    hasConductor,
    category,
  };
}


path: src/commands/publish.js
content: import path from 'node:path';
import { writeText, fileExists, readJson, readText, getUpstreamDir, listRunDirs, getRunDir } from '../lib/fs.js';

/**
 * ustack publish <upstream-id>
 *
 * Generates a website-ready markdown page from the latest analysis.
 * Output: .ustack/runs/<id>/<timestamp>/publish.md
 *
 * Format designed to drop directly into AGI.security blog/update feed.
 */
export async function runPublish({ cwd, args = [] }) {
  const upstreamId = args[0];

  if (!upstreamId) {
    console.error('Usage: ustack publish <upstream-id>');
    process.exitCode = 1;
    return;
  }

  const upstreamDir = getUpstreamDir(cwd, upstreamId);
  const manifestPath = path.join(upstreamDir, 'manifest.json');

  if (!fileExists(manifestPath)) {
    console.error(`Upstream "${upstreamId}" not found. Run: ustack import then ustack analyze.`);
    process.exitCode = 1;
    return;
  }

  const manifest = readJson(manifestPath);

  if (!manifest.lastRunTimestamp) {
    console.error(`No analysis found for "${upstreamId}". Run: ustack analyze ${upstreamId}`);
    process.exitCode = 1;
    return;
  }

  // Find latest run
  const runs = listRunDirs(cwd, upstreamId);
  if (!runs.length) {
    console.error(`No run directories found. Run: ustack analyze ${upstreamId}`);
    process.exitCode = 1;
    return;
  }

  const latestRun = runs[0];
  const runDir = path.join(cwd, '.ustack', 'runs', upstreamId, latestRun);
  const analysisPath = path.join(runDir, 'analysis.json');

  if (!fileExists(analysisPath)) {
    console.error(`Analysis artifact missing at ${analysisPath}`);
    process.exitCode = 1;
    return;
  }

  const analysis = readJson(analysisPath);

  console.log(`Publishing update page for: ${upstreamId}`);
  console.log(`From analysis: ${latestRun}`);

  const page = renderPublishPage(analysis, manifest);
  const outputPath = path.join(runDir, 'publish.md');
  writeText(outputPath, page);

  // Also write to site/updates dir for direct blog integration
  const siteDir = path.join(cwd, '.ustack', 'site', 'updates', upstreamId);
  const slugDate = analysis.analyzedAt.split('T')[0];
  const sitePagePath = path.join(siteDir, `${slugDate}-${analysis.toShaShort}.md`);
  writeText(sitePagePath, page);

  console.log(`\nPublish page generated:`);
  console.log(`  Run: ${outputPath.replace(cwd + '/', '')}`);
  console.log(`  Site: ${sitePagePath.replace(cwd + '/', '')}`);
  console.log(`\nReady to drop into AGI.security blog/updates feed.`);
}

function renderPublishPage(analysis, manifest) {
  const date = analysis.analyzedAt.split('T')[0];
  const upstreamName = upstreamDisplayName(analysis.upstreamId);
  const hasChanges = analysis.commitCount > 0 || analysis.changedFileCount > 0;

  const highAreas = Object.entries(analysis.impact).filter(([, v]) => v === 'high').map(([k]) => k);
  const mediumAreas = Object.entries(analysis.impact).filter(([, v]) => v === 'medium').map(([k]) => k);
  const activeAreas = [...highAreas, ...mediumAreas];

  const lines = [];

  // Frontmatter
  lines.push('---');
  lines.push(`title: "${upstreamName} Update — ${date}"`);
  lines.push(`date: ${date}`);
  lines.push(`upstream: ${analysis.repoUrl}`);
  lines.push(`from_sha: ${analysis.fromShaShort || 'initial'}`);
  lines.push(`to_sha: ${analysis.toShaShort}`);
  lines.push(`commits: ${analysis.commitCount}`);
  lines.push(`files_changed: ${analysis.changedFileCount}`);
  lines.push(`impact_areas: ${activeAreas.join(', ') || 'none'}`);
  lines.push(`portable: ${analysis.portability.portable.length}`);
  lines.push(`needs_adaptation: ${analysis.portability.needsAdaptation.length}`);
  lines.push(`host_specific: ${analysis.portability.hostSpecific.length}`);
  lines.push('---');
  lines.push('');

  // Header
  lines.push(`# ${upstreamName} Update — ${date}`);
  lines.push('');

  if (!hasChanges) {
    lines.push('No changes detected since last analysis.');
    return lines.join('\n') + '\n';
  }

  // Lead
  lines.push(`**${analysis.commitCount} commit(s)** · **${analysis.changedFileCount} file(s) changed** · from \`${analysis.fromShaShort || 'initial'}\` → \`${analysis.toShaShort}\``);
  lines.push('');

  // Summary
  lines.push('## What Changed');
  lines.push('');
  lines.push(analysis.summary);
  lines.push('');

  // Notable commits
  if (analysis.commits.length > 0) {
    lines.push('### Notable Commits');
    lines.push('');
    for (const c of analysis.commits.slice(0, 8)) {
      lines.push(`- \`${c.shortSha}\` — ${c.subject}`);
    }
    if (analysis.commits.length > 8) {
      lines.push(`- _...and ${analysis.commits.length - 8} more_`);
    }
    lines.push('');
  }

  // Impact
  lines.push('## Impact by Area');
  lines.push('');
  const impactTable = Object.entries(analysis.impact)
    .filter(([, v]) => v !== 'none')
    .map(([area, level]) => {
      const icon = level === 'high' ? '🔴' : level === 'medium' ? '🟡' : '🟢';
      const desc = IMPACT_DESCRIPTIONS[area] || area;
      return `| ${icon} ${area} | ${level} | ${desc} |`;
    });

  if (impactTable.length) {
    lines.push('| Area | Impact | What it means |');
    lines.push('|------|--------|----------------|');
    lines.push(...impactTable);
  } else {
    lines.push('No significant impact areas detected.');
  }
  lines.push('');

  // Portability
  lines.push('## Portability for Other Agents');
  lines.push('');
  lines.push('These changes have been classified for portability to non-Claude Code runtimes:');
  lines.push('');

  lines.push(`**✅ Portable as-is** (${analysis.portability.portable.length} files)`);
  if (analysis.portability.portable.length > 0) {
    lines.push('');
    analysis.portability.portable.slice(0, 12).forEach(f => lines.push(`- \`${f}\``));
    if (analysis.portability.portable.length > 12) lines.push(`- _...and ${analysis.portability.portable.length - 12} more_`);
  }
  lines.push('');

  if (analysis.portability.needsAdaptation.length > 0) {
    lines.push(`**🔧 Needs adaptation** (${analysis.portability.needsAdaptation.length} files)`);
    lines.push('');
    lines.push('These files use conventions that need to be translated for other runtimes:');
    analysis.portability.needsAdaptation.slice(0, 10).forEach(f => lines.push(`- \`${f}\``));
    lines.push('');
  }

  if (analysis.portability.hostSpecific.length > 0) {
    lines.push(`**🔒 Claude Code–specific** (${analysis.portability.hostSpecific.length} files)`);
    lines.push('');
    lines.push('These files use Claude Code–specific conventions. uStack emits advisory notes for other runtimes.');
    analysis.portability.hostSpecific.slice(0, 10).forEach(f => lines.push(`- \`${f}\``));
    lines.push('');
  }

  // How to use
  lines.push('## Using This Update');
  lines.push('');
  lines.push('If you track this upstream via uStack, run:');
  lines.push('');
  lines.push('```bash');
  lines.push(`ustack update ${analysis.upstreamId}`);
  lines.push('```');
  lines.push('');
  lines.push('This will pull the latest changes, re-analyze, and regenerate adapted output for your configured target runtimes.');
  lines.push('');

  // Trust note
  lines.push('## Trust Note');
  lines.push('');
  lines.push('This analysis is generated mechanically from git metadata. It classifies files by pattern, not by semantic understanding.');
  lines.push('Review the portability classification carefully before applying adapted output to production workflows.');
  lines.push('');

  // Footer
  lines.push('---');
  lines.push(`_Analyzed by [uStack](https://agi.security) · ${date} · [${analysis.repoUrl}](${analysis.repoUrl})_`);

  return lines.join('\n') + '\n';
}

const IMPACT_DESCRIPTIONS = {
  skills: 'One or more slash-command skills were updated',
  install: 'Setup, install, or onboarding flow changed',
  browser: 'Browser automation tooling changed',
  workflow: 'Core planning/review/ship workflow skills changed',
  safety: 'Safety guardrail behaviors changed',
  tooling: 'Internal binaries or scripts changed',
  docs: 'Documentation or README updated',
  config: 'Package config or CI configuration changed',
};

function upstreamDisplayName(id) {
  const names = {
    gstack: 'Gstack',
  };
  return names[id] || id.charAt(0).toUpperCase() + id.slice(1);
}


path: src/commands/update.js
content: import { runImport } from './import.js';
import { runAnalyze } from './analyze.js';
import { runPublish } from './publish.js';
import { fileExists, readJson, getUpstreamDir } from '../lib/fs.js';
import path from 'node:path';

/**
 * ustack update <upstream-id>
 *
 * Combines: import (pull latest) → analyze → publish.
 * The canonical "check for updates and produce output" command.
 */
export async function runUpdate({ cwd, args = [] }) {
  const upstreamId = args[0];

  if (!upstreamId) {
    console.error('Usage: ustack update <upstream-id>');
    process.exitCode = 1;
    return;
  }

  const manifestPath = path.join(getUpstreamDir(cwd, upstreamId), 'manifest.json');

  if (!fileExists(manifestPath)) {
    console.error(`Upstream "${upstreamId}" not found. Run: ustack import <url> --name ${upstreamId}`);
    process.exitCode = 1;
    return;
  }

  const before = readJson(manifestPath);
  const previousSha = before.headSha;

  console.log(`=== ustack update: ${upstreamId} ===`);
  console.log('');

  // Step 1: Pull latest
  console.log('[1/3] Pulling upstream...');
  await runImport({ cwd, args: [before.repoUrl, '--name', upstreamId] });
  console.log('');

  // Check if anything changed
  const after = readJson(manifestPath);
  if (after.headSha === previousSha) {
    console.log('No changes upstream. Nothing to analyze or publish.');
    return;
  }

  // Step 2: Analyze
  console.log('[2/3] Analyzing changes...');
  await runAnalyze({ cwd, args: [upstreamId] });
  console.log('');

  // Step 3: Publish
  console.log('[3/3] Generating publish page...');
  await runPublish({ cwd, args: [upstreamId] });
  console.log('');

  console.log(`=== Update complete ===`);
  console.log(`From: ${previousSha.slice(0, 12)} → To: ${after.headSha.slice(0, 12)}`);
  console.log(`Run artifacts: .ustack/runs/${upstreamId}/`);
  console.log(`Site page: .ustack/site/updates/${upstreamId}/`);
}


path: src/extractors/gstack.js
content: import path from 'node:path';
import { readText, fileExists, readJson } from '../lib/fs.js';
import { emptyIR, emptySkill, emptyService, mergeIR } from '../lib/ir.js';
import { getHead } from '../lib/git.js';

/**
 * Extract gstack's structure into uStack IR format.
 * Focuses on:
 * - Skills/commands
 * - Core workflow graph
 * - Services (browser daemon)
 * - Install methods
 * - Portability classification
 */
export async function extractGstack({ repoDir, manifest }) {
  const ir = emptyIR(manifest.id);
  ir.upstreamId = manifest.id; // Re-confirm upstream ID from manifest
  ir.workspace.name = 'Gstack';
  ir.workspace.repoUrl = manifest.repoUrl;
  ir.workspace.description = 'gstack is my answer. I\'ve been building products for twenty years, and right now I\'m shipping more code than I ever have...';
  ir.workspace.license = 'MIT'; // Assume MIT based on repo

  const upstreamDir = repoDir; // gstack is cloned directly, not in a subdir of repoDir

  // --- Extract Skills ---
  const skillsDir = path.join(upstreamDir, 'skills');
  if (fileExists(skillsDir)) {
    const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
      .filter(d => d.isDirectory())
      .map(d => d.name);

    for (const skillId of skillDirs) {
      const skillPath = path.join(skillsDir, skillId, 'SKILL.md');
      if (fileExists(skillPath)) {
        const skillIR = await extractSkillIR({ skillId, skillPath, upstreamDir, manifest });
        ir.skills.push(skillIR);
      }
    }
  }

  // --- Extract Core Workflow Graph ---
  // Gstack's workflow is implicit in command sequence and docs.
  // We infer a basic graph based on common sprint steps.
  ir.workflow = {
    stages: [
      { id: 'think', name: 'Think' },
      { id: 'plan', name: 'Plan' },
      { id: 'build', name: 'Build' },
      { id: 'review', name: 'Review' },
      { id: 'test', name: 'Test' },
      { id: 'ship', name: 'Ship' },
      { id: 'reflect', name: 'Reflect' },
    ],
    edges: [
      { from: 'think', to: 'plan', artifact: 'design-doc' },
      // plan -> build implicitly
      { from: 'build', to: 'review', artifact: 'code-changes' },
      { from: 'review', to: 'test', artifact: 'code-changes' },
      { from: 'test', to: 'ship', artifact: 'test-report' },
      { from: 'ship', to: 'reflect', artifact: 'release-notes' },
      // Design skills feed into planning
      { from: 'design-consultation', to: 'plan', artifact: 'design-system' },
      { from: 'design-shotgun', to: 'plan', artifact: 'design-mockups' },
      { from: 'design-html', to: 'build', artifact: 'html-component' },
      // Review -> Ship loop
      { from: 'review', to: 'ship', artifact: 'review-report' },
      // CI/CD flow implied
    ],
  };

  // --- Extract Services ---
  // Gstack's browser daemon is the primary service
  const browserService = extractBrowserService(upstreamDir, manifest);
  ir.services.push(browserService);

  // --- Extract Install Methods ---
  extractInstallMethods(upstreamDir, ir);

  // --- Classify Portability ---
  classifyPortability(ir, upstreamDir);

  return ir;
}

async function extractSkillIR({ skillId, skillPath, upstreamDir, manifest }) {
  const skillIR = emptySkill(skillId);
  skillIR.sourceFile = path.relative(upstreamDir, skillPath);
  skillIR.portability = 'portable'; // Default, refined below

  const content = readText(skillPath);
  const lines = content.split('\n');

  // Parse metadata from SKILL.md header or frontmatter
  let meta = {};
  if (content.includes('---')) {
    const frontmatter = content.split('---')[1];
    try {
      const fmLines = frontmatter.split('\n');
      meta = fmLines.reduce((acc, line) => {
        const parts = line.split(':');
        if (parts.length >= 2) {
          const key = parts[0].trim();
          const value = parts.slice(1).join(':').trim();
          if (key && value) acc[key] = value;
        }
        return acc;
      }, {});
    } catch (e) {
      console.warn(`Could not parse frontmatter for ${skillPath}: ${e.message}`);
    }
  }

  skillIR.name = meta.name || skillId.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
  skillIR.trigger = meta.trigger || `/${skillId}`;
  skillIR.description = meta.description || '';
  skillIR.category = meta.category || 'workflow'; // Default to workflow
  skillIR.safetyLevel = meta.safety || 'safe';
  skillIR.requiredTools = meta.tools || [];
  skillIR.hostNotes = {}; // To be filled by classification

  // Try to infer Host Specificity from SKILL.md comments/content
  if (content.includes('Claude Code-specific') || content.includes('Claude only')) {
    skillIR.hostNotes['claude-code'] = 'Content suggests Claude Code specific interaction';
    skillIR.portability = 'host-specific';
  } else if (content.includes('Codex specific') || content.includes('Codex only')) {
    skillIR.hostNotes['codex'] = 'Content suggests Codex specific interaction';
    skillIR.portability = 'host-specific';
  } else if (content.includes('OpenClaw specific') || content.includes('OpenClaw only')) {
    skillIR.hostNotes['openclaw'] = 'Content suggests OpenClaw specific interaction';
    skillIR.portability = 'host-specific';
  }

  // Infer artifacts
  const produced = meta.produces?.split(',').map(p => p.trim()).filter(Boolean) || [];
  const consumed = meta.consumes?.split(',').map(p => p.trim()).filter(Boolean) || [];
  skillIR.downstreamArtifacts = produced;
  skillIR.upstreamArtifacts = consumed;

  // Infer prototype code link from SKILL.md if available
  const prototypeMatch = content.match(/prototype: `([^`]+)`/);
  if (prototypeMatch && prototypeMatch[1]) {
    skillIR.purpose = `Prototype available: ${prototypeMatch[1]}`;
  }

  return skillIR;
}

function extractBrowserService(repoDir, manifest) {
  const serviceIR = emptyService('browser');
  serviceIR.name = 'Browser Daemon';
  serviceIR.description = 'Persistent Chromium instance for browser automation';
  serviceIR.type = 'daemon';
  serviceIR.protocol = 'http';
  serviceIR.portRange = '10000-60000'; // Dynamic port selection
  serviceIR.auth = 'bearer-token';
  serviceIR.hostSpecific = true; // Browser interact requires specific runtime environment

  // Infer start command and state file from gstack's ARCHITECTURE.md and bin/
  const architecturePath = path.join(repoDir, 'ARCHITECTURE.md');
  const binaryPath = path.join(repoDir, 'bin', 'gstack'); // Assuming compiled binary name
  const stateFileBase = '.gstack/browse.json'; // Common convention

  if (fileExists(architecturePath)) {
    const archContent = readText(architecturePath);
    const match = archContent.match(/`([^`]+)`.*to\s*localhost:PORT/s); // Crude extraction
    if (match && match[1]) {
      serviceIR.startCommand = match[1].replace('localhost:PORT', 'localhost:{{PORT}}'); // Placeholder
    }
  }

  // If binaryPath exists, reference it as the executable
  if (fileExists(binaryPath)) {
    serviceIR.startCommand = serviceIR.startCommand || `./bin/gstack --host browser`; // Fallback start command
  } else {
    // Fallback if binary not found directly, use generic command
    serviceIR.startCommand = serviceIR.startCommand || './run-browser-daemon.sh';
  }

  serviceIR.stateFile = stateFileBase;

  return serviceIR;
}

function extractInstallMethods(repoDir, ir) {
  // Primitive detection based on common install scripts / docs
  const installMethods = [];

  // Method 1: Repo-local install (typical for Claude Code skills)
  if (fileExists(path.join(repoDir, '.claude/skills/gstack'))) {
    installMethods.push({
      id: 'repo-local-claude',
      description: 'Install gstack skills within the project\'s .claude/ directory',
      targetPath: '.claude/skills/gstack',
      steps: [
        'Clone the gstack repository',
        'Run `./setup` within the cloned directory',
        'Add gstack section to project\'s CLAUDE.md',
      ],
      perRepoSupported: true,
      userGlobalSupported: false,
    });
  }

  // Method 2: User-global install (typical for CLI tools)
  if (fileExists(path.join(repoDir, 'bin/gstack')) || fileExists(path.join(repoDir, 'setup'))) {
    installMethods.push({
      id: 'user-global-cli',
      description: 'Install gstack CLI globally for user account',
      targetPath: '~/.gstack', // Convention based on ARCITECTURE.md
      steps: [
        'Clone the gstack repository to e.g. ~/.gstack',
        'Run `./setup --host auto` (or specify codex/factory)',
      ],
      perRepoSupported: false,
      userGlobalSupported: true,
    });
  }

  ir.install.methods = installMethods;
  ir.install.bootstrapCommand = './setup'; // Default common command
}


path: src/index.js
content: import { runImport } from './commands/import.js';
import { runAnalyze } from './commands/analyze.js';
import { runPublish } from './commands/publish.js';
import { runUpdate } from './commands/update.js';
import { runDoctor } from './commands/doctor.js';
import { renderHelp } from './lib/help.js';

export async function run(args = []) {
  const [command, ...rest] = args;

  if (!command || command === '--help' || command === '-h' || command === 'help') {
    console.log(renderHelp());
    return;
  }

  if (command === '--version' || command === '-v' || command === 'version') {
    const { createRequire } = await import('node:module');
    const require = createRequire(import.meta.url);
    const pkg = require('../../package.json');
    console.log(`@agisecurity/ustack ${pkg.version}`);
    return;
  }

  if (command === 'import') {
    await runImport({ cwd: process.cwd(), args: rest });
    return;
  }

  if (command === 'analyze') {
    await runAnalyze({ cwd: process.cwd(), args: rest });
    return;
  }

  if (command === 'publish') {
    await runPublish({ cwd: process.cwd(), args: rest });
    return;
  }

  if (command === 'update') {
    await runUpdate({ cwd: process.cwd(), args: rest });
    return;
  }

  if (command === 'doctor') {
    await runDoctor({ cwd: process.cwd(), args: rest });
    return;
  }

  console.error(`Unknown command: ${command}`);
  console.log(renderHelp());
  process.exitCode = 1;
}


path: src/lib/fs.js
content: import fs from 'node:fs';
import path from 'node:path';

export function readJson(filePath) {
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}

export function writeJson(filePath, data) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
}

export function writeText(filePath, text) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, text, 'utf8');
}

export function readText(filePath) {
  return fs.readFileSync(filePath, 'utf8');
}

export function fileExists(filePath) {
  return fs.existsSync(filePath);
}

export function ensureDir(dirPath) {
  fs.mkdirSync(dirPath, { recursive: true });
}

export function getUstackDir(cwd) {
  return path.join(cwd, '.ustack');
}

export function getUpstreamDir(cwd, upstreamId) {
  return path.join(getUstackDir(cwd), 'upstreams', upstreamId);
}

export function getRunDir(cwd, upstreamId, timestamp) {
  return path.join(getUstackDir(cwd), 'runs', upstreamId, timestamp);
}

export function listRunDirs(cwd, upstreamId) {
  const runsBase = path.join(getUstackDir(cwd), 'runs', upstreamId);
  if (!fileExists(runsBase)) return [];
  return fs.readdirSync(runsBase).sort().reverse(); // newest first
}


path: src/lib/git.js
content: import { execSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

/**
 * Clone or pull an upstream repo into a target directory.
 * Returns the HEAD commit sha after the operation.
 */
export function syncUpstream({ repoUrl, targetDir }) {
  if (fs.existsSync(path.join(targetDir, '.git'))) {
    // Pull latest
    const result = spawnSync('git', ['pull', '--ff-only'], {
      cwd: targetDir,
      encoding: 'utf8',
    });
    if (result.status !== 0) {
      throw new Error(`git pull failed: ${result.stderr}`);
    }
  } else {
    // Clone
    fs.mkdirSync(targetDir, { recursive: true });
    const result = spawnSync('git', ['clone', '--depth', '50', repoUrl, targetDir], {
      encoding: 'utf8',
    });
    if (result.status !== 0) {
      throw new Error(`git clone failed: ${result.stderr}`);
    }
  }

  return getHead(targetDir);
}

/**
 * Get the current HEAD sha.
 */
export function getHead(repoDir) {
  return execSync('git rev-parse HEAD', { cwd: repoDir, encoding: 'utf8' }).trim();
}

/**
 * Get the short sha.
 */
export function getShortHead(repoDir) {
  return execSync('git rev-parse --short HEAD', { cwd: repoDir, encoding: 'utf8' }).trim();
}

/**
 * Get commit log between two shas.
 * Returns array of { sha, shortSha, date, subject }
 */
export function getCommitLog(repoDir, fromSha, toSha = 'HEAD') {
  const sep = '|||';
  const format = `%H${sep}%h${sep}%ci${sep}%s`;
  const range = fromSha ? `${fromSha}..${toSha}` : toSha;

  let raw;
  try {
    raw = execSync(`git log --format="${format}" ${range}`, {
      cwd: repoDir,
      encoding: 'utf8',
    }).trim();
  } catch {
    return [];
  }

  if (!raw) return [];

  return raw.split('\n').map((line) => {
    const [sha, shortSha, date, ...subjectParts] = line.split(sep);
    return { sha, shortSha, date: date.trim(), subject: subjectParts.join(sep) };
  });
}

/**
 * Get list of files changed between two shas.
 * Returns array of { status, path } where status is A/M/D/R etc.
 */
export function getChangedFiles(repoDir, fromSha, toSha = 'HEAD') {
  if (!fromSha) return [];

  let raw;
  try {
    raw = execSync(`git diff --name-status ${fromSha} ${toSha}`, {
      cwd: repoDir,
      encoding: 'utf8',
    }).trim();
  } catch {
    return [];
  }

  if (!raw) return [];

  return raw.split('\n').map((line) => {
    const parts = line.split('\t');
    return { status: parts[0], path: parts[1] || parts[2] || '' };
  });
}

/**
 * Get the content of a file at a specific sha.
 */
export function getFileAtRevision(repoDir, filePath, sha) {
  try {
    return execSync(`git show ${sha}:${filePath}`, {
      cwd: repoDir,
      encoding: 'utf8',
    });
  } catch {
    return null;
  }
}

/**
 * Get tags sorted by version/date (newest first).
 */
export function getTags(repoDir) {
  try {
    return execSync('git tag --sort=-version:refname', {
      cwd: repoDir,
      encoding: 'utf8',
    }).trim().split('\n').filter(Boolean);
  } catch {
    return [];
  }
}


path: src/lib/help.js
content: export function renderHelp() {
  return `uStack — universal agent workspace compatibility and update engine

One workspace. Any agent.

Usage:
  ustack <command> [options]

Commands:
  import <github-url> [--name <id>]   Import and snapshot an upstream agent framework
  analyze <upstream-id>               Analyze latest upstream changes, produce structured report
  publish <upstream-id>               Generate website-ready update page from analysis
  update <upstream-id>                Pull latest upstream, analyze changes, and publish
  doctor                              Check workspace health and upstream state

Options:
  -h, --help     Show this help
  -v, --version  Show version

Examples:
  ustack import https://github.com/garrytan/gstack --name gstack
  ustack analyze gstack
  ustack publish gstack
  ustack update gstack
  ustack doctor

Upstream run artifacts live in:
  .ustack/upstreams/<id>/

Analysis and publish artifacts live in:
  .ustack/runs/<id>/<timestamp>/
`;
}


path: src/lib/ir.js
content: /**
 * uStack Intermediate Representation (IR)
 *
 * The IR is the normalized, runtime-agnostic description of an upstream
 * agent framework. It sits between the upstream import and the target adapters.
 *
 * Architecture:
 *   Upstream repo → IR extraction → IR schema → Target adapter → Adapted output
 *
 * The IR lets us add new upstreams and new targets independently.
 * Each upstream gets one extractor. Each target gets one renderer.
 * No pairwise adapters.
 */

/**
 * Build an empty IR workspace document.
 */
export function emptyIR(upstreamId) {
  return {
    schema: 'ustack-ir/v1',
    upstreamId,
    extractedAt: new Date().toISOString(),

    // Workspace-level metadata
    workspace: {
      name: '',
      description: '',
      category: '',           // e.g. workflow-skill-framework, skill-library, agent-workspace
      hostConventions: [],    // original host(s): claude-code, codex, etc.
      license: '',
      repoUrl: '',
    },

    // Skills: the core slash-command / invocable units
    skills: [],

    // Workflow graph: how skills connect
    workflow: {
      stages: [],             // e.g. think, plan, build, review, test, ship, reflect
      edges: [],              // { from, to, artifact } — what flows between stages
    },

    // Artifacts: named documents that flow between skills
    artifacts: [],

    // Tooling services: long-running daemons or binaries
    services: [],

    // Install model: how the framework is installed
    install: {
      methods: [],            // { id, description, targetPath, steps }
      bootstrapCommand: '',
      perRepoSupported: false,
      userGlobalSupported: false,
    },

    // Compatibility notes
    compatibility: {
      portableComponents: [],
      needsAdaptation: [],
      hostSpecific: [],
      notes: '',
    },
  };
}

/**
 * IR Skill schema:
 * {
 *   id: string,               // e.g. 'review'
 *   name: string,             // human name, e.g. 'Staff Engineer Review'
 *   trigger: string,          // e.g. '/review'
 *   category: string,         // workflow | power-tool | safety | browser | memory
 *   description: string,
 *   purpose: string,
 *   requiredTools: string[],  // e.g. ['browser', 'git']
 *   safetyLevel: string,      // safe | caution | destructive
 *   upstreamArtifacts: string[],  // artifacts consumed
 *   downstreamArtifacts: string[], // artifacts produced
 *   hostNotes: {              // host-specific implementation notes
 *     [hostId]: string
 *   },
 *   portability: string,      // portable | needs-adaptation | host-specific
 *   sourceFile: string,       // relative path in upstream repo
 * }
 */
export function emptySkill(id) {
  return {
    id,
    name: '',
    trigger: `/${id}`,
    category: 'workflow',
    description: '',
    purpose: '',
    requiredTools: [],
    safetyLevel: 'safe',
    upstreamArtifacts: [],
    downstreamArtifacts: [],
    hostNotes: {},
    portability: 'portable',
    sourceFile: '',
  };
}

/**
 * IR Artifact schema:
 * {
 *   id: string,
 *   name: string,
 *   description: string,
 *   format: string,           // markdown | json | yaml | text
 *   producedBy: string[],     // skill ids
 *   consumedBy: string[],     // skill ids
 *   typicalPath: string,      // e.g. 'DESIGN.md', '.claude/design-doc.md'
 * }
 */
export function emptyArtifact(id) {
  return {
    id,
    name: '',
    description: '',
    format: 'markdown',
    producedBy: [],
    consumedBy: [],
    typicalPath: '',
  };
}

/**
 * IR Service schema:
 * {
 *   id: string,
 *   name: string,
 *   description: string,
 *   type: string,             // daemon | binary | script
 *   startCommand: string,
 *   stateFile: string,
 *   protocol: string,         // http | stdio | none
 *   portRange: string,        // e.g. '10000-60000'
 *   auth: string,             // bearer-token | none
 *   hostSpecific: boolean,
 * }
 */
export function emptyService(id) {
  return {
    id,
    name: '',
    description: '',
    type: 'daemon',
    startCommand: '',
    stateFile: '',
    protocol: 'none',
    portRange: '',
    auth: 'none',
    hostSpecific: false,
  };
}

/**
 * Validate an IR document. Returns { valid, errors }.
 */
export function validateIR(ir) {
  const errors = [];

  if (ir.schema !== 'ustack-ir/v1') errors.push('schema must be ustack-ir/v1');
  if (!ir.upstreamId) errors.push('upstreamId is required');
  if (!Array.isArray(ir.skills)) errors.push('skills must be an array');
  if (!Array.isArray(ir.artifacts)) errors.push('artifacts must be an array');
  if (!Array.isArray(ir.services)) errors.push('services must be an array');

  for (const skill of (ir.skills || [])) {
    if (!skill.id) errors.push(`skill missing id: ${JSON.stringify(skill)}`);
    if (!['portable', 'needs-adaptation', 'host-specific'].includes(skill.portability)) {
      errors.push(`skill ${skill.id}: invalid portability value "${skill.portability}"`);
    }
    if (!['workflow', 'power-tool', 'safety', 'browser', 'memory', 'update'].includes(skill.category)) {
      errors.push(`skill ${skill.id}: invalid category "${skill.category}"`);
    }
  }

  return { valid: errors.length === 0, errors };
}

/**
 * Merge a partial IR update into an existing IR document.
 * Skills and artifacts are merged by id (upsert).
 */
export function mergeIR(base, patch) {
  const result = { ...base, ...patch };

  // Merge skills by id
  const skillMap = new Map((base.skills || []).map(s => [s.id, s]));
  for (const s of (patch.skills || [])) {
    skillMap.set(s.id, { ...(skillMap.get(s.id) || {}), ...s });
  }
  result.skills = Array.from(skillMap.values());

  // Merge artifacts by id
  const artifactMap = new Map((base.artifacts || []).map(a => [a.id, a]));
  for (const a of (patch.artifacts || [])) {
    artifactMap.set(a.id, { ...(artifactMap.get(a.id) || {}), ...a });
  }
  result.artifacts = Array.from(artifactMap.values());

  // Merge services by id
  const serviceMap = new Map((base.services || []).map(sv => [sv.id, sv]));
  for (const sv of (patch.services || [])) {
    serviceMap.set(sv.id, { ...(serviceMap.get(sv.id) || {}), ...sv });
  }
  result.services = Array.from(serviceMap.values());

  return result;
}


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)