diff --git a/.gitignore b/.gitignore index 59b32d9..b4946d6 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ npm-debug.log* .opencode/ .events.json skills-lock.json + +# graphify knowledge graph (generated) +graphify-out/ diff --git a/src/commands/skills.mjs b/src/commands/skills.mjs index 761a9cb..a150d31 100644 --- a/src/commands/skills.mjs +++ b/src/commands/skills.mjs @@ -100,9 +100,16 @@ export async function cmdAdd(args, flags) { const manifest = await fetchEventsManifest(source); if (manifest && manifest.events) { const targetEventsPath = join(projectRoot, ".events.json"); + // Persist the manifest to the project root so later commands + // (update/remove) can re-resolve events without re-fetching. + // Local sources: copy the file (preserves comments/ordering). + // Remote sources: write the fetched manifest (resolve() on a + // repo-id was never a valid path — the copy silently never ran). const sourceEventsPath = resolve(source, ".events.json"); if (source !== "." && existsSync(sourceEventsPath)) { copyFileSync(sourceEventsPath, targetEventsPath); + } else if (!existsSync(targetEventsPath)) { + writeFileSync(targetEventsPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8"); } const resolvedEvents = resolveEvents(manifest, agentEventConfig); const eventCount = Object.keys(resolvedEvents).length; diff --git a/src/commands/team.mjs b/src/commands/team.mjs index 7bcd76f..063b672 100644 --- a/src/commands/team.mjs +++ b/src/commands/team.mjs @@ -32,7 +32,7 @@ export async function cmdTeamSetup(args, flags) { if (skillsDir) { const skills = await findInstalledSkills(skillsDir, projectRoot); if (skills.length === 0) { - const code = await cmdAdd([source], { agents: [agentKey] }); + const code = await cmdAdd([source], { agents: [agentKey], yes: flags.yes }); if (code !== 0) { console.error("✗ Skills installation failed — aborting team setup"); return code || 1; diff --git a/src/commands/workspace.mjs b/src/commands/workspace.mjs new file mode 100644 index 0000000..824dc53 --- /dev/null +++ b/src/commands/workspace.mjs @@ -0,0 +1,275 @@ +// Workspace commands: setup (profile-driven), init (brownfield), status (audit). +// Setup separates deterministic steps (git clone, skills add — no LLM) from +// agent-led steps (workspace init/link, goal — via agent run). + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { parseYaml } from "../utils/yaml.mjs"; +import { readAgent } from "../utils/init-options.mjs"; + +const PROFILE_PATH = ".adlc/workspace-profile.yml"; + +// ── setup ────────────────────────────────────────────────────────────── +export async function cmdWorkspaceSetup(args, flags) { + const projectRoot = process.cwd(); + + // 1. Resolve profile source: explicit arg > ADLC_WORKSPACE_PROFILE env > local file + const explicit = args.find((a) => !a.startsWith("-")); + const envProfile = (process.env.ADLC_WORKSPACE_PROFILE || "").trim(); + const source = + explicit || + (envProfile !== "" ? envProfile : null) || + (existsSync(join(projectRoot, PROFILE_PATH)) ? PROFILE_PATH : null); + + if (!source) { + console.error( + "Error: no workspace profile found. Pass a path/URL, set ADLC_WORKSPACE_PROFILE, or create .adlc/workspace-profile.yml", + ); + return 1; + } + + // 2. Load profile (local file or HTTP) + const content = await loadProfile(source, projectRoot); + if (content === null) return 1; + + // 3. Parse + validate + let profile; + try { + profile = parseYaml(content); + } catch (err) { + console.error(`Error: invalid workspace profile: ${err.message}`); + return 1; + } + if (!profile || typeof profile !== "object" || !profile.schema_version) { + console.error(`Error: workspace profile missing schema_version (${source})`); + return 1; + } + + // 4. Resolve agent: -a flag > profile.agent > init-options.json + const agent = (flags.agents && flags.agents[0]) || profile.agent || readAgent(); + if (!agent) { + console.error("Error: agent required (-a , profile agent:, or init-options.json)"); + return 1; + } + + const dryRun = flags.dryRun || false; + console.log(`Workspace profile: ${profile.name || source}`); + console.log(`Agent: ${agent}${dryRun ? " [dry-run]" : ""}`); + + const { cmdAgentRun } = await import("./agent.mjs"); + const { cmdAdd } = await import("./skills.mjs"); + const { cmdTeamSetup } = await import("./team.mjs"); + + // 5. workspace.git — deterministic clones (no LLM) + const gitModules = profile.workspace && profile.workspace.git; + if (Array.isArray(gitModules) && gitModules.length > 0) { + console.log(`\n┌─ workspace.git (${gitModules.length} repo(s))`); + for (const mod of gitModules) { + if (!mod || !mod.repo || !mod.path) { + console.error("│ ✗ git entry missing repo or path"); + console.log(`└─ failed`); + return 1; + } + const target = join(projectRoot, mod.path); + if (existsSync(target)) { + console.log(`│ = ${mod.path} (exists, skipping clone)`); + continue; + } + const gitArgs = ["clone", mod.repo, mod.path]; + if (mod.branch) gitArgs.push("--branch", mod.branch); + console.log(`│ $ git ${gitArgs.join(" ")}`); + if (!dryRun) { + const result = spawnSync("git", gitArgs, { stdio: "inherit", cwd: projectRoot }); + if (result.status !== 0) { + console.error(`│ ✗ git clone failed for ${mod.path}`); + console.log(`└─ failed`); + return result.status || 1; + } + if (mod.ref) { + const co = spawnSync("git", ["-C", mod.path, "checkout", mod.ref], { + stdio: "inherit", + cwd: projectRoot, + }); + if (co.status !== 0) { + console.error(`│ ✗ git checkout ${mod.ref} failed for ${mod.path}`); + console.log(`└─ failed`); + return co.status || 1; + } + } + } + } + console.log(`└─ done`); + } + + // 5.5 workspace.dirs — deterministic empty-dir scaffolding (greenfield) + const ws = profile.workspace || {}; + const dirs = ws.dirs; + if (Array.isArray(dirs) && dirs.length > 0) { + console.log(`\n┌─ workspace.dirs (${dirs.length})`); + for (const dir of dirs) { + if (typeof dir !== "string" || !dir || dir.includes("..")) { + console.error(`│ ✗ invalid workspace.dirs entry: ${JSON.stringify(dir)}`); + console.log(`└─ failed`); + return 1; + } + const target = join(projectRoot, dir); + if (existsSync(target)) { + console.log(`│ = ${dir} (exists, skipping)`); + continue; + } + console.log(`│ mkdir -p ${dir}`); + if (!dryRun) mkdirSync(target, { recursive: true }); + } + console.log(`└─ done`); + } + + // 6. workspace.init / workspace.link — agent-led (workspace skill) + if (ws.init) { + const linkFlag = ws.link ? " --link" : ""; + console.log(`\n┌─ workspace init${ws.link ? " + link" : ""} (agent-led)`); + if (dryRun) { + console.log(`│ agent run -a ${agent} "Run /workspace --init${linkFlag}"`); + } else { + const code = await cmdAgentRun(["-a", agent, `Run /workspace --init${linkFlag}`]); + if (code !== 0) { + console.error("│ ✗ workspace init failed"); + console.log(`└─ failed`); + return code || 1; + } + } + console.log(`└─ done`); + } + + // 7. skills.sources — deterministic install (no LLM); -y: setup is non-interactive + const sources = profile.skills && profile.skills.sources; + if (Array.isArray(sources) && sources.length > 0) { + console.log(`\n┌─ skills.sources (${sources.length} source(s))`); + for (const src of sources) { + console.log(`│ skills add ${src}`); + if (!dryRun) { + const code = await cmdAdd([src], { agents: [agent], yes: true }); + if (code !== 0) { + console.error(`│ ✗ skills add failed for ${src}`); + console.log(`└─ failed`); + return code || 1; + } + } + } + console.log(`└─ done`); + } + + // 8. commands — sequential, stop on first failure + const commands = profile.commands; + if (Array.isArray(commands) && commands.length > 0) { + console.log(`\n┌─ commands (${commands.length})`); + for (const command of commands) { + const code = await runProfileCommand(String(command), agent, cmdAgentRun, cmdAdd, cmdTeamSetup, dryRun); + if (code !== 0) { + console.log(`└─ failed`); + return code || 1; + } + } + console.log(`└─ done`); + } + + console.log(`\nWorkspace setup complete.`); + console.log(`Next: adlc-cli agent run "" -a ${agent}`); + return 0; +} + +async function loadProfile(source, projectRoot) { + if (/^https?:\/\//.test(source)) { + try { + const res = await fetch(source); + if (!res.ok) { + console.error(`Error: failed to fetch profile from ${source} (HTTP ${res.status})`); + return null; + } + return await res.text(); + } catch (err) { + console.error(`Error: failed to fetch profile from ${source}: ${err.message}`); + return null; + } + } + const path = resolve(projectRoot, source); + if (!existsSync(path)) { + console.error(`Error: workspace profile not found at ${path}`); + return null; + } + return readFileSync(path, "utf-8"); +} + +async function runProfileCommand(command, agent, cmdAgentRun, cmdAdd, cmdTeamSetup, dryRun) { + const trimmed = command.trim(); + + // agent run "" — prompts contain spaces/quotes + let match = trimmed.match(/^agent run "([\s\S]*)"$/); + if (match) { + console.log(`│ agent run "${match[1]}"`); + if (dryRun) return 0; + return await cmdAgentRun(["-a", agent, match[1]]); + } + // agent run + match = trimmed.match(/^agent run (.+)$/); + if (match) { + console.log(`│ agent run ${match[1]}`); + if (dryRun) return 0; + return await cmdAgentRun(["-a", agent, match[1]]); + } + // skills add + match = trimmed.match(/^skills add (\S+)$/); + if (match) { + console.log(`│ skills add ${match[1]}`); + if (dryRun) return 0; + return await cmdAdd([match[1]], { agents: [agent], yes: true }); + } + // team setup + match = trimmed.match(/^team setup (\S+)$/); + if (match) { + console.log(`│ team setup ${match[1]}`); + if (dryRun) return 0; + return await cmdTeamSetup([match[1]], { agents: [agent], yes: true }); + } + + console.error(`│ ✗ unknown command: "${trimmed}" (supported: skills add, team setup, agent run)`); + return 1; +} + +// ── init (brownfield) ────────────────────────────────────────────────── +export async function cmdWorkspaceInit(args, flags) { + const agent = (flags.agents && flags.agents[0]) || readAgent(); + if (!agent) { + console.error("Error: Agent required (-a or set in init-options.json)"); + return 1; + } + + let prompt = "Run /workspace --init"; + if (flags.link) prompt += " --link"; + else if (flags.ignoreOnly) prompt += " --ignore-only"; + + if (flags.dryRun) { + console.log(`agent run -a ${agent} "${prompt}"`); + return 0; + } + + const { cmdAgentRun } = await import("./agent.mjs"); + return cmdAgentRun(["-a", agent, prompt]); +} + +// ── status (audit) ───────────────────────────────────────────────────── +export async function cmdWorkspaceStatus(args, flags) { + const agent = (flags.agents && flags.agents[0]) || readAgent(); + if (!agent) { + console.error("Error: Agent required (-a or set in init-options.json)"); + return 1; + } + + if (flags.dryRun) { + console.log(`agent run -a ${agent} "Run /workspace --status"`); + return 0; + } + + const { cmdAgentRun } = await import("./agent.mjs"); + return cmdAgentRun(["-a", agent, "Run /workspace --status"]); +} diff --git a/src/dispatch.mjs b/src/dispatch.mjs index 5557861..43d7427 100644 --- a/src/dispatch.mjs +++ b/src/dispatch.mjs @@ -4,7 +4,15 @@ import { AGENTS } from "./registry.mjs"; import { cmdAdd, cmdUpdate, cmdRemove, cmdStatus } from "./commands/skills.mjs"; import { cmdTeamSetup, cmdTeamUpdate, cmdTeamRepair } from "./commands/team.mjs"; import { cmdAgentRun, cmdAgentList } from "./commands/agent.mjs"; -import { printCliHelp, printSkillsHelp, printTeamHelp, printAgentHelp, printHelp } from "./help.mjs"; +import { cmdWorkspaceSetup, cmdWorkspaceInit, cmdWorkspaceStatus } from "./commands/workspace.mjs"; +import { + printCliHelp, + printSkillsHelp, + printTeamHelp, + printAgentHelp, + printWorkspaceHelp, + printHelp, +} from "./help.mjs"; const VERSION = "1.0.2"; @@ -91,6 +99,28 @@ function runNewTree({ command, args, flags }, argv) { return 1; } } + // Top-level `run` — compat alias for `agent run` (runtime contract, ADR-368). + case "run": + return cmdAgentRun(argv.slice(1)); + case "workspace": { + const sub = args[0] ?? "status"; + const rest = args.slice(1); + switch (sub) { + case "setup": + return cmdWorkspaceSetup(rest, flags); + case "init": + return cmdWorkspaceInit(rest, flags); + case "status": + return cmdWorkspaceStatus(rest, flags); + case "help": + printWorkspaceHelp(); + return 0; + default: + console.error(`Unknown workspace command: "${sub}"`); + printWorkspaceHelp(); + return 1; + } + } case "version": console.log(`adlc-cli ${VERSION}`); return 0; @@ -139,6 +169,12 @@ function parseArgs(argv) { flags.validateDrafts = true; } else if (arg === "--commands-dir") { flags.commandsDir = rest[++i]; + } else if (arg === "--dry-run") { + flags.dryRun = true; + } else if (arg === "--link") { + flags.link = true; + } else if (arg === "--ignore-only") { + flags.ignoreOnly = true; } else if (!arg.startsWith("-")) { args.push(arg); } diff --git a/src/help.mjs b/src/help.mjs index 4543e7c..df07a54 100644 --- a/src/help.mjs +++ b/src/help.mjs @@ -17,6 +17,9 @@ COMMANDS: team repair Validate and repair team-ai-directives state agent run "" [flags] Run a coding agent headlessly with a task agent list List supported agents + run profiles + workspace setup [profile] Apply workspace profile (git modules, skills, commands, goal) + workspace init [--link] Brownfield init: .adlc/ structure + discover child repos + workspace status Audit workspace health (branch, dirty, unpushed, drift) version Print installed version help Show this help @@ -37,6 +40,7 @@ EXAMPLES: adlc-cli skills add tikalk/adlc-team-skills -a opencode adlc-cli team setup tikalk/adlc-team-skills -a opencode adlc-cli agent run "Fix the failing auth test" -a opencode + adlc-cli workspace setup -a opencode --dry-run cat brief.md | adlc-cli agent run - --format json `); } @@ -102,6 +106,45 @@ RUN FLAGS: `); } +export function printWorkspaceHelp() { + console.log(` +USAGE: + adlc-cli workspace [flags] + +COMMANDS: + setup [profile] Apply workspace profile (.adlc/workspace-profile.yml, path, or URL) + init Brownfield init: create .adlc/ structure, discover child repos + status Audit workspace health (branch, dirty, unpushed, SHA drift) + +SETUP FLAGS: + -a Agent key (default: profile agent, then init-options.json) + --dry-run Print planned actions without executing + +INIT FLAGS: + -a Agent key (default: from init-options.json) + --link Register discovered child repos as submodules + --ignore-only Add child repos to .gitignore instead of submodules + --dry-run Preview without executing + +PROFILE (.adlc/workspace-profile.yml): + workspace.git[] repos to clone (repo, path, branch, ref) — deterministic + workspace.dirs[] empty directories to create (greenfield scaffolding) — deterministic + workspace.init create .adlc/ structure via /workspace skill — agent-led + workspace.link register cloned repos as submodules — agent-led + skills.sources[] skill sources installed via skills add — deterministic + commands[] sequential commands: skills add | team setup | agent run + + Source resolution: explicit arg > ADLC_WORKSPACE_PROFILE env > local file + Setup converges the environment only — run your goal via + 'adlc-cli agent run ""' after setup. + +EXAMPLES: + adlc-cli workspace setup # use .adlc/workspace-profile.yml + adlc-cli workspace setup https://host/p.yml # fetch profile over HTTP + adlc-cli workspace setup -a opencode --dry-run # preview planned actions +`); +} + export function printHelp() { console.log(` adlc-skills-cli (legacy alias for adlc-cli) diff --git a/src/run.mjs b/src/run.mjs index 3efb7fa..db471df 100644 --- a/src/run.mjs +++ b/src/run.mjs @@ -41,8 +41,14 @@ function buildCommandFromProfile(profile, prompt, { model, requireApproval } = { export function runTask({ profile, prompt, model, requireApproval, cwd, onLine }) { const { cmd, args, env } = buildCommandFromProfile(profile, prompt, { model, requireApproval }); + const childCwd = cwd ?? process.cwd(); + // Sync PWD with the spawn cwd — agents that resolve their project directory + // from env.PWD (e.g. opencode) would otherwise land in the parent's cwd + // (docker WORKDIR) instead of the requested workspace. + env.PWD = childCwd; + const child = spawn(cmd, args, { - cwd: cwd ?? process.cwd(), + cwd: childCwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true, diff --git a/src/utils/yaml.mjs b/src/utils/yaml.mjs new file mode 100644 index 0000000..00ab5ac --- /dev/null +++ b/src/utils/yaml.mjs @@ -0,0 +1,135 @@ +// Minimal YAML parser for workspace profiles. +// Supports: nested mappings, lists (scalar + mapping items), quoted strings, +// booleans, numbers, null, folded block scalars ("|" / ">"). +// Zero dependencies — same constraint as frontmatter.mjs. + +const MAPPING_LINE_RE = /^([^:#]+?)\s*:\s*(.*)$/; +const KEY_FIRST_RE = /^[\w.-]+:(\s|$)/; + +export function parseYaml(text) { + const lines = text + .split(/\r?\n/) + .map((raw) => { + const indent = raw.match(/^ */)[0].length; + return { indent, content: raw.trim() }; + }) + .filter((l) => l.content !== "" && !l.content.startsWith("#")); + + let pos = 0; + + function parseBlock(indent) { + const line = lines[pos]; + if (!line) return null; + if (line.content === "-" || line.content.startsWith("- ")) { + return parseList(indent); + } + return parseMapping(indent); + } + + function parseMapping(indent) { + const result = {}; + while (pos < lines.length) { + const line = lines[pos]; + if (line.indent < indent) break; + if (line.indent > indent) break; + if (line.content === "-" || line.content.startsWith("- ")) break; + + const match = line.content.match(MAPPING_LINE_RE); + if (!match) { + pos++; + continue; + } + + const key = match[1].trim(); + const value = match[2].trim(); + pos++; + + if (value === "" || value === "|" || value === ">") { + const folded = value === "|" || value === ">"; + if (pos < lines.length && lines[pos].indent > indent) { + if (folded && !isMappingLine(lines[pos].content)) { + result[key] = parseFolded(indent, value === "|"); + } else { + result[key] = parseBlock(lines[pos].indent); + } + } else { + result[key] = folded ? "" : null; + } + } else { + result[key] = parseScalar(value); + } + } + return result; + } + + function parseFolded(indent, keepNewlines) { + const parts = []; + while (pos < lines.length && lines[pos].indent > indent) { + parts.push(lines[pos].content); + pos++; + } + return parts.join(keepNewlines ? "\n" : " ").trim(); + } + + function parseList(indent) { + const result = []; + while (pos < lines.length) { + const line = lines[pos]; + if (line.indent !== indent) break; + if (line.content !== "-" && !line.content.startsWith("- ")) break; + + const rest = line.content.replace(/^-\s?/, ""); + + if (rest === "") { + pos++; + if (pos < lines.length && lines[pos].indent > indent) { + result.push(parseBlock(lines[pos].indent)); + } else { + result.push(null); + } + continue; + } + + if (KEY_FIRST_RE.test(rest)) { + // Inline mapping item: "- key: value" — reparse as mapping at item indent. + const itemIndent = + pos + 1 < lines.length && lines[pos + 1].indent > indent && !lines[pos + 1].content.startsWith("- ") + ? lines[pos + 1].indent + : indent + 2; + lines[pos] = { indent: itemIndent, content: rest }; + result.push(parseMapping(itemIndent)); + continue; + } + + result.push(parseScalar(rest)); + pos++; + } + return result; + } + + function isMappingLine(content) { + return KEY_FIRST_RE.test(content); + } + + function parseScalar(value) { + const isQuoted = + (value.startsWith('"') && value.endsWith('"') && value.length >= 2) || + (value.startsWith("'") && value.endsWith("'") && value.length >= 2); + // Strip trailing comments (" # ...") only when the value is not quoted. + // A "#" without preceding whitespace (e.g. git+https://...#v6.4.1) is kept. + if (!isQuoted) value = value.replace(/\s+#.*$/, ""); + if (isQuoted) { + return value.slice(1, -1); + } + if (value === "true") return true; + if (value === "false") return false; + if (value === "null" || value === "~") return null; + if (/^-?\d+$/.test(value)) return parseInt(value, 10); + if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value); + return value; + } + + if (lines.length === 0) return null; + const result = parseBlock(lines[0].indent); + return result; +} diff --git a/tests/e2e-profiles.sh b/tests/e2e-profiles.sh new file mode 100755 index 0000000..b948aeb --- /dev/null +++ b/tests/e2e-profiles.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# E2E: brownfield workspace setup with the team-directives fixture profile. +# +# Scenario (single, brownfield): +# 1. Temp workspace with a pre-cloned real brownfield repo (hermes demo). +# 2. `adlc-cli workspace setup ` — profile passed explicitly. +# 3. Asserts: public HTTPS clone of team-ai-directives, workspace.dirs +# scaffolding, agent-led .adlc/ init, skills from the remote GitHub +# source (+ events wiring), team setup command, brownfield discovery + +# submodule registration of BOTH the pre-cloned repo and the profile +# clone. Ends with a direct `agent run` marker proving post-setup +# agent execution (intent ≡ agent run arg). +# +# Requires: network (GitHub), local opencode auth, npx. +# Usage: tests/e2e-profiles.sh [--skip-agent] +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +BIN="$HERE/../bin/adlc-cli.mjs" +PROFILE="$HERE/e2e/profiles/team-directives-profile.yml" +SKIP_AGENT="${1:-}" + +pass() { echo " PASS: $1"; } +fail() { echo " FAIL: $1"; echo "E2E-PROFILES FAILED (artifacts: $ROOT)"; exit 1; } +assert_exists() { [ -e "$1" ] && pass "exists $1" || fail "missing $1"; } + +echo "== Preparing brownfield workspace ==" +ROOT="$(mktemp -d /tmp/adlc-e2e-profiles.XXXXXX)" +WS="$ROOT/ws" +mkdir -p "$WS" +( cd "$WS" && git init -q -b main && git config user.email e2e@test && git config user.name e2e ) + +# Pre-cloned brownfield repo — NOT in the profile; must be discovered at depth 1 +echo "Cloning hermes brownfield demo (shallow)..." +git clone -q --depth 1 https://github.com/mnriem/spec-kit-go-brownfield-demo.git "$WS/hermes-project" \ + || fail "hermes clone failed (network?)" +HERMES_HEAD="$( git -c safe.directory='*' -C "$WS/hermes-project" rev-parse HEAD )" +[ -e "$WS/hermes-project/go.mod" ] && pass "hermes-project cloned (Go codebase present)" \ + || fail "hermes-project content missing" + +echo; echo "== workspace setup (fixture profile) ==" +OUT="$( cd "$WS" && node "$BIN" workspace setup "$PROFILE" )" \ + || { echo "$OUT"; fail "workspace setup exited non-zero"; } +echo "$OUT" | grep -q "Team AI Directives Demo Workspace" || { echo "$OUT"; fail "profile name missing"; } +echo "$OUT" | grep -q "git clone https://github.com/tikalk/agentic-sdlc-team-ai-directives.git agentic-sdlc-team-ai-directives" \ + || { echo "$OUT"; fail "directives clone missing from plan"; } +echo "$OUT" | grep -q "mkdir -p adlc-team-skills" || { echo "$OUT"; fail "dirs scaffold missing"; } +echo "$OUT" | grep -q "Workspace setup complete" || { echo "$OUT"; fail "setup did not complete"; } + +# Deterministic asserts +assert_exists "$WS/agentic-sdlc-team-ai-directives/README.md" +[ -d "$WS/adlc-team-skills" ] && [ -z "$(ls -A "$WS/adlc-team-skills")" ] \ + && pass "adlc-team-skills is an empty dir (greenfield scaffold)" \ + || fail "adlc-team-skills not empty or missing" + +# Agent-led init (workspace skill) — unless skipped +if [ "$SKIP_AGENT" = "--skip-agent" ]; then + echo; echo "== post-setup asserts skipped after init (--skip-agent halts before agent steps) ==" + echo "NOTE: rerun without --skip-agent for full coverage" + rm -rf "$ROOT" + echo; echo "E2E-PROFILES PASSED (deterministic subset)" + exit 0 +fi + +assert_exists "$WS/.adlc/product" +assert_exists "$WS/.adlc/architecture" +assert_exists "$WS/.adlc/context" +pass "agent-led workspace init created .adlc/ tree" + +# Skills from remote GitHub source +assert_exists "$WS/.agents/skills/team-boot/SKILL.md" +assert_exists "$WS/.opencode/commands/team-boot.md" +assert_exists "$WS/.events.json" +assert_exists "$WS/.agents/dispatcher.mjs" +assert_exists "$WS/.opencode/plugin/adlc-skills-events.ts" +pass "skills installed from remote GitHub source + events wired" + +# team setup command ran — deterministic part (agent + skills_source) configured. +# NOTE: team_ai_directives wiring requires the interactive /team-setup skill +# (mode selection) — headless runs can't answer it. Follow-up: non-interactive +# mode for team setup when the directives path is known (e.g. from profile git). +[ -f "$WS/.adlc/init-options.json" ] || fail "init-options.json missing (team setup did not run)" +grep -q '"agent"' "$WS/.adlc/init-options.json" && pass "init-options has agent" || fail "init-options missing agent" +grep -q '"skills_source"' "$WS/.adlc/init-options.json" && pass "init-options has skills_source" \ + || fail "init-options missing skills_source" + +# Brownfield: pre-cloned repo untouched by clone phase, discovered + linked +[ "$( git -c safe.directory='*' -C "$WS/hermes-project" rev-parse HEAD )" = "$HERMES_HEAD" ] \ + && pass "hermes-project HEAD unchanged by setup" || fail "hermes-project was modified" +[ -f "$WS/.gitmodules" ] || fail ".gitmodules missing (link did not run)" +grep -q "hermes-project" "$WS/.gitmodules" && pass "hermes-project registered as submodule (brownfield discovery)" \ + || { cat "$WS/.gitmodules"; fail "hermes-project not in .gitmodules"; } +grep -q "agentic-sdlc-team-ai-directives" "$WS/.gitmodules" && pass "directives clone registered as submodule" \ + || { cat "$WS/.gitmodules"; fail "directives not in .gitmodules"; } +# Adopted repos register as gitlinks (mode 160000) in the parent index — +# git keeps their standalone .git dir ("Adding existing repo to the index"), +# so the gitfile form is NOT the marker here. +git -c safe.directory='*' -C "$WS" ls-files --stage 2>/dev/null | grep -qE "^160000.*[[:space:]]hermes-project$" \ + && pass "hermes-project registered as gitlink in parent index" \ + || fail "hermes-project gitlink missing from parent index" +git -c safe.directory='*' -C "$WS" ls-files --stage 2>/dev/null | grep -qE "^160000.*[[:space:]]agentic-sdlc-team-ai-directives$" \ + && pass "directives registered as gitlink in parent index" \ + || fail "directives gitlink missing from parent index" + +# Empty dirs must NOT be registered as submodules +! grep -q "adlc-team-skills" "$WS/.gitmodules" && pass "empty dir adlc-team-skills not in .gitmodules" \ + || fail "empty dir incorrectly registered as submodule" + +echo; echo "== post-setup goal: direct agent run ==" +OUT="$( cd "$WS" && node "$BIN" agent run "Reply with exactly: E2E-GOAL-OK and nothing else." -a opencode )" \ + || { echo "$OUT"; fail "agent run exited non-zero"; } +echo "$OUT" | grep -q "E2E-GOAL-OK" && pass "agent run output contains E2E-GOAL-OK" \ + || { echo "$OUT"; fail "E2E-GOAL-OK missing from agent run output"; } + +echo; echo "E2E-PROFILES PASSED (all assertions green)" +rm -rf "$ROOT" diff --git a/tests/e2e-workspace.sh b/tests/e2e-workspace.sh new file mode 100755 index 0000000..92bf4af --- /dev/null +++ b/tests/e2e-workspace.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# E2E: adlc-cli workspace setup — real execution (not dry-run). +# +# Covers: +# 1. Deterministic pass: git module clones (branch + ref pin), skills install +# from a local source (incl. .events.json wiring), command routing. +# 2. Idempotent re-run: existing paths skipped, exit 0. +# 3. Agent-led goal: real `opencode run` via cmdAgentRun (requires local opencode auth). +# 4. Agent-led workspace init: installed /workspace skill executes its bash script. +# +# Usage: tests/e2e-workspace.sh [--skip-agent] (--skip-agent skips parts 3+4) +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +BIN="$HERE/../bin/adlc-cli.mjs" +ADLC_REPO="$(cd "$HERE/.." && pwd)" +SKILL_SRC="${SKILL_SRC:-$(cd "$ADLC_REPO/../adlc-team-skills/skills/team/workspace" 2>/dev/null && pwd)}" +SKIP_AGENT="${1:-}" + +if [ -z "$SKILL_SRC" ] || [ ! -f "$SKILL_SRC/SKILL.md" ]; then + echo "FATAL: workspace skill source not found (set SKILL_SRC env)"; exit 1 +fi + +ROOT="$(mktemp -d /tmp/adlc-e2e-ws.XXXXXX)" +FIX="$ROOT/fixtures"; WS="$ROOT/ws" +mkdir -p "$FIX" "$WS/.adlc" + +pass() { echo " PASS: $1"; } +fail() { echo " FAIL: $1"; echo "E2E FAILED (artifacts: $ROOT)"; exit 1; } +assert_exists() { [ -e "$1" ] && pass "exists $1" || fail "missing $1"; } + +# ── Fixtures ──────────────────────────────────────────────────────────── +echo "== Building fixtures ==" +# backend.git — bare repo, main + tag v1.0 (on a second commit), plus a dev branch off main +git init -q --bare "$FIX/backend.git" +git init -q -b main "$ROOT/backend-work" +( cd "$ROOT/backend-work" + git config user.email e2e@test && git config user.name e2e + echo one > file.txt && git add . && git commit -qm one + echo two > file.txt && git add . && git commit -qm two + git tag v1.0 + git branch -q dev + git push -q "$FIX/backend.git" main dev v1.0 ) +V1_SHA="$( git -C "$ROOT/backend-work" rev-parse v1.0 )" + +# frontend.git — bare repo whose default branch is dev +git init -q --bare -b dev "$FIX/frontend.git" +git init -q -b dev "$ROOT/frontend-work" +( cd "$ROOT/frontend-work" + git config user.email e2e@test && git config user.name e2e + echo front > f.txt && git add . && git commit -qm front + git push -q "$FIX/frontend.git" dev ) + +# skills-src — mirrors adlc-team-skills shape: skills/team// + .events.json at root +mkdir -p "$FIX/skills-src/skills/team" +cp -r "$SKILL_SRC" "$FIX/skills-src/skills/team/workspace" +cat > "$FIX/skills-src/.events.json" <<'EOF' +{ + "events": { + "session_start": [ + { "skill": "workspace", "description": "E2E wiring check", "timeout": 60 } + ] + } +} +EOF + +# workspace repo (mounted-workspace pattern) +( cd "$WS" && git init -q -b main && git config user.email e2e@test && git config user.name e2e ) + +cat > "$WS/.adlc/workspace-profile.yml" < + exec(process.execPath, [BIN, ...args], { cwd }).catch((e) => e); + +const PROFILE = `schema_version: "1.0" +name: "Test Workspace" +version: "1.0.0" +agent: opencode + +workspace: + git: + - repo: https://github.com/org/backend + path: backend + branch: main + dirs: + - adlc-team-skills + link: true + init: true + +skills: + sources: + - tikalk/adlc-team-skills + +commands: + - skills add tikalk/adlc-team-skills + - team setup tikalk/adlc-team-skills + - agent run "Fetch and follow instructions from https://example.com/INSTALL.md" +`; + +function makeTempWorkspace(profile = PROFILE) { + const dir = mkdtempSync(join(tmpdir(), "adlc-ws-test-")); + mkdirSync(join(dir, ".adlc"), { recursive: true }); + writeFileSync(join(dir, ".adlc", "workspace-profile.yml"), profile, "utf-8"); + return dir; +} + +test("setup --dry-run prints the full plan without executing", async () => { + const dir = makeTempWorkspace(); + try { + const r = await run(["workspace", "setup", "--dry-run"], dir); + assert.equal(r.code ?? 0, 0, r.stderr); + assert.match(r.stdout, /Test Workspace/); + assert.match(r.stdout, /git clone https:\/\/github\.com\/org\/backend backend --branch main/); + assert.match(r.stdout, /mkdir -p adlc-team-skills/); + assert.match(r.stdout, /workspace init \+ link \(agent-led\)/); + assert.match(r.stdout, /Run \/workspace --init --link/); + assert.match(r.stdout, /skills add tikalk\/adlc-team-skills/); + assert.match(r.stdout, /agent run "Fetch and follow instructions from https:\/\/example\.com\/INSTALL\.md"/); + assert.match(r.stdout, /Next: adlc-cli agent run/); + // goal is no longer a profile concept — no goal step in the plan + assert.doesNotMatch(r.stdout, /goal \(agent-led\)/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("setup errors when no profile exists anywhere", async () => { + const dir = mkdtempSync(join(tmpdir(), "adlc-ws-test-")); + try { + const r = await run(["workspace", "setup"], dir); + assert.notEqual(r.code, 0); + assert.match(String(r.message ?? r.stderr), /no workspace profile found/i); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("setup errors on profile missing schema_version", async () => { + const dir = makeTempWorkspace("name: broken\nagent: opencode\n"); + try { + const r = await run(["workspace", "setup"], dir); + assert.notEqual(r.code, 0); + assert.match(String(r.message ?? r.stderr), /schema_version/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("setup skips clone when target path already exists (idempotent)", async () => { + const dir = makeTempWorkspace(); + mkdirSync(join(dir, "backend")); // pre-existing repo dir + try { + const r = await run(["workspace", "setup", "--dry-run"], dir); + assert.equal(r.code ?? 0, 0, r.stderr); + assert.match(r.stdout, /= backend \(exists, skipping clone\)/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("setup creates workspace.dirs (real run, no agent steps needed)", async () => { + // Profile with only dirs — no init/link/skills/commands → no agent spawn, pure mkdir + const dir = makeTempWorkspace(`schema_version: "1.0" +name: "Dirs Only" +agent: opencode +workspace: + dirs: + - adlc-team-skills + - scratch/nested +`); + try { + const r = await run(["workspace", "setup"], dir); + assert.equal(r.code ?? 0, 0, r.stderr); + assert.ok(existsSync(join(dir, "adlc-team-skills")), "adlc-team-skills dir created"); + assert.ok(existsSync(join(dir, "scratch", "nested")), "nested dir created"); + assert.match(r.stdout, /Workspace setup complete/); + assert.match(r.stdout, /Next: adlc-cli agent run/); + + // Idempotent re-run skips existing dirs + const r2 = await run(["workspace", "setup"], dir); + assert.equal(r2.code ?? 0, 0, r2.stderr); + assert.match(r2.stdout, /= adlc-team-skills \(exists, skipping\)/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("setup rejects path-traversal workspace.dirs entries", async () => { + const dir = makeTempWorkspace(`schema_version: "1.0" +name: "Bad Dirs" +agent: opencode +workspace: + dirs: + - ../escape +`); + try { + const r = await run(["workspace", "setup"], dir); + assert.notEqual(r.code, 0); + assert.match(String(r.message ?? r.stderr), /invalid workspace\.dirs entry/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("init --dry-run prints the agent-run invocation", async () => { + const r = await run(["workspace", "init", "-a", "opencode", "--link", "--dry-run"]); + assert.equal(r.code ?? 0, 0); + assert.match(r.stdout, /agent run -a opencode "Run \/workspace --init --link"/); +}); + +test("status --dry-run prints the agent-run invocation", async () => { + const r = await run(["workspace", "status", "-a", "opencode", "--dry-run"]); + assert.equal(r.code ?? 0, 0); + assert.match(r.stdout, /agent run -a opencode "Run \/workspace --status"/); +}); + +test("unknown workspace subcommand exits 1 with usage", async () => { + const r = await run(["workspace", "bogus"]); + assert.notEqual(r.code, 0); + assert.match(String(r.message ?? r.stdout), /Unknown workspace command/); +}); + +test("top-level `run` is a compat alias for agent run (runtime contract)", async () => { + const r = await run(["run"]); // no task → agent-run arg error, not help + assert.notEqual(r.code, 0); + assert.match(String(r.message ?? r.stderr), /task is required/); +}); + +test("workspace help lists setup, init, status", async () => { + const r = await run(["workspace", "help"]); + assert.match(r.stdout, /setup \[profile\]/); + assert.match(r.stdout, /init/); + assert.match(r.stdout, /status/); +}); diff --git a/tests/yaml.test.mjs b/tests/yaml.test.mjs new file mode 100644 index 0000000..c61dfbc --- /dev/null +++ b/tests/yaml.test.mjs @@ -0,0 +1,100 @@ +// YAML parser contract for workspace profiles. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseYaml } from "../src/utils/yaml.mjs"; + +const PROFILE = ` +schema_version: "1.0" +name: "Tikal Default Workspace" +version: "1.0.0" +agent: opencode + +workspace: + git: + - repo: https://github.com/org/backend + path: backend + branch: main + - repo: git@github.com:tikalk/adlc-team-skills.git + path: adlc-team-skills + ref: v2.3.0 + link: true + init: true + +skills: + sources: + - tikalk/adlc-team-skills + +goal: "Run /team-setup to configure team-ai-directives for this project" + +commands: + - skills add tikalk/adlc-team-skills + - team setup tikalk/adlc-team-skills + - agent run "Fetch and follow instructions from https://example.com/INSTALL.md" +`; + +test("parses a full workspace profile", () => { + const p = parseYaml(PROFILE); + assert.equal(p.schema_version, "1.0"); + assert.equal(p.name, "Tikal Default Workspace"); + assert.equal(p.agent, "opencode"); + assert.equal(p.workspace.link, true); + assert.equal(p.workspace.init, true); + assert.equal(p.workspace.git.length, 2); + assert.equal(p.workspace.git[0].repo, "https://github.com/org/backend"); + assert.equal(p.workspace.git[0].path, "backend"); + assert.equal(p.workspace.git[0].branch, "main"); + assert.equal(p.workspace.git[1].repo, "git@github.com:tikalk/adlc-team-skills.git"); + assert.equal(p.workspace.git[1].ref, "v2.3.0"); + assert.deepEqual(p.skills.sources, ["tikalk/adlc-team-skills"]); + assert.equal(p.goal, "Run /team-setup to configure team-ai-directives for this project"); + assert.equal(p.commands.length, 3); + assert.equal(p.commands[0], "skills add tikalk/adlc-team-skills"); + assert.equal( + p.commands[2], + 'agent run "Fetch and follow instructions from https://example.com/INSTALL.md"', + ); +}); + +test("parses scalars: booleans, numbers, null, quotes", () => { + const p = parseYaml("a: true\nb: false\nc: 42\nd: 3.14\ne: null\nf: 'quoted'\ng: \"dq\""); + assert.equal(p.a, true); + assert.equal(p.b, false); + assert.equal(p.c, 42); + assert.equal(p.d, 3.14); + assert.equal(p.e, null); + assert.equal(p.f, "quoted"); + assert.equal(p.g, "dq"); +}); + +test("parses values containing colons (URLs, SSH remotes)", () => { + const p = parseYaml("repo: https://github.com/org/repo.git\nremote: git@github.com:org/repo.git"); + assert.equal(p.repo, "https://github.com/org/repo.git"); + assert.equal(p.remote, "git@github.com:org/repo.git"); +}); + +test("parses list items without continuation keys as scalars", () => { + const p = parseYaml("commands:\n - skills add tikalk/adlc-team-skills\n - team setup tikalk/adlc-team-skills"); + assert.deepEqual(p.commands, ["skills add tikalk/adlc-team-skills", "team setup tikalk/adlc-team-skills"]); +}); + +test("parses nested empty key as null", () => { + const p = parseYaml("top:\n missing:\npresent: 1"); + assert.equal(p.top.missing, null); + assert.equal(p.present, 1); +}); + +test("handles comments and blank lines", () => { + const p = parseYaml("# comment\n\nkey: value # trailing\n"); + assert.equal(p.key, "value"); +}); + +test("returns null for empty document", () => { + assert.equal(parseYaml(""), null); + assert.equal(parseYaml("# only comments\n"), null); +}); + +test("parses folded block scalars", () => { + const p = parseYaml("goal: >\n Run the setup\n for this project\nnext: 1"); + assert.equal(p.goal, "Run the setup for this project"); + assert.equal(p.next, 1); +});