From b27d1e57cdfca48a441ac178934a414b5207b199 Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa Date: Thu, 2 Jul 2026 13:29:38 +0200 Subject: [PATCH 1/5] feat(unic-archon-dlc): rebuild /setup as conversational skill + thin config lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign step 03. `/setup` becomes a conversational command that detects the stack, runs verify-only skill discovery (MCP/skills introspection + CLI probes, never installs), registers a capability→tool map, verifies Matt Pocock's declared skill suite (warn + degrade, non-blocking), and writes the rich `.archon/unic-dlc.config.yaml`. Supersedes ADR-0001 (ADR-0019). Why: under the two-axis architecture (ADR-0016/0018) setup is tracker/tenant/OS wiring — the archetypal "how" — so it belongs in config + composition, not a bespoke lib. Only the genuinely deterministic concern (schema-validate + idempotent merge + YAML emit) stays as tested code. - New lib/config-schema.mjs (imports `yaml`): loadConfig (yaml/json), validateConfig, deep idempotent mergeConfig, migrateLegacy (flat JSON → rich shape, preserving hand-added labels like `release`), toYaml, detectRepoLayout. Covered by test/config-schema.test.mjs. - archon-check.mjs: behavioural `>= 0.5.0` min-floor (MIN_ARCHON_VERSION) replacing exact-version match; unparseable is non-blocking; override kept. - Dissolve install-runner, setup-explorer, config-loader, agent-docs-writer (+ their tests); docs/agents + CLAUDE.md marker-block re-homed to command prose. See ADR-0018. - Add `yaml` dep (pinned via pnpm catalog). README config reference rewritten to the rich YAML schema. Dogfood `.archon/unic-dlc.config.yaml` generated by running the built lib (legacy `.json` left in place for other tools). Co-Authored-By: Claude Opus 4.8 --- .archon/unic-dlc.config.yaml | 70 +++++ apps/claude-code/unic-archon-dlc/CHANGELOG.md | 10 +- apps/claude-code/unic-archon-dlc/README.md | 43 +-- .../unic-archon-dlc/commands/setup.md | 217 +++++++------- .../unic-archon-dlc/lib/agent-docs-writer.mjs | 267 ------------------ .../unic-archon-dlc/lib/archon-check.mjs | 64 ++++- .../unic-archon-dlc/lib/config-loader.mjs | 95 ------- .../unic-archon-dlc/lib/config-schema.mjs | 239 ++++++++++++++++ .../unic-archon-dlc/lib/install-runner.mjs | 182 ------------ .../unic-archon-dlc/lib/setup-explorer.mjs | 99 ------- apps/claude-code/unic-archon-dlc/package.json | 5 +- .../test/archon-check.test.mjs | 36 ++- .../test/config-loader.test.mjs | 57 ---- .../test/config-schema.test.mjs | 168 +++++++++++ .../test/install-agent-docs.test.mjs | 107 ------- .../test/install-claude-md.test.mjs | 117 -------- .../test/install-helpers.test.mjs | 71 ----- .../test/install-runner.test.mjs | 267 ------------------ .../test/setup-explorer.test.mjs | 36 --- pnpm-lock.yaml | 14 + pnpm-workspace.yaml | 1 + 21 files changed, 738 insertions(+), 1427 deletions(-) create mode 100644 .archon/unic-dlc.config.yaml delete mode 100644 apps/claude-code/unic-archon-dlc/lib/agent-docs-writer.mjs delete mode 100644 apps/claude-code/unic-archon-dlc/lib/config-loader.mjs create mode 100644 apps/claude-code/unic-archon-dlc/lib/config-schema.mjs delete mode 100644 apps/claude-code/unic-archon-dlc/lib/install-runner.mjs delete mode 100644 apps/claude-code/unic-archon-dlc/lib/setup-explorer.mjs delete mode 100644 apps/claude-code/unic-archon-dlc/test/config-loader.test.mjs create mode 100644 apps/claude-code/unic-archon-dlc/test/config-schema.test.mjs delete mode 100644 apps/claude-code/unic-archon-dlc/test/install-agent-docs.test.mjs delete mode 100644 apps/claude-code/unic-archon-dlc/test/install-claude-md.test.mjs delete mode 100644 apps/claude-code/unic-archon-dlc/test/install-helpers.test.mjs delete mode 100644 apps/claude-code/unic-archon-dlc/test/install-runner.test.mjs delete mode 100644 apps/claude-code/unic-archon-dlc/test/setup-explorer.test.mjs diff --git a/.archon/unic-dlc.config.yaml b/.archon/unic-dlc.config.yaml new file mode 100644 index 00000000..8425d8d0 --- /dev/null +++ b/.archon/unic-dlc.config.yaml @@ -0,0 +1,70 @@ +project: + name: unic-agents-plugins + repo_layout: multi-context + branching: gitflow + pr_strategy: merge +tracker: + type: github + access: + mcp: null + cli: gh + coords: + owner: unic + repo: unic-agents-plugins +docs: + type: markdown + publish: false + access: + mcp: null + cli: null +design: + type: none + access: + mcp: null +repos: [] +templates: + prd: null + issue: null + bug: null +classification: + labels: + state: + needs-triage: needs-triage + needs-info: needs-info + needs-specs: needs-specs + ready-for-agent: ready-for-agent + ready-for-human: ready-for-human + resolved: resolved + closed: closed + rejected: rejected + type: + feature: feature + bug: bug + spike: spike + tech-debt: tech-debt + docs: docs + release: release + priority: + p0: p0 + p1: p1 + p2: p2 + p3: p3 +gates: + build: hitl + qa: hitl + pr-review: hitl + explore: hitl +build: + fresh_context_red_green: true + tdd_mode: true + nyquist_validation: true + slopsquatting_gate: true + e2e_command: null + coverage_threshold: null +estimations: off +artifacts_dir: workflows +model_profile: balanced +skills: + matt_suite: + present: true + missing: [] diff --git a/apps/claude-code/unic-archon-dlc/CHANGELOG.md b/apps/claude-code/unic-archon-dlc/CHANGELOG.md index 3e626531..4c76ac17 100644 --- a/apps/claude-code/unic-archon-dlc/CHANGELOG.md +++ b/apps/claude-code/unic-archon-dlc/CHANGELOG.md @@ -3,10 +3,16 @@ ## [Unreleased] ### Breaking -- (none) +- **`/setup` is now conversational and writes the rich `.archon/unic-dlc.config.yaml`** (ADR-0019, supersedes ADR-0001), replacing the flat `.archon/unic-dlc.config.json`. The command detects the stack, runs verify-only skill discovery (introspect MCP/skills + CLI probes; never installs) to register a capability→tool map, verifies Matt Pocock's declared skill suite (warn + degrade, non-blocking on a missing required capability), and composes the team's system-skills for the _how_. An existing legacy `.json` is read and migrated but **left in place** (other tools may read it) — no backup file, no delete. +- **Dissolved the heavy setup libs** `lib/install-runner.mjs`, `lib/setup-explorer.mjs`, `lib/config-loader.mjs`, and `lib/agent-docs-writer.mjs` (and their tests). Their `docs/agents/` + `CLAUDE.md` marker-block behaviour is re-homed to idempotent prose steps in `commands/setup.md`. See ADR-0018. ### Added -- (none) +- **`lib/config-schema.mjs`** — the one surviving tested lib (imports `yaml`): `loadConfig` (parses `.yaml`/`.json`), `validateConfig` (mandatory-path invariant), `mergeConfig` (deep, idempotent, `defaults < existing < answers`), `migrateLegacy` (flat ADR-0001 JSON → rich nested shape, preserving hand-added labels such as `release`), `toYaml`, and `detectRepoLayout`. Covered by `test/config-schema.test.mjs`. +- **`yaml`** runtime dependency (pinned via the pnpm catalog). + +### Changed +- **`lib/archon-check.mjs`** now enforces a behavioural min-floor (`checkArchon` rejects Archon `< 0.5.0` via `MIN_ARCHON_VERSION`) instead of an exact-version match — the key-discriminated schema (gates/loops/fresh-context) requires `≥ 0.5.0` (ADR-0011/0019). Unparseable versions are non-blocking. The `incompatibleVersions` override is preserved (bare-array and options-object forms both accepted). +- **`README.md`** configuration reference rewritten to the rich YAML schema. ### Fixed - (none) diff --git a/apps/claude-code/unic-archon-dlc/README.md b/apps/claude-code/unic-archon-dlc/README.md index 9dcfd3ac..1cddc11a 100644 --- a/apps/claude-code/unic-archon-dlc/README.md +++ b/apps/claude-code/unic-archon-dlc/README.md @@ -161,24 +161,31 @@ From here, the full lifecycle is: explore → plan → build → qa → cleanup ## Configuration reference -The `/unic-archon-dlc:setup` command writes `.archon/unic-dlc.config.json` with these keys: - -| Key | Default | Valid values | Description | -| ----------------------- | -------------------------------- | -------------------------------------------- | --------------------------------------------------- | -| `tracker` | auto-detected | `github` · `ado` · `jira` · `local-markdown` | Issue tracker backend | -| `pr_strategy` | `squash` | `squash` · `merge` · `rebase` | Merge strategy for PRs | -| `branching` | `gitflow` | `gitflow` · `github-flow` | Branching model in use | -| `e2e_command` | `""` | any shell command string | Command that runs the full e2e test suite | -| `model_profile` | `balanced` | `fast` · `balanced` · `max` | Archon model tier for workflow nodes | -| `tdd_mode` | `true` | `true` · `false` | Enforce red→green discipline in build workflow | -| `nyquist_validation` | `true` | `true` · `false` | Require test_command on every issue before yaml-gen | -| `slopsquatting_gate` | `true` | `true` · `false` | Enable slopcheck package verification | -| `coverage_threshold` | `null` | number (0–100) or `null` | Minimum % coverage; `null` skips the check | -| `workflow.discuss_mode` | `interview` | `interview` · `assumptions` | Specs node dialogue style | -| `repo_layout` | `single-context (auto-detected)` | `single-context` · `multi-context` | Whether CONTEXT-MAP.md is present | -| `labels.state.*` | canonical | any string | Override tracker state label strings | -| `labels.type.*` | canonical | any string | Override tracker type label strings | -| `labels.priority.*` | canonical | any string | Override tracker priority label strings | +The `/unic-archon-dlc:setup` command writes the rich `.archon/unic-dlc.config.yaml` ([ADR-0018](docs/adr/0018-generic-core-config-compose.md), [ADR-0019](docs/adr/0019-conversational-setup.md)). It is the single config substrate every box reads; setup is its sole writer, is idempotent (a re-run merges, never clobbers), and reads any legacy `.archon/unic-dlc.config.json` to migrate it (the old file is left in place). Top-level sections: + +| Path | Default | Valid values | Description | +| ----------------------------- | ---------------------- | -------------------------------------------- | ----------------------------------------------------------------- | +| `project.name` | asked | any string | Project name | +| `project.repo_layout` | auto-detected | `single-context` · `multi-context` | Whether `CONTEXT-MAP.md` is present | +| `project.branching` | asked | `gitflow` · `github-flow` | Branching model (mandatory) | +| `project.pr_strategy` | asked | `squash` · `merge` · `rebase` | PR merge strategy (mandatory) | +| `tracker.type` | auto-detected | `github` · `ado` · `jira` · `local-markdown` | Issue tracker backend (mandatory) | +| `tracker.access` | discovered | `{ mcp, cli }` | Capability→tool for the tracker (MCP-first, CLI-fallback) | +| `tracker.coords` | asked | tracker-specific map | e.g. `{ owner, repo }` (github) / `{ org, project, repo }` (ado) | +| `docs.type` | `markdown` | `confluence` · `markdown` · `none` | Where the team's product specs live (drives `/specs` publishing) | +| `docs.publish` | `false` | `true` · `false` | Opt-in publishing of the PRD to the docs system | +| `design.type` | `none` | `figma` · `none` | Design system source | +| `templates.{prd,issue,bug}` | `null` | template string | Config-driven artifact templates (ADR-0018) | +| `classification.labels.*` | canonical | any string | 3-tier label mapping (state · type · priority) | +| `gates.{build,qa,pr-review,explore}` | `hitl` | `hitl` · `afk` | Per-Archon-box gate mode (ADR-0017); interactive boxes are HITL | +| `build.fresh_context_red_green` | `true` | `true` · `false` | Anti-cheat fresh-context red/green separation (ADR-0012) | +| `build.{tdd_mode,nyquist_validation,slopsquatting_gate}` | `true` | `true` · `false` | Build discipline toggles | +| `build.e2e_command` | `null` | shell command string | Full e2e suite command | +| `build.coverage_threshold` | `null` | number (0–100) or `null` | Minimum % coverage; `null` skips the check | +| `estimations` | `off` | `off` · `provisional` · `definitive` · `both`| Estimation waves (ADR-0020) | +| `artifacts_dir` | `workflows` | dir name | Session artifact home base (`//`) | +| `model_profile` | `balanced` | `fast` · `balanced` · `max` | Model tier for workflow nodes | +| `skills.matt_suite` | discovered | `{ present, missing }` | Verify-only discovery result for Matt Pocock's declared skill suite | Label canonical names: states `needs-triage` · `needs-info` · `needs-specs` · `ready-for-agent` · `ready-for-human` · `resolved` · `closed` · `rejected`; types `feature` · `bug` · `spike` · diff --git a/apps/claude-code/unic-archon-dlc/commands/setup.md b/apps/claude-code/unic-archon-dlc/commands/setup.md index 2be526fa..7cb66be6 100644 --- a/apps/claude-code/unic-archon-dlc/commands/setup.md +++ b/apps/claude-code/unic-archon-dlc/commands/setup.md @@ -1,20 +1,22 @@ --- allowed-tools: ['Bash'] argument-hint: '[reconfigure | ]' -description: 'Configure unic-archon-dlc for this project: tracker, branching strategy, and optional e2e command' +description: 'Configure unic-archon-dlc for this project: detect the stack, register the team system-skills, and write .archon/unic-dlc.config.yaml' --- # unic-archon-dlc:setup -> Design rationale: [ADR-0001 — Setup is a slash command delegating to lib/install-runner.mjs](docs/adr/0001-setup-as-slash-command.md) +> Design rationale: [ADR-0019 — Conversational `/setup` + one thin tested schema lib](docs/adr/0019-conversational-setup.md) (supersedes ADR-0001). **Arguments:** "$ARGUMENTS" -Follow these steps in order. Do not skip any step. Do not write any files except through Step 5. +`/setup` is the **sole configuration entry point** and is **conversational**: it detects the stack, **composes the team's system-skills** to discover what the team has, and writes the rich `.archon/unic-dlc.config.yaml` that every other box reads. Only one deterministic concern is delegated to tested code — schema-validate + idempotent merge + YAML emit — in `lib/config-schema.mjs`. Conduct the conversation yourself; do not invent config keys the schema doesn't define. -> **Shell requirement**: Steps 1, 2, and 5 use `<<'EOJS'` heredoc syntax, which requires a POSIX-compatible shell. On Windows, run inside WSL2 or Git Bash; cmd.exe and PowerShell do not support heredocs. +Follow these steps in order. Do not skip any step. Do not write any files except through Step 5 (config) and Step 6 (docs). -## Step 1 — Archon preflight +> **Shell requirement**: Steps 1, 2, and 5 use `<<'EOJS'` heredoc syntax, which requires a POSIX-compatible shell. On Windows, run inside WSL2 or Git Bash; cmd.exe and PowerShell do not support heredocs. All filesystem work uses Node's `node:fs`/`node:path`, so paths are cross-platform. + +## Step 1 — Archon preflight (behavioural `≥ 0.5.0`) Run: @@ -32,133 +34,150 @@ process.stdout.write(JSON.stringify(result) + '\n') EOJS ``` -Parse the JSON output. If `ok` is `false`, print `message` verbatim and stop — do not proceed to Step 2. +Parse the JSON output. If `ok` is `false`, print `message` verbatim and stop — do not proceed. (The check enforces the key-discriminated schema floor: gates, loops, and `context: fresh` only run correctly on Archon `≥ 0.5.0`.) -## Step 2 — Discover config state +## Step 2 — Discover current config state -Run: +Run (reads the rich `.yaml` if present, else a legacy `.json` for migration; detects git remote + repo layout): ```bash node --input-type=module <<'EOJS' let output try { const { pathToFileURL } = await import('node:url') - const { exploreProject } = await import(pathToFileURL(`${process.env.CLAUDE_PLUGIN_ROOT}/lib/setup-explorer.mjs`).href) - const { readFileSync, existsSync } = await import('node:fs') + const mod = await import(pathToFileURL(`${process.env.CLAUDE_PLUGIN_ROOT}/lib/config-schema.mjs`).href) + const { existsSync } = await import('node:fs') + const { execFileSync } = await import('node:child_process') const { join } = await import('node:path') const cwd = process.cwd() - const snap = exploreProject(cwd) + + const yamlPath = join(cwd, '.archon', 'unic-dlc.config.yaml') + const jsonPath = join(cwd, '.archon', 'unic-dlc.config.json') let config = null - const configPath = join(cwd, '.archon', 'unic-dlc.config.json') - if (snap.archonConfigPresent && existsSync(configPath)) { - let rawConfig - try { - rawConfig = readFileSync(configPath, 'utf8') - } catch (readErr) { - output = { error: `Cannot read config file at ${configPath}: ${readErr?.message ?? String(readErr)}. Check file permissions or delete the file and re-run setup.`, gitRemote: snap.gitRemote, config: null } - } - if (!output) { - try { - config = JSON.parse(rawConfig) - } catch (parseErr) { - output = { error: `Config file at ${configPath} contains invalid JSON. Fix or delete the file and re-run setup. Parse error: ${parseErr?.message ?? String(parseErr)}`, gitRemote: snap.gitRemote, config: null } - } - } + let source = null + let legacy = false + if (existsSync(yamlPath)) { + const r = mod.loadConfig(yamlPath) + if ('error' in r) { output = { error: r.message }; } else { config = r.config; source = yamlPath } + } else if (existsSync(jsonPath)) { + const r = mod.loadConfig(jsonPath) + if ('error' in r) { output = { error: r.message }; } else { config = r.config; source = jsonPath; legacy = mod.isLegacyConfig(r.config) } } + if (!output) { - const agentDocsPath = join(cwd, 'docs', 'agents', 'issue-tracker.md') - const claudeMdPath = join(cwd, 'CLAUDE.md') - const docsPresent = existsSync(agentDocsPath) - let claudeMdPresent = false - if (existsSync(claudeMdPath)) { - claudeMdPresent = readFileSync(claudeMdPath, 'utf8').includes('') + let gitRemote = null + try { gitRemote = execFileSync('git', ['remote', 'get-url', 'origin'], { stdio: ['pipe','pipe','pipe'], timeout: 5000 }).toString().trim() } catch {} + const repoLayout = mod.detectRepoLayout(cwd) + // Normalise to the rich shape so validation reflects what /setup will actually write. + const normalised = legacy ? mod.mergeConfig(mod.migrateLegacy(config)) : (config ? mod.mergeConfig(config) : null) + const validation = normalised ? mod.validateConfig(normalised) : { error: true, missing: mod.MANDATORY_PATHS } + output = { + gitRemote, + repoLayout, + source, + legacy, + hasConfig: config != null, + current: normalised, + missing: 'error' in validation ? validation.missing : [], } - output = { gitRemote: snap.gitRemote, config, docsPresent, claudeMdPresent } } } catch (err) { - output = { error: `Plugin load error: ${err?.message ?? String(err)}`, gitRemote: null, config: null } + output = { error: `Plugin load error: ${err?.message ?? String(err)}` } } process.stdout.write(JSON.stringify(output) + '\n') EOJS ``` -Parse the output. If `error` is present, print `error` verbatim and stop. Otherwise set `CONFIG` from `config`, `GIT_REMOTE` from `gitRemote`, `DOCS_PRESENT` from `docsPresent`, `CLAUDE_MD_PRESENT` from `claudeMdPresent`. +Parse the output. If `error` is present, print it verbatim and stop. Otherwise set `GIT_REMOTE`, `REPO_LAYOUT`, `LEGACY` (true = a legacy flat `.json` will be migrated), `CURRENT` (the normalised config, or null), and `MISSING` (mandatory paths still unset). Determine `STATE`: -- `CONFIG` is null → `STATE = 'fresh'` -- `CONFIG` is missing any of `tracker`, `pr_strategy`, `branching` → `STATE = 'partial'` +- `CURRENT` is null → `STATE = 'fresh'` +- `MISSING` is non-empty → `STATE = 'partial'` - Otherwise → `STATE = 'full'` -## Step 3 — Parse arguments and determine mode - -Arguments: `$ARGUMENTS` - -- Empty or whitespace only → `MODE = 'default'` -- Trimmed lowercase equals `reconfigure` → `MODE = 'reconfigure'` -- Otherwise → `MODE = 'intent'`, `INTENT = $ARGUMENTS` - -## Step 4 — Act on STATE × MODE +## Step 3 — Verify-only skill discovery (never installs) -### Full + default: print summary and exit (or repair) +Build a **capability → tool** registry the downstream boxes read (`mcp | cli | skill`, **MCP-first**). Discovery is **verify-only**: introspect what is installed; never install anything. -If `STATE = 'full'` and `MODE = 'default'` and `DOCS_PRESENT` is `true` and `CLAUDE_MD_PRESENT` is `true`, print the current configuration and **stop** (do not call `runInstall`): +- **MCP servers**: note which relevant MCP servers are available in this session (tracker, docs, design — e.g. a GitHub/ADO/Jira MCP, a Confluence MCP, the Figma MCP). +- **CLI probes** (portable — no `jq`/`awk`/`sort`): `gh --version`, `az --version`, `jira version` (or `jira --help`), etc. Record which succeed. +- **Matt Pocock's skill suite** is a **declared dependency** ([ADR-0021](docs/adr/0021-earns-its-place-compose-verbatim.md)): verify the skills the DLC composes are present (`grill-with-docs`, `to-prd`, `to-issues`, `triage`, `improve-codebase-architecture`, `handoff`, `prototype`). Check the available skills list. -``` -unic-archon-dlc is already configured: - tracker: {CONFIG.tracker} - pr_strategy: {CONFIG.pr_strategy} - branching: {CONFIG.branching} - e2e_command: {CONFIG.e2e_command or '(none)'} - model_profile: {CONFIG.model_profile or 'balanced'} - -Run `/unic-archon-dlc:setup reconfigure` to update settings. -``` +For each capability pick the tool MCP-first, else CLI, else skill. A **missing _required_ capability → warn + degrade, non-blocking**: complete setup, record it unavailable in the config, and **list the boxes it blocks** (boxes re-probe at runtime and fail with a clear "install X"). Never abort setup for a missing capability. -If `STATE = 'full'` and `MODE = 'default'` but `DOCS_PRESENT` is `false` or `CLAUDE_MD_PRESENT` is `false`, print a repair notice and proceed to Step 5 with `partialAnswers = {}`: +## Step 4 — Parse arguments, then collect only the gaps conversationally -``` -Config is complete but setup did not finish — rerunning to repair docs/CLAUDE.md. -``` - -### Determine which fields to collect +Arguments: `$ARGUMENTS` -- `STATE = 'fresh'` → collect all three mandatory fields + optional e2e_command -- `STATE = 'partial'` and `MODE = 'default'` → collect only the fields missing from `CONFIG` (skip any already present) -- `STATE = 'partial'` and `MODE = 'intent'` → collect missing mandatory fields first (to ensure completeness), then interpret `INTENT` to determine if any already-present field should also be updated -- `MODE = 'reconfigure'` → collect all three mandatory fields + optional e2e_command -- `STATE = 'full'` and `MODE = 'intent'` → interpret `INTENT` to decide which field(s) to update; ask only about those fields +- Empty/whitespace → `MODE = 'default'` +- Trimmed lowercase equals `reconfigure` → `MODE = 'reconfigure'` +- Otherwise → `MODE = 'intent'`, `INTENT = $ARGUMENTS` -### Collect answers conversationally +If `STATE = 'full'` and `MODE = 'default'`, print the current configuration summary and **stop** (do not rewrite) — tell the user to run `/unic-archon-dlc:setup reconfigure` to change settings. Exception: if `LEGACY` is true, proceed to migrate even in this case (a rich `.yaml` does not yet exist). -For each field to collect, ask the user conversationally. Surface auto-detected hints: +Otherwise collect the fields to fill: -- `GIT_REMOTE` contains `github.com` → suggest `tracker = github` -- `GIT_REMOTE` contains `dev.azure.com` → suggest `tracker = ado` +- `STATE = 'fresh'` → collect all mandatory fields + the optional ones below. +- `STATE = 'partial'` (default) → collect only `MISSING` fields. +- `MODE = 'reconfigure'` → collect all fields. +- `MODE = 'intent'` → collect `MISSING` first, then interpret `INTENT` to decide which already-set field(s) to also update. -Mandatory fields (ask in this order): +Surface auto-detected hints while asking: `GIT_REMOTE` contains `github.com` → suggest `tracker.type = github`; `dev.azure.com`/`visualstudio.com` → `ado`. Always pass `REPO_LAYOUT` through (never ask). -- **tracker** — issue tracker backend: `github` | `ado` | `jira` | `local-markdown` -- **pr_strategy** — PR merge strategy: `merge` | `squash` | `rebase` -- **branching** — branching model: `gitflow` | `github-flow` +Fields (map answers onto the schema paths — see `docs/adr/0018-generic-core-config-compose.md`): -Optional field: +- **project** — `project.name`, `project.branching` (`gitflow | github-flow`), `project.pr_strategy` (`merge | squash | rebase`). _(mandatory: branching, pr_strategy)_ +- **tracker** — `tracker.type` (`github | ado | jira | local-markdown`) _(mandatory)_; `tracker.coords` (e.g. `{owner, repo}` for github, `{org, project, repo}` for ado); `tracker.access` filled from Step 3 (`{mcp, cli}`). +- **docs** — `docs.type` (`confluence | markdown | none`) — where the team's **product specs** live; `docs.publish` (default `false`, opt-in). `docs.access` from Step 3. _(Independent of the `docs/agents/*.md` files Step 6 always writes.)_ +- **design** — `design.type` (`figma | none`), `design.access` from Step 3. +- **gates** — per Archon box (`build`, `qa`, `pr-review`, `explore`): `hitl` (default) or `afk`. Interactive skill boxes are always HITL and are not listed here. +- **build** — `build.e2e_command` (optional), `build.coverage_threshold` (optional). Leave `build.fresh_context_red_green`, `tdd_mode`, `nyquist_validation`, `slopsquatting_gate` at their defaults unless the user asks. +- **estimations** — `off | provisional | definitive | both` (default `off`). +- **model_profile** — `fast | balanced | max` (default `balanced`). -- **e2e_command** — shell command to run e2e tests (e.g. `pnpm test:e2e`); user may leave empty +Build a single `ANSWERS` object containing **only** the fields you collected, keyed by the nested schema paths above, plus: -Build `partialAnswers` containing only the fields you collected. Fields with an empty e2e_command should be set to `null`. +- `project.repo_layout` = `REPO_LAYOUT`, +- the Step-3 discovery results under `tracker.access` / `docs.access` / `design.access` and a `skills` block (`{ matt_suite: { present, missing } }`). -## Step 5 — Write config +## Step 5 — Write the config -Substitute `{ANSWERS_JSON}` with the JSON-serialised `partialAnswers` object (the literal JSON text is placed directly inside the heredoc), then run: +Substitute `{ANSWERS_JSON}` with the JSON-serialised `ANSWERS` object (placed directly inside the heredoc — never via a shell variable), then run: ```bash node --input-type=module <<'EOJS' let result try { const { pathToFileURL } = await import('node:url') - const { runInstall } = await import(pathToFileURL(`${process.env.CLAUDE_PLUGIN_ROOT}/lib/install-runner.mjs`).href) - result = runInstall(process.cwd(), {ANSWERS_JSON}) + const mod = await import(pathToFileURL(`${process.env.CLAUDE_PLUGIN_ROOT}/lib/config-schema.mjs`).href) + const { existsSync, mkdirSync, writeFileSync } = await import('node:fs') + const { join } = await import('node:path') + const cwd = process.cwd() + const answers = {ANSWERS_JSON} + + const yamlPath = join(cwd, '.archon', 'unic-dlc.config.yaml') + const jsonPath = join(cwd, '.archon', 'unic-dlc.config.json') + + // Load whatever exists; migrate a legacy flat .json but NEVER delete or modify it. + let existing = {} + if (existsSync(yamlPath)) { + const r = mod.loadConfig(yamlPath) + if (!('error' in r)) existing = r.config + } else if (existsSync(jsonPath)) { + const r = mod.loadConfig(jsonPath) + if (!('error' in r)) existing = mod.isLegacyConfig(r.config) ? mod.migrateLegacy(r.config) : r.config + } + + const merged = mod.mergeConfig(existing, answers) + const emitted = mod.toYaml(merged) + if ('error' in emitted) { + result = { ok: false, stage: 'validate', message: emitted.message } + } else { + mkdirSync(join(cwd, '.archon'), { recursive: true }) + writeFileSync(yamlPath, emitted.yaml) + result = { ok: true, configPath: yamlPath, legacyKept: existsSync(jsonPath) ? jsonPath : null } + } } catch (err) { result = { ok: false, stage: 'unexpected', message: `Unexpected error: ${err?.message ?? String(err)}` } } @@ -166,17 +185,25 @@ process.stdout.write(JSON.stringify(result) + '\n') EOJS ``` -Parse the JSON output: +Parse the JSON output. If `ok` is `false`, print `message` and stop. If `ok` is `true`, note `configPath` and (if present) `legacyKept` — the legacy `.json` is **left in place** because other tools may still read it. + +## Step 6 — Update agent docs (idempotent) + +Write/refresh the auto-managed `## Agent skills` block in the consumer's `CLAUDE.md`, delimited by `` / ``. Replace only the content **between** the markers (preserve everything outside verbatim); if the file or block is absent, create it. The block points readers at the box set (`/specs → /tickets → /build → /pr-review → /qa`; on-ramps `/triage`, `/qa`; off-line `/setup`, `/explore`, `/improve-architecture`, `/cleanup`) and at `.archon/unic-dlc.config.yaml` as the config source of truth. This runs regardless of `docs.type`. + +Keep the edit idempotent: re-running `/setup` replaces the block in place, never appends a second one. -- If `ok` is `true`, print: +## Step 7 — Summary - ``` - unic-archon-dlc configured. - config: {result.configPath} - workflows: {result.workflowsCopied} workflow(s) copied - commands: {result.commandsCopied} command(s) copied - docs: written - CLAUDE.md: updated - ``` +Print a concise summary: + +``` +unic-archon-dlc configured. + config: {configPath} + tracker: {tracker.type} (access: {mcp|cli}) + docs: {docs.type} (publish: {docs.publish}) + gates: build={…} qa={…} pr-review={…} explore={…} + skills: Matt suite {present|MISSING: …}; blocked boxes: {…|none} +``` -- If `ok` is `false`, print `result.message` and stop. +Then note: **re-run `/unic-archon-dlc:setup` after updating the plugin** to pick up new config keys (the merge is idempotent — your existing values are preserved). diff --git a/apps/claude-code/unic-archon-dlc/lib/agent-docs-writer.mjs b/apps/claude-code/unic-archon-dlc/lib/agent-docs-writer.mjs deleted file mode 100644 index 55443356..00000000 --- a/apps/claude-code/unic-archon-dlc/lib/agent-docs-writer.mjs +++ /dev/null @@ -1,267 +0,0 @@ -// @ts-check -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join, relative } from 'node:path' -import { AGENT_DOC_BANNER, prependBanner, SKILLS_BLOCK_BANNER } from './dogfood-banner.mjs' - -const AGENT_SKILLS_BEGIN = '' -const AGENT_SKILLS_END = '' - -const AGENT_SKILLS_LINKS = `- [issue-tracker.md](docs/agents/issue-tracker.md) — issue tracker backend, CLI, create/update conventions -- [labels.md](docs/agents/labels.md) — three-tier label taxonomy: state, type, priority -- [branching.md](docs/agents/branching.md) — branching strategy, branch names, PR targets -- [domain.md](docs/agents/domain.md) — single-context vs multi-context, CONTEXT.md and ADR locations -- [workflow.md](docs/agents/workflow.md) — seven workflow phases, artifact outputs, docs/workflow/ paths` - -/** - * @typedef {import('./labels-config.mjs').LabelMapping} LabelMapping - * @typedef {import('./tracker-adapter.mjs').TrackerBackend} TrackerBackend - * @typedef {import('./config-loader.mjs').PrStrategy} PrStrategy - * @typedef {import('./config-loader.mjs').BranchingStrategy} BranchingStrategy - */ - -/** - * @typedef {Object} AgentDocsConfig - * @property {TrackerBackend} tracker - * @property {PrStrategy} pr_strategy - * @property {BranchingStrategy} branching - * @property {string} [repo_layout] - * @property {LabelMapping} labels - */ - -/** - * Write all five docs/agents/*.md files. - * These files are fully auto-generated; the function overwrites them on each run. - * @param {string} projectDir - * @param {AgentDocsConfig} config - */ -export function writeAgentDocs(projectDir, config) { - const dir = join(projectDir, 'docs', 'agents') - mkdirSync(dir, { recursive: true }) - - writeFileSync(join(dir, 'issue-tracker.md'), prependBanner(AGENT_DOC_BANNER, buildIssueTrackerDoc(config))) - writeFileSync(join(dir, 'labels.md'), prependBanner(AGENT_DOC_BANNER, buildLabelsDoc(config))) - writeFileSync(join(dir, 'branching.md'), prependBanner(AGENT_DOC_BANNER, buildBranchingDoc(config))) - writeFileSync(join(dir, 'domain.md'), prependBanner(AGENT_DOC_BANNER, buildDomainDoc(config, projectDir))) - writeFileSync(join(dir, 'workflow.md'), prependBanner(AGENT_DOC_BANNER, buildWorkflowDoc())) -} - -/** - * Append or refresh the ## Agent skills block in CLAUDE.md using marker-delimited regions. - * Does not destroy any content outside the marked block. - * Creates CLAUDE.md with only the skills block if the file does not yet exist. - * @param {string} projectDir - */ -export function updateAgentSkillsBlock(projectDir) { - const claudePath = join(projectDir, 'CLAUDE.md') - - const innerBlock = `${SKILLS_BLOCK_BANNER}\n\n${AGENT_SKILLS_LINKS}` - const block = `## Agent skills\n\n${AGENT_SKILLS_BEGIN}\n${innerBlock}\n${AGENT_SKILLS_END}` - - let content - try { - content = readFileSync(claudePath, 'utf8') - } catch (err) { - // File absent — create it from scratch. - if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') { - writeFileSync(claudePath, `${block}\n`) - return - } - throw err - } - const beginIdx = content.indexOf(AGENT_SKILLS_BEGIN) - const endIdx = content.indexOf(AGENT_SKILLS_END) - - if (beginIdx !== -1 && endIdx !== -1) { - // Replace only the content between markers (inclusive) - content = `${content.slice(0, beginIdx) + AGENT_SKILLS_BEGIN}\n${innerBlock}\n${content.slice(endIdx)}` - writeFileSync(claudePath, content) - return - } - - // No markers yet — append the full block - const separator = content.endsWith('\n') ? '\n' : '\n\n' - writeFileSync(claudePath, `${content + separator + block}\n`) -} - -// --- template builders --- - -/** @param {AgentDocsConfig} c */ -function buildIssueTrackerDoc(c) { - const cliMap = { - github: { - create: 'gh issue create --title "" --label "<label>"', - update: 'gh issue edit <number> --add-label "<label>"', - }, - ado: { - create: 'az boards work-item create --title "<title>" --type Bug', - update: 'az boards work-item update --id <id> --fields "System.Tags=<label>"', - }, - jira: { - create: 'jira issue create --project <KEY> --summary "<title>"', - update: 'jira issue edit <KEY>-<number> --custom label:"<label>"', - }, - 'local-markdown': { - create: 'Create docs/issues/<slug>/index.md with Status: needs-triage', - update: 'Edit the Status: line in docs/issues/<slug>/index.md', - }, - } - const cli = - /** @type {Record<string,{create:string;update:string}>} */ (cliMap)[c.tracker] ?? cliMap['local-markdown'] - - return `# Issue Tracker: ${c.tracker} - -Configured by unic-archon-dlc. - -## Backend - -**Tracker:** \`${c.tracker}\` -**PR strategy:** \`${c.pr_strategy}\` - -## Create a new issue - -\`\`\`sh -${cli.create} -\`\`\` - -## Update issue state - -\`\`\`sh -${cli.update} -\`\`\` - -## Conventions - -- Issue state is tracked via labels matching the canonical triage vocabulary (see \`docs/agents/labels.md\`). -- Dependency links use the tracker's native "blocked by" field; for local-markdown, use a \`## Blocked by\` heading. -- The tracker adapter (\`lib/tracker-adapter.mjs\`) translates canonical label names to tracker strings at write time. -` -} - -/** @param {AgentDocsConfig} c */ -function buildLabelsDoc(c) { - const stateRows = Object.entries(c.labels.state) - .map(([k, v]) => `| ${k} | ${v} |`) - .join('\n') - const typeRows = Object.entries(c.labels.type) - .map(([k, v]) => `| ${k} | ${v} |`) - .join('\n') - const priorityRows = Object.entries(c.labels.priority) - .map(([k, v]) => `| ${k} | ${v} |`) - .join('\n') - - return `# Labels - -Three-tier taxonomy for \`${c.tracker}\`. Canonical names are used inside workflows; the tracker adapter maps them to tracker strings at write time. - -## State labels - -| Canonical | Tracker string | -|-----------|---------------| -${stateRows} - -## Type labels - -| Canonical | Tracker string | -|-----------|---------------| -${typeRows} - -## Priority labels - -| Canonical | Tracker string | -|-----------|---------------| -${priorityRows} -` -} - -/** @param {AgentDocsConfig} c */ -function buildBranchingDoc(c) { - const isGitflow = c.branching === 'gitflow' - // Both Gitflow and GitHub Flow use 'main' as the production branch by convention - const mainBranch = 'main' - const devBranch = isGitflow ? 'develop' : 'main' - const featurePrefix = 'feature/' - const prTarget = isGitflow ? 'develop' : 'main' - - return `# Branching Strategy - -Configured by unic-archon-dlc. - -## Strategy: ${isGitflow ? 'Gitflow' : 'GitHub Flow'} - -| Branch type | Pattern | PR target | -|-------------|---------|-----------| -| Production | \`${mainBranch}\` | — | -${isGitflow ? `| Integration | \`${devBranch}\` | — |\n` : ''}| Feature | \`${featurePrefix}<name>\` | \`${prTarget}\` | -${isGitflow ? '| Hotfix | `hotfix/<name>` | `main` + `develop` |' : ''} - -## Default branch names - -- **Main branch:** \`${mainBranch}\` -${isGitflow ? `- **Integration branch:** \`${devBranch}\`\n` : ''}- **Feature branch prefix:** \`${featurePrefix}\` - -## PR conventions - -All PRs target \`${prTarget}\`. Merge strategy: \`${c.pr_strategy}\`. -` -} - -/** - * @param {AgentDocsConfig} c - * @param {string} projectDir - */ -function buildDomainDoc(c, projectDir) { - const layout = c.repo_layout ?? 'single-context' - const isMulti = layout === 'multi-context' - - return `# Domain - -Configured by unic-archon-dlc. - -## Repository layout: ${layout} - -${ - isMulti - ? `This repository uses **multi-context** layout. Each package/app has its own \`CONTEXT.md\` file. A \`CONTEXT-MAP.md\` at the repo root maps each context to its location. - -- **Context map:** \`${relative(projectDir, join(projectDir, 'CONTEXT-MAP.md'))}\` -- **ADRs:** monorepo-wide decisions live in root \`docs/adr/\`; each context may also keep its own \`docs/adr/\` for decisions scoped to that context.` - : `This repository uses **single-context** layout. One \`CONTEXT.md\` file lives at the repo root. - -- **Domain context:** \`CONTEXT.md\` -- **ADRs:** \`docs/adr/\`` -} - -## How agents use this - -${ - isMulti - ? `Every agent working in this repo should read the relevant \`CONTEXT.md\` (located via \`CONTEXT-MAP.md\`) and the ADRs in root \`docs/adr/\` plus any context-scoped \`docs/adr/\` before proposing terminology changes or architectural decisions.` - : `Every agent working in this repo should read \`CONTEXT.md\` (and the ADRs in \`docs/adr/\`) before proposing terminology changes or architectural decisions.` -} -` -} - -function buildWorkflowDoc() { - return `# Workflow Phases - -unic-archon-dlc ships seven Archon workflow YAML DAGs. Six are lifecycle phases (explore → plan → build → qa → cleanup → triage) that produce persistent artifacts committed to \`docs/workflow/<slug>/\`. The \`review\` workflow is on-demand and posts (or updates) a structured review on the current PR via the configured tracker backend. - -| Phase | Command | Artifact outputs | -|-------|---------|-----------------| -| explore | \`/unic-dlc-explore <slug>\` | \`docs/workflow/<slug>/findings.md\` | -| plan | \`/unic-dlc-plan <slug>\` | \`docs/workflow/<slug>/PRD.md\`, \`issues.json\`, \`build-<slug>.yaml\` | -| build | \`/unic-dlc-build <slug>\` | \`docs/workflow/<slug>/report.md\` | -| qa | \`/unic-dlc-qa <slug>\` | merged PR | -| cleanup | \`/unic-dlc-cleanup <slug>\` | \`docs/workflow/<slug>/arch-review.md\` | -| triage | \`/unic-dlc-triage\` | \`HANDOFF.md\`, \`docs/workflow/ROADMAP.md\` | -| review | \`/unic-dlc-review\` | PR comment (github/ado/jira) or \`docs/workflow/<slug>/review-comment.md\` (local-markdown); idempotent re-runs | - -## State separation - -| Layer | Storage | Who owns it | -|-------|---------|-------------| -| Transient workflow state | \`$ARTIFACTS_DIR\` (Archon native) | Archon runtime | -| Persistent project artifacts | \`docs/workflow/<slug>/\` | Committed to repo | -| Issue / ticket tracking | Configured tracker | Tracker backend | -` -} diff --git a/apps/claude-code/unic-archon-dlc/lib/archon-check.mjs b/apps/claude-code/unic-archon-dlc/lib/archon-check.mjs index 86067329..de8963b3 100644 --- a/apps/claude-code/unic-archon-dlc/lib/archon-check.mjs +++ b/apps/claude-code/unic-archon-dlc/lib/archon-check.mjs @@ -2,9 +2,13 @@ import { execFileSync } from 'node:child_process' // Intentionally immutable; update this list in code as schema-incompatible Archon versions are identified. -// Tests and callers can pass overrides via checkArchon(..., incompatibleVersions). +// Tests and callers can pass overrides via checkArchon(..., { incompatibleVersions }). export const INCOMPATIBLE_ARCHON_VERSIONS = /** @type {readonly string[]} */ (Object.freeze([])) +// Behavioural min-floor (ADR-0011/0019): the key-discriminated schema — gates/loops/fresh-context — +// only runs correctly on Archon >= 0.5.0. This replaces the fictional exact-version assertion. +export const MIN_ARCHON_VERSION = '0.5.0' + /** * @typedef {{ ok: true, version: string }} ArchonOk * @typedef {{ ok: false, code: 'enoent' | 'incompatible' | 'other', message: string }} ArchonFail @@ -16,17 +20,55 @@ export const INCOMPATIBLE_ARCHON_VERSIONS = /** @type {readonly string[]} */ (Ob */ /** - * Checks whether archon is on PATH and returns a result object — never calls process.exit(). + * Parse a `major.minor.patch` triple out of an `archon --version` string (which may carry a + * program-name prefix, a `v` prefix, or a pre-release suffix). Returns null when no triple is found. + * @param {string} raw + * @returns {[number, number, number] | null} + */ +function parseVersion(raw) { + const match = raw.match(/(\d+)\.(\d+)\.(\d+)/) + if (!match) return null + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +/** + * Returns true when `version` is strictly below `floor`. Unparseable versions are treated as + * satisfying the floor (non-blocking — see ADR-0019 warn-and-degrade posture). + * @param {string} version + * @param {string} floor + * @returns {boolean} + */ +function isBelow(version, floor) { + const v = parseVersion(version) + const f = parseVersion(floor) + if (!v || !f) return false + for (let i = 0; i < 3; i++) { + if (v[i] < f[i]) return true + if (v[i] > f[i]) return false + } + return false +} + +/** + * @typedef {Object} CheckArchonOptions + * @property {readonly string[]} [incompatibleVersions] - exact version strings to reject outright + * @property {string} [minVersion] - behavioural min-floor (default {@link MIN_ARCHON_VERSION}) + */ + +/** + * Checks whether archon is on PATH and meets the behavioural min-floor — never calls process.exit(). * Pass a custom execFn in tests to avoid requiring archon on PATH. * * @param {ExecFn} [execFn] - * @param {readonly string[]} [incompatibleVersions] + * @param {CheckArchonOptions | readonly string[]} [options] - options object; a bare array is + * accepted as `incompatibleVersions` for backward compatibility. * @returns {ArchonCheckResult} */ -export function checkArchon( - execFn = /** @type {ExecFn} */ (/** @type {unknown} */ (execFileSync)), - incompatibleVersions = INCOMPATIBLE_ARCHON_VERSIONS -) { +export function checkArchon(execFn = /** @type {ExecFn} */ (/** @type {unknown} */ (execFileSync)), options = {}) { + const opts = /** @type {CheckArchonOptions} */ (Array.isArray(options) ? { incompatibleVersions: options } : options) + const incompatibleVersions = opts.incompatibleVersions ?? INCOMPATIBLE_ARCHON_VERSIONS + const minVersion = opts.minVersion ?? MIN_ARCHON_VERSION + try { const version = execFn('archon', ['--version'], { stdio: ['pipe', 'pipe', 'pipe'], @@ -43,6 +85,14 @@ export function checkArchon( } } + if (isBelow(version, minVersion)) { + return { + ok: false, + code: 'incompatible', + message: `Archon ${version} is below the minimum supported version ${minVersion}. The key-discriminated workflow schema (gates, loops, fresh-context) requires Archon >= ${minVersion}. Please upgrade Archon.`, + } + } + return { ok: true, version } } catch (err) { if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') { diff --git a/apps/claude-code/unic-archon-dlc/lib/config-loader.mjs b/apps/claude-code/unic-archon-dlc/lib/config-loader.mjs deleted file mode 100644 index 67df9c4c..00000000 --- a/apps/claude-code/unic-archon-dlc/lib/config-loader.mjs +++ /dev/null @@ -1,95 +0,0 @@ -// @ts-check -import { readFileSync } from 'node:fs' - -/** - * @typedef {import('./tracker-adapter.mjs').TrackerBackend} TrackerBackend - */ - -/** - * Supported PR merge strategies. - * @typedef {'merge' | 'squash' | 'rebase'} PrStrategy - */ - -/** - * Supported branching strategies. - * @typedef {'gitflow' | 'github-flow'} BranchingStrategy - */ - -/** @type {readonly string[]} */ -const MANDATORY_FIELDS = ['tracker', 'pr_strategy', 'branching'] - -/** @type {readonly string[]} */ -const KNOWN_FIELDS = [ - 'tracker', - 'pr_strategy', - 'branching', - 'e2e_command', - 'model_profile', - 'tdd_mode', - 'nyquist_validation', - 'slopsquatting_gate', - 'repo_layout', - 'context_paths', - 'labels', - 'workflow', - 'coverage_threshold', -] - -/** - * @typedef {Object} DlcConfig - * @property {TrackerBackend} tracker - * @property {PrStrategy} pr_strategy - * @property {BranchingStrategy} branching - * @property {string | null} [e2e_command] - * @property {string} [model_profile] - * @property {boolean} [tdd_mode] - * @property {boolean} [nyquist_validation] - * @property {boolean} [slopsquatting_gate] - * @property {string} [repo_layout] - * @property {number} [coverage_threshold] - */ - -/** - * @typedef {Object} ConfigError - * @property {true} error - * @property {string[]} missing - * @property {string} message - */ - -/** - * Type guard — narrows a `DlcConfig | ConfigError` to `ConfigError`. - * @param {DlcConfig | ConfigError} result - * @returns {result is ConfigError} - */ -export function isConfigError(result) { - return /** @type {ConfigError} */ (result).error === true -} - -/** - * Reads and validates .archon/unic-dlc.config.json. - * Returns a typed config object or a structured error. - * Use `isConfigError(result)` to discriminate the union. - * Unknown keys in the file are silently ignored. - * @param {string} path - absolute path to the config file - * @returns {DlcConfig | ConfigError} - */ -export function loadConfig(path) { - let raw - try { - raw = JSON.parse(readFileSync(path, 'utf8')) - } catch (err) { - return { error: true, missing: [], message: `Cannot read config at ${path}: ${/** @type {Error} */ (err).message}` } - } - - const missing = MANDATORY_FIELDS.filter((f) => !(f in raw)) - if (missing.length > 0) { - return { error: true, missing, message: `Missing mandatory fields: ${missing.join(', ')}` } - } - - /** @type {Record<string, unknown>} */ - const result = {} - for (const key of KNOWN_FIELDS) { - if (key in raw) result[key] = raw[key] - } - return /** @type {DlcConfig} */ (result) -} diff --git a/apps/claude-code/unic-archon-dlc/lib/config-schema.mjs b/apps/claude-code/unic-archon-dlc/lib/config-schema.mjs new file mode 100644 index 00000000..4cf330a5 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/config-schema.mjs @@ -0,0 +1,239 @@ +// @ts-check +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' +import { getDefaultLabels } from './labels-config.mjs' + +/** + * The rich `.archon/unic-dlc.config.yaml` schema (ADR-0018/0019/0020). This is the single + * config substrate every DLC box reads; `/setup` is its sole writer. See ADR-0019. + * + * `null` on a mandatory leaf means "not yet answered" — `validateConfig` treats it as missing. + * @typedef {Record<string, unknown>} DlcConfig + */ + +/** + * @typedef {Object} ConfigError + * @property {true} error + * @property {string[]} missing + * @property {string} message + */ + +/** + * Dotted paths that must be present and non-null after a merge for a config to be usable. + * @type {readonly string[]} + */ +export const MANDATORY_PATHS = ['tracker.type', 'project.pr_strategy', 'project.branching'] + +/** + * Default rich-config skeleton. Merge precedence is `DEFAULTS < existing < answers`, so every + * key a downstream box reads has a defined shape even on a fresh install. Mandatory leaves are + * `null` here so `validateConfig` fails until `/setup` fills them. + * @returns {DlcConfig} + */ +export function defaultConfig() { + return { + project: { name: null, repo_layout: 'single-context', branching: null, pr_strategy: null }, + tracker: { type: null, access: { mcp: null, cli: null }, coords: {} }, + docs: { type: 'markdown', publish: false, access: { mcp: null, cli: null } }, + design: { type: 'none', access: { mcp: null } }, + repos: [], + templates: { prd: null, issue: null, bug: null }, + classification: { labels: getDefaultLabels('') }, + gates: { build: 'hitl', qa: 'hitl', 'pr-review': 'hitl', explore: 'hitl' }, + build: { + fresh_context_red_green: true, + tdd_mode: true, + nyquist_validation: true, + slopsquatting_gate: true, + e2e_command: null, + coverage_threshold: null, + }, + estimations: 'off', + artifacts_dir: 'workflows', + model_profile: 'balanced', + skills: { matt_suite: { present: false, missing: [] } }, + } +} + +/** + * Map a tracker type to its default CLI tool (MCP-first is expressed separately via `access.mcp`). + * @param {unknown} tracker + * @returns {string | null} + */ +function defaultTrackerCli(tracker) { + if (tracker === 'github') return 'gh' + if (tracker === 'ado') return 'az' + if (tracker === 'jira') return 'jira' + return null +} + +/** + * Plain-object test — arrays and null are NOT plain objects (they never deep-merge). + * @param {unknown} v + * @returns {v is Record<string, unknown>} + */ +function isPlainObject(v) { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +/** + * Recursively merge `source` over `target`. Plain objects merge key-by-key; every other value + * (arrays, scalars, null) is replaced wholesale by `source`. Never mutates the inputs. + * @param {Record<string, unknown>} target + * @param {Record<string, unknown>} source + * @returns {Record<string, unknown>} + */ +function deepMerge(target, source) { + /** @type {Record<string, unknown>} */ + const out = { ...target } + for (const [key, value] of Object.entries(source)) { + const existing = out[key] + out[key] = isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value + } + return out +} + +/** + * Merge answers over an existing config over the defaults: `DEFAULTS < existing < answers`. + * Deep-merges nested objects (so partial answers never drop untouched keys) and is idempotent: + * `mergeConfig(mergeConfig(a, b)) deep-equals mergeConfig(a, b)`. + * @param {DlcConfig} [existing] + * @param {Partial<DlcConfig>} [answers] + * @returns {DlcConfig} + */ +export function mergeConfig(existing = {}, answers = {}) { + return deepMerge(deepMerge(defaultConfig(), existing), answers) +} + +/** + * Detect a legacy flat ADR-0001 config (top-level `tracker` is a string, not an object). + * @param {unknown} obj + * @returns {boolean} + */ +export function isLegacyConfig(obj) { + return isPlainObject(obj) && typeof obj.tracker === 'string' +} + +/** + * Translate a flat ADR-0001 JSON config into the rich nested shape. Preserves the file's own + * `labels` verbatim (so hand-added label types such as `release` survive) rather than + * regenerating from `getDefaultLabels`. Returns a PARTIAL — run it through `mergeConfig` to fill + * defaults. Never touches or deletes the source file. + * @param {Record<string, unknown>} flat + * @returns {Partial<DlcConfig>} + */ +export function migrateLegacy(flat) { + /** @type {Record<string, unknown>} */ + const project = {} + if (flat.repo_layout !== undefined) project.repo_layout = flat.repo_layout + if (flat.branching !== undefined) project.branching = flat.branching + if (flat.pr_strategy !== undefined) project.pr_strategy = flat.pr_strategy + + /** @type {Record<string, unknown>} */ + const build = {} + if (flat.tdd_mode !== undefined) build.tdd_mode = flat.tdd_mode + if (flat.nyquist_validation !== undefined) build.nyquist_validation = flat.nyquist_validation + if (flat.slopsquatting_gate !== undefined) build.slopsquatting_gate = flat.slopsquatting_gate + if (flat.e2e_command !== undefined) build.e2e_command = flat.e2e_command + if (flat.coverage_threshold !== undefined) build.coverage_threshold = flat.coverage_threshold + + /** @type {Partial<DlcConfig>} */ + const rich = { + project, + tracker: { + type: flat.tracker, + access: { mcp: null, cli: defaultTrackerCli(flat.tracker) }, + coords: {}, + }, + build, + } + if (flat.labels !== undefined) rich.classification = { labels: flat.labels } + if (flat.model_profile !== undefined) rich.model_profile = flat.model_profile + return rich +} + +/** + * Read a value at a dotted path (e.g. `tracker.type`) from a nested object. + * @param {Record<string, unknown>} obj + * @param {string} path + * @returns {unknown} + */ +function getPath(obj, path) { + /** @type {unknown} */ + let cursor = obj + for (const segment of path.split('.')) { + if (!isPlainObject(cursor)) return undefined + cursor = cursor[segment] + } + return cursor +} + +/** + * Validate that every mandatory path is present and non-null. + * @param {DlcConfig} config + * @returns {{ ok: true, config: DlcConfig } | ConfigError} + */ +export function validateConfig(config) { + const missing = MANDATORY_PATHS.filter((path) => { + const value = getPath(config, path) + return value === undefined || value === null || value === '' + }) + if (missing.length > 0) { + return { error: true, missing, message: `Missing mandatory config fields: ${missing.join(', ')}` } + } + return { ok: true, config } +} + +/** + * Parse a config file by extension: `.yaml`/`.yml` as YAML, `.json` as JSON, anything else tried + * as JSON then YAML. Returns a structured error rather than throwing. + * @param {string} path - absolute path to the config file + * @returns {{ ok: true, config: DlcConfig } | ConfigError} + */ +export function loadConfig(path) { + let raw + try { + raw = readFileSync(path, 'utf8') + } catch (err) { + return { error: true, missing: [], message: `Cannot read config at ${path}: ${/** @type {Error} */ (err).message}` } + } + const lower = path.toLowerCase() + try { + if (lower.endsWith('.json')) return { ok: true, config: JSON.parse(raw) } + if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return { ok: true, config: parseYaml(raw) } + try { + return { ok: true, config: JSON.parse(raw) } + } catch { + return { ok: true, config: parseYaml(raw) } + } + } catch (err) { + return { + error: true, + missing: [], + message: `Cannot parse config at ${path}: ${/** @type {Error} */ (err).message}`, + } + } +} + +/** + * Serialize a config to a YAML string. Validates first; returns a structured error on failure so + * callers never write an invalid config to disk. + * @param {DlcConfig} config + * @returns {{ ok: true, yaml: string } | ConfigError} + */ +export function toYaml(config) { + const validated = validateConfig(config) + if ('error' in validated) return validated + return { ok: true, yaml: stringifyYaml(config) } +} + +/** + * Detect repo layout the same way the ADR-0001 installer did: multi-context iff a root + * `CONTEXT-MAP.md` exists. Callers pass the result into `answers.project.repo_layout`. + * @param {string} projectDir + * @returns {'single-context' | 'multi-context'} + */ +export function detectRepoLayout(projectDir) { + return existsSync(join(projectDir, 'CONTEXT-MAP.md')) ? 'multi-context' : 'single-context' +} diff --git a/apps/claude-code/unic-archon-dlc/lib/install-runner.mjs b/apps/claude-code/unic-archon-dlc/lib/install-runner.mjs deleted file mode 100644 index c5d21df0..00000000 --- a/apps/claude-code/unic-archon-dlc/lib/install-runner.mjs +++ /dev/null @@ -1,182 +0,0 @@ -// @ts-check -import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { updateAgentSkillsBlock, writeAgentDocs } from './agent-docs-writer.mjs' -import { getDefaultLabels } from './labels-config.mjs' - -/** - * @typedef {import('./tracker-adapter.mjs').TrackerBackend} TrackerBackend - * @typedef {import('./config-loader.mjs').DlcConfig} DlcConfig - * @typedef {import('./config-loader.mjs').PrStrategy} PrStrategy - * @typedef {import('./config-loader.mjs').BranchingStrategy} BranchingStrategy - */ - -/** - * @typedef {{ ok: true, configPath: string, workflowsCopied: number, commandsCopied: number }} RunInstallOk - * @typedef {{ ok: false, stage: 'validate' | 'config' | 'workflows' | 'commands' | 'docs' | 'claude-md', message: string }} RunInstallFail - * @typedef {RunInstallOk | RunInstallFail} RunInstallResult - */ - -/** - * @param {string | null} remoteUrl - * @returns {'github' | 'ado' | 'jira' | 'local-markdown' | null} - */ -export function detectTracker(remoteUrl) { - if (!remoteUrl) return null - if (remoteUrl.includes('github.com')) return 'github' - if (remoteUrl.includes('dev.azure.com') || remoteUrl.includes('visualstudio.com')) return 'ado' - return null -} - -/** @param {string} tracker */ -export function deducePrStrategy(tracker) { - if (tracker === 'github' || tracker === 'ado') return 'squash' - return 'merge' -} - -/** @type {Record<string, unknown>} */ -const DEFAULTS = { - model_profile: 'balanced', - tdd_mode: true, - nyquist_validation: true, - slopsquatting_gate: true, -} - -/** - * @param {string} projectDir - * @returns {string} - */ -export function detectRepoLayout(projectDir) { - return existsSync(join(projectDir, 'CONTEXT-MAP.md')) ? 'multi-context' : 'single-context' -} - -/** - * Writes .archon/unic-dlc.config.json, copies bundled workflows, writes docs/agents/, - * and updates the CLAUDE.md Agent skills block. - * Merge precedence: defaults < existing < partialAnswers. - * Does not call checkArchon() or prompt — the slash command owns both. - * - * @param {string} projectDir - * @param {Partial<DlcConfig>} [partialAnswers] - * @param {string | null} [pluginRoot] - plugin installation directory; defaults to CLAUDE_PLUGIN_ROOT env var - * @returns {RunInstallResult} - */ -export function runInstall(projectDir, partialAnswers = {}, pluginRoot = process.env['CLAUDE_PLUGIN_ROOT'] ?? null) { - const configPath = join(projectDir, '.archon', 'unic-dlc.config.json') - - let existing = /** @type {Record<string, unknown>} */ ({}) - if (existsSync(configPath)) { - let raw - try { - raw = readFileSync(configPath, 'utf8') - } catch (err) { - return { - ok: false, - stage: 'config', - message: `Cannot read existing config at ${configPath}: ${/** @type {Error} */ (err).message}`, - } - } - try { - existing = /** @type {Record<string, unknown>} */ (JSON.parse(raw)) - } catch (err) { - return { - ok: false, - stage: 'config', - message: `Existing config at ${configPath} contains invalid JSON. Fix or delete the file and re-run setup. Parse error: ${/** @type {Error} */ (err).message}`, - } - } - } - - const merged = /** @type {Record<string, unknown>} */ ({ - ...DEFAULTS, - ...existing, - .../** @type {Record<string, unknown>} */ (partialAnswers), - repo_layout: detectRepoLayout(projectDir), - }) - - const missing = ['tracker', 'pr_strategy', 'branching'].filter((f) => !(f in merged)) - if (missing.length > 0) { - return { ok: false, stage: 'validate', message: `Missing mandatory fields: ${missing.join(', ')}` } - } - - if (!merged.labels) merged.labels = getDefaultLabels(/** @type {string} */ (merged.tracker)) - - try { - mkdirSync(join(projectDir, '.archon'), { recursive: true }) - writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}\n`) - } catch (err) { - return { ok: false, stage: 'config', message: `Failed to write config: ${/** @type {Error} */ (err).message}` } - } - - let workflowsCopied = 0 - let commandsCopied = 0 - if (pluginRoot) { - const archonAssets = - /** @type {Array<{ subdir: string, ext: string, stageName: 'workflows' | 'commands', counter: (n: number) => void }>} */ ([ - { - subdir: 'workflows', - ext: '.yaml', - stageName: 'workflows', - counter: (n) => { - workflowsCopied = n - }, - }, - { - subdir: 'commands', - ext: '.md', - stageName: 'commands', - counter: (n) => { - commandsCopied = n - }, - }, - ]) - for (const { subdir, ext, stageName, counter } of archonAssets) { - const srcDir = join(pluginRoot, '.archon', subdir) - const destDir = join(projectDir, '.archon', subdir) - try { - if (existsSync(srcDir)) { - mkdirSync(destDir, { recursive: true }) - const files = readdirSync(srcDir).filter((f) => f.endsWith(ext)) - for (const file of files) { - copyFileSync(join(srcDir, file), join(destDir, file)) - } - counter(files.length) - } - } catch (err) { - return { - ok: false, - stage: stageName, - message: `Config written to ${configPath}. Failed to copy ${subdir}: ${/** @type {Error} */ (err).message}`, - } - } - } - } - - try { - writeAgentDocs(projectDir, { - tracker: /** @type {TrackerBackend} */ (merged.tracker), - pr_strategy: /** @type {PrStrategy} */ (merged.pr_strategy), - branching: /** @type {BranchingStrategy} */ (merged.branching), - repo_layout: /** @type {string | undefined} */ (merged.repo_layout), - labels: /** @type {import('./labels-config.mjs').LabelMapping} */ (merged.labels), - }) - } catch (err) { - return { - ok: false, - stage: 'docs', - message: `Config written to ${configPath}. Failed to write docs/agents/: ${/** @type {Error} */ (err).message}`, - } - } - - try { - updateAgentSkillsBlock(projectDir) - } catch (err) { - return { - ok: false, - stage: 'claude-md', - message: `Config and docs written. Failed to update CLAUDE.md: ${/** @type {Error} */ (err).message}`, - } - } - - return { ok: true, configPath, workflowsCopied, commandsCopied } -} diff --git a/apps/claude-code/unic-archon-dlc/lib/setup-explorer.mjs b/apps/claude-code/unic-archon-dlc/lib/setup-explorer.mjs deleted file mode 100644 index b4761a78..00000000 --- a/apps/claude-code/unic-archon-dlc/lib/setup-explorer.mjs +++ /dev/null @@ -1,99 +0,0 @@ -// @ts-check -import { execFileSync } from 'node:child_process' -import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { join } from 'node:path' - -/** - * @typedef {Object} FilePresence - * @property {boolean} present - * @property {string | null} content - */ - -/** - * @typedef {Object} ProjectSnapshot - * @property {string | null} gitRemote - origin URL or null if absent / not a git repo - * @property {FilePresence} claudeMd - * @property {FilePresence} contextMd - * @property {FilePresence} contextMapMd - * @property {string[]} adrFiles - basenames of .md files in docs/adr/ - * @property {boolean} archonConfigPresent - * @property {string | null} existingConfig - raw JSON string if present, else null - */ - -/** - * @param {string} filePath - * @returns {FilePresence} - */ -function readOptional(filePath) { - if (!existsSync(filePath)) return { present: false, content: null } - try { - return { present: true, content: readFileSync(filePath, 'utf8') } - } catch (err) { - // Re-throw unexpected errors (e.g. EACCES, EISDIR) so callers are not - // silently misled into thinking the file is merely absent. - const code = /** @type {NodeJS.ErrnoException} */ (err).code - if (code === 'ENOENT') return { present: false, content: null } - throw err - } -} - -/** - * Reads project state without throwing on missing files. - * @param {string} projectDir - absolute path to the target project root - * @returns {ProjectSnapshot} - */ -export function exploreProject(projectDir) { - let gitRemote = null - try { - const raw = execFileSync('git', ['remote', 'get-url', 'origin'], { - cwd: projectDir, - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000, - }) - .toString() - .trim() - gitRemote = raw || null - } catch (err) { - // Expected: not a git repo, no 'origin' remote, or git not on PATH. - // These all produce a non-zero exit code / ENOENT and are non-fatal. - // Re-throw genuinely unexpected errors (timeouts surface as ETIMEDOUT, - // but execFileSync wraps them in a plain Error — we rely on the timeout - // option to keep the window short and accept those as non-fatal too). - const code = /** @type {NodeJS.ErrnoException} */ (err).code - // ENOENT = git binary absent; status != 0 = no remote / not a git repo - // Anything else (e.g. ENOMEM, EPERM) is truly unexpected — log and continue - // rather than silently swallowing, so operators see the warning. - if (code !== undefined && code !== 'ENOENT') { - process.stderr.write( - `[unic-archon-dlc] Warning: unexpected error reading git remote (${code}): ${/** @type {Error} */ (err).message}\n` - ) - } - // gitRemote stays null — callers handle this gracefully - } - - const claudeMd = readOptional(join(projectDir, 'CLAUDE.md')) - const contextMd = readOptional(join(projectDir, 'CONTEXT.md')) - const contextMapMd = readOptional(join(projectDir, 'CONTEXT-MAP.md')) - - let adrFiles = /** @type {string[]} */ ([]) - try { - adrFiles = readdirSync(join(projectDir, 'docs', 'adr')).filter((f) => f.endsWith('.md')) - } catch (err) { - // Only tolerate a missing docs/adr directory; re-throw permission or other I/O errors. - const code = /** @type {NodeJS.ErrnoException} */ (err).code - if (code !== 'ENOENT' && code !== 'ENOTDIR') throw err - // docs/adr absent — treat as empty - } - - const archonConfig = readOptional(join(projectDir, '.archon', 'unic-dlc.config.json')) - - return { - gitRemote, - claudeMd, - contextMd, - contextMapMd, - adrFiles, - archonConfigPresent: archonConfig.present, - existingConfig: archonConfig.present ? archonConfig.content : null, - } -} diff --git a/apps/claude-code/unic-archon-dlc/package.json b/apps/claude-code/unic-archon-dlc/package.json index be1bd020..5b016fae 100644 --- a/apps/claude-code/unic-archon-dlc/package.json +++ b/apps/claude-code/unic-archon-dlc/package.json @@ -10,13 +10,16 @@ "pnpm": ">=10" }, "scripts": { - "test": "node --test test/config-loader.test.mjs test/setup-explorer.test.mjs test/labels-config.test.mjs test/install-agent-docs.test.mjs test/install-claude-md.test.mjs test/tracker-adapter.test.mjs test/handoff-generator.test.mjs test/findings-writer.test.mjs test/prd-writer.test.mjs test/spike-verdicts.test.mjs test/issues-schema.test.mjs test/dag-builder.test.mjs test/slopcheck.test.mjs test/stub-detector.test.mjs test/install-helpers.test.mjs test/archon-check.test.mjs test/install-runner.test.mjs test/dogfood-banner.test.mjs", + "test": "node --test test/config-schema.test.mjs test/labels-config.test.mjs test/tracker-adapter.test.mjs test/handoff-generator.test.mjs test/findings-writer.test.mjs test/prd-writer.test.mjs test/spike-verdicts.test.mjs test/issues-schema.test.mjs test/dag-builder.test.mjs test/slopcheck.test.mjs test/stub-detector.test.mjs test/archon-check.test.mjs test/dogfood-banner.test.mjs", "typecheck": "tsc --noEmit --project tsconfig.json", "bump": "unic-bump", "sync-version": "unic-sync-version", "tag": "unic-tag", "verify:changelog": "unic-verify-changelog" }, + "dependencies": { + "yaml": "catalog:" + }, "devDependencies": { "@types/node": "catalog:", "@unic/release-tools": "workspace:*", diff --git a/apps/claude-code/unic-archon-dlc/test/archon-check.test.mjs b/apps/claude-code/unic-archon-dlc/test/archon-check.test.mjs index 09d7cee9..380cf74d 100644 --- a/apps/claude-code/unic-archon-dlc/test/archon-check.test.mjs +++ b/apps/claude-code/unic-archon-dlc/test/archon-check.test.mjs @@ -2,11 +2,11 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { checkArchon, INCOMPATIBLE_ARCHON_VERSIONS } from '../lib/archon-check.mjs' +import { checkArchon, INCOMPATIBLE_ARCHON_VERSIONS, MIN_ARCHON_VERSION } from '../lib/archon-check.mjs' /** @typedef {import('../lib/archon-check.mjs').ExecFn} ExecFn */ -test('returns ok with version when archon is on PATH', () => { +test('returns ok with version when archon is on PATH and meets the floor', () => { /** @type {ExecFn} */ const execFn = () => '0.9.2' const result = checkArchon(execFn) @@ -15,6 +15,30 @@ test('returns ok with version when archon is on PATH', () => { assert.equal(result.version, '0.9.2') }) +test('accepts exactly the min-floor version', () => { + const result = checkArchon(() => MIN_ARCHON_VERSION) + assert.ok(result.ok, 'min-floor version should be accepted') +}) + +test('rejects a version below the min-floor', () => { + const result = checkArchon(() => '0.4.9') + assert.ok(!result.ok, 'should not be ok') + if (result.ok) return + assert.equal(result.code, 'incompatible') + assert.ok(result.message.includes('0.4.9'), `message should include the found version: ${result.message}`) + assert.ok(result.message.includes(MIN_ARCHON_VERSION), `message should include the floor: ${result.message}`) +}) + +test('parses a version string with a program-name / v prefix', () => { + assert.ok(checkArchon(() => 'archon v0.6.1').ok, 'prefixed version >= floor should be ok') + assert.ok(!checkArchon(() => 'archon v0.3.0').ok, 'prefixed version < floor should fail') +}) + +test('unparseable version is non-blocking (warn-and-degrade)', () => { + const result = checkArchon(() => 'dev-build') + assert.ok(result.ok, 'unparseable version should not block') +}) + test('returns enoent failure when archon is not on PATH', () => { /** @type {ExecFn} */ const execFn = () => { @@ -27,14 +51,14 @@ test('returns enoent failure when archon is not on PATH', () => { assert.ok(result.message.includes('not found'), `message should mention "not found": ${result.message}`) }) -test('returns incompatible failure for known bad version', () => { +test('returns incompatible failure for an explicitly listed bad version (bare-array back-compat)', () => { /** @type {ExecFn} */ - const execFn = () => '0.0.1-bad' - const result = checkArchon(execFn, ['0.0.1-bad']) + const execFn = () => '9.9.9-bad' + const result = checkArchon(execFn, ['9.9.9-bad']) assert.ok(!result.ok, 'should not be ok') if (result.ok) return assert.equal(result.code, 'incompatible') - assert.ok(result.message.includes('0.0.1-bad'), `message should include version: ${result.message}`) + assert.ok(result.message.includes('9.9.9-bad'), `message should include version: ${result.message}`) }) test('returns other failure for unexpected errors', () => { diff --git a/apps/claude-code/unic-archon-dlc/test/config-loader.test.mjs b/apps/claude-code/unic-archon-dlc/test/config-loader.test.mjs deleted file mode 100644 index 970542b4..00000000 --- a/apps/claude-code/unic-archon-dlc/test/config-loader.test.mjs +++ /dev/null @@ -1,57 +0,0 @@ -// @ts-check - -import assert from 'node:assert/strict' -import { mkdirSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { loadConfig } from '../lib/config-loader.mjs' - -let _seq = 0 -function tempDir() { - const p = join(tmpdir(), `unic-dlc-cfg-${Date.now()}-${++_seq}`) - mkdirSync(p, { recursive: true }) - return p -} - -test('valid config parses correctly', () => { - const path = join(tempDir(), 'unic-dlc.config.json') - writeFileSync(path, JSON.stringify({ tracker: 'github', pr_strategy: 'squash', branching: 'gitflow' })) - const result = loadConfig(path) - assert.ok(!('error' in result), 'should not have error') - if ('error' in result) return - assert.equal(result.tracker, 'github') - assert.equal(result.pr_strategy, 'squash') - assert.equal(result.branching, 'gitflow') -}) - -test('missing mandatory fields return structured error', () => { - const path = join(tempDir(), 'unic-dlc.config.json') - writeFileSync(path, JSON.stringify({ tracker: 'github' })) - const result = loadConfig(path) - assert.ok('error' in result && result.error === true, 'should have error flag') - if (!('error' in result)) return - assert.ok(Array.isArray(result.missing), 'should have missing array') - assert.ok(result.missing.includes('pr_strategy')) - assert.ok(result.missing.includes('branching')) -}) - -test('unknown keys are ignored and mandatory fields still parse', () => { - const path = join(tempDir(), 'unic-dlc.config.json') - writeFileSync( - path, - JSON.stringify({ - tracker: 'github', - pr_strategy: 'squash', - branching: 'gitflow', - unknown_key: 'should-be-ignored', - another_unknown: 42, - }) - ) - const result = loadConfig(path) - assert.ok(!('error' in result), 'should not have error') - if ('error' in result) return - assert.equal(result.tracker, 'github') - assert.ok(!('unknown_key' in result), 'unknown_key should not be in result') - assert.ok(!('another_unknown' in result), 'another_unknown should not be in result') -}) diff --git a/apps/claude-code/unic-archon-dlc/test/config-schema.test.mjs b/apps/claude-code/unic-archon-dlc/test/config-schema.test.mjs new file mode 100644 index 00000000..112d5ced --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/config-schema.test.mjs @@ -0,0 +1,168 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { parse as parseYaml } from 'yaml' +import { + defaultConfig, + isLegacyConfig, + loadConfig, + mergeConfig, + migrateLegacy, + toYaml, + validateConfig, +} from '../lib/config-schema.mjs' + +let _seq = 0 +function tempDir() { + const p = join(tmpdir(), `unic-dlc-schema-${Date.now()}-${++_seq}`) + mkdirSync(p, { recursive: true }) + return p +} + +/** The exact dogfood config this repo ships today (flat ADR-0001 JSON). */ +const DOGFOOD_JSON = { + model_profile: 'balanced', + tdd_mode: true, + nyquist_validation: true, + slopsquatting_gate: true, + tracker: 'github', + pr_strategy: 'merge', + branching: 'gitflow', + e2e_command: null, + repo_layout: 'multi-context', + labels: { + state: { + 'needs-triage': 'needs-triage', + 'needs-info': 'needs-info', + 'needs-specs': 'needs-specs', + 'ready-for-agent': 'ready-for-agent', + 'ready-for-human': 'ready-for-human', + resolved: 'resolved', + closed: 'closed', + rejected: 'rejected', + }, + type: { + feature: 'feature', + bug: 'bug', + spike: 'spike', + 'tech-debt': 'tech-debt', + docs: 'docs', + release: 'release', + }, + priority: { p0: 'p0', p1: 'p1', p2: 'p2', p3: 'p3' }, + }, +} + +test('loadConfig parses JSON by extension', () => { + const path = join(tempDir(), 'unic-dlc.config.json') + writeFileSync(path, JSON.stringify(DOGFOOD_JSON)) + const result = loadConfig(path) + assert.ok(!('error' in result)) + if ('error' in result) return + assert.equal(result.config.tracker, 'github') +}) + +test('loadConfig parses YAML by extension', () => { + const path = join(tempDir(), 'unic-dlc.config.yaml') + writeFileSync(path, 'tracker:\n type: github\nproject:\n branching: gitflow\n') + const result = loadConfig(path) + assert.ok(!('error' in result)) + if ('error' in result) return + assert.deepEqual(result.config.tracker, { type: 'github' }) +}) + +test('loadConfig returns structured error for a missing file', () => { + const result = loadConfig(join(tempDir(), 'nope.yaml')) + assert.ok('error' in result && result.error === true) +}) + +test('validateConfig flags each missing mandatory path', () => { + const result = validateConfig(defaultConfig()) // all mandatory leaves are null + assert.ok('error' in result && result.error === true) + if (!('error' in result)) return + assert.deepEqual(result.missing.sort(), ['project.branching', 'project.pr_strategy', 'tracker.type']) +}) + +test('validateConfig passes once mandatory paths are filled', () => { + const config = mergeConfig(defaultConfig(), { + tracker: { type: 'github' }, + project: { branching: 'gitflow', pr_strategy: 'merge' }, + }) + const result = validateConfig(config) + assert.ok(!('error' in result), 'expected a valid config') +}) + +test('mergeConfig precedence: defaults < existing < answers', () => { + const existing = { model_profile: 'fast', build: { tdd_mode: false } } + const answers = { model_profile: 'max' } + const merged = mergeConfig(existing, answers) + assert.equal(merged.model_profile, 'max') // answer wins over existing + assert.equal(/** @type {any} */ (merged.build).tdd_mode, false) // existing wins over default + assert.equal(/** @type {any} */ (merged.build).nyquist_validation, true) // default fills the gap +}) + +test('mergeConfig deep-merges nested objects rather than replacing them', () => { + const merged = mergeConfig({ tracker: { type: 'ado' } }, {}) + const tracker = /** @type {any} */ (merged.tracker) + assert.equal(tracker.type, 'ado') + assert.deepEqual(tracker.access, { mcp: null, cli: null }) // default sub-keys retained +}) + +test('mergeConfig is idempotent', () => { + const once = mergeConfig(migrateLegacy(DOGFOOD_JSON)) + const twice = mergeConfig(once) + assert.deepEqual(twice, once) +}) + +test('isLegacyConfig detects flat vs rich', () => { + assert.equal(isLegacyConfig(DOGFOOD_JSON), true) + assert.equal(isLegacyConfig({ tracker: { type: 'github' } }), false) +}) + +test('migrateLegacy maps flat JSON into the rich shape without data loss', () => { + const config = mergeConfig(migrateLegacy(DOGFOOD_JSON)) + const tracker = /** @type {any} */ (config.tracker) + const project = /** @type {any} */ (config.project) + const build = /** @type {any} */ (config.build) + + assert.equal(tracker.type, 'github') + assert.equal(tracker.access.cli, 'gh') + assert.equal(project.branching, 'gitflow') + assert.equal(project.pr_strategy, 'merge') + assert.equal(project.repo_layout, 'multi-context') + assert.equal(build.tdd_mode, true) + assert.equal(build.e2e_command, null) + assert.equal(config.model_profile, 'balanced') +}) + +test('migrateLegacy preserves hand-added label types such as `release`', () => { + const config = mergeConfig(migrateLegacy(DOGFOOD_JSON)) + const labels = /** @type {any} */ (config.classification).labels + assert.equal(labels.type.release, 'release') + assert.equal(Object.keys(labels.type).length, 6) + assert.equal(Object.keys(labels.state).length, 8) +}) + +test('toYaml validates then serializes; round-trips through loadConfig', () => { + const config = mergeConfig(migrateLegacy(DOGFOOD_JSON)) + const emitted = toYaml(config) + assert.ok(!('error' in emitted), 'valid config should serialize') + if ('error' in emitted) return + + const path = join(tempDir(), 'unic-dlc.config.yaml') + writeFileSync(path, emitted.yaml) + const reloaded = loadConfig(path) + assert.ok(!('error' in reloaded)) + if ('error' in reloaded) return + assert.deepEqual(reloaded.config, config) // full round-trip fidelity + assert.deepEqual(parseYaml(emitted.yaml), config) +}) + +test('toYaml refuses to serialize an invalid config', () => { + const emitted = toYaml(defaultConfig()) // mandatory leaves still null + assert.ok('error' in emitted && emitted.error === true) +}) diff --git a/apps/claude-code/unic-archon-dlc/test/install-agent-docs.test.mjs b/apps/claude-code/unic-archon-dlc/test/install-agent-docs.test.mjs deleted file mode 100644 index ff78fd33..00000000 --- a/apps/claude-code/unic-archon-dlc/test/install-agent-docs.test.mjs +++ /dev/null @@ -1,107 +0,0 @@ -// @ts-check - -import assert from 'node:assert/strict' -import { existsSync, mkdirSync, readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { writeAgentDocs } from '../lib/agent-docs-writer.mjs' -import { AGENT_DOC_BANNER } from '../lib/dogfood-banner.mjs' -import { getDefaultLabels } from '../lib/labels-config.mjs' - -const TEST_CONFIG = { - tracker: /** @type {const} */ ('local-markdown'), - pr_strategy: /** @type {const} */ ('merge'), - branching: /** @type {const} */ ('gitflow'), - repo_layout: 'single-context', - labels: getDefaultLabels('local-markdown'), -} - -test('writeAgentDocs writes all 5 docs/agents/*.md files with expected content', () => { - const dir = join(tmpdir(), `unic-dlc-docs-${Date.now()}`) - mkdirSync(dir, { recursive: true }) - - writeAgentDocs(dir, TEST_CONFIG) - - // All 5 files exist - for (const name of ['issue-tracker.md', 'labels.md', 'branching.md', 'domain.md', 'workflow.md']) { - assert.ok(existsSync(join(dir, 'docs', 'agents', name)), `${name} should exist`) - } - - // labels.md contains all three tiers - const labels = readFileSync(join(dir, 'docs', 'agents', 'labels.md'), 'utf8') - assert.ok(labels.includes('needs-triage'), 'labels.md should include state label') - assert.ok(labels.includes('feature'), 'labels.md should include type label') - assert.ok(labels.includes('p0'), 'labels.md should include priority label') - - // branching.md reflects gitflow - const branching = readFileSync(join(dir, 'docs', 'agents', 'branching.md'), 'utf8') - assert.ok(branching.includes('gitflow') || branching.includes('Gitflow'), 'branching.md should mention gitflow') - - // domain.md reflects single-context - const domain = readFileSync(join(dir, 'docs', 'agents', 'domain.md'), 'utf8') - assert.ok(domain.includes('single-context') || domain.includes('single'), 'domain.md should mention single-context') - assert.ok( - !domain.includes('each context may also keep its own'), - 'single-context domain.md should not mention per-context ADR pattern' - ) - assert.ok( - !domain.includes('located via `CONTEXT-MAP.md`'), - 'single-context domain.md should not contain the multi-context "How agents use this" wording' - ) - - // workflow.md mentions all 7 workflow phases - const workflow = readFileSync(join(dir, 'docs', 'agents', 'workflow.md'), 'utf8') - for (const phase of ['explore', 'plan', 'build', 'qa', 'cleanup', 'triage', 'review']) { - assert.ok(workflow.toLowerCase().includes(phase), `workflow.md should mention ${phase}`) - } - // 'review' is also a substring of 'arch-review' and appears in surrounding prose — anchor on the - // unique command string to make sure the review *row* is present, not just the word. - assert.ok(workflow.includes('/unic-dlc-review'), 'workflow.md should list the /unic-dlc-review command') -}) - -test('multi-context domain.md mentions per-context ADR pattern', () => { - const dir = join(tmpdir(), `unic-dlc-docs-multi-${Date.now()}`) - mkdirSync(dir, { recursive: true }) - - writeAgentDocs(dir, { ...TEST_CONFIG, repo_layout: 'multi-context' }) - - const domain = readFileSync(join(dir, 'docs', 'agents', 'domain.md'), 'utf8') - assert.ok(domain.includes('multi-context'), 'multi-context domain.md should mention multi-context') - assert.ok( - domain.includes('each context may also keep its own'), - 'multi-context domain.md should mention per-context docs/adr/ pattern' - ) - assert.ok( - domain.includes('located via `CONTEXT-MAP.md`'), - 'multi-context domain.md "How agents use this" should branch on multi-context wording' - ) - assert.ok(domain.startsWith(AGENT_DOC_BANNER), 'multi-context domain.md should still begin with AGENT_DOC_BANNER') -}) - -test('each generated docs/agents/*.md file begins with AGENT_DOC_BANNER', () => { - const dir = join(tmpdir(), `unic-dlc-docs-banner-${Date.now()}`) - mkdirSync(dir, { recursive: true }) - - writeAgentDocs(dir, TEST_CONFIG) - - for (const name of ['issue-tracker.md', 'labels.md', 'branching.md', 'domain.md', 'workflow.md']) { - const content = readFileSync(join(dir, 'docs', 'agents', name), 'utf8') - assert.ok(content.startsWith(AGENT_DOC_BANNER), `${name} should begin with AGENT_DOC_BANNER`) - } -}) - -test('AGENT_DOC_BANNER appears exactly once per file on repeated writeAgentDocs calls', () => { - const dir = join(tmpdir(), `unic-dlc-docs-dedup-${Date.now()}`) - mkdirSync(dir, { recursive: true }) - - writeAgentDocs(dir, TEST_CONFIG) - writeAgentDocs(dir, TEST_CONFIG) - writeAgentDocs(dir, TEST_CONFIG) - - for (const name of ['issue-tracker.md', 'labels.md', 'branching.md', 'domain.md', 'workflow.md']) { - const content = readFileSync(join(dir, 'docs', 'agents', name), 'utf8') - const occurrences = content.split(AGENT_DOC_BANNER).length - 1 - assert.equal(occurrences, 1, `${name}: AGENT_DOC_BANNER should appear exactly once`) - } -}) diff --git a/apps/claude-code/unic-archon-dlc/test/install-claude-md.test.mjs b/apps/claude-code/unic-archon-dlc/test/install-claude-md.test.mjs deleted file mode 100644 index 05ad4ee0..00000000 --- a/apps/claude-code/unic-archon-dlc/test/install-claude-md.test.mjs +++ /dev/null @@ -1,117 +0,0 @@ -// @ts-check - -import assert from 'node:assert/strict' -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { updateAgentSkillsBlock } from '../lib/agent-docs-writer.mjs' -import { SKILLS_BLOCK_BANNER } from '../lib/dogfood-banner.mjs' - -let _seq = 0 -function tempDir() { - const p = join(tmpdir(), `unic-dlc-claude-${Date.now()}-${++_seq}`) - mkdirSync(p, { recursive: true }) - return p -} - -test('## Agent skills block is written on first run', () => { - const dir = tempDir() - writeFileSync(join(dir, 'CLAUDE.md'), '# Project\n\nSome existing content.\n') - - updateAgentSkillsBlock(dir) - - const content = readFileSync(join(dir, 'CLAUDE.md'), 'utf8') - assert.ok(content.includes('## Agent skills'), 'block heading should be present') - assert.ok(content.includes('issue-tracker.md'), 'issue-tracker link should be present') - assert.ok(content.includes('labels.md'), 'labels link should be present') - // Original content preserved - assert.ok(content.includes('Some existing content.'), 'original content must not be destroyed') -}) - -test('## Agent skills block is refreshed idempotently — never duplicated', () => { - const dir = tempDir() - writeFileSync(join(dir, 'CLAUDE.md'), '# Project\n\nContent.\n') - - updateAgentSkillsBlock(dir) - updateAgentSkillsBlock(dir) - updateAgentSkillsBlock(dir) - - const content = readFileSync(join(dir, 'CLAUDE.md'), 'utf8') - const headingMatches = (content.match(/## Agent skills/g) ?? []).length - assert.equal(headingMatches, 1, 'heading should appear exactly once even after multiple runs') - // Original content preserved - assert.ok(content.includes('Content.'), 'original content must not be destroyed') -}) - -test('SKILLS_BLOCK_BANNER appears inside the marker block after updateAgentSkillsBlock', () => { - const dir = tempDir() - writeFileSync(join(dir, 'CLAUDE.md'), '# Project\n\nSome content.\n') - - updateAgentSkillsBlock(dir) - - const content = readFileSync(join(dir, 'CLAUDE.md'), 'utf8') - const begin = content.indexOf('<!-- unic-archon-dlc:begin -->') - const end = content.indexOf('<!-- unic-archon-dlc:end -->') - assert.ok(begin !== -1 && end !== -1, 'markers must be present') - const block = content.slice(begin, end) - assert.ok(block.includes(SKILLS_BLOCK_BANNER), 'SKILLS_BLOCK_BANNER must appear inside the marker block') - // Surrounding content preserved - assert.ok(content.includes('Some content.'), 'original content must not be destroyed') -}) - -test('SKILLS_BLOCK_BANNER appears exactly once inside the block after multiple updateAgentSkillsBlock runs', () => { - const dir = tempDir() - writeFileSync(join(dir, 'CLAUDE.md'), '# Project\n\nContent.\n') - - updateAgentSkillsBlock(dir) - updateAgentSkillsBlock(dir) - updateAgentSkillsBlock(dir) - - const content = readFileSync(join(dir, 'CLAUDE.md'), 'utf8') - const begin = content.indexOf('<!-- unic-archon-dlc:begin -->') - const end = content.indexOf('<!-- unic-archon-dlc:end -->') - const block = content.slice(begin, end) - const bannerCount = block.split(SKILLS_BLOCK_BANNER).length - 1 - assert.equal(bannerCount, 1, 'SKILLS_BLOCK_BANNER must appear exactly once inside the block') -}) - -test('updateAgentSkillsBlock replaces stale content inside markers (does not append)', () => { - const dir = tempDir() - const seeded = [ - '# Project', - '', - 'Pre-content.', - '', - '## Agent skills', - '', - '<!-- unic-archon-dlc:begin -->', - 'OLD STUFF THAT MUST BE REPLACED', - 'ANOTHER LINE', - '<!-- unic-archon-dlc:end -->', - '', - 'Post-content.', - '', - ].join('\n') - writeFileSync(join(dir, 'CLAUDE.md'), seeded) - - updateAgentSkillsBlock(dir) - - const content = readFileSync(join(dir, 'CLAUDE.md'), 'utf8') - assert.ok(!content.includes('OLD STUFF THAT MUST BE REPLACED'), 'stale content must be removed') - assert.ok(!content.includes('ANOTHER LINE'), 'stale content must be removed') - - const begin = content.indexOf('<!-- unic-archon-dlc:begin -->') - const end = content.indexOf('<!-- unic-archon-dlc:end -->') - assert.ok(begin !== -1 && end !== -1, 'markers must still be present') - const block = content.slice(begin, end) - const bannerCount = block.split(SKILLS_BLOCK_BANNER).length - 1 - assert.equal(bannerCount, 1, 'SKILLS_BLOCK_BANNER must appear exactly once between markers') - - for (const link of ['issue-tracker.md', 'labels.md', 'branching.md', 'domain.md', 'workflow.md']) { - assert.ok(content.includes(link), `${link} link must be present after refresh`) - } - - assert.ok(content.includes('Pre-content.'), 'surrounding pre-content must be preserved') - assert.ok(content.includes('Post-content.'), 'surrounding post-content must be preserved') -}) diff --git a/apps/claude-code/unic-archon-dlc/test/install-helpers.test.mjs b/apps/claude-code/unic-archon-dlc/test/install-helpers.test.mjs deleted file mode 100644 index 94472e39..00000000 --- a/apps/claude-code/unic-archon-dlc/test/install-helpers.test.mjs +++ /dev/null @@ -1,71 +0,0 @@ -// @ts-check - -import assert from 'node:assert/strict' -import { mkdirSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { deducePrStrategy, detectRepoLayout, detectTracker } from '../lib/install-runner.mjs' - -// --- detectTracker --- - -test('detectTracker: github.com remote → github tracker', () => { - assert.equal(detectTracker('https://github.com/org/repo.git'), 'github') -}) - -test('detectTracker: ssh github.com remote → github tracker', () => { - assert.equal(detectTracker('git@github.com:org/repo.git'), 'github') -}) - -test('detectTracker: visualstudio.com remote → ado tracker', () => { - assert.equal(detectTracker('https://org.visualstudio.com/project/_git/repo'), 'ado') -}) - -test('detectTracker: dev.azure.com remote → ado tracker', () => { - assert.equal(detectTracker('https://dev.azure.com/org/project/_git/repo'), 'ado') -}) - -test('detectTracker: null remote → null (no tracker detected)', () => { - assert.equal(detectTracker(null), null) -}) - -test('detectTracker: unknown/unrecognised remote → null (fallback)', () => { - assert.equal(detectTracker('https://bitbucket.org/org/repo.git'), null) -}) - -// --- deducePrStrategy --- - -test('deducePrStrategy: github tracker → squash merge strategy', () => { - assert.equal(deducePrStrategy('github'), 'squash') -}) - -test('deducePrStrategy: ado tracker → squash merge strategy', () => { - assert.equal(deducePrStrategy('ado'), 'squash') -}) - -test('deducePrStrategy: jira tracker → merge strategy (fallback)', () => { - assert.equal(deducePrStrategy('jira'), 'merge') -}) - -test('deducePrStrategy: local-markdown tracker → merge strategy (fallback)', () => { - assert.equal(deducePrStrategy('local-markdown'), 'merge') -}) - -test('deducePrStrategy: unknown tracker → merge strategy (fallback)', () => { - assert.equal(deducePrStrategy('unknown-tracker'), 'merge') -}) - -// --- detectRepoLayout --- - -test('detectRepoLayout: CONTEXT-MAP.md present → multi-context', () => { - const dir = join(tmpdir(), `unic-dlc-layout-multi-${Date.now()}`) - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'CONTEXT-MAP.md'), '# Context Map') - assert.equal(detectRepoLayout(dir), 'multi-context') -}) - -test('detectRepoLayout: CONTEXT-MAP.md absent → single-context', () => { - const dir = join(tmpdir(), `unic-dlc-layout-single-${Date.now()}`) - mkdirSync(dir, { recursive: true }) - assert.equal(detectRepoLayout(dir), 'single-context') -}) diff --git a/apps/claude-code/unic-archon-dlc/test/install-runner.test.mjs b/apps/claude-code/unic-archon-dlc/test/install-runner.test.mjs deleted file mode 100644 index 794a56f5..00000000 --- a/apps/claude-code/unic-archon-dlc/test/install-runner.test.mjs +++ /dev/null @@ -1,267 +0,0 @@ -// @ts-check - -import assert from 'node:assert/strict' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { runInstall } from '../lib/install-runner.mjs' - -test('fresh install: partialAnswers provides all mandatory fields, defaults applied', () => { - const dir = join(tmpdir(), `unic-dlc-runner-fresh-${Date.now()}`) - mkdirSync(dir, { recursive: true }) - - const result = runInstall(dir, { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow' }) - - assert.ok(result.ok, `expected ok:true but got ${result.ok === false ? result.message : ''}`) - assert.equal(result.configPath, join(dir, '.archon', 'unic-dlc.config.json')) - - const config = JSON.parse(readFileSync(result.configPath, 'utf8')) - assert.equal(config.tracker, 'github') - assert.equal(config.pr_strategy, 'squash') - assert.equal(config.branching, 'gitflow') - assert.equal(config.model_profile, 'balanced') - assert.ok(existsSync(join(dir, 'docs', 'agents', 'issue-tracker.md'))) - assert.ok(existsSync(join(dir, 'CLAUDE.md'))) -}) - -test('partial fill: existing optional fields survive merge, partialAnswers fills new field', () => { - const dir = join(tmpdir(), `unic-dlc-runner-partial-${Date.now()}`) - mkdirSync(join(dir, '.archon'), { recursive: true }) - - const existing = { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow', model_profile: 'fast' } - writeFileSync(join(dir, '.archon', 'unic-dlc.config.json'), `${JSON.stringify(existing, null, 2)}\n`) - - const result = runInstall(dir, { e2e_command: 'npm test' }) - - assert.ok(result.ok) - const config = JSON.parse(readFileSync(result.configPath, 'utf8')) - assert.equal(config.model_profile, 'fast') // existing overrides default 'balanced' - assert.equal(config.e2e_command, 'npm test') // new field added by partialAnswers - assert.equal(config.tracker, 'github') // mandatory preserved from existing -}) - -test('full config, no changes: existing values written back unchanged when partialAnswers is empty', () => { - const dir = join(tmpdir(), `unic-dlc-runner-full-${Date.now()}`) - mkdirSync(join(dir, '.archon'), { recursive: true }) - - const existing = { tracker: 'ado', pr_strategy: 'squash', branching: 'github-flow' } - writeFileSync(join(dir, '.archon', 'unic-dlc.config.json'), `${JSON.stringify(existing, null, 2)}\n`) - - const result = runInstall(dir, {}) - - assert.ok(result.ok) - const config = JSON.parse(readFileSync(result.configPath, 'utf8')) - assert.equal(config.tracker, 'ado') - assert.equal(config.pr_strategy, 'squash') - assert.equal(config.branching, 'github-flow') -}) - -test('reconfigure: partialAnswers overwrite existing fields, non-overridden fields preserved', () => { - const dir = join(tmpdir(), `unic-dlc-runner-reconfig-${Date.now()}`) - mkdirSync(join(dir, '.archon'), { recursive: true }) - - const existing = { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow' } - writeFileSync(join(dir, '.archon', 'unic-dlc.config.json'), `${JSON.stringify(existing, null, 2)}\n`) - - const result = runInstall(dir, { tracker: 'ado', branching: 'github-flow' }) - - assert.ok(result.ok) - const config = JSON.parse(readFileSync(result.configPath, 'utf8')) - assert.equal(config.tracker, 'ado') // overwritten by partialAnswers - assert.equal(config.branching, 'github-flow') // overwritten by partialAnswers - assert.equal(config.pr_strategy, 'squash') // preserved from existing (not in partialAnswers) -}) - -test('missing mandatory field after merge returns validate error without writing', () => { - const dir = join(tmpdir(), `unic-dlc-runner-validate-${Date.now()}`) - mkdirSync(dir, { recursive: true }) - - const result = runInstall(dir, {}) - - assert.equal(result.ok, false) - assert.ok(!result.ok) - assert.equal(result.stage, 'validate') - assert.ok(result.message.includes('Missing mandatory fields')) - assert.ok(!existsSync(join(dir, '.archon', 'unic-dlc.config.json'))) -}) - -test('partial config: optional fields preserved when mandatory fields are missing from existing file', () => { - const dir = join(tmpdir(), `unic-dlc-runner-partialopt-${Date.now()}`) - mkdirSync(join(dir, '.archon'), { recursive: true }) - - // File has optional fields but is missing all mandatory fields - const partial = { e2e_command: 'pnpm test', model_profile: 'fast' } - writeFileSync(join(dir, '.archon', 'unic-dlc.config.json'), `${JSON.stringify(partial, null, 2)}\n`) - - // partialAnswers supplies the missing mandatory fields - const result = runInstall(dir, { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow' }) - - assert.ok(result.ok, `expected ok:true but got ${result.ok === false ? result.message : ''}`) - const config = JSON.parse(readFileSync(result.configPath, 'utf8')) - assert.equal(config.e2e_command, 'pnpm test') // optional field preserved - assert.equal(config.model_profile, 'fast') // optional field preserved (overrides default) - assert.equal(config.tracker, 'github') // mandatory field from partialAnswers -}) - -test('writeAgentDocs failure: error message includes config path confirmation', async () => { - // Block writeAgentDocs by pre-creating docs/ as a file — mkdirSync inside - // writeAgentDocs will throw ENOTDIR, exercising the docs-stage error path. - const { mkdirSync: mkdir, writeFileSync: wf, readFileSync: rf, existsSync: ef } = await import('node:fs') - const dir = join(tmpdir(), `unic-dlc-runner-docserr-${Date.now()}`) - mkdir(dir, { recursive: true }) - - // Block docs/agents/ by making docs/ a file (not a directory) - wf(join(dir, 'docs'), 'not-a-directory') - - const result = runInstall(dir, { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow' }) - - // Config should have been written - assert.ok(ef(join(dir, '.archon', 'unic-dlc.config.json')), 'config file should exist') - - assert.equal(result.ok, false) - assert.ok(!result.ok) - assert.equal(result.stage, 'docs') - assert.ok( - result.message.includes('Config written to'), - `Expected "Config written to" in message, got: ${result.message}` - ) - - void rf -}) - -test('corrupt config: invalid JSON returns config stage error with parse message', () => { - const dir = join(tmpdir(), `unic-dlc-runner-corrupt-${Date.now()}`) - mkdirSync(join(dir, '.archon'), { recursive: true }) - - // Write deliberately broken JSON - writeFileSync(join(dir, '.archon', 'unic-dlc.config.json'), '{ not valid json !!!') - - const result = runInstall(dir, { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow' }) - - assert.equal(result.ok, false) - assert.equal(result.stage, 'config') - assert.ok(result.message.includes('invalid JSON'), `Expected "invalid JSON" in message, got: ${result.message}`) - assert.ok(result.message.includes('Parse error:'), `Expected "Parse error:" in message, got: ${result.message}`) -}) - -test('updateAgentSkillsBlock failure: stage is claude-md and message includes config/docs confirmation', () => { - const dir = join(tmpdir(), `unic-dlc-runner-claudemderr-${Date.now()}`) - mkdirSync(dir, { recursive: true }) - - // Block CLAUDE.md from being written by pre-creating it as a directory - mkdirSync(join(dir, 'CLAUDE.md')) - - const result = runInstall(dir, { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow' }) - - assert.equal(result.ok, false) - assert.equal(result.stage, 'claude-md') - assert.ok( - result.message.includes('Config and docs written'), - `Expected "Config and docs written" in message, got: ${result.message}` - ) -}) - -test('asset copy: workflows and commands are copied from pluginRoot', () => { - const dir = join(tmpdir(), `unic-dlc-runner-assets-${Date.now()}`) - const pluginRoot = join(tmpdir(), `unic-dlc-plugin-${Date.now()}`) - mkdirSync(join(pluginRoot, '.archon', 'workflows'), { recursive: true }) - mkdirSync(join(pluginRoot, '.archon', 'commands'), { recursive: true }) - writeFileSync(join(pluginRoot, '.archon', 'workflows', 'build.yaml'), 'name: unic-dlc-build\n') - writeFileSync(join(pluginRoot, '.archon', 'workflows', 'plan.yaml'), 'name: unic-dlc-plan\n') - writeFileSync(join(pluginRoot, '.archon', 'workflows', 'README.md'), '# ignored') - writeFileSync(join(pluginRoot, '.archon', 'commands', 'unic-dlc-build.md'), '# build command\n') - writeFileSync(join(pluginRoot, '.archon', 'commands', 'unic-dlc-plan.md'), '# plan command\n') - writeFileSync(join(pluginRoot, '.archon', 'commands', '.gitkeep'), '') - mkdirSync(dir, { recursive: true }) - - const result = runInstall(dir, { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow' }, pluginRoot) - - assert.ok(result.ok) - assert.equal(result.workflowsCopied, 2) - assert.equal(result.commandsCopied, 2) - assert.ok(existsSync(join(dir, '.archon', 'workflows', 'build.yaml'))) - assert.ok(existsSync(join(dir, '.archon', 'workflows', 'plan.yaml'))) - assert.ok(!existsSync(join(dir, '.archon', 'workflows', 'README.md'))) - assert.ok(existsSync(join(dir, '.archon', 'commands', 'unic-dlc-build.md'))) - assert.ok(existsSync(join(dir, '.archon', 'commands', 'unic-dlc-plan.md'))) - assert.ok(!existsSync(join(dir, '.archon', 'commands', '.gitkeep'))) -}) - -test('asset copy: skipped gracefully when pluginRoot is null', () => { - const dir = join(tmpdir(), `unic-dlc-runner-noassets-${Date.now()}`) - mkdirSync(dir, { recursive: true }) - - const result = runInstall(dir, { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow' }, null) - - assert.ok(result.ok) - assert.equal(result.workflowsCopied, 0) - assert.equal(result.commandsCopied, 0) - assert.ok(!existsSync(join(dir, '.archon', 'workflows'))) - assert.ok(!existsSync(join(dir, '.archon', 'commands'))) -}) - -test('asset copy: skipped gracefully when pluginRoot has no .archon subdirs', () => { - const dir = join(tmpdir(), `unic-dlc-runner-noarchon-${Date.now()}`) - const pluginRoot = join(tmpdir(), `unic-dlc-plugin-empty-${Date.now()}`) - mkdirSync(pluginRoot, { recursive: true }) - mkdirSync(dir, { recursive: true }) - - const result = runInstall(dir, { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow' }, pluginRoot) - - assert.ok(result.ok) - assert.equal(result.workflowsCopied, 0) - assert.equal(result.commandsCopied, 0) -}) - -test('asset copy: overwrites existing files on re-run', () => { - const dir = join(tmpdir(), `unic-dlc-runner-overwrite-${Date.now()}`) - const pluginRoot = join(tmpdir(), `unic-dlc-plugin-ow-${Date.now()}`) - mkdirSync(join(pluginRoot, '.archon', 'workflows'), { recursive: true }) - mkdirSync(join(pluginRoot, '.archon', 'commands'), { recursive: true }) - writeFileSync(join(pluginRoot, '.archon', 'workflows', 'build.yaml'), 'name: unic-dlc-build\nruntime: bun\n') - writeFileSync(join(pluginRoot, '.archon', 'commands', 'unic-dlc-build.md'), '# updated command\n') - mkdirSync(join(dir, '.archon', 'workflows'), { recursive: true }) - mkdirSync(join(dir, '.archon', 'commands'), { recursive: true }) - writeFileSync(join(dir, '.archon', 'workflows', 'build.yaml'), 'name: unic-dlc-build\n') - writeFileSync(join(dir, '.archon', 'commands', 'unic-dlc-build.md'), '# old command\n') - - const result = runInstall(dir, { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow' }, pluginRoot) - - assert.ok(result.ok) - assert.equal(result.workflowsCopied, 1) - assert.equal(result.commandsCopied, 1) - const workflow = readFileSync(join(dir, '.archon', 'workflows', 'build.yaml'), 'utf8') - assert.ok(workflow.includes('runtime: bun'), 'stale workflow should be overwritten') - const command = readFileSync(join(dir, '.archon', 'commands', 'unic-dlc-build.md'), 'utf8') - assert.ok(command.includes('# updated command'), 'stale command should be overwritten') -}) - -test('custom labels in existing config are preserved on reconfigure', () => { - const dir = join(tmpdir(), `unic-dlc-runner-labels-${Date.now()}`) - mkdirSync(join(dir, '.archon'), { recursive: true }) - - // Valid LabelMapping shape with custom tracker strings (different from defaults) - const customLabels = { - state: { - 'needs-triage': 'triage', - 'needs-info': 'info', - 'needs-specs': 'spec', - 'ready-for-agent': 'agent', - 'ready-for-human': 'human', - resolved: 'done', - closed: 'closed', - rejected: 'wont-fix', - }, - type: { feature: 'enhancement', bug: 'defect', spike: 'spike', 'tech-debt': 'debt', docs: 'docs' }, - priority: { p0: 'critical', p1: 'high', p2: 'medium', p3: 'low' }, - } - const existing = { tracker: 'github', pr_strategy: 'squash', branching: 'gitflow', labels: customLabels } - writeFileSync(join(dir, '.archon', 'unic-dlc.config.json'), `${JSON.stringify(existing, null, 2)}\n`) - - const result = runInstall(dir, {}) - - assert.ok(result.ok, `expected ok:true but got ${result.ok === false ? result.message : ''}`) - const config = JSON.parse(readFileSync(result.configPath, 'utf8')) - assert.deepEqual(config.labels, customLabels, 'custom labels should be preserved') -}) diff --git a/apps/claude-code/unic-archon-dlc/test/setup-explorer.test.mjs b/apps/claude-code/unic-archon-dlc/test/setup-explorer.test.mjs deleted file mode 100644 index 6762704f..00000000 --- a/apps/claude-code/unic-archon-dlc/test/setup-explorer.test.mjs +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-check - -import assert from 'node:assert/strict' -import { mkdirSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { exploreProject } from '../lib/setup-explorer.mjs' - -test('returns structured snapshot; missing files are absent not throwing', async () => { - const dir = join(tmpdir(), `unic-dlc-explore-${Date.now()}`) - mkdirSync(dir, { recursive: true }) - // Only create CLAUDE.md — leave CONTEXT.md, CONTEXT-MAP.md, docs/adr/, .archon/ absent - writeFileSync(join(dir, 'CLAUDE.md'), '# test project') - - const snapshot = exploreProject(dir) - - // gitRemote: no git repo in tmpdir — should be null, not throw - assert.equal(snapshot.gitRemote, null) - - // CLAUDE.md present - assert.equal(snapshot.claudeMd.present, true) - assert.ok(snapshot.claudeMd.content?.includes('test project')) - - // Missing files reported as absent - assert.equal(snapshot.contextMd.present, false) - assert.equal(snapshot.contextMd.content, null) - assert.equal(snapshot.contextMapMd.present, false) - - // ADR directory absent — empty array, not throw - assert.deepEqual(snapshot.adrFiles, []) - - // No existing .archon config - assert.equal(snapshot.archonConfigPresent, false) - assert.equal(snapshot.existingConfig, null) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2fe9a1c..e441d91f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: typescript: specifier: 5.8.3 version: 5.8.3 + yaml: + specifier: 2.6.1 + version: 2.6.1 importers: @@ -67,6 +70,10 @@ importers: version: link:../../../packages/release-tools apps/claude-code/unic-archon-dlc: + dependencies: + yaml: + specifier: 'catalog:' + version: 2.6.1 devDependencies: '@types/node': specifier: 'catalog:' @@ -223,6 +230,11 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + yaml@2.6.1: + resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==} + engines: {node: '>= 14'} + hasBin: true + snapshots: '@biomejs/biome@2.4.0': @@ -271,3 +283,5 @@ snapshots: typescript@5.8.3: {} undici-types@7.16.0: {} + + yaml@2.6.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e906dba1..d0c51bc7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ catalog: "@types/node": 24.12.2 prettier: 3.4.2 typescript: 5.8.3 + yaml: 2.6.1 catalogMode: prefer From 7dc79409650dfff728de1e63612b03fc56ceecf1 Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa <oriol.torrent@unic.com> Date: Thu, 2 Jul 2026 13:30:28 +0200 Subject: [PATCH 2/5] =?UTF-8?q?chore(unic-archon-dlc):=20bump=200.1.2=20?= =?UTF-8?q?=E2=86=92=200.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the conversational-/setup CHANGELOG entry and sync marketplace.json + package.json. Minor bump: the config format moves from JSON to the rich `.archon/unic-dlc.config.yaml` (ADR-0018/0019). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../unic-archon-dlc/.claude-plugin/marketplace.json | 2 +- .../unic-archon-dlc/.claude-plugin/plugin.json | 2 +- apps/claude-code/unic-archon-dlc/CHANGELOG.md | 11 +++++++++++ apps/claude-code/unic-archon-dlc/package.json | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/claude-code/unic-archon-dlc/.claude-plugin/marketplace.json b/apps/claude-code/unic-archon-dlc/.claude-plugin/marketplace.json index 9968a423..c6f39214 100644 --- a/apps/claude-code/unic-archon-dlc/.claude-plugin/marketplace.json +++ b/apps/claude-code/unic-archon-dlc/.claude-plugin/marketplace.json @@ -21,7 +21,7 @@ "name": "unic-archon-dlc", "source": "./", "tags": ["productivity", "workflow", "ai-development"], - "version": "0.1.2" + "version": "0.2.0" } ] } diff --git a/apps/claude-code/unic-archon-dlc/.claude-plugin/plugin.json b/apps/claude-code/unic-archon-dlc/.claude-plugin/plugin.json index 6baec753..0fcd6461 100644 --- a/apps/claude-code/unic-archon-dlc/.claude-plugin/plugin.json +++ b/apps/claude-code/unic-archon-dlc/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "unic-archon-dlc", - "version": "0.1.2", + "version": "0.2.0", "description": "A complete Archon-powered AI development lifecycle as an installable DLC pack — explore, plan, build, qa, cleanup, and triage workflows.", "author": { "name": "Unic AG", diff --git a/apps/claude-code/unic-archon-dlc/CHANGELOG.md b/apps/claude-code/unic-archon-dlc/CHANGELOG.md index 4c76ac17..b6da9b06 100644 --- a/apps/claude-code/unic-archon-dlc/CHANGELOG.md +++ b/apps/claude-code/unic-archon-dlc/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +### Breaking +- (none) + +### Added +- (none) + +### Fixed +- (none) + +## [0.2.0] — 2026-07-02 + ### Breaking - **`/setup` is now conversational and writes the rich `.archon/unic-dlc.config.yaml`** (ADR-0019, supersedes ADR-0001), replacing the flat `.archon/unic-dlc.config.json`. The command detects the stack, runs verify-only skill discovery (introspect MCP/skills + CLI probes; never installs) to register a capability→tool map, verifies Matt Pocock's declared skill suite (warn + degrade, non-blocking on a missing required capability), and composes the team's system-skills for the _how_. An existing legacy `.json` is read and migrated but **left in place** (other tools may read it) — no backup file, no delete. - **Dissolved the heavy setup libs** `lib/install-runner.mjs`, `lib/setup-explorer.mjs`, `lib/config-loader.mjs`, and `lib/agent-docs-writer.mjs` (and their tests). Their `docs/agents/` + `CLAUDE.md` marker-block behaviour is re-homed to idempotent prose steps in `commands/setup.md`. See ADR-0018. diff --git a/apps/claude-code/unic-archon-dlc/package.json b/apps/claude-code/unic-archon-dlc/package.json index 5b016fae..0b3c40bb 100644 --- a/apps/claude-code/unic-archon-dlc/package.json +++ b/apps/claude-code/unic-archon-dlc/package.json @@ -1,6 +1,6 @@ { "name": "unic-archon-dlc", - "version": "0.1.2", + "version": "0.2.0", "private": true, "license": "LGPL-3.0-or-later", "type": "module", From 950da36a7aee29c806e97ef4ac06b681511dc1c7 Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa <oriol.torrent@unic.com> Date: Thu, 2 Jul 2026 13:31:15 +0200 Subject: [PATCH 3/5] docs(unic-archon-dlc): mark redesign step 03 (/setup) done in progress table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set row 03 → ✅ (PR #263) and advance row 04 (/specs) to ▶️ next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- apps/claude-code/unic-archon-dlc/docs/redesign/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/claude-code/unic-archon-dlc/docs/redesign/README.md b/apps/claude-code/unic-archon-dlc/docs/redesign/README.md index ad34dc20..d1af8e91 100644 --- a/apps/claude-code/unic-archon-dlc/docs/redesign/README.md +++ b/apps/claude-code/unic-archon-dlc/docs/redesign/README.md @@ -15,8 +15,8 @@ This directory drives the refactor of `unic-archon-dlc` into a **thin, Matt-alig | 00 | Archon schema pre-work | — | ✅ done | ADR-0011 (#262) | | 01 | Foundations | — | ✅ done | ADRs 0012–0021 + AGENTS/CONTEXT sweep (#262) | | 02 | `/handoff` | Matt's, ref | ⏭️ skip | referenced verbatim, not shipped; dependency declared in `/setup` | -| 03 | `/setup` | skill | ▶️ next | linchpin — skill-discovery + registry every other box reads | -| 04 | `/specs` | skill | ⬜ todo | branch-on-input | +| 03 | `/setup` | skill | ✅ done | conversational skill + `config-schema` lib; rich YAML config (#263) | +| 04 | `/specs` | skill | ▶️ next | branch-on-input | | 05 | `/tickets` | skill | ⬜ todo | + dag-builder / nyquist lib | | 06 | `/build` | Archon | ⬜ todo | keystone — anti-cheat red/green | | 07 | `/triage` | skill | ⬜ todo | intake on-ramp | From df5fe8cb52e6cf86f703bccfafb8292020b18c87 Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa <oriol.torrent@unic.com> Date: Thu, 2 Jul 2026 13:32:41 +0200 Subject: [PATCH 4/5] style(unic-archon-dlc): prettier-format README config reference table The rich-YAML config table was edited after the format pass; re-run Prettier so the root checks pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- apps/claude-code/unic-archon-dlc/README.md | 46 +++++++++++----------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/claude-code/unic-archon-dlc/README.md b/apps/claude-code/unic-archon-dlc/README.md index 1cddc11a..788501ef 100644 --- a/apps/claude-code/unic-archon-dlc/README.md +++ b/apps/claude-code/unic-archon-dlc/README.md @@ -163,29 +163,29 @@ From here, the full lifecycle is: explore → plan → build → qa → cleanup The `/unic-archon-dlc:setup` command writes the rich `.archon/unic-dlc.config.yaml` ([ADR-0018](docs/adr/0018-generic-core-config-compose.md), [ADR-0019](docs/adr/0019-conversational-setup.md)). It is the single config substrate every box reads; setup is its sole writer, is idempotent (a re-run merges, never clobbers), and reads any legacy `.archon/unic-dlc.config.json` to migrate it (the old file is left in place). Top-level sections: -| Path | Default | Valid values | Description | -| ----------------------------- | ---------------------- | -------------------------------------------- | ----------------------------------------------------------------- | -| `project.name` | asked | any string | Project name | -| `project.repo_layout` | auto-detected | `single-context` · `multi-context` | Whether `CONTEXT-MAP.md` is present | -| `project.branching` | asked | `gitflow` · `github-flow` | Branching model (mandatory) | -| `project.pr_strategy` | asked | `squash` · `merge` · `rebase` | PR merge strategy (mandatory) | -| `tracker.type` | auto-detected | `github` · `ado` · `jira` · `local-markdown` | Issue tracker backend (mandatory) | -| `tracker.access` | discovered | `{ mcp, cli }` | Capability→tool for the tracker (MCP-first, CLI-fallback) | -| `tracker.coords` | asked | tracker-specific map | e.g. `{ owner, repo }` (github) / `{ org, project, repo }` (ado) | -| `docs.type` | `markdown` | `confluence` · `markdown` · `none` | Where the team's product specs live (drives `/specs` publishing) | -| `docs.publish` | `false` | `true` · `false` | Opt-in publishing of the PRD to the docs system | -| `design.type` | `none` | `figma` · `none` | Design system source | -| `templates.{prd,issue,bug}` | `null` | template string | Config-driven artifact templates (ADR-0018) | -| `classification.labels.*` | canonical | any string | 3-tier label mapping (state · type · priority) | -| `gates.{build,qa,pr-review,explore}` | `hitl` | `hitl` · `afk` | Per-Archon-box gate mode (ADR-0017); interactive boxes are HITL | -| `build.fresh_context_red_green` | `true` | `true` · `false` | Anti-cheat fresh-context red/green separation (ADR-0012) | -| `build.{tdd_mode,nyquist_validation,slopsquatting_gate}` | `true` | `true` · `false` | Build discipline toggles | -| `build.e2e_command` | `null` | shell command string | Full e2e suite command | -| `build.coverage_threshold` | `null` | number (0–100) or `null` | Minimum % coverage; `null` skips the check | -| `estimations` | `off` | `off` · `provisional` · `definitive` · `both`| Estimation waves (ADR-0020) | -| `artifacts_dir` | `workflows` | dir name | Session artifact home base (`<artifacts_dir>/<slug>/`) | -| `model_profile` | `balanced` | `fast` · `balanced` · `max` | Model tier for workflow nodes | -| `skills.matt_suite` | discovered | `{ present, missing }` | Verify-only discovery result for Matt Pocock's declared skill suite | +| Path | Default | Valid values | Description | +| -------------------------------------------------------- | ------------- | --------------------------------------------- | ------------------------------------------------------------------- | +| `project.name` | asked | any string | Project name | +| `project.repo_layout` | auto-detected | `single-context` · `multi-context` | Whether `CONTEXT-MAP.md` is present | +| `project.branching` | asked | `gitflow` · `github-flow` | Branching model (mandatory) | +| `project.pr_strategy` | asked | `squash` · `merge` · `rebase` | PR merge strategy (mandatory) | +| `tracker.type` | auto-detected | `github` · `ado` · `jira` · `local-markdown` | Issue tracker backend (mandatory) | +| `tracker.access` | discovered | `{ mcp, cli }` | Capability→tool for the tracker (MCP-first, CLI-fallback) | +| `tracker.coords` | asked | tracker-specific map | e.g. `{ owner, repo }` (github) / `{ org, project, repo }` (ado) | +| `docs.type` | `markdown` | `confluence` · `markdown` · `none` | Where the team's product specs live (drives `/specs` publishing) | +| `docs.publish` | `false` | `true` · `false` | Opt-in publishing of the PRD to the docs system | +| `design.type` | `none` | `figma` · `none` | Design system source | +| `templates.{prd,issue,bug}` | `null` | template string | Config-driven artifact templates (ADR-0018) | +| `classification.labels.*` | canonical | any string | 3-tier label mapping (state · type · priority) | +| `gates.{build,qa,pr-review,explore}` | `hitl` | `hitl` · `afk` | Per-Archon-box gate mode (ADR-0017); interactive boxes are HITL | +| `build.fresh_context_red_green` | `true` | `true` · `false` | Anti-cheat fresh-context red/green separation (ADR-0012) | +| `build.{tdd_mode,nyquist_validation,slopsquatting_gate}` | `true` | `true` · `false` | Build discipline toggles | +| `build.e2e_command` | `null` | shell command string | Full e2e suite command | +| `build.coverage_threshold` | `null` | number (0–100) or `null` | Minimum % coverage; `null` skips the check | +| `estimations` | `off` | `off` · `provisional` · `definitive` · `both` | Estimation waves (ADR-0020) | +| `artifacts_dir` | `workflows` | dir name | Session artifact home base (`<artifacts_dir>/<slug>/`) | +| `model_profile` | `balanced` | `fast` · `balanced` · `max` | Model tier for workflow nodes | +| `skills.matt_suite` | discovered | `{ present, missing }` | Verify-only discovery result for Matt Pocock's declared skill suite | Label canonical names: states `needs-triage` · `needs-info` · `needs-specs` · `ready-for-agent` · `ready-for-human` · `resolved` · `closed` · `rejected`; types `feature` · `bug` · `spike` · From 9a01b04c3b5fb7a56456eadf1ac80c931b11f035 Mon Sep 17 00:00:00 2001 From: Oriol Torrent Florensa <oriol.torrent@unic.com> Date: Thu, 2 Jul 2026 14:06:13 +0200 Subject: [PATCH 5/5] fix(unic-archon-dlc): address Copilot review on #263 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup.md Step 5: fail fast with `stage: 'config'` when an existing config is present but unreadable, instead of falling back to `{}` and clobbering it (restores the never-clobber guarantee). Guard the loadConfig error contract with a new malformed-content test. - setup.md + README: soften the "config every box reads" claim — it is the substrate the *redesigned* boxes read; pre-redesign `.archon/workflows/*` still use the old JSON/keys until migrated in their own steps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- apps/claude-code/unic-archon-dlc/README.md | 2 +- .../unic-archon-dlc/commands/setup.md | 28 ++++++++++++------- .../test/config-schema.test.mjs | 12 ++++++++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/claude-code/unic-archon-dlc/README.md b/apps/claude-code/unic-archon-dlc/README.md index 788501ef..8159bce9 100644 --- a/apps/claude-code/unic-archon-dlc/README.md +++ b/apps/claude-code/unic-archon-dlc/README.md @@ -161,7 +161,7 @@ From here, the full lifecycle is: explore → plan → build → qa → cleanup ## Configuration reference -The `/unic-archon-dlc:setup` command writes the rich `.archon/unic-dlc.config.yaml` ([ADR-0018](docs/adr/0018-generic-core-config-compose.md), [ADR-0019](docs/adr/0019-conversational-setup.md)). It is the single config substrate every box reads; setup is its sole writer, is idempotent (a re-run merges, never clobbers), and reads any legacy `.archon/unic-dlc.config.json` to migrate it (the old file is left in place). Top-level sections: +The `/unic-archon-dlc:setup` command writes the rich `.archon/unic-dlc.config.yaml` ([ADR-0018](docs/adr/0018-generic-core-config-compose.md), [ADR-0019](docs/adr/0019-conversational-setup.md)). It is the config substrate the **redesigned** boxes read; setup is its sole writer, is idempotent (a re-run merges, never clobbers — a present-but-malformed config fails fast rather than being overwritten), and reads any legacy `.archon/unic-dlc.config.json` to migrate it (the old file is left in place). The pre-redesign workflows under `.archon/workflows/` still read the old JSON schema and are migrated onto this file box by box in later redesign steps. Top-level sections: | Path | Default | Valid values | Description | | -------------------------------------------------------- | ------------- | --------------------------------------------- | ------------------------------------------------------------------- | diff --git a/apps/claude-code/unic-archon-dlc/commands/setup.md b/apps/claude-code/unic-archon-dlc/commands/setup.md index 7cb66be6..5d6ee887 100644 --- a/apps/claude-code/unic-archon-dlc/commands/setup.md +++ b/apps/claude-code/unic-archon-dlc/commands/setup.md @@ -10,7 +10,7 @@ description: 'Configure unic-archon-dlc for this project: detect the stack, regi **Arguments:** "$ARGUMENTS" -`/setup` is the **sole configuration entry point** and is **conversational**: it detects the stack, **composes the team's system-skills** to discover what the team has, and writes the rich `.archon/unic-dlc.config.yaml` that every other box reads. Only one deterministic concern is delegated to tested code — schema-validate + idempotent merge + YAML emit — in `lib/config-schema.mjs`. Conduct the conversation yourself; do not invent config keys the schema doesn't define. +`/setup` is the **sole configuration entry point** and is **conversational**: it detects the stack, **composes the team's system-skills** to discover what the team has, and writes the rich `.archon/unic-dlc.config.yaml` — the config substrate the redesigned boxes read (each box is migrated onto it in its own redesign step; pre-redesign workflows under `.archon/workflows/` still reference the old JSON/keys until then). Only one deterministic concern is delegated to tested code — schema-validate + idempotent merge + YAML emit — in `lib/config-schema.mjs`. Conduct the conversation yourself; do not invent config keys the schema doesn't define. Follow these steps in order. Do not skip any step. Do not write any files except through Step 5 (config) and Step 6 (docs). @@ -160,23 +160,31 @@ try { const jsonPath = join(cwd, '.archon', 'unic-dlc.config.json') // Load whatever exists; migrate a legacy flat .json but NEVER delete or modify it. + // A present-but-malformed config MUST fail fast — never fall back to {} and clobber it. let existing = {} + let loadError = null if (existsSync(yamlPath)) { const r = mod.loadConfig(yamlPath) - if (!('error' in r)) existing = r.config + if ('error' in r) loadError = r.message + else existing = r.config } else if (existsSync(jsonPath)) { const r = mod.loadConfig(jsonPath) - if (!('error' in r)) existing = mod.isLegacyConfig(r.config) ? mod.migrateLegacy(r.config) : r.config + if ('error' in r) loadError = r.message + else existing = mod.isLegacyConfig(r.config) ? mod.migrateLegacy(r.config) : r.config } - const merged = mod.mergeConfig(existing, answers) - const emitted = mod.toYaml(merged) - if ('error' in emitted) { - result = { ok: false, stage: 'validate', message: emitted.message } + if (loadError) { + result = { ok: false, stage: 'config', message: `Existing config is present but unreadable — refusing to overwrite it. Fix or remove the file and re-run. ${loadError}` } } else { - mkdirSync(join(cwd, '.archon'), { recursive: true }) - writeFileSync(yamlPath, emitted.yaml) - result = { ok: true, configPath: yamlPath, legacyKept: existsSync(jsonPath) ? jsonPath : null } + const merged = mod.mergeConfig(existing, answers) + const emitted = mod.toYaml(merged) + if ('error' in emitted) { + result = { ok: false, stage: 'validate', message: emitted.message } + } else { + mkdirSync(join(cwd, '.archon'), { recursive: true }) + writeFileSync(yamlPath, emitted.yaml) + result = { ok: true, configPath: yamlPath, legacyKept: existsSync(jsonPath) ? jsonPath : null } + } } } catch (err) { result = { ok: false, stage: 'unexpected', message: `Unexpected error: ${err?.message ?? String(err)}` } diff --git a/apps/claude-code/unic-archon-dlc/test/config-schema.test.mjs b/apps/claude-code/unic-archon-dlc/test/config-schema.test.mjs index 112d5ced..354a2265 100644 --- a/apps/claude-code/unic-archon-dlc/test/config-schema.test.mjs +++ b/apps/claude-code/unic-archon-dlc/test/config-schema.test.mjs @@ -80,6 +80,18 @@ test('loadConfig returns structured error for a missing file', () => { assert.ok('error' in result && result.error === true) }) +test('loadConfig returns structured error for malformed content (setup relies on this to fail fast)', () => { + const jsonPath = join(tempDir(), 'unic-dlc.config.json') + writeFileSync(jsonPath, '{ not: valid json,,, ') + const jsonResult = loadConfig(jsonPath) + assert.ok('error' in jsonResult && jsonResult.error === true, 'malformed JSON should error') + + const yamlPath = join(tempDir(), 'unic-dlc.config.yaml') + writeFileSync(yamlPath, 'project:\n name: "unterminated\n\tbad: indent') + const yamlResult = loadConfig(yamlPath) + assert.ok('error' in yamlResult && yamlResult.error === true, 'malformed YAML should error') +}) + test('validateConfig flags each missing mandatory path', () => { const result = validateConfig(defaultConfig()) // all mandatory leaves are null assert.ok('error' in result && result.error === true)