diff --git a/AGENTS.md b/AGENTS.md index 4245f0c..80de425 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,8 @@ apps/ ├── claude-code/ # Claude Code plugins — one dir per plugin │ ├── pr-review/ │ ├── auto-format/ -│ └── unic-confluence/ +│ ├── unic-confluence/ +│ └── unic-archon-dlc/ └── copilot/ # GitHub Copilot plugins (future) packages/ ├── biome-config/ # @unic/biome-config diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index ddd3cbc..c6ebe22 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -9,10 +9,12 @@ - [auto-format](./apps/claude-code/auto-format/CONTEXT.md) — formatting automation hook for Claude Code - [pr-review](./apps/claude-code/pr-review/CONTEXT.md) — PR review command targeting Azure DevOps - [unic-confluence](./apps/claude-code/unic-confluence/CONTEXT.md) — Markdown-to-Confluence publishing command +- [unic-archon-dlc](./apps/claude-code/unic-archon-dlc/CONTEXT.md) — Archon-powered AI development lifecycle DLC ## Relationships -- All three Plugin contexts share the vocabulary defined in the monorepo context -- **auto-format** and **pr-review** are Claude Code Plugins with no runtime dependencies on each other +- All Plugin contexts share the vocabulary defined in the monorepo context +- **auto-format**, **pr-review**, and **unic-archon-dlc** are Claude Code Plugins with no runtime dependencies on each other - **unic-confluence** can be installed as a git dependency for use outside Claude Code. - **pr-review** has a soft dependency on the `pr-review-toolkit` plugin from `anthropics/claude-plugins-official` +- **unic-archon-dlc** requires the Archon workflow engine (version ≥ 0.10) in the target project; it has no runtime dependencies on any other plugin in this repo diff --git a/README.md b/README.md index 62e3424..6904d1f 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ A monorepo of AI agent plugins developed at Unic. Currently hosts Claude Code pl ## Plugins -| Plugin | Agent | Description | -| ------------------------------------------------------ | ----------- | -------------------------------------- | -| [`pr-review`](apps/claude-code/pr-review/) | Claude Code | Review Azure DevOps pull requests | -| [`auto-format`](apps/claude-code/auto-format/) | Claude Code | Auto-format and lint files after edits | -| [`unic-confluence`](apps/claude-code/unic-confluence/) | Claude Code | Publish Markdown files to Confluence | +| Plugin | Agent | Description | +| ------------------------------------------------------ | ----------- | ---------------------------------------------------------------------------------------- | +| [`pr-review`](apps/claude-code/pr-review/) | Claude Code | Review Azure DevOps pull requests | +| [`auto-format`](apps/claude-code/auto-format/) | Claude Code | Auto-format and lint files after edits | +| [`unic-confluence`](apps/claude-code/unic-confluence/) | Claude Code | Publish Markdown files to Confluence | +| [`unic-archon-dlc`](apps/claude-code/unic-archon-dlc/) | Claude Code | Archon-powered AI development lifecycle (explore → plan → build → qa → cleanup → triage) | ## Installing plugins (Claude Code) @@ -24,6 +25,7 @@ Then install individual plugins: /plugin install pr-review@unic-agent-plugins /plugin install auto-format@unic-agent-plugins /plugin install unic-confluence@unic-agent-plugins +/plugin install unic-archon-dlc@unic-agent-plugins ``` ## Development diff --git a/apps/claude-code/pr-review/tests/notices.test.mjs b/apps/claude-code/pr-review/tests/notices.test.mjs index 805ff77..299878c 100644 --- a/apps/claude-code/pr-review/tests/notices.test.mjs +++ b/apps/claude-code/pr-review/tests/notices.test.mjs @@ -131,7 +131,7 @@ describe('formatTrailer', () => { describe('mergeNotices', () => { it('mergeNotices tolerates null and undefined sources', () => { const n = createNotice('info', 'doc-context', 'test') - // @ts-ignore — intentional test of runtime tolerance for null/undefined + // @ts-expect-error — intentional test of runtime tolerance for null/undefined const result = mergeNotices(null, [n], undefined) assert.equal(result.length, 1) assert.equal(result[0].kind, 'doc-context') diff --git a/apps/claude-code/unic-archon-dlc/.archon/commands/.gitkeep b/apps/claude-code/unic-archon-dlc/.archon/commands/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-build.md b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-build.md new file mode 100644 index 0000000..917bee9 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-build.md @@ -0,0 +1,59 @@ +# /unic-dlc-build + +Execute the TDD build workflow for a planning session, enforcing red → green per issue. + +## Usage + +``` +/unic-dlc-build +``` + +Where `` is the session identifier used when you ran `/unic-dlc-plan`. The generated +`.archon/workflows/build-.yaml` must already exist (produced by the `yaml-gen` node in plan). + +## What this command does + +1. **Slopcheck gate** — before any implementation begins, scans `package.json` (and other + manifest files) for packages introduced since the last commit. Each new package is checked + against the npm registry. Packages that fail the check are flagged `[ASSUMED]` and require + explicit human approval before the build can continue. + + Registry check strategy (in order of preference): + + - Python `slopcheck` tool (GSD's slopsquatting gate) if available on `PATH` + - npm registry HEAD request fallback + - If neither is available: all new packages are treated as `[ASSUMED]` (strict default) + + To bypass slopcheck for known-safe cases: `SLOPCHECK_BYPASS=1 /unic-dlc-build ` + +2. **Generated build workflow** — executes `.archon/workflows/build-.yaml` which was + produced by the `yaml-gen` node in `/unic-dlc-plan`. The generated workflow: + - Issues a `code-red` node per issue: writes FAILING acceptance tests + - Issues a `code-green` node per issue: writes minimum implementation to pass those tests + - Enforces `code-red` before `code-green` within each issue via `depends_on` edges + - Runs independent issues' `code-red` (and `code-green`) phases in parallel + +## Prerequisites + +- `/unic-dlc-plan ` must have been run and the plan PR approved +- `.archon/workflows/build-.yaml` must exist +- `.archon/unic-dlc.config.json` must be present (created by the install hook) + +## TDD contract + +Every issue in the build goes through: + +``` +RED: code-red- → write failing acceptance tests +GREEN: code-green- → write minimum implementation to pass those tests +``` + +No `code-green` node may run before its corresponding `code-red` node completes. +Independent issues run their `code-red` and `code-green` phases in parallel, +giving the fastest possible end-to-end build time. + +## Runs + +``` +archon run .archon/workflows/build.yaml --input slug= +``` diff --git a/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-cleanup.md b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-cleanup.md new file mode 100644 index 0000000..86d98e3 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-cleanup.md @@ -0,0 +1,60 @@ +# /unic-dlc-cleanup + +Post-merge cleanup for a planning session: architecture review, ADR consolidation, and a +triage pass to refresh project state. + +## Usage + +``` +/unic-dlc-cleanup +``` + +Where `` is the session identifier used throughout the plan → build → qa cycle. + +## What this command does + +1. **`arch-review` node** — reads `docs/workflow//PRD.md` (intent) and + `docs/workflow//report.md` (technical outcome) alongside the changed code. + Detects three categories of drift: + + - **Technical drift**: too-shallow modules, tight coupling, leaky abstractions. + - **Intent drift**: delivered behaviour that diverges from the PRD; silently dropped + acceptance criteria; scope creep added during build. + - **Deepening opportunities**: modules that could hide more complexity behind their + current interface. + + Output: `docs/workflow//arch-review.md` + +2. **`adr-consolidation` interactive node** — presents each proposed ADR individually for + human approval. Sources: + + - "Decisions Made" section of `report.md` + - "Accept as ADR" items from `arch-review.md` + + Each ADR is shown with Context, Decision, and Consequences. The user accepts (A), + rejects (R), or edits (E) each candidate. Only accepted ADRs are written to `docs/adr/`. + +3. **`run-triage` node** — invokes the shared `.archon/workflows/triage.yaml` directly via + `archon run`. No triage logic is duplicated in the cleanup workflow. This produces + `HANDOFF.md` and updates `docs/workflow/ROADMAP.md` exactly once per cleanup run. + +## Prerequisites + +- The build and QA cycles for `` must be complete (build PR merged, QA approved) +- `docs/workflow//PRD.md` and `docs/workflow//report.md` must exist +- `.archon/unic-dlc.config.json` must be present + +## Outputs + +| File | Description | +| ------------------------------------- | ----------------------------------------------- | +| `docs/workflow//arch-review.md` | Architecture review with drift findings | +| `docs/adr/NNNN-*.md` | Accepted ADRs (one file per accepted candidate) | +| `HANDOFF.md` | Updated handoff snapshot (from triage) | +| `docs/workflow/ROADMAP.md` | Updated project phase (from triage) | + +## Runs + +``` +archon run .archon/workflows/cleanup.yaml --input slug= +``` diff --git a/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-explore.md b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-explore.md new file mode 100644 index 0000000..10a1c3f --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-explore.md @@ -0,0 +1,54 @@ +--- +description: Run the unic-archon-dlc explore workflow — parallel research across stack, features, architecture, and pitfalls, then synthesize into docs/workflow//findings.md +--- + +# /unic-dlc-explore + +Runs the `explore` workflow: four parallel research agents investigate the project from different angles, then a synthesize node combines their findings into a single `findings.md` document. + +## When to use + +- At the start of a new feature or investigation to build a shared mental model. +- When onboarding to an unfamiliar project or module. +- Before writing a spec, to surface pitfalls and architecture constraints early. +- Any time you want a structured snapshot of what the project is, what it does, and where the risks are. + +## What it produces + +- **`docs/workflow//findings.md`** — five sections: + - **Stack** — runtime, package manager, toolchain + - **Features** — shipped capabilities, in-progress work, planned features + - **Architecture** — directory structure, design patterns, ADR decisions, cross-platform constraints + - **Pitfalls** — TODOs, untested modules, CI issues, HANDOFF.md blockers + - **Integrated Brief** — 3-5 sentence synthesis with the highest-impact next step + +## Usage + +```sh +archon run .archon/workflows/explore.yaml --input slug= +``` + +Or invoke from Claude Code: + +``` +/unic-dlc-explore +``` + +Replace `` with a short identifier for this exploration (e.g. `auth-refactor`, `v2-planning`). + +## Workflow structure + +``` +research-stack ──┐ +research-features ─┤ + ├──▶ synthesize ──▶ findings.md +research-architecture ─┤ +research-pitfalls ──┘ +``` + +All four research nodes are independent and run in parallel. The `synthesize` node depends on all four and writes the final output. + +## Inspiration + +- Parallel research pattern from the Archon workflow specification. +- This plugin's `lib/findings-writer.mjs` handles idempotent directory and file creation. diff --git a/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-plan.md b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-plan.md new file mode 100644 index 0000000..3c55e8d --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-plan.md @@ -0,0 +1,61 @@ +--- +description: Run the unic-archon-dlc plan workflow — adversarial spec interview, PRD synthesis, and human PR gate +--- + +# /unic-dlc-plan + +Runs the `plan` workflow: loads project context and prior research, runs an adversarial interview to surface requirements and decisions, synthesises the transcript into a structured PRD, and opens a PR for human review before proceeding. + +## When to use + +- After `/unic-dlc-explore` to turn research findings into a formal PRD. +- When starting a new feature and you want to stress-test assumptions before writing specs. +- Any time you need a structured `PRD.md` that captures problem, solution, stories, decisions, and scope. + +## What it produces + +- **`docs/workflow//PRD.md`** — seven sections: Problem Statement, Solution, User Stories, Implementation Decisions, Testing Decisions, Out of Scope, Further Notes. +- **`docs/adr/NNNN-.md`** — one or more ADR files for non-obvious decisions surfaced during the interview (written live, confirmed by user). +- **PR** targeting `develop` — pauses until approved. Rejection returns control to the adversarial interview for refinement. + +## Usage + +```sh +archon run .archon/workflows/plan.yaml --input slug= +``` + +Or invoke from Claude Code: + +``` +/unic-dlc-plan +``` + +Replace `` with a short identifier for this planning session (e.g. `auth-refactor`, `v2-planning`). + +## Options + +Set `workflow.discuss_mode` in `.archon/unic-dlc.config.json` to control the interview style: + +| Value | Behaviour | +| --------------------- | ------------------------------------------------------------------------ | +| `interview` (default) | One focused, adversarial question per turn; probes deeply | +| `assumptions` | Surfaces all implicit assumptions upfront, then confirms or refutes each | + +## Workflow structure + +``` +load-context ──▶ specs (loop) ──▶ to-prd ──▶ prd-gate (interactive) + ▲ │ + └──────── rejected ────────────┘ +``` + +- `load-context` reads CONTEXT.md, CONTEXT-MAP.md, all ADRs, and findings.md (if present). +- `specs` runs the adversarial interview; writes ADRs live for confirmed decisions. +- `to-prd` synthesises the interview transcript into PRD.md. +- `prd-gate` validates all 7 sections, opens a PR, and waits for human approval. + +## Inspiration + +- The `grill-with-docs` skill — adversarial interview pattern against the domain model. +- The `to-prd` skill — structured PRD synthesis from conversation context. +- Archon PR-gate pattern for human-in-the-loop workflow checkpoints. diff --git a/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-qa.md b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-qa.md new file mode 100644 index 0000000..49b62d1 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-qa.md @@ -0,0 +1,78 @@ +--- +description: Run the unic-archon-dlc QA workflow — e2e tests, coverage gate, UAT checklist, and merge +--- + +# /unic-dlc-qa + +Runs the `qa` workflow: executes end-to-end tests, enforces the coverage threshold, walks through +a UAT checklist derived from the PRD's acceptance criteria, and merges the PR once the human approves. + +## When to use + +- After `/unic-dlc-build ` is complete and the build PR has been reviewed and approved. +- When you want a structured QA gate before merging a feature branch. +- Any time you need to confirm that acceptance criteria are met before shipping. + +## What it produces + +- **e2e results** — pass/fail output from the command configured in `e2e_command`. +- **Coverage report** — pass/fail against the `coverage_threshold` set in `.archon/unic-dlc.config.json`. +- **UAT checklist** — numbered acceptance criteria from `docs/workflow//PRD.md`, presented alongside build coverage evidence. +- **Merged PR** — once UAT is approved, the PR is merged via the configured tracker CLI and the feature branch is cleaned up (Gitflow only). + +## Usage + +```sh +archon run .archon/workflows/qa.yaml --input slug= +``` + +Or invoke from Claude Code: + +``` +/unic-dlc-qa +``` + +Replace `` with the same identifier used in `/unic-dlc-plan` and `/unic-dlc-build`. + +## Prerequisites + +- `/unic-dlc-build ` must have been run and the build PR approved. +- `.archon/unic-dlc.config.json` must be present (created by the install hook). +- `e2e_command` must be set in the config. If not, run `archon install --reconfigure` to set it. +- The current branch must have an open PR targeting `develop` (for the merge step). + +## Workflow structure + +``` +e2e ──▶ coverage-gate ──▶ uat-gate (interactive) ──▶ merge +``` + +- `e2e` — reads `e2e_command` from config; fails fast with a helpful message if it is not set. +- `coverage-gate` — runs `pnpm test --coverage`; parses the output to compare against `coverage_threshold`. Skipped if no threshold is configured. +- `uat-gate` — presents the numbered UAT checklist; waits for APPROVE or REJECT from the human. +- `merge` — merges the PR via the tracker CLI (gh / az / manual instructions for jira and local-markdown); deletes the feature branch on Gitflow. + +## Configuration reference + +All settings are read from `.archon/unic-dlc.config.json`: + +| Field | Type | Default | Description | +| -------------------- | ------ | ----------- | ------------------------------------------------------------------------ | +| `e2e_command` | string | — | Shell command to run e2e tests (e.g. `"pnpm test:e2e"`) | +| `coverage_threshold` | number | — | Minimum coverage % required (e.g. `80`). Omit to skip the gate. | +| `tracker` | string | `"github"` | Issue tracker: `github`, `ado`, `jira`, `local-markdown` | +| `branching` | string | `"gitflow"` | Branch strategy: `gitflow` (delete feature branch) or `github-flow` | +| `pr_strategy` | string | `"squash"` | GitHub merge style: `squash` or `merge` (used for `github` tracker only) | + +## UAT interaction + +The `uat-gate` node pauses and asks for your decision: + +- Type **`APPROVE`** to proceed to merge. +- Type **`REJECT AC-N`** (e.g. `REJECT AC-3`) to flag a failing criterion. The workflow halts and you are returned to address the issue before re-running. + +## Runs + +``` +archon run .archon/workflows/qa.yaml --input slug= +``` diff --git a/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-review.md b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-review.md new file mode 100644 index 0000000..9fe27e8 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-review.md @@ -0,0 +1,82 @@ +--- +description: Run the unic-archon-dlc self-contained code review workflow — four aspects, structured findings, single PR comment, idempotent re-run +--- + +# /unic-dlc-review + +Runs the `review` workflow: analyses the current PR diff across four review aspects +(code quality, test coverage, silent failures, type design), then posts — or updates — a +single structured comment on the PR. + +**No runtime dependency on pr-review-toolkit or any other plugin.** +All review logic is self-contained in `.archon/workflows/review.yaml`. + +## When to use + +- After opening a PR and before requesting human review. +- As a final quality gate before merging. +- Re-run it after addressing feedback — it will **update** the prior comment rather than + posting a duplicate. + +## What it produces + +A single structured PR comment (or updated comment on re-run) with four sections: + +1. **Code quality** — readability, naming, function length, project-convention adherence +2. **Test coverage adequacy** — exercises public interfaces, output-focused assertions, no + internal-collaborator mocks +3. **Silent failure patterns** — swallowed exceptions, empty catch blocks, fallbacks that + hide bugs +4. **Type design quality** — encapsulation, invariants in types, illegal-state-unrepresentable + patterns + +Each section lists specific findings with `file:line` references, or an explicit +"No findings." line if nothing was detected. + +## Usage + +```sh +archon run .archon/workflows/review.yaml +``` + +Or invoke from Claude Code: + +``` +/unic-dlc-review +``` + +No arguments needed. The workflow reads `.archon/unic-dlc.config.json` to determine the +tracker and the current open PR. + +## Workflow structure + +``` +code-review ──▶ structured comment posted / updated on PR +``` + +Single-node DAG. The node reads CLAUDE.md / AGENTS.md for project conventions, runs the +four review aspects, and calls the tracker adapter to post or update the comment. + +## Re-run behaviour + +When you run `/unic-dlc-review` again on the same PR: + +- The workflow searches for a prior comment whose body contains the sentinel marker + ``. +- If found, it **updates** that comment in-place. +- If not found, it creates a new comment. + +This prevents duplicate review threads accumulating over multiple runs. + +## Inspiration + +- `apps/claude-code/pr-review/` — multi-aspect analysis structure, compact finding + schema (`severity / filePath / startLine / endLine / title / body`), re-review + detection via sentinel, and the discipline of posting a single summary comment rather + than many inline threads. +- Matt Pocock's review skill + (https://github.com/mattpocock/skills/blob/main/skills/in-progress/review/SKILL.md) — + aspect-driven review structure, explicit "no findings" lines, and the principle that + every run should produce actionable output even when everything looks good. +- This plugin's `lib/tracker-adapter.mjs` — used to post or update the comment via the + configured tracker backend (github / ado / jira / local-markdown). diff --git a/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-triage.md b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-triage.md new file mode 100644 index 0000000..e30554b --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/commands/unic-dlc-triage.md @@ -0,0 +1,35 @@ +--- +description: Run the unic-archon-dlc triage workflow — produces HANDOFF.md and updates ROADMAP.md +--- + +# /unic-dlc-triage + +Runs the `triage` workflow: reads current issue states from the configured tracker, reconciles them against `docs/workflow/ROADMAP.md`, and produces `HANDOFF.md` at the repo root. + +## When to use + +- After a session boundary to resume cleanly in a fresh context. +- As the final step of the `/unic-dlc-cleanup` workflow (reused by the cleanup DAG). +- Any time you want a status snapshot of open issues, blockers, and recent decisions. + +## What it produces + +- **`HANDOFF.md`** (repo root) — four sections: current phase, open issues by state, blockers, recent decisions. +- **`docs/workflow/ROADMAP.md`** — created on first run; phase status updated on each run. Human-edited sections are preserved. + +## Usage + +```sh +archon run .archon/workflows/triage.yaml +``` + +Or invoke from Claude Code: + +``` +/unic-dlc-triage +``` + +## Inspiration + +- Matt Pocock skills repo — `/triage` skill pattern. +- This plugin's `docs/agents/` files (written by the install hook) provide the domain context consumed by the triage prompt nodes. diff --git a/apps/claude-code/unic-archon-dlc/.archon/workflows/.gitkeep b/apps/claude-code/unic-archon-dlc/.archon/workflows/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/claude-code/unic-archon-dlc/.archon/workflows/build.yaml b/apps/claude-code/unic-archon-dlc/.archon/workflows/build.yaml new file mode 100644 index 0000000..da2433a --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/workflows/build.yaml @@ -0,0 +1,363 @@ +name: unic-dlc-build +description: > + TDD build execution wrapper: slopcheck gate → generated per-slug build workflow. + Each issue goes red (failing tests) before green (passing implementation). + Independent issues run in parallel; chained issues run in dependency order. + +inputs: + slug: + description: Planning session identifier — matches the slug used in /unic-dlc-plan. + required: true + +nodes: + - id: slopcheck + name: Slopcheck — Verify New Packages + type: bash + depends_on: [] + script: | + #!/usr/bin/env node + // @ts-check + import { execFileSync } from 'node:child_process' + import { existsSync, readFileSync } from 'node:fs' + import { join } from 'node:path' + import { classifyPackages, parseNewPackages } from './lib/slopcheck.mjs' + + const projectDir = process.cwd() + const pkgPath = join(projectDir, 'package.json') + + if (!existsSync(pkgPath)) { + console.log('slopcheck: no package.json found, skipping.') + process.exit(0) + } + + // Get the previous package.json from HEAD (if in a git repo) + let prevDeps = {} + try { + const prev = execFileSync('git', ['show', 'HEAD:package.json'], { cwd: projectDir, encoding: 'utf8' }) + const parsed = JSON.parse(prev) + prevDeps = { ...parsed.dependencies, ...parsed.devDependencies } + } catch { + // Not in a git repo or no HEAD — treat all current packages as new (strictest) + console.log('slopcheck: cannot read HEAD:package.json, treating all packages as new.') + } + + const current = JSON.parse(readFileSync(pkgPath, 'utf8')) + const currentDeps = { ...current.dependencies, ...current.devDependencies } + const newPkgs = parseNewPackages(prevDeps, currentDeps) + + if (newPkgs.length === 0) { + console.log('slopcheck: no new packages detected. ✓') + process.exit(0) + } + + console.log(`slopcheck: found ${newPkgs.length} new package(s): ${newPkgs.join(', ')}`) + + // Determine registry check strategy + let registryFn = null + + // Strategy 1: Python slopcheck tool + try { + execFileSync('slopcheck', ['--version'], { encoding: 'utf8' }) + registryFn = async (/** @type {string} */ name) => { + try { + execFileSync('slopcheck', ['check', name], { encoding: 'utf8' }) + return true + } catch { + return false + } + } + console.log('slopcheck: using Python slopcheck tool.') + } catch { + // Python tool not on PATH, try npm registry HEAD check + try { + const { default: https } = await import('node:https') + registryFn = (/** @type {string} */ name) => + new Promise((resolve) => { + const encoded = encodeURIComponent(name).replace(/^%40/, '@') + const req = https.request( + { hostname: 'registry.npmjs.org', path: `/${encoded}`, method: 'HEAD' }, + (res) => resolve(res.statusCode === 200), + ) + req.on('error', () => resolve(false)) + req.setTimeout(5000, () => { + req.destroy() + resolve(false) + }) + req.end() + }) + console.log('slopcheck: using npm registry HEAD check fallback.') + } catch { + console.log('slopcheck: no registry check available — all new packages treated as [ASSUMED].') + } + } + + const verdicts = await classifyPackages(newPkgs, registryFn) + const assumed = verdicts.filter((v) => v.assumed) + + if (assumed.length === 0) { + console.log('slopcheck: all new packages verified in registry. ✓') + process.exit(0) + } + + console.log('\nslopcheck: [ASSUMED] packages require human verification:') + for (const pkg of assumed) { + console.log(` ⚠ ${pkg.name} — not found in registry or unverifiable`) + console.log(` checkpoint:human-verify — approve or reject this package before install continues`) + } + console.log( + '\nslopcheck: halting build. Resolve the above packages (remove them, pin a real version,', + ) + console.log('or run the build with SLOPCHECK_BYPASS=1 to accept assumed packages explicitly.') + + if (!process.env.SLOPCHECK_BYPASS) { + process.exit(1) + } + + - id: run-build + name: Execute Generated Build Workflow + type: prompt + depends_on: [slopcheck] + prompt: | + You are running the unic-archon-dlc build workflow — run-build node. + + The slopcheck gate has passed. Now execute the generated build workflow for this slug. + + The generated workflow is at: .archon/workflows/build-{{ inputs.slug }}.yaml + + Actions: + 1. Verify that .archon/workflows/build-{{ inputs.slug }}.yaml exists. + If it does not exist, print: + "build-{{ inputs.slug }}.yaml not found. Run /unic-dlc-plan {{ inputs.slug }} first to generate it." + and halt. + + 2. Invoke the generated workflow via Archon: + Run: archon run .archon/workflows/build-{{ inputs.slug }}.yaml --input slug={{ inputs.slug }} + + The generated workflow contains: + - code-red- nodes: write FAILING acceptance tests per issue + - code-green- nodes: write minimum implementation to pass those tests + - Dependency edges enforce: red before green within each issue + - Independent issues run code-red (and code-green) in parallel + + 3. After the workflow completes, print: + "build-{{ inputs.slug }} complete. All issues have been implemented following red→green discipline." + + TDD rules enforced by the generated DAG: + - Never write implementation before writing failing tests. + - code-green nodes write ONLY enough code to pass the current tests. + - No speculative features, no extra abstractions. + - If a code-green node fails (tests still failing after implementation), pause and + ask the user whether to retry or flag the issue for human review. + + - id: verification + name: Verification — Test Suite, Stubs, Wiring + type: bash + depends_on: [run-build] + script: | + #!/usr/bin/env node + // @ts-check + import { readFileSync, readdirSync, statSync } from 'node:fs' + import { join, extname } from 'node:path' + import { execFileSync } from 'node:child_process' + import { detectStubs } from './lib/stub-detector.mjs' + + const slug = process.env.ARCHON_INPUT_SLUG + if (!slug) { + console.error('verification: ARCHON_INPUT_SLUG is not set') + process.exit(1) + } + + let exitCode = 0 + + // 1. Run the test suite + console.log('\n=== verification: running test suite ===') + try { + execFileSync('pnpm', ['test'], { stdio: 'inherit' }) + console.log('verification: tests PASSED ✓') + } catch { + console.error('verification: tests FAILED ✗') + exitCode = 1 + } + + // 2. Detect stub patterns in changed source files + console.log('\n=== verification: checking for stub patterns ===') + let changedFiles = [] + try { + const diff = execFileSync('git', ['diff', '--name-only', 'HEAD'], { encoding: 'utf8' }) + changedFiles = diff.split('\n').filter(Boolean).filter(f => /\.(mjs|js|ts|py|rb|go|rs)$/.test(f)) + } catch { + console.log('verification: cannot read git diff, skipping stub check.') + } + + const stubFindings = [] + for (const file of changedFiles) { + try { + const src = readFileSync(file, 'utf8') + const stubs = detectStubs(src) + for (const s of stubs) stubFindings.push({ file, ...s }) + } catch { /* file deleted or unreadable */ } + } + + if (stubFindings.length > 0) { + console.error(`verification: found ${stubFindings.length} stub pattern(s):`) + for (const f of stubFindings) { + console.error(` ${f.file}:${f.line} [${f.pattern}] ${f.text}`) + } + exitCode = 1 + } else { + console.log('verification: no stub patterns detected ✓') + } + + // 3. Check coverage threshold + const configPath = '.archon/unic-dlc.config.json' + let coverageThreshold = null + try { + const cfg = JSON.parse(readFileSync(configPath, 'utf8')) + coverageThreshold = cfg.coverage_threshold ?? null + } catch { /* config absent */ } + + if (coverageThreshold !== null) { + console.log(`\n=== verification: checking coverage threshold (${coverageThreshold}%) ===`) + try { + execFileSync('pnpm', ['test', '--coverage'], { stdio: 'inherit' }) + } catch { + console.error(`verification: coverage check failed (threshold: ${coverageThreshold}%)`) + exitCode = 1 + } + } + + process.exit(exitCode) + + - id: goals-check + name: Goals-Check — PRD Coverage Matrix + type: prompt + depends_on: [verification] + prompt: | + You are running the unic-archon-dlc build workflow — goals-check node. + + Goal: produce a coverage matrix that maps every acceptance criterion from the PRD + to implementation evidence (file paths and test names). Any criterion without evidence + is "MISSING" and fails this node. + + ## Step 1 — Load the PRD and issues + + Read docs/workflow/{{ inputs.slug }}/PRD.md in full. + Read docs/workflow/{{ inputs.slug }}/issues.json and parse the issues array. + + Extract every acceptance criterion from: + - Each user story in the PRD's "User Stories" section (format: "As a ... I want ...") + - Each issue's `acceptance_criteria` array in issues.json + + Deduplicate criteria that appear in both (match by meaning, not exact text). + + ## Step 2 — Find implementation evidence + + For each criterion, search the codebase for: + - Source files that implement the described behaviour (file paths with function/class names) + - Test files that assert this criterion passes (test names, file:line references) + + Use your knowledge of the changed files from the just-completed build to focus the search. + + ## Step 3 — Build the coverage matrix + + Format the matrix as a Markdown table: + + | Criterion | Evidence | Status | + |-----------|----------|--------| + | | | ✓ COVERED / ✗ MISSING | + + Rules: + - A criterion is COVERED if at least one test asserts it AND at least one source file + implements it. + - A criterion is MISSING if either the test or the implementation is absent. + + ## Step 4 — Report and fail if any MISSING + + If any rows are MISSING: + Print: "goals-check: FAILED — N criterion/criteria without coverage." + List the missing criteria. + The workflow will return control to `verification` after the report step. + + If all criteria are COVERED: + Print: "goals-check: PASSED — all acceptance criteria covered. ✓" + + Store the matrix as `{{ matrix }}` for use in the report node. + + - id: report + name: Report — Consolidate Build Outcomes + type: prompt + depends_on: [goals-check] + prompt: | + You are running the unic-archon-dlc build workflow — report node. + + Goal: write docs/workflow/{{ inputs.slug }}/report.md consolidating all build outcomes. + + ## Required sections (exactly five) + + ### 1. What Was Built + A high-level summary (3-5 bullets) of the issues that were implemented in this build cycle. + Reference each issue id and title. + + ### 2. Goals-Check Coverage Matrix + Paste the coverage matrix produced by the goals-check node verbatim. + + ### 3. Test Outcomes + - Pass/fail counts from the test run. + - Coverage numbers if available. + - Any flaky or skipped tests. + + ### 4. Decisions Made + Bullet list of technical decisions made during implementation that should be recorded. + For each decision, propose an ADR filename (docs/adr/NNNN-.md) if the decision + is significant enough to warrant one. Write the ADR inline for review. + + ### 5. Tech Debt Flagged + Bullet list of items deferred during this build (stub patterns that were accepted, + known limitations, shortcuts taken under time pressure). These items feed into + the cleanup workflow (slice 13). + + ## Output + + Write the report to docs/workflow/{{ inputs.slug }}/report.md. + Print: "report written to docs/workflow/{{ inputs.slug }}/report.md" + + If goals-check returned any MISSING criteria, include a note in the "What Was Built" + section: "⚠ goals-check found N uncovered criteria — see matrix below." + + - id: build-pr-gate + name: Build PR Gate — Human Review + type: interactive + fresh_context: true + depends_on: [report] + prompt: | + You are running the unic-archon-dlc build workflow — build-pr-gate node. + + The implementation, goals-check matrix, and report are ready for human review. + + Actions: + 1. Stage all changed source and test files plus the report: + - All files changed during the build (git status --short) + - docs/workflow/{{ inputs.slug }}/report.md + - Any ADR files proposed in the report (docs/adr/NNNN-*.md that are new) + 2. Open a PR targeting the develop branch with: + - Title: "build({{ inputs.slug }}): implementation" + - Body: + ## Summary + - TDD implementation for `{{ inputs.slug }}` session + - Goals-check matrix: all criteria covered (or: N criteria missing — see report) + - report.md attached + + ## Review Checklist + - [ ] All acceptance criteria have implementation evidence + - [ ] No TODO / FIXME stubs left in implementation code + - [ ] Test coverage meets configured threshold + - [ ] Tech debt section in report.md is accurate + - [ ] Proposed ADRs (if any) are ready to record + 3. Pause and wait for human review. + - If the PR is **approved** and merged: + Print: "Build PR gate passed." + Exit. + - If the PR is **rejected** or changes are requested: + Print: "Build PR gate: changes requested. Returning to verification." + Return control to the verification node for another pass. + Do NOT restart the full build (only re-verify and re-report). diff --git a/apps/claude-code/unic-archon-dlc/.archon/workflows/cleanup.yaml b/apps/claude-code/unic-archon-dlc/.archon/workflows/cleanup.yaml new file mode 100644 index 0000000..eb6d54b --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/workflows/cleanup.yaml @@ -0,0 +1,157 @@ +name: unic-dlc-cleanup +description: > + Post-merge cleanup: architecture review, ADR consolidation, then triage rerun. + Catches technical and intent drift, locks architectural decisions, and refreshes + project state via the shared triage workflow. + +inputs: + slug: + description: Planning session identifier — matches the slug from the completed build cycle. + required: true + +nodes: + - id: arch-review + name: Architecture Review — Drift Detection + type: prompt + depends_on: [] + prompt: | + You are running the unic-archon-dlc cleanup workflow — arch-review node. + + Goal: compare what was intended (PRD) against what was shipped (report + code) and + surface any drift before the project moves on. + + ## Step 1 — Load context + + Read the following files in full: + - docs/workflow/{{ inputs.slug }}/PRD.md (intent) + - docs/workflow/{{ inputs.slug }}/report.md (technical outcome) + - docs/workflow/{{ inputs.slug }}/issues.json (issue breakdown) + + Also read the list of changed files from the build: + Run: git diff --name-only HEAD~1 HEAD + (or adapt the range if HEAD~1 is not the merge commit) + + For each changed source file, read its content. + + ## Step 2 — Detect technical drift + + Review the shipped code against architectural best practices: + + - **Too-shallow modules**: functions or classes that are mere pass-throughs with no + real logic (a deep module hides complexity behind a small interface — the inverse is + the anti-pattern here). + - **Tight coupling**: direct imports of internal implementation details across module + boundaries; callers that know about the internals of their dependencies. + - **Leaky abstractions**: functions that expose internal data structures, require + callers to know about implementation order, or have error modes that only make sense + if you understand the internals. + + For each issue found, note: file path, line(s), problem description, suggested fix. + + ## Step 3 — Detect intent drift + + Compare the PRD's user stories and acceptance criteria against the implementation: + + - Delivered behaviour that diverges from what the PRD describes (not the same as tech + debt — this is wrong behaviour, not merely imperfect code). + - Acceptance criteria from the PRD that were silently dropped or narrowed during build. + - Features added during build that were not in the PRD (scope creep). + + For each issue found, note: PRD section referenced, what was described vs what was + delivered, recommended action (fix now / create follow-up issue / accept as ADR). + + ## Step 4 — Identify deepening opportunities + + List at least one module that could be deepened to hide more complexity behind its + current interface — or explicitly state "No deepening opportunities identified." + + ## Step 5 — Write arch-review.md + + Write docs/workflow/{{ inputs.slug }}/arch-review.md with the following sections: + + ## Architecture Review — {{ inputs.slug }} + + ### Technical Drift + + + ### Intent Drift + + + ### Deepening Opportunities + + + ### Summary + CLEAN | ISSUES FOUND (with count) + + Print: "arch-review written to docs/workflow/{{ inputs.slug }}/arch-review.md" + + - id: adr-consolidation + name: ADR Consolidation — Per-ADR Approval Gate + type: interactive + fresh_context: true + depends_on: [arch-review] + prompt: | + You are running the unic-archon-dlc cleanup workflow — adr-consolidation node. + + Goal: review every architectural decision recorded during the build cycle and get + explicit human approval before writing any ADR to docs/adr/. + + ## Step 1 — Collect ADR candidates + + Gather decisions from: + 1. `docs/workflow/{{ inputs.slug }}/report.md` — "Decisions Made" section + 2. `docs/workflow/{{ inputs.slug }}/arch-review.md` — any decisions surfaced during review + (e.g. "accept as ADR" items from intent drift findings) + 3. Any inline ADR drafts written by the build workflow nodes + + For each candidate, present it in this format: + + --- + ADR Candidate N of M + Proposed file: docs/adr/NNNN-.md + Status: [Proposed] + Context: + Decision: + Consequences: + --- + + Accept (A) / Reject (R) / Edit (E)? + + ## Step 2 — Gate each ADR individually + + Wait for the user to respond with A, R, or E for each candidate: + - **A (Accept)**: write the ADR to docs/adr/NNNN-.md using the next + available NNNN (check existing files in docs/adr/). + Print: "ADR written: docs/adr/NNNN-.md" + - **R (Reject)**: skip this candidate. + Print: "ADR skipped: " + - **E (Edit)**: show the ADR draft again and allow the user to revise the + Context, Decision, or Consequences fields. Re-present for A/R after edits. + + ## Step 3 — Summary + + After all candidates are processed, print: + + adr-consolidation complete. + Accepted: N | Rejected: M | Edited then accepted: K + Files written: (list of docs/adr/NNNN-*.md paths, or "none") + + - id: run-triage + name: Run Triage — Refresh Project State + type: bash + depends_on: [adr-consolidation] + script: | + #!/usr/bin/env node + // Reuse the shared triage workflow — no duplicate triage logic in cleanup. + // triage produces HANDOFF.md and updates ROADMAP.md exactly once per run. + import { execFileSync } from 'node:child_process' + + try { + execFileSync('archon', ['run', '.archon/workflows/triage.yaml'], { + stdio: 'inherit', + env: { ...process.env }, + }) + } catch (err) { + console.error('run-triage: archon run failed:', err.message) + process.exit(1) + } diff --git a/apps/claude-code/unic-archon-dlc/.archon/workflows/explore.yaml b/apps/claude-code/unic-archon-dlc/.archon/workflows/explore.yaml new file mode 100644 index 0000000..a112c5d --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/workflows/explore.yaml @@ -0,0 +1,210 @@ +name: unic-dlc-explore +description: Parallel research across four dimensions, then synthesize findings into docs/workflow/<slug>/findings.md. + +inputs: + slug: + description: Short identifier for this exploration (used as the findings directory name under docs/workflow/). + required: true + +nodes: + - id: research-stack + name: Research Stack + type: prompt + depends_on: [] + prompt: | + You are running the unic-archon-dlc explore workflow — research-stack node. + + Goal: understand the technology stack of this project. + + 1. Read package.json (root and any workspace members listed in pnpm-workspace.yaml). + 2. Note the runtime, package manager, module system, and key devDependencies. + 3. Check .nvmrc or .node-version for the pinned Node.js version. + 4. Look for any framework, test runner, linter, or build tool references. + 5. Output a concise Markdown summary under the heading "## Stack Findings" covering: + - Runtime version + - Package manager + - Module system (CJS/ESM) + - Key frameworks / libraries + - Toolchain (lint, format, type-check, test) + + - id: research-features + name: Research Features + type: prompt + depends_on: [] + prompt: | + You are running the unic-archon-dlc explore workflow — research-features node. + + Goal: identify the key features and capabilities of this project. + + 1. Read README.md (if present) for a high-level feature list. + 2. Scan docs/plans/ and docs/issues/ for feature specs and issue slugs. + 3. Inspect any CHANGELOG.md files for shipped feature entries. + 4. Look at exported function names in lib/*.mjs to infer capabilities. + 5. Output a concise Markdown summary under the heading "## Features Findings" covering: + - Core capabilities already shipped + - In-progress or planned features (from specs/issues) + - Any notable user-facing commands or workflows + + - id: research-architecture + name: Research Architecture + type: prompt + depends_on: [] + prompt: | + You are running the unic-archon-dlc explore workflow — research-architecture node. + + Goal: understand the structural and architectural decisions of this project. + + 1. Read CONTEXT.md (if present) for the domain model and layering decisions. + 2. Read docs/adr/ entries (newest five by filename) for recorded decisions. + 3. Examine the directory tree: lib/, test/, hooks/, .archon/, and any packages/. + 4. Identify patterns: pure functions vs. stateful modules, DAG/workflow orchestration, plugin conventions. + 5. Note any cross-platform constraints (Windows, macOS, Linux) visible in the code. + 6. Output a concise Markdown summary under the heading "## Architecture Findings" covering: + - Directory structure and module responsibilities + - Key design patterns and conventions + - Recorded decisions from ADRs + - Cross-platform or portability constraints + + - id: research-pitfalls + name: Research Pitfalls + type: prompt + depends_on: [] + prompt: | + You are running the unic-archon-dlc explore workflow — research-pitfalls node. + + Goal: surface known pitfalls, edge cases, and areas of technical debt. + + 1. Search for TODO, FIXME, HACK, and XXX comments across all source files. + 2. Look for error-handling gaps: try/catch blocks that swallow errors, unhandled promise rejections. + 3. Check CI configuration (.github/workflows/ or .gitlab-ci.yml) for flaky or skipped jobs. + 4. Inspect test coverage: are there lib modules with no corresponding test file? + 5. Read the most recent HANDOFF.md (if present) for documented blockers. + 6. Output a concise Markdown summary under the heading "## Pitfalls Findings" covering: + - Known bugs or TODOs in the code + - Untested modules or coverage gaps + - CI issues or flaky behaviours + - Blockers from the latest HANDOFF.md + + - id: synthesize + name: Synthesize Findings + type: prompt + depends_on: [research-stack, research-features, research-architecture, research-pitfalls] + prompt: | + You are running the unic-archon-dlc explore workflow — synthesize node. + + You have four research outputs from the parallel nodes: + - research-stack → "## Stack Findings" + - research-features → "## Features Findings" + - research-architecture → "## Architecture Findings" + - research-pitfalls → "## Pitfalls Findings" + + Additionally: + - The user's stated intent for this exploration is captured in the `slug` workflow input. + - Read CONTEXT.md at the project root if it exists — it contains the canonical domain vocabulary. + - If this is a monorepo, also read CONTEXT-MAP.md at the repo root. + + Your job: + 1. Call initFindingsDir(projectDir, slug) to create docs/workflow/<slug>/ (idempotent). + 2. Call writeFindingsMd(findingsDir, { stack, features, architecture, pitfalls, brief }) where: + - `stack`, `features`, `architecture`, `pitfalls` are the respective findings summaries + (strip the "## … Findings" heading and keep only the body text). + - `brief` is a new 3-5 sentence "Integrated Brief" that synthesises all four dimensions + into a single coherent narrative, highlighting the most important insight from each, + surfacing any cross-cutting concerns, and recommending the single highest-impact next step. + 3. Print: "Explore complete. Findings written to docs/workflow/<slug>/findings.md" + + - id: prototype + name: Prototype Experiments + type: prompt + depends_on: [synthesize] + prompt: | + You are running the unic-archon-dlc explore workflow — prototype node. + + Goal: design and evaluate one or more experiments that test the key unknowns + identified in the findings, then record a verdict for each. + + Inputs available: + - `slug` workflow input (the exploration identifier) + - docs/workflow/<slug>/findings.md (written by the synthesize node) + + Steps: + 1. Read docs/workflow/<slug>/findings.md. + 2. From the Integrated Brief and Pitfalls section identify 1-3 concrete unknowns + worth spiking. For each unknown, design a minimal experiment: + - Give it a short descriptive title. + - Describe what you would build or measure. + - Reason through what outcome is most likely given the existing evidence. + - Assign exactly ONE verdict: VALIDATED, INVALIDATED, or PARTIAL. + · VALIDATED — the approach is confirmed viable; evidence is sufficient. + · INVALIDATED — the approach is ruled out; a blocker was found. + · PARTIAL — the approach shows promise but requires follow-up work. + - Write 2-4 sentences of supporting evidence or reasoning. + 3. Call appendSpikeVerdicts(findingsDir, verdicts) from lib/spike-verdicts.mjs, + passing an array of { title, verdict, notes } objects. + This appends a "## Spike verdicts" section to findings.md. + 4. Print: "Prototype complete. <N> verdict(s) written to docs/workflow/<slug>/findings.md" + + - id: code-preserve-gate + name: Spike Branch Gate + type: interactive + fresh_context: true + depends_on: [prototype] + prompt: | + You are running the unic-archon-dlc explore workflow — code-preserve-gate node. + + Goal: decide whether to preserve any spike code on a dedicated branch. + + Ask the user exactly this question: + "Should I preserve this spike code on a branch? (yes/no)" + + If the user answers YES (or y): + 1. Derive a branch name: spike/<slug> (e.g. spike/auth-refresh). + 2. Run: git checkout -b spike/<slug> + 3. Stage and commit any unstaged files related to this spike: + git add -A + git commit -m "spike(<slug>): preserve prototype code from explore workflow" + 4. Print: "Spike code committed on branch spike/<slug>." + + If the user answers NO (or n): + 1. Leave the worktree unchanged — do NOT stage or commit anything. + 2. Print: "Spike code discarded. Worktree left clean." + + - id: create-spike-ticket + name: Create Spike Ticket + type: prompt + depends_on: [code-preserve-gate] + prompt: | + You are running the unic-archon-dlc explore workflow — create-spike-ticket node. + + Goal: create (or update) a spike ticket in the configured tracker. + + Inputs available: + - `slug` workflow input + - docs/workflow/<slug>/findings.md (contains the ## Spike verdicts section) + + Steps: + 1. Read docs/workflow/<slug>/findings.md. + 2. Call parseSpikeVerdicts(content) from lib/spike-verdicts.mjs to extract verdicts. + 3. Build a one-line verdict summary, e.g.: + "1 VALIDATED, 1 INVALIDATED, 1 PARTIAL" + 4. Load the tracker configuration with loadConfig(projectDir) from lib/config-loader.mjs. + Use config.tracker (defaults to 'github' if absent). + 5. Load labels with getDefaultLabels(tracker) from lib/labels-config.mjs. + 6. Check whether a spike ticket for this slug already exists: + - GitHub: gh issue list --label spike --search "<slug>" --json number,title + - local-markdown: check for docs/issues/<slug>-spike/index.md + - Other trackers: search by title containing slug. + 7a. If NO existing ticket: + Call buildCreateCommand(tracker, title, 'spike', 'p2', labels) where + title = "Spike: <slug> — <one-line verdict summary>". + The body should include: + - Link to docs/workflow/<slug>/findings.md + - The full ## Spike verdicts section text + Execute or print the generated command. + 7b. If ticket EXISTS: + Call buildUpdateCommand(tracker, issueId, 'needs-triage', labels) + and additionally update the body to append the new verdict summary. + Execute or print the generated command. + 8. Print one of: + "Spike ticket created: <title>" + "Spike ticket updated: <issueId>" diff --git a/apps/claude-code/unic-archon-dlc/.archon/workflows/plan.yaml b/apps/claude-code/unic-archon-dlc/.archon/workflows/plan.yaml new file mode 100644 index 0000000..6c12706 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/workflows/plan.yaml @@ -0,0 +1,420 @@ +name: unic-dlc-plan +description: > + Adversarial spec interview → PRD synthesis → human PR gate. + Produces docs/workflow/<slug>/PRD.md and optional ADR entries. + +inputs: + slug: + description: Short identifier for this planning session (used as the workflow directory name under docs/workflow/). + required: true + +nodes: + - id: load-context + name: Load Context + type: prompt + depends_on: [] + prompt: | + You are running the unic-archon-dlc plan workflow — load-context node. + + Goal: build a rich shared context before the adversarial interview begins. + + 1. Read CONTEXT.md at the project root if it exists. Note the domain vocabulary, key entities, and any established conventions. + 2. Read CONTEXT-MAP.md at the repo root if it exists (present in monorepos). Note which sub-project this workflow targets. + 3. Read all ADR files in docs/adr/ (sorted by filename). For each, note the decision title and outcome. + 4. If docs/workflow/{{ inputs.slug }}/findings.md exists, read it. Note the Integrated Brief and any surfaced pitfalls. + 5. Output a structured context summary with the following headings: + ## Domain Model + (key entities and vocabulary from CONTEXT.md) + ## Established Decisions + (list of ADRs: filename → one-line summary) + ## Prior Research + (Integrated Brief from findings.md, or "No findings.md for this slug." if absent) + + - id: specs + name: Adversarial Spec Interview + type: loop + depends_on: [load-context] + prompt: | + You are running the unic-archon-dlc plan workflow — specs (adversarial interview) node. + + Context already loaded in load-context: + - Domain vocabulary and entities + - Established ADR decisions + - Prior research findings (if any) + + Discuss mode: {{ workflow.discuss_mode | default('interview') }} + - "interview" → ask one open-ended question at a time; probe deeply; challenge assumptions + - "assumptions" → surface all implicit assumptions upfront, then confirm or refute each + + Interview guidelines: + 1. If discuss_mode is "assumptions": enumerate every assumption you can identify about the + problem space, proposed solution, constraints, and acceptance criteria before asking questions. + For each assumption, ask the user to confirm, refute, or refine it. + If discuss_mode is "interview": ask one focused, adversarial question at a time. + Do not ask multiple questions in a single turn. + 2. For every non-obvious architectural or process decision that emerges, propose an ADR entry: + - Suggest a filename following the pattern docs/adr/NNNN-<short-slug>.md + - Draft the ADR inline (Status, Context, Decision, Consequences) + - Write it live to docs/adr/NNNN-<short-slug>.md if the user confirms + - Use the next available NNNN by checking existing files in docs/adr/ + 3. Continue until the user signals they are done (e.g. "done", "that's enough", "proceed"). + 4. Before exiting, produce a structured interview transcript summary: + ## Interview Summary + (3-5 bullet points capturing the key decisions and constraints surfaced) + ## ADRs Produced + (list of any ADR files written during this session, or "None.") + + - id: to-prd + name: Synthesise PRD + type: prompt + depends_on: [specs] + prompt: | + You are running the unic-archon-dlc plan workflow — to-prd node. + + You have: + - The context summary from load-context (domain model, ADRs, prior research) + - The interview transcript summary and ADRs produced from the specs node + - The workflow slug: {{ inputs.slug }} + + Your job: synthesise all of the above into a structured PRD. + + 1. Call writePrd(projectDir, slug, sections) where sections has exactly these keys: + - problemStatement: 2-4 sentences describing the pain or gap being addressed + - solution: 2-4 sentences describing the proposed approach at a high level + - userStories: a bulleted list of "As a <role>, I want <capability>, so that <benefit>." stories (at least 3) + - implementationDecisions: bullet list of key technical choices; reference ADR filenames where applicable + - testingDecisions: bullet list of testing strategy choices (framework, coverage targets, edge cases to cover) + - outOfScope: bullet list of explicitly excluded items to prevent scope creep + - furtherNotes: any open questions, dependencies, or items to revisit after v1 + + 2. The resulting file must be docs/workflow/{{ inputs.slug }}/PRD.md. + 3. Print: "PRD written to docs/workflow/{{ inputs.slug }}/PRD.md" + + - id: prd-gate + name: PRD Human Gate + type: interactive + fresh_context: true + depends_on: [to-prd] + prompt: | + You are running the unic-archon-dlc plan workflow — prd-gate (first human PR gate) node. + + The PRD and any new ADRs are ready for human review. + + Actions: + 1. Run validatePrdSections(content) on the PRD.md content to confirm all 7 sections are present. + If any sections are missing, list them and offer to fix them before opening the PR. + 2. Stage the following files for a new PR: + - docs/workflow/{{ inputs.slug }}/PRD.md + - Any ADR files written during the specs node (docs/adr/NNNN-*.md that are new or modified) + 3. Open a PR targeting the develop branch with: + - Title: "plan({{ inputs.slug }}): PRD and ADRs" + - Body: + ## Summary + - PRD for `{{ inputs.slug }}` workflow session + - Lists any new or updated ADR files + - Includes all 7 required PRD sections + + ## Review Checklist + - [ ] Problem Statement clearly describes the pain or gap + - [ ] Solution is specific and implementable + - [ ] User Stories cover the primary use cases + - [ ] Implementation Decisions reference relevant ADRs + - [ ] Testing Decisions are concrete and actionable + - [ ] Out of Scope prevents scope creep + - [ ] Further Notes captures open questions + 4. Pause and wait for human review. + - If the PR is **approved** and merged: print "PRD gate passed. Proceeding." and exit. + - If the PR is **rejected** or changes are requested: print "PRD gate: changes requested. Returning to specs." + Then return control to the specs node so the interview can be resumed or refined. + + - id: to-issues + name: Decompose PRD into Issues + type: prompt + depends_on: [prd-gate] + prompt: | + You are running the unic-archon-dlc plan workflow — to-issues node. + + Goal: decompose the approved PRD into independently-deliverable, vertically-sliced issues and + publish them to the tracker in dependency order. + + ## Step 1 — Read the PRD + + Read docs/workflow/{{ inputs.slug }}/PRD.md in full. + + ## Step 2 — Decompose into vertical slices + + Produce a draft list of issues. Each issue MUST have all of the following fields: + + - id : short kebab-case identifier unique within this file (e.g. "issue-01") + - title : one-line description of the deliverable + - type : one of: feature | bug | spike | tech-debt | docs + - priority : one of: p0 | p1 | p2 | p3 + - blocked_by : array of IDs in *this* file that must ship first (empty array if independent) + - acceptance_criteria : non-empty array of statements that can each be demonstrated independently + - summary : one short paragraph describing the work + + Rules for good vertical slices: + - Each slice delivers observable user value on its own (or unblocks a slice that does). + - Slices are as small as possible while still being independently deployable/demonstrable. + - No slice should duplicate work covered by another slice. + - blocked_by must form a DAG — no cycles. + + ## Step 3 — Validation loop (iterate until approved) + + Present the breakdown to the user as a numbered list in this format: + <number>. [<type>/<priority>] <title> + blocked_by: <comma-separated IDs, or "none"> + summary: <summary> + acceptance_criteria: + - <criterion 1> + - … + + Then ask: "Validate this breakdown, or tell me which issues to split, merge, or relabel." + + Iterate — apply requested changes and re-present — until the user approves explicitly + (e.g. replies "approved", "looks good", "proceed"). + + ## Step 4 — Validate schema before publishing + + Before publishing, call validateIssue(issue) on every issue in the list. + If any issue fails validation, list the errors and fix them before proceeding. + Call sortByDependency(issues) to obtain the publication order. + If a circular dependency is detected, surface it to the user and ask them to resolve it. + + ## Step 5 — Publish to tracker in dependency order + + Load the tracker configuration from .archon/unic-dlc.config.json (field: tracker). + For each issue in sorted order: + 1. Call buildCreateCommand(tracker, issue.title, issue.type, issue.priority, labels) to generate + the CLI command. + 2. Print the command. + 3. Execute the command (or, if in dry-run mode, skip execution and print "[dry-run]"). + + ## Step 6 — Write issues.json + + Call buildIssuesJson(issues) and write the result to docs/workflow/{{ inputs.slug }}/issues.json. + Print: "issues.json written to docs/workflow/{{ inputs.slug }}/issues.json" + + - id: nyquist-map + name: Nyquist Map — Attach Test Commands + type: prompt + depends_on: [to-issues] + prompt: | + You are running the unic-archon-dlc plan workflow — nyquist-map node. + + Goal: for every issue in the just-published list, decide the exact shell command (or flag as + planned) that will verify the issue is done. This ensures every issue is testable before + implementation begins. + + ## Step 1 — Load issues + + Read docs/workflow/{{ inputs.slug }}/issues.json and parse the issues array. + + ## Step 2 — Attach test commands + + For each issue, determine the most appropriate verification command: + + - If a concrete, runnable test command exists (e.g. `pnpm test`, `pnpm --filter <name> test`, + a specific test file path, or an integration check script), set: + test_command: "<exact shell command>" + + - If no test exists yet (e.g. the issue creates new untested functionality, or the test itself + is the deliverable of another issue), set: + test_command_planned: true + + Guidelines: + - Prefer the most specific command that exercises only the acceptance criteria of that issue. + - A `test_command_planned: true` is not a failure — it signals that a test must be written as + part of implementing that issue. + - Do NOT set both test_command and test_command_planned on the same issue. + - If the issue's type is 'docs' or 'spike', set test_command_planned: true unless a linting + or build check is meaningful. + + ## Step 3 — Update issues.json + + Merge the test_command / test_command_planned fields into each issue object. + Call buildIssuesJson(updatedIssues) and overwrite docs/workflow/{{ inputs.slug }}/issues.json. + Print a summary table: + + Issue ID | Test command / planned + -------------|-------------------------------------- + issue-01 | pnpm --filter my-plugin test + issue-02 | [planned] + … + + Print: "nyquist-map complete. issues.json updated with test commands." + + - id: plan-checker + name: Plan Checker — Validate Issue Plan + type: loop + depends_on: [nyquist-map] + prompt: | + You are running the unic-archon-dlc plan workflow — plan-checker node. + + Goal: validate the issue plan across four dimensions before generating the build YAML. + Max 3 iterations. Escalate immediately to the human gate if the issue count does not + decrease between consecutive iterations (stall detection). + + ## Step 1 — Load issues + + Read docs/workflow/{{ inputs.slug }}/issues.json and parse the issues array. + Track: iteration number (start at 1), previous issue count (start at -1). + + ## Step 2 — Stall detection + + If iteration > 1 AND current issue count >= previous issue count: + Print: "plan-checker: stall detected (issue count did not decrease). Escalating to human gate." + Exit the loop and proceed to yaml-gen. + + If iteration > 3: + Print: "plan-checker: max iterations reached. Escalating to human gate." + Exit the loop and proceed to yaml-gen. + + ## Step 3 — Validate four dimensions + + ### 3.1 Consistency + - Collect all `blocked_by` values across all issues. + - Check that every referenced ID exists in the issue list. + - Orphaned references: IDs in `blocked_by` that don't exist in the list. + - Check that every acceptance criterion from every PRD user story is covered by + at least one issue's acceptance_criteria. + - Report: list of orphaned references; list of uncovered PRD criteria. + + ### 3.2 Completeness + - For every issue, verify all mandatory fields are present and non-empty: + id, title, type, priority, blocked_by, acceptance_criteria, summary. + - acceptance_criteria must be a non-empty array. + - Report: list of issues with missing or empty mandatory fields. + + ### 3.3 Decision coverage + - Read CONTEXT.md at the project root (if present). Extract trackable decisions + (sentences or bullet points that describe a binding technical or process choice). + - Read the interview transcript summary produced by the specs node (look for + "## Interview Summary" in the workflow session's notes or the specs node output). + - For each trackable decision, check whether at least one issue references or + addresses it (in title, summary, or acceptance_criteria). + - Report: list of unaddressed decisions. + + ### 3.4 Nyquist compliance + - Check each issue for either `test_command` (string) or `test_command_planned: true`. + - Issues missing both fields fail this check. + - Report: list of issues without test coverage mapping. + + ## Step 4 — Build the validation report + + Produce a structured report: + + ## plan-checker Report (iteration {{ loop.iteration }}) + + ### Consistency + <orphaned references or "✓ No orphaned references"> + <uncovered PRD criteria or "✓ All PRD criteria covered"> + + ### Completeness + <missing fields per issue or "✓ All issues have required fields"> + + ### Decision Coverage + <unaddressed decisions or "✓ All trackable decisions addressed"> + + ### Nyquist Compliance + <issues missing test mapping or "✓ All issues have test_command or test_command_planned"> + + ### Summary + <overall PASS or FAIL with counts> + + Write the report to docs/workflow/{{ inputs.slug }}/plan-checker-report.md + (overwrite on each iteration). + + ## Step 5 — Act on the report + + If all four dimensions PASS: + Print: "plan-checker: all checks passed. Proceeding to yaml-gen." + Exit the loop. + + Otherwise: + Present the report to the user. + Ask: "Which issues should I fix? (Or type 'accept' to proceed despite failures.)" + If the user types 'accept': + Print: "plan-checker: user accepted failures. Proceeding." + Exit the loop. + Otherwise: + Apply the requested fixes to issues.json. + Increment iteration counter and loop again. + + - id: yaml-gen + name: YAML Gen — Build Workflow Generator + type: bash + depends_on: [plan-checker] + script: | + #!/usr/bin/env node + // @ts-check + import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' + import { join } from 'node:path' + import { buildYaml } from './lib/dag-builder.mjs' + + const slug = process.env.ARCHON_INPUT_SLUG + if (!slug) { + console.error('yaml-gen: ARCHON_INPUT_SLUG is not set') + process.exit(1) + } + + const issuesPath = join('docs', 'workflow', slug, 'issues.json') + let issues + try { + issues = JSON.parse(readFileSync(issuesPath, 'utf8')) + } catch (err) { + console.error(`yaml-gen: cannot read ${issuesPath}: ${err.message}`) + process.exit(1) + } + + let yaml + try { + yaml = buildYaml(slug, issues) + } catch (err) { + console.error(`yaml-gen: ${err.message}`) + process.exit(1) + } + + const outDir = join('.archon', 'workflows') + mkdirSync(outDir, { recursive: true }) + const outPath = join(outDir, `build-${slug}.yaml`) + writeFileSync(outPath, yaml) + console.log(`yaml-gen: build workflow written to ${outPath}`) + + - id: plan-pr-gate + name: Plan PR Gate — Human Review + type: interactive + fresh_context: true + depends_on: [yaml-gen] + prompt: | + You are running the unic-archon-dlc plan workflow — plan-pr-gate (second human PR gate) node. + + The issue plan and build workflow are ready for human review. + + Actions: + 1. Stage the following files for a new PR: + - docs/workflow/{{ inputs.slug }}/issues.json + - docs/workflow/{{ inputs.slug }}/plan-checker-report.md + - .archon/workflows/build-{{ inputs.slug }}.yaml + 2. Open a PR targeting the develop branch with: + - Title: "plan({{ inputs.slug }}): issue plan and build workflow" + - Body: + ## Summary + - Issue plan for `{{ inputs.slug }}` ({{ issues | length }} issues) + - plan-checker validation report attached + - Auto-generated `build-{{ inputs.slug }}.yaml` Archon workflow + + ## Review Checklist + - [ ] All issues are independently deliverable vertical slices + - [ ] Dependency order is correct (no orphaned blocked_by references) + - [ ] Every issue has a test command or is marked test_command_planned + - [ ] build-{{ inputs.slug }}.yaml parallel/serial structure looks correct + - [ ] plan-checker report shows no critical failures (or failures are intentional) + 3. Pause and wait for human review. + - If the PR is **approved** and merged: + Print: "Plan PR gate passed. Build workflow ready at .archon/workflows/build-{{ inputs.slug }}.yaml" + Exit. + - If the PR is **rejected** or changes are requested: + Print: "Plan PR gate: changes requested. Returning to plan-checker for another validation pass." + Return control to the plan-checker node so validation can be re-run on the updated plan. + Do NOT return to to-issues (no full re-decomposition). diff --git a/apps/claude-code/unic-archon-dlc/.archon/workflows/qa.yaml b/apps/claude-code/unic-archon-dlc/.archon/workflows/qa.yaml new file mode 100644 index 0000000..3146594 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/workflows/qa.yaml @@ -0,0 +1,357 @@ +name: unic-dlc-qa +description: > + QA workflow: e2e test execution → coverage gate → UAT checklist → merge. + Runs after the build workflow is complete and the build PR has been approved. + +inputs: + slug: + description: Planning session identifier — matches the slug used in /unic-dlc-plan and /unic-dlc-build. + required: true + +nodes: + - id: e2e + name: End-to-End Tests + type: bash + depends_on: [] + script: | + #!/usr/bin/env node + // @ts-check + import { execFileSync } from 'node:child_process' + import { existsSync, readFileSync } from 'node:fs' + import { join } from 'node:path' + + const projectDir = process.cwd() + const configPath = join(projectDir, '.archon', 'unic-dlc.config.json') + + if (!existsSync(configPath)) { + console.error('e2e: .archon/unic-dlc.config.json not found.') + console.error('Run the install hook first: archon install') + process.exit(1) + } + + const config = JSON.parse(readFileSync(configPath, 'utf8')) + const e2eCommand = config.e2e_command ?? null + + if (!e2eCommand) { + console.error('e2e: e2e_command is not configured.') + console.error('') + console.error('To set it, run the install hook with --reconfigure:') + console.error(' archon install --reconfigure') + console.error('and provide a value for "e2e_command" (e.g. "pnpm test:e2e" or "npx playwright test").') + process.exit(1) + } + + const parts = e2eCommand.trim().split(/\s+/) + const cmd = parts[0] + const args = parts.slice(1) + + console.log(`e2e: running → ${e2eCommand}`) + console.log('') + + try { + execFileSync(cmd, args, { cwd: projectDir, stdio: 'inherit' }) + console.log('') + console.log('e2e: PASSED ✓') + } catch { + console.error('') + console.error('e2e: FAILED ✗') + process.exit(1) + } + + - id: coverage-gate + name: Coverage Gate + type: bash + depends_on: [e2e] + script: | + #!/usr/bin/env node + // @ts-check + import { execFileSync } from 'node:child_process' + import { existsSync, readFileSync } from 'node:fs' + import { join } from 'node:path' + + const projectDir = process.cwd() + const configPath = join(projectDir, '.archon', 'unic-dlc.config.json') + + let coverageThreshold = null + if (existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, 'utf8')) + coverageThreshold = config.coverage_threshold ?? null + } + + if (coverageThreshold === null) { + console.log('coverage-gate: no coverage_threshold configured — skipping.') + console.log('To enable, add "coverage_threshold": <number> to .archon/unic-dlc.config.json.') + process.exit(0) + } + + console.log(`coverage-gate: threshold is ${coverageThreshold}%`) + console.log('coverage-gate: running test suite with coverage...') + console.log('') + + // Run tests with coverage; capture output to parse coverage percentage + let coverageOutput = '' + let failed = false + try { + coverageOutput = execFileSync('pnpm', ['test', '--coverage'], { + cwd: projectDir, + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'pipe'], + }) + process.stdout.write(coverageOutput) + } catch (/** @type {any} */ err) { + if (err.stdout) process.stdout.write(err.stdout) + if (err.stderr) process.stderr.write(err.stderr) + coverageOutput = (err.stdout ?? '') + (err.stderr ?? '') + failed = true + } + + // Attempt to extract a coverage percentage from the output. + // Handles common formats: "All files | 87.5 |", "Stmts: 87.5%", "87.5% statements" + const percentMatch = + coverageOutput.match(/All files\s*\|\s*([\d.]+)/) ?? + coverageOutput.match(/Stmts\s*[:\|]\s*([\d.]+)/) ?? + coverageOutput.match(/([\d.]+)%\s+statements/i) + + if (percentMatch) { + const actual = parseFloat(percentMatch[1]) + if (actual < coverageThreshold) { + console.error(`\ncoverage-gate: FAILED (${actual}% < threshold ${coverageThreshold}%)`) + process.exit(1) + } + console.log(`\ncoverage-gate: PASSED (${actual}%)`) + } else if (failed) { + // Could not parse percentage but the command failed + console.error(`\ncoverage-gate: FAILED (test suite exited non-zero; threshold ${coverageThreshold}%)`) + process.exit(1) + } else { + // Command passed but no parseable percentage — treat as passed with a warning + console.log(`\ncoverage-gate: PASSED (coverage % could not be parsed; threshold ${coverageThreshold}%)`) + } + + - id: uat-gate + name: UAT Gate — Acceptance Checklist + type: interactive + fresh_context: true + depends_on: [coverage-gate] + prompt: | + You are running the unic-archon-dlc QA workflow — uat-gate node. + + Goal: walk through the UAT checklist derived from the PRD's acceptance criteria alongside + the e2e results, then get human sign-off before the merge step proceeds. + + ## Step 1 — Load artefacts + + 1. Read `docs/workflow/{{ inputs.slug }}/PRD.md`. + Extract all acceptance criteria. They are typically found in: + - Each "User Story" block as sub-bullet "Acceptance criteria:" lists. + - A top-level "## Acceptance Criteria" section if present. + Number each criterion sequentially: AC-1, AC-2, … + + 2. Check whether `docs/workflow/{{ inputs.slug }}/report.md` exists and read it if so. + The report contains the goals-check coverage matrix and test outcomes from the build phase. + + ## Step 2 — Present the UAT checklist + + Print the following header: + + === UAT Checklist for {{ inputs.slug }} === + + Then for each acceptance criterion: + + AC-N: <criterion text> + Evidence: <matching row from coverage matrix, or "not recorded"> + [ ] Verified by human + + Follow the list with: + + e2e result: PASSED (or FAILED — see e2e node output above) + Coverage: PASSED (or SKIPPED / FAILED — see coverage-gate output above) + + ## Step 3 — Ask for sign-off + + Ask the user: + "Please review each item above and confirm: + 1. All acceptance criteria are verified (tick each [ ] mentally or note any failures). + 2. You approve this build for merge. + + Type APPROVE to continue to the merge step. + Type REJECT <AC-N> to flag a failing checklist item — I will return control to whoever + owns that item for remediation." + + ## Step 4 — Act on the response + + If the user types APPROVE (case-insensitive): + Print: "uat-gate: APPROVED. Proceeding to merge." + + If the user types REJECT <AC-N> (or REJECT with any text): + Print: "uat-gate: REJECTED — {{ user response }}." + Print: "Returning control. Address the failing criterion and re-run /unic-dlc-qa {{ inputs.slug }} to retry." + Halt the workflow (do not proceed to the merge node). + + - id: verify-pr-base + name: Verify PR Base Branch + type: bash + depends_on: [uat-gate] + script: | + #!/usr/bin/env node + // @ts-check + import { readFileSync } from 'node:fs' + import { execFileSync } from 'node:child_process' + + let config = {} + try { + config = JSON.parse(readFileSync('.archon/unic-dlc.config.json', 'utf8')) + } catch { + console.error('verify-pr-base: cannot read .archon/unic-dlc.config.json') + process.exit(1) + } + + const tracker = config.tracker ?? 'github' + const branching = config.branching ?? 'gitflow' + const expectedBase = branching === 'gitflow' ? 'develop' : 'main' + + if (tracker === 'github') { + let actualBase + try { + const out = execFileSync('gh', ['pr', 'view', '--json', 'baseRefName'], { encoding: 'utf8' }) + actualBase = JSON.parse(out).baseRefName + } catch (err) { + console.error(`verify-pr-base: cannot query PR base — ${err.message}`) + process.exit(1) + } + if (actualBase !== expectedBase) { + console.error( + `verify-pr-base: PR targets "${actualBase}" but expected "${expectedBase}" (branching: ${branching}).`, + ) + console.error(' Retarget the PR before merging.') + process.exit(1) + } + console.log(`verify-pr-base: PR base is "${actualBase}" ✓`) + } else if (tracker === 'ado') { + let actualBase + try { + const out = execFileSync('az', ['repos', 'pr', 'show', '--output', 'json'], { encoding: 'utf8' }) + actualBase = JSON.parse(out).targetRefName?.replace('refs/heads/', '') + } catch (err) { + console.error(`verify-pr-base: cannot query ADO PR — ${err.message}`) + process.exit(1) + } + if (actualBase !== expectedBase) { + console.error( + `verify-pr-base: PR targets "${actualBase}" but expected "${expectedBase}" (branching: ${branching}).`, + ) + process.exit(1) + } + console.log(`verify-pr-base: PR base is "${actualBase}" ✓`) + } else { + // Jira / local-markdown — no CLI to query PR base + console.log( + `verify-pr-base: tracker "${tracker}" does not support PR base verification. Skipping (warning only).`, + ) + } + + - id: merge + name: Merge PR + type: bash + depends_on: [verify-pr-base] + script: | + #!/usr/bin/env node + // @ts-check + import { execFileSync } from 'node:child_process' + import { existsSync, readFileSync } from 'node:fs' + import { join } from 'node:path' + + const projectDir = process.cwd() + const configPath = join(projectDir, '.archon', 'unic-dlc.config.json') + + if (!existsSync(configPath)) { + console.error('merge: .archon/unic-dlc.config.json not found.') + process.exit(1) + } + + const config = JSON.parse(readFileSync(configPath, 'utf8')) + const tracker = config.tracker ?? 'github' + const branching = config.branching ?? 'gitflow' + const prStrategy = config.pr_strategy ?? 'squash' + const slug = process.env.ARCHON_INPUT_SLUG ?? '' + + console.log(`merge: tracker=${tracker}, branching=${branching}, pr_strategy=${prStrategy}`) + console.log('') + + // Merge via the configured tracker + switch (tracker) { + case 'github': { + const mergeFlag = prStrategy === 'merge' ? '--merge' : '--squash' + console.log(`merge: running gh pr merge ${mergeFlag}`) + try { + execFileSync('gh', ['pr', 'merge', mergeFlag], { cwd: projectDir, stdio: 'inherit' }) + console.log('merge: PR merged successfully via GitHub CLI.') + } catch { + console.error('merge: gh pr merge failed. Ensure you are on a feature branch with an open PR.') + process.exit(1) + } + break + } + + case 'ado': { + console.log('merge: running az repos pr update --status completed') + try { + execFileSync('az', ['repos', 'pr', 'update', '--status', 'completed'], { + cwd: projectDir, + stdio: 'inherit', + }) + console.log('merge: PR completed successfully via Azure DevOps CLI.') + } catch { + console.error('merge: az repos pr update failed. Ensure the Azure DevOps PR is open and the CLI is authenticated.') + process.exit(1) + } + break + } + + case 'jira': + console.log('Merge via Jira: transition the linked PR to Done manually.') + console.log(' 1. Open the Jira issue linked to this feature.') + console.log(' 2. Transition it to "Done" (or "In Review → Done" depending on your board).') + console.log(' 3. Merge the corresponding PR in your VCS provider.') + break + + case 'local-markdown': + console.log('Merge: update Status in issues to resolved.') + console.log(` Edit docs/issues/<slug>/index.md and set: Status: resolved`) + console.log(' Then commit and push the change manually.') + break + + default: + console.log(`merge: unknown tracker "${tracker}". Merge the PR manually in your tracker.`) + break + } + + // Delete feature branch after merge when using Gitflow + if (branching === 'gitflow' && (tracker === 'github' || tracker === 'ado')) { + console.log('') + console.log('merge: Gitflow — deleting feature branch...') + try { + // Determine current branch name + const currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: projectDir, + encoding: 'utf8', + }).trim() + + if (currentBranch.startsWith('feature/')) { + // Switch to develop before deleting + execFileSync('git', ['checkout', 'develop'], { cwd: projectDir, stdio: 'inherit' }) + execFileSync('git', ['branch', '-d', currentBranch], { cwd: projectDir, stdio: 'inherit' }) + console.log(`merge: feature branch "${currentBranch}" deleted. ✓`) + } else { + console.log(`merge: current branch "${currentBranch}" is not a feature branch — skipping delete.`) + } + } catch { + console.log('merge: could not delete feature branch automatically — delete it manually if needed.') + } + } else if (branching !== 'gitflow') { + console.log('') + console.log('merge: github-flow — leaving branch cleanup to GitHub (auto-delete on merge).') + } + + console.log('') + console.log(`merge: QA workflow for "${slug}" complete. ✓`) diff --git a/apps/claude-code/unic-archon-dlc/.archon/workflows/review.yaml b/apps/claude-code/unic-archon-dlc/.archon/workflows/review.yaml new file mode 100644 index 0000000..b085baa --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/workflows/review.yaml @@ -0,0 +1,239 @@ +name: unic-dlc-review +description: > + Self-contained code review across four aspects — code quality, test coverage adequacy, + silent failure patterns, and type design quality. Posts (or updates) a single structured + comment on the current PR via the configured tracker adapter. + No runtime dependency on pr-review-toolkit or any other plugin. + +nodes: + - id: code-review + name: Code Review — Four Aspects + type: prompt + depends_on: [] + prompt: | + You are running the unic-archon-dlc review workflow — code-review node. + + ## Setup + + 1. Read `.archon/unic-dlc.config.json` to determine: + - `tracker` (github | ado | jira | local-markdown; default: github) + - `branching` strategy if present + + 2. Read project conventions from whichever file exists (in order): + - `CLAUDE.md` + - `AGENTS.md` + - `.claude/CLAUDE.md` + Note all style rules, naming conventions, tab/space preferences, and + explicit "do not" rules. + + 3. Identify the current open PR: + - github: `gh pr view --json number,title,body,headRefName` + - ado: `az repos pr list --status active --output json | head -1` + - local-markdown: use the most recent `docs/workflow/*/report.md` as the review target + Store `PR_ID` (issue/PR number or identifier) and `PR_TITLE`. + + 4. Compute the diff to review: + - github / ado / local-markdown: + `git diff origin/$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null | sed 's|.*/||' || echo main)...HEAD` + - If the above fails, fall back to: `git diff HEAD~1...HEAD` + Store the result as `DIFF_TEXT`. + + 5. List all source files touched in the diff: + `git diff --name-only origin/$(git symbolic-ref --short HEAD 2>/dev/null)...HEAD 2>/dev/null || git diff --name-only HEAD~1` + Store as `CHANGED_FILES` (newline-separated list). + + 6. Read each file in `CHANGED_FILES` in full (skip deleted files). + + --- + + ## Aspect 1 — Code Quality + + Analyse the diff and changed file contents for: + - **Readability**: Is each function easy to understand in isolation? + - **Naming**: Do variable, function, and module names communicate intent without + needing comments? + - **Function length**: Are any functions longer than ~40 lines? Could they be split? + - **Project-convention adherence**: Check against the rules you read from CLAUDE.md / + AGENTS.md. Examples include tab vs. space indentation, quote style, semicolons, + trailing commas, explicit returns, early returns over nested conditionals. + + For each problem found, record a finding in this exact format: + - `file:line — [code-quality] <one-line description>` + + If no problems are found, record: + - `No findings.` + + Store all findings under the heading `### Code Quality`. + + --- + + ## Aspect 2 — Test Coverage Adequacy + + Locate test files in `CHANGED_FILES` or in `test/`, `tests/`, `__tests__/`, + `*.test.*`, `*.spec.*` directories. For each changed source module, check whether: + + - Tests exercise the module through its **public interface** (exported functions), + not internal helpers. + - Assertions verify **outputs and side-effects**, not internal state or call counts + on internal collaborators. + - No internal collaborators are mocked — only true external boundaries (network, + disk, third-party CLIs) should be mocked. + - Every exported function has at least one happy-path test and at least one + edge-case or error-path test. + + For each gap found, record a finding: + - `file:line — [test-coverage] <one-line description>` + + If no gaps are found, record: + - `No findings.` + + Store all findings under the heading `### Test Coverage Adequacy`. + + --- + + ## Aspect 3 — Silent Failure Patterns + + Scan the diff for patterns that swallow errors and hide bugs: + + - **Empty catch blocks**: `catch { }` or `catch (e) { /* ignored */ }` + - **Swallowed exceptions**: `catch` that logs but does not rethrow, return a default, + or propagate the error to the caller. + - **Silent fallbacks**: `?? defaultValue` or `|| fallback` on values that should + never be absent — where the fallback masks a missing configuration or corrupt state. + - **Unhandled promise rejections**: `.then(…)` without `.catch(…)` or `await` outside + try/catch in async functions. + - **Ignored return values**: calling a function whose return value signals an error + or state change, then discarding it. + + For each pattern found, record a finding: + - `file:line — [silent-failure] <one-line description>` + + If no patterns are found, record: + - `No findings.` + + Store all findings under the heading `### Silent Failure Patterns`. + + --- + + ## Aspect 4 — Type Design Quality + + Analyse type annotations, JSDoc types, or TypeScript types introduced or changed + in the diff: + + - **Encapsulation**: Are types narrow enough to prevent invalid usage? Or are they + overly broad (e.g. `string` where a union literal would be more precise)? + - **Invariants in types**: Do types encode business rules? E.g. a `NonEmptyArray<T>` + instead of `T[]` where empty is invalid. + - **Illegal-state-unrepresentable**: Are there mutually exclusive fields that could + be unified into a discriminated union? Are there optional fields that should be + required, or vice-versa? + - **JSDoc completeness** (for `.mjs` / `// @ts-check` files): Are all exported + function parameters and return types annotated? + + For each weakness found, record a finding: + - `file:line — [type-design] <one-line description>` + + If no weaknesses are found, record: + - `No findings.` + + Store all findings under the heading `### Type Design Quality`. + + --- + + ## Compose the review comment + + Assemble a Markdown comment with this exact structure: + + ``` + <!-- unic-dlc-review --> + ## unic-dlc-review + + > PR: <PR_TITLE> + + ### Code Quality + <findings or "No findings."> + + ### Test Coverage Adequacy + <findings or "No findings."> + + ### Silent Failure Patterns + <findings or "No findings."> + + ### Type Design Quality + <findings or "No findings."> + + --- + *Generated by unic-archon-dlc · re-run `/unic-dlc-review` to update this comment.* + ``` + + The `<!-- unic-dlc-review -->` HTML comment at the very first line is the sentinel + used for update detection. Do not remove or alter it. + + Store the assembled Markdown as `REVIEW_BODY`. + + --- + + ## Post or update the comment + + Search for an existing review comment on this PR: + + **github:** + ``` + gh pr view "$PR_ID" --json comments --jq '.comments[] | select(.body | startswith("<!-- unic-dlc-review -->")) | .databaseId' + ``` + If a comment ID is found (call it `EXISTING_ID`): + Update: `gh api repos/{owner}/{repo}/issues/comments/$EXISTING_ID -X PATCH -f body="$REVIEW_BODY"` + Print: "review: updated existing comment $EXISTING_ID on PR #$PR_ID" + If no comment exists: + Create: `gh pr comment "$PR_ID" --body "$REVIEW_BODY"` + Print: "review: posted new comment on PR #$PR_ID" + + **ado:** + Fetch threads: `az repos pr thread list --id "$PR_ID" --output json` + Search for a thread whose first comment starts with `<!-- unic-dlc-review -->`. + If found (`THREAD_ID`, `COMMENT_ID`): + Update: `az repos pr thread comment update --id "$PR_ID" --thread-id "$THREAD_ID" --comment-id "$COMMENT_ID" --content "$REVIEW_BODY"` + Print: "review: updated existing thread $THREAD_ID on PR $PR_ID" + If not found: + Create: `az repos pr thread create --id "$PR_ID" --comment "$REVIEW_BODY"` + Print: "review: posted new thread on PR $PR_ID" + + **local-markdown:** + Look for `docs/workflow/<slug>/review-comment.md` (where slug is the most recent + workflow directory under docs/workflow/). + If it exists: + Overwrite the file with `REVIEW_BODY`. + Print: "review: updated docs/workflow/<slug>/review-comment.md" + If it does not exist: + Write `REVIEW_BODY` to `docs/workflow/<slug>/review-comment.md`. + Print: "review: created docs/workflow/<slug>/review-comment.md" + + **jira:** + Search for a comment whose body starts with `<!-- unic-dlc-review -->`: + `jira issue comment list "$PR_ID" --output json` + If found (`COMMENT_ID`): + `jira issue comment update "$PR_ID" "$COMMENT_ID" --body "$REVIEW_BODY"` + Print: "review: updated existing comment $COMMENT_ID on issue $PR_ID" + If not found: + `jira issue comment add "$PR_ID" --body "$REVIEW_BODY"` + Print: "review: posted new comment on issue $PR_ID" + + --- + + ## Summary + + After posting or updating, print a human-readable summary: + + ``` + unic-dlc-review complete. + + Aspects reviewed: Code Quality · Test Coverage Adequacy · Silent Failure Patterns · Type Design Quality + PR: <PR_TITLE> (<PR_ID>) + Action: <created | updated> review comment + + Findings: + Code Quality: <N finding(s) | No findings> + Test Coverage Adequacy: <N finding(s) | No findings> + Silent Failure Patterns: <N finding(s) | No findings> + Type Design Quality: <N finding(s) | No findings> + ``` diff --git a/apps/claude-code/unic-archon-dlc/.archon/workflows/triage.yaml b/apps/claude-code/unic-archon-dlc/.archon/workflows/triage.yaml new file mode 100644 index 0000000..6134ee6 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.archon/workflows/triage.yaml @@ -0,0 +1,47 @@ +name: unic-dlc-triage +description: Read current issue states and produce a HANDOFF.md status snapshot. + +nodes: + - id: read-state + name: Read Issue States + type: prompt + depends_on: [] + prompt: | + You are running the unic-archon-dlc triage workflow. + + 1. Read .archon/unic-dlc.config.json to determine the configured tracker and branching strategy. + 2. Read docs/workflow/ROADMAP.md if it exists; note the last-updated phase. + 3. List all issues from the configured tracker using the appropriate CLI: + - github: `gh issue list --json number,title,labels,state` + - ado: `az boards work-item list --query "[*].{id:id,title:fields.\"System.Title\",state:fields.\"System.State\"}"` + - jira: `jira issue list --output json` + - local-markdown: Read docs/issues/**/index.md and parse Status: lines + 4. Group issues by their canonical state label. + 5. Identify blockers: issues whose blocked_by references are still open. + 6. List the five most recent ADR files in docs/adr/ (by filename order). + 7. Determine the current lifecycle phase from ROADMAP.md or "unknown" if absent. + 8. Output a JSON object with shape: + { + "phase": "<phase>", + "openIssues": { "<state>": ["<title>", ...] }, + "blockers": ["<blocker description>", ...], + "recentDecisions": ["<adr-filename>", ...] + } + + - id: produce-handoff + name: Produce HANDOFF.md + type: prompt + depends_on: [read-state] + prompt: | + Using the JSON snapshot from read-state: + + 1. Write HANDOFF.md at the repo root with exactly four sections: + ## Current Phase — the phase value + ## Open Issues by State — issues grouped by state label + ## Blockers — open issues whose blockers are still unresolved + ## Recent Decisions — ADR filenames from docs/adr/ + + 2. Call updateRoadmap() to refresh docs/workflow/ROADMAP.md with the current phase. + The ROADMAP.md uses marker-delimited auto-generated regions — do not overwrite human content. + + 3. Print a short summary: "Triage complete. HANDOFF.md written. ROADMAP.md updated." diff --git a/apps/claude-code/unic-archon-dlc/.claude-plugin/marketplace.json b/apps/claude-code/unic-archon-dlc/.claude-plugin/marketplace.json new file mode 100644 index 0000000..503f88c --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.claude-plugin/marketplace.json @@ -0,0 +1,27 @@ +{ + "metadata": { + "description": "A complete Archon-powered AI development lifecycle as an installable DLC pack", + "homepage": "https://github.com/unic/unic-agents-plugins" + }, + "name": "unic-archon-dlc", + "owner": { + "name": "Unic AG" + }, + "plugins": [ + { + "author": { + "name": "Unic AG" + }, + "category": "productivity", + "description": "A complete Archon-powered AI development lifecycle — explore, plan, build, qa, cleanup, and triage workflows as installable YAML DAGs with human approval gates.", + "displayName": "Unic Archon DLC", + "homepage": "https://github.com/unic/unic-agents-plugins", + "keywords": ["archon", "workflow", "lifecycle", "tdd", "unic"], + "license": "LGPL-3.0-or-later", + "name": "unic-archon-dlc", + "source": "./", + "tags": ["productivity", "workflow", "ai-development"], + "version": "0.1.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 new file mode 100644 index 0000000..b17fec6 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "unic-archon-dlc", + "version": "0.1.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", + "url": "https://www.unic.com" + }, + "homepage": "https://github.com/unic/unic-agents-plugins", + "license": "LGPL-3.0-or-later", + "keywords": ["archon", "workflow", "lifecycle", "tdd", "unic"] +} diff --git a/apps/claude-code/unic-archon-dlc/CHANGELOG.md b/apps/claude-code/unic-archon-dlc/CHANGELOG.md new file mode 100644 index 0000000..36881bf --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +## [Unreleased] + +### Breaking +- (none) + +### Added +- (none) + +### Fixed +- (none) + +## [0.1.0] — 2026-05-15 + +Initial release of the unic-archon-dlc plugin. Ships the complete AI development lifecycle +as six Archon workflow DAGs with human approval gates at every decision boundary. + +### Added + +- **Install hook** (`/unic-dlc-install`): auto-detects tracker from git remote, deduces PR + strategy and branching model, writes `.archon/unic-dlc.config.json`, agent skill docs under + `docs/agents/`, and idempotent `## Agent skills` block in `CLAUDE.md`. +- **`triage` workflow** (`/unic-dlc-triage`): headless/on-demand; reads current issue states, + reconciles `docs/workflow/ROADMAP.md`, and produces `HANDOFF.md` with phase, open issues, + blockers, and recent decisions. +- **`explore` workflow** (`/unic-dlc-explore <slug>`): four parallel research nodes + (stack/features/architecture/pitfalls) → synthesize → prototype + spike verdicts → + interactive code-preserve gate → spike ticket creation. +- **`plan` workflow** (`/unic-dlc-plan <slug>`): adversarial spec interview (loop) → PRD + synthesis → human PRD gate → issue decomposition → Nyquist test-command mapping → + plan-checker validation loop (max 3 iterations, stall detection) → YAML generator → + human plan gate. +- **`build` workflow** (`/unic-dlc-build <slug>`): slopcheck package gate → generated + `build-<slug>.yaml` (red→green TDD per issue, parallel across independent issues) → + verification (stub detector, coverage) → goals-check coverage matrix → consolidation + report → human build PR gate. +- **`review` command** (`/unic-dlc-review`): self-contained four-aspect code review (code + quality, test adequacy, silent failures, type design); posts structured comment via tracker + adapter; updates prior comment on re-run. No dependency on `pr-review-toolkit`. +- **`qa` workflow** (`/unic-dlc-qa <slug>`): e2e suite → coverage gate → interactive UAT + gate (acceptance criteria checklist) → PR base verification → merge via tracker CLI with + branching-strategy-aware branch deletion. +- **`cleanup` workflow** (`/unic-dlc-cleanup <slug>`): architecture review (technical drift, + intent drift, deepening opportunities) → per-ADR interactive consolidation gate → reuse of + shared triage workflow. +- **lib modules**: `config-loader`, `setup-explorer`, `labels-config`, `agent-docs-writer`, + `tracker-adapter`, `handoff-generator`, `findings-writer`, `prd-writer`, `spike-verdicts`, + `issues-schema` (topological sort), `dag-builder` (YAML generator), `slopcheck`, + `stub-detector`. +- **86 `node:test` tests** covering all lib modules. diff --git a/apps/claude-code/unic-archon-dlc/CONTEXT.md b/apps/claude-code/unic-archon-dlc/CONTEXT.md new file mode 100644 index 0000000..60e8f6a --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/CONTEXT.md @@ -0,0 +1,97 @@ +# unic-archon-dlc + +An Archon-powered AI development lifecycle DLC that scaffolds six workflow DAGs (explore, plan, +build, qa, cleanup, triage) plus agent-skill docs into any target project via an install hook. + +Requires the Archon workflow engine (version ≥ 0.10) in the target project. + +## Language + +### Session lifecycle + +**Slug**: +A short identifier chosen at explore time that scopes all session artefacts +(e.g. `docs/workflow/<slug>/`, `.archon/workflows/build-<slug>.yaml`). +_Avoid_: session name, run id, job id + +**Session**: +One complete explore → plan → build → qa → cleanup cycle tied to a single Slug. +_Avoid_: run, job, sprint (which has a specific meaning here) + +**HANDOFF.md**: +A persistent snapshot of project state refreshed by every triage run. Lives at the repo root. +_Avoid_: status doc, handoff note + +**ROADMAP.md**: +Persistent roadmap under `docs/workflow/ROADMAP.md`; human-written content outside +`<!-- unic-archon-dlc:begin/end -->` markers is never overwritten. +_Avoid_: roadmap file, project roadmap + +### Planning artifacts + +**PRD**: +Product Requirements Document produced by the `to-prd` node in the plan workflow and stored at +`docs/workflow/<slug>/PRD.md`. Must contain exactly the seven mandatory sections. +_Avoid_: spec, requirements doc + +**Findings**: +The explore output at `docs/workflow/<slug>/findings.md` — five sections: Stack, Features, +Architecture, Pitfalls, Integrated Brief. +_Avoid_: research doc, exploration report + +**Issues JSON**: +The decomposed vertical slices at `docs/workflow/<slug>/issues.json`. +Each entry carries a `test_command` required for Nyquist validation. +_Avoid_: tickets, tasks list + +**Nyquist map**: +The node in the plan workflow that validates every issue in Issues JSON has a `test_command` +before yaml-gen runs. Named after the Nyquist sampling theorem analogy: you must observe +behaviour at twice the frequency to reconstruct it faithfully. +_Avoid_: validation node, test-command check + +### Build discipline + +**code-red**: +The TDD node that writes failing acceptance tests for one issue before any implementation. +The `code-red-<id>` Archon node depends on the `code-green` nodes of all blocked-by issues. +_Avoid_: failing tests, red phase + +**code-green**: +The TDD node that writes minimum implementation to make the acceptance tests pass. +The `code-green-<id>` Archon node depends on `code-red-<id>` for the same issue. +_Avoid_: implementation, green phase + +**Slopcheck gate**: +A pre-build verification that every new package in `package.json` exists on the npm registry. +Packages that fail the check are flagged `[ASSUMED]` and require explicit human approval. +_Avoid_: package check, dependency audit + +**yaml-gen**: +The bash node in the plan workflow that generates `.archon/workflows/build-<slug>.yaml` — a DAG +of `code-red` and `code-green` nodes for every issue, with correct `depends_on` edges derived +from the `blocked_by` fields in Issues JSON. +_Avoid_: build generator, workflow generator + +### Cleanup artifacts + +**arch-review**: +The architecture review output at `docs/workflow/<slug>/arch-review.md`, produced by the +`arch-review` node in the cleanup workflow. Identifies technical drift, intent drift, and +deepening opportunities. +_Avoid_: architecture report, code review + +**ADR**: +Architecture Decision Record. Written to `docs/adr/NNNN-*.md` only after explicit human +approval in the `adr-consolidation` interactive node of the cleanup workflow. +_Avoid_: decision doc, architecture note + +## Relationships + +- A **Session** is scoped by a **Slug** and produces **Findings**, a **PRD**, **Issues JSON**, and a `build-<slug>.yaml` +- **yaml-gen** depends on **Nyquist map** completing without errors +- Every issue in **yaml-gen** output gets exactly one **code-red** node and one **code-green** node +- **code-green** depends on **code-red** within the same issue; independent issues run in parallel +- **adr-consolidation** sources candidates from the "Decisions Made" section of `report.md` and "Accept as ADR" items from **arch-review** +- **HANDOFF.md** and **ROADMAP.md** are written exclusively by the **triage** workflow +- The install hook writes `.archon/unic-dlc.config.json` and `docs/agents/*.md` into the target project diff --git a/apps/claude-code/unic-archon-dlc/README.md b/apps/claude-code/unic-archon-dlc/README.md new file mode 100644 index 0000000..63d0abc --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/README.md @@ -0,0 +1,234 @@ +# unic-archon-dlc + +A complete Archon-powered AI development lifecycle as an installable DLC pack — six workflow DAGs +covering explore, plan, build, qa, cleanup, and triage with human approval gates at every +decision boundary. + +Archon has no marketplace; this plugin rides the Claude Code plugin marketplace and scaffolds all +six workflows plus agent-skill docs into the target project during install. + +--- + +## Workflows + +```mermaid +flowchart TD + subgraph triage["🔄 triage"] + T1[read-state] + T2[produce-handoff] + T1 --> T2 + end + + subgraph explore["🔍 explore"] + E1[research-stack] + E2[research-features] + E3[research-architecture] + E4[research-pitfalls] + E5[synthesize] + E6[prototype] + E7["code-preserve-gate ✓"] + E8[create-spike-ticket] + E1 & E2 & E3 & E4 --> E5 --> E6 --> E7 --> E8 + end + + subgraph plan["📋 plan"] + P1[load-context] + P2[specs loop] + P3[to-prd] + P4["prd-gate ✓"] + P5[to-issues] + P6[nyquist-map] + P7[plan-checker loop] + P8[yaml-gen] + P9["plan-pr-gate ✓"] + P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7 --> P8 --> P9 + end + + subgraph build["🔨 build"] + B1[slopcheck] + B2[run-build] + B3[verification] + B4[goals-check] + B5[report] + B6["build-pr-gate ✓"] + B1 --> B2 --> B3 --> B4 --> B5 --> B6 + end + + subgraph review["👁️ review"] + R1[code-review] + end + + subgraph qa["✅ qa"] + Q1[e2e] + Q2[coverage-gate] + Q3["uat-gate ✓"] + Q4[verify-pr-base] + Q5[merge] + Q1 --> Q2 --> Q3 --> Q4 --> Q5 + end + + subgraph cleanup["🧹 cleanup"] + C1[arch-review] + C2["adr-consolidation ✓"] + C3[run-triage] + C1 --> C2 --> C3 + end + + plan --> build + build --> review + build --> qa + qa --> cleanup + cleanup --> triage +``` + +> **✓** = interactive human gate (workflow pauses until human approves or rejects) + +--- + +## Node reference + +| Workflow | Node | Type | Human gate | +| -------- | --------------------- | ----------- | ---------- | +| triage | read-state | prompt | — | +| triage | produce-handoff | prompt | — | +| explore | research-stack | prompt | — | +| explore | research-features | prompt | — | +| explore | research-architecture | prompt | — | +| explore | research-pitfalls | prompt | — | +| explore | synthesize | prompt | — | +| explore | prototype | prompt | — | +| explore | code-preserve-gate | interactive | ✓ | +| explore | create-spike-ticket | prompt | — | +| plan | load-context | prompt | — | +| plan | specs | loop | — | +| plan | to-prd | prompt | — | +| plan | prd-gate | interactive | ✓ | +| plan | to-issues | prompt | — | +| plan | nyquist-map | prompt | — | +| plan | plan-checker | loop | — | +| plan | yaml-gen | bash | — | +| plan | plan-pr-gate | interactive | ✓ | +| build | slopcheck | bash | — | +| build | run-build | prompt | — | +| build | verification | bash | — | +| build | goals-check | prompt | — | +| build | report | prompt | — | +| build | build-pr-gate | interactive | ✓ | +| review | code-review | prompt | — | +| qa | e2e | bash | — | +| qa | coverage-gate | bash | — | +| qa | uat-gate | interactive | ✓ | +| qa | verify-pr-base | bash | — | +| qa | merge | bash | — | +| cleanup | arch-review | prompt | — | +| cleanup | adr-consolidation | interactive | ✓ | +| cleanup | run-triage | bash | — | + +--- + +## Quick start + +**Step 1 — Install** + +Open Claude Code in any project and run: + +``` +/unic-dlc-install +``` + +The install hook auto-detects your tracker (GitHub, ADO, Jira, or local-markdown), deduces a +PR strategy, and writes all agent docs and workflow files into your project. + +**Step 2 — Explore** + +Kick off research on any new problem space: + +``` +/unic-dlc-explore my-feature +``` + +**Step 3 — Triage** + +Check current project state and produce a HANDOFF.md at any time: + +``` +/unic-dlc-triage +``` + +From here, the full lifecycle is: explore → plan → build → qa → cleanup → triage. + +--- + +## Configuration reference + +The install hook 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` | `single` · `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 | + +Label canonical names: states `needs-triage` · `needs-info` · `needs-specs` · `ready-for-agent` · +`ready-for-human` · `resolved` · `closed` · `rejected`; types `feature` · `bug` · `spike` · +`tech-debt` · `docs`; priorities `p0` · `p1` · `p2` · `p3`. + +--- + +## docs/workflow/ layout + +The DLC creates three layers of persistent artefacts: + +``` +docs/ +└── workflow/ + ├── ROADMAP.md # 3️⃣ Persistent — human + auto-generated (marker-delimited) + ├── HANDOFF.md # 3️⃣ Persistent — refreshed by every triage run + └── <slug>/ + ├── findings.md # 2️⃣ Session — explore output (stack, features, pitfalls, brief) + ├── PRD.md # 2️⃣ Session — plan output (7 mandatory sections) + ├── issues.json # 2️⃣ Session — decomposed vertical slices + test commands + ├── plan-checker-report.md # 2️⃣ Session — plan validation results + ├── report.md # 2️⃣ Session — build outcomes (5 sections) + └── arch-review.md # 2️⃣ Session — cleanup drift analysis + +.archon/ +└── workflows/ + └── build-<slug>.yaml # 1️⃣ Transient — auto-generated by yaml-gen, re-generated each plan +``` + +**Three-layer separation:** + +| Layer | Location | Owner | Lifecycle | +| ------------- | ---------------------------------------- | --------- | ----------------------------------------------------- | +| 1️⃣ Transient | `.archon/workflows/build-*.yaml` | yaml-gen | Re-generated each plan cycle; safe to delete | +| 2️⃣ Session | `docs/workflow/<slug>/` | DLC nodes | Scoped to one planning session; accumulates artifacts | +| 3️⃣ Persistent | `docs/workflow/ROADMAP.md`, `HANDOFF.md` | triage | Lives for the life of the project; human-editable | + +Human-written content in `ROADMAP.md` outside the `<!-- unic-archon-dlc:begin/end -->` markers is +never overwritten. + +--- + +## Dependency map + +- **Archon**: version ≥ 0.10 required (supports `type: loop`, `type: bash`, `fresh_context: true`) +- **Required peer plugins**: none +- **Optional tool**: Python `slopcheck` CLI (GSD's slopsquatting gate) — if on `PATH`, the + slopcheck node defers to it; otherwise falls back to npm registry HEAD checks +- **Tracker CLIs** (install the one matching your config): + - GitHub: `gh` (GitHub CLI) + - Azure DevOps: `az` (Azure CLI with `azure-devops` extension) + - Jira: `jira` (go-jira or Atlassian CLI) + - local-markdown: no CLI needed diff --git a/apps/claude-code/unic-archon-dlc/hooks/install.mjs b/apps/claude-code/unic-archon-dlc/hooks/install.mjs new file mode 100644 index 0000000..c4069e9 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/hooks/install.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node +// @ts-check +/** + * unic-archon-dlc install hook + * + * Run in target project: node ${CLAUDE_PLUGIN_ROOT}/hooks/install.mjs + * Re-run to fill in missing config: same command (additive — only fills missing fields) + * Force reconfiguration: node ${CLAUDE_PLUGIN_ROOT}/hooks/install.mjs --reconfigure + * + * Requires: archon on PATH. See README for installation. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { createInterface } from 'node:readline/promises' +import { fileURLToPath } from 'node:url' +import { updateAgentSkillsBlock, writeAgentDocs } from '../lib/agent-docs-writer.mjs' +import { loadConfig } from '../lib/config-loader.mjs' +import { getDefaultLabels } from '../lib/labels-config.mjs' +import { exploreProject } from '../lib/setup-explorer.mjs' + +// Populated as schema-incompatible Archon versions are observed +const INCOMPATIBLE_ARCHON_VERSIONS = /** @type {string[]} */ ([]) + +/** + * @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' +} + +/** @returns {string} */ +function checkArchon() { + try { + const version = execFileSync('archon', ['--version'], { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000, + }) + .toString() + .trim() + if (INCOMPATIBLE_ARCHON_VERSIONS.includes(version)) { + console.warn( + `\nWarning: Archon ${version} has known schema incompatibilities with unic-archon-dlc. Please upgrade Archon.\n` + ) + } + return version + } catch (err) { + if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') { + console.error('\nError: archon binary not found on PATH.') + console.error('Install Archon before using this plugin. See the README for instructions.\n') + } else { + console.error(`\nError: failed to run archon: ${/** @type {Error} */ (err).message}\n`) + } + process.exit(1) + } +} + +/** + * @param {string} projectDir + * @returns {string} + */ +export function detectRepoLayout(projectDir) { + return existsSync(join(projectDir, 'CONTEXT-MAP.md')) ? 'multi-context' : 'single-context' +} + +async function main() { + const projectDir = process.cwd() + const reconfigure = process.argv.includes('--reconfigure') + + checkArchon() + + const snapshot = exploreProject(projectDir) + const configPath = join(projectDir, '.archon', 'unic-dlc.config.json') + + let existing = /** @type {Record<string, unknown>} */ ({}) + if (snapshot.archonConfigPresent) { + const loaded = loadConfig(configPath) + if (!('error' in loaded)) existing = /** @type {Record<string, unknown>} */ (loaded) + } + + const mandatoryFilled = 'tracker' in existing && 'pr_strategy' in existing && 'branching' in existing + + if (mandatoryFilled && !reconfigure) { + console.log('\nunic-archon-dlc is already configured:') + console.log(` tracker: ${existing.tracker}`) + console.log(` pr_strategy: ${existing.pr_strategy}`) + console.log(` branching: ${existing.branching}`) + console.log('\nRun with --reconfigure to update these values.\n') + process.exit(0) + } + + const rl = createInterface({ input: process.stdin, output: process.stdout }) + + // --- mandatory tier --- + let trackerDetected = detectTracker(snapshot.gitRemote) + if (trackerDetected) { + console.log(`\nAuto-detected tracker: ${trackerDetected} (from ${snapshot.gitRemote})`) + const ans = await rl.question(`Use ${trackerDetected}? [Y/n] `) + if (ans.trim().toLowerCase() === 'n') trackerDetected = null + } + const trackerRaw = + trackerDetected ?? (await rl.question('\nIssue tracker (github / ado / jira / local-markdown): ')).trim() + const tracker = /** @type {import('../lib/tracker-adapter.mjs').TrackerBackend} */ (trackerRaw || 'local-markdown') + + const prStrategy = deducePrStrategy(tracker) + console.log(`PR strategy: ${prStrategy} (deduced from tracker)`) + + const branchRaw = await rl.question('\nBranching strategy [gitflow / github-flow] (default: gitflow): ') + const branching = branchRaw.trim() === 'github-flow' ? 'github-flow' : 'gitflow' + + // --- skippable tier: e2e_command --- + const e2eRaw = await rl.question('\nE2E test command (leave blank to configure later): ') + const e2eCommand = e2eRaw.trim() || null + + rl.close() + + // --- multi-context detection --- + const repoLayout = detectRepoLayout(projectDir) + if (repoLayout === 'multi-context') { + console.log('Multi-context repo detected (CONTEXT-MAP.md found).') + } + + // --- defaulted tier (never prompted unless --reconfigure) --- + const defaults = { + model_profile: 'balanced', + tdd_mode: true, + nyquist_validation: true, + slopsquatting_gate: true, + } + + // --- label tier --- + const labels = getDefaultLabels(tracker) + + // Merge: defaults < existing < mandatory tier + const config = { + ...defaults, + ...existing, + tracker, + pr_strategy: prStrategy, + branching, + e2e_command: e2eCommand, + repo_layout: repoLayout, + labels, + } + + mkdirSync(join(projectDir, '.archon'), { recursive: true }) + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + console.log('\nWrote .archon/unic-dlc.config.json') + + try { + writeAgentDocs(projectDir, { + tracker, + pr_strategy: prStrategy, + branching, + repo_layout: repoLayout, + labels, + }) + console.log('Wrote docs/agents/ (5 files)') + } catch (err) { + console.error( + `\nError: Failed to write docs/agents/ files: ${/** @type {Error} */ (err).message}` + + '\nThe config has been saved. Re-run the install hook once the permission issue is resolved.\n' + ) + process.exit(1) + } + + try { + updateAgentSkillsBlock(projectDir) + console.log('Updated CLAUDE.md ## Agent skills block') + } catch (err) { + console.error( + `\nError: Failed to update CLAUDE.md: ${/** @type {Error} */ (err).message}` + + '\nThe config and docs/agents/ files have been saved. Re-run the install hook to retry.\n' + ) + process.exit(1) + } + + console.log('\nunic-archon-dlc install complete.\n') +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((err) => { + // Print the full stack so install failures are debuggable, not just a bare message. + console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)) + process.exit(1) + }) +} 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 new file mode 100644 index 0000000..5b01c29 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/agent-docs-writer.mjs @@ -0,0 +1,260 @@ +// @ts-check +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join, relative } from 'node:path' + +const AGENT_SKILLS_BEGIN = '<!-- unic-archon-dlc:begin -->' +const AGENT_SKILLS_END = '<!-- unic-archon-dlc: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) — six 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'), buildIssueTrackerDoc(config)) + writeFileSync(join(dir, 'labels.md'), buildLabelsDoc(config)) + writeFileSync(join(dir, 'branching.md'), buildBranchingDoc(config)) + writeFileSync(join(dir, 'domain.md'), buildDomainDoc(config, projectDir)) + writeFileSync(join(dir, 'workflow.md'), 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 block = `## Agent skills\n\n${AGENT_SKILLS_BEGIN}\n${AGENT_SKILLS_LINKS}\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${AGENT_SKILLS_LINKS}\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 "<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:** \`docs/adr/\` (repo-level decisions)` + : `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 + +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 six Archon workflow YAML DAGs. Each phase produces persistent artifacts committed to \`docs/workflow/<slug>/\`. + +| 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\` | + +## 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/config-loader.mjs b/apps/claude-code/unic-archon-dlc/lib/config-loader.mjs new file mode 100644 index 0000000..67df9c4 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/config-loader.mjs @@ -0,0 +1,95 @@ +// @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/dag-builder.mjs b/apps/claude-code/unic-archon-dlc/lib/dag-builder.mjs new file mode 100644 index 0000000..67d4c7d --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/dag-builder.mjs @@ -0,0 +1,131 @@ +// @ts-check + +/** + * @typedef {import('./issues-schema.mjs').Issue} Issue + */ + +/** + * Detect circular dependencies using DFS. + * Returns null if no cycles; returns the cycle path array if one is found. + * @param {Issue[]} issues + * @returns {string[] | null} + */ +export function detectCircular(issues) { + /** @type {Map<string, string[]>} */ + const deps = new Map(issues.map((i) => [i.id, i.blocked_by ?? []])) + + // DFS with three-colour marking: WHITE=0, GREY=1 (in stack), BLACK=2 (done) + /** @type {Map<string, number>} */ + const colour = new Map() + + /** + * @param {string} id + * @param {string[]} path + * @returns {string[] | null} + */ + function visit(id, path) { + const c = colour.get(id) ?? 0 + if (c === 2) return null + if (c === 1) return [...path, id] // cycle found + + colour.set(id, 1) + for (const dep of deps.get(id) ?? []) { + const cycle = visit(dep, [...path, id]) + if (cycle) return cycle + } + colour.set(id, 2) + return null + } + + for (const issue of issues) { + if (!colour.has(issue.id)) { + const cycle = visit(issue.id, []) + if (cycle) return cycle + } + } + + return null +} + +/** + * Build the Archon build-<slug>.yaml for a set of issues. + * Each issue produces two nodes: code-red-<id> and code-green-<id>. + * code-red-<id> depends on code-green nodes of all blocked_by issues. + * code-green-<id> depends on code-red-<id>. + * Throws if a circular dependency is detected. + * @param {string} slug + * @param {Issue[]} issues + * @returns {string} + */ +export function buildYaml(slug, issues) { + const cycle = detectCircular(issues) + if (cycle) { + throw new Error(`Circular dependency detected: ${cycle.join(' → ')}`) + } + + const lines = [ + `name: unic-dlc-build-${slug}`, + `description: >`, + ` Auto-generated build workflow for ${slug}.`, + ` Produced by unic-archon-dlc yaml-gen node.`, + ``, + `inputs:`, + ` slug:`, + ` description: Planning session identifier.`, + ` required: true`, + ``, + `nodes:`, + ] + + for (const issue of issues) { + const blockedByGreens = (issue.blocked_by ?? []).map((dep) => `code-green-${dep}`) + const redDeps = blockedByGreens.length ? `[${blockedByGreens.join(', ')}]` : '[]' + + lines.push( + ` - id: code-red-${issue.id}`, + ` name: "Red — ${issue.title}"`, + ` type: prompt`, + ` depends_on: ${redDeps}`, + ` prompt: |`, + ` You are running the unic-archon-dlc build workflow — code-red node for issue ${issue.id}.`, + ``, + ` Issue: ${issue.title}`, + ` Summary: ${issue.summary}`, + ``, + ` Write FAILING acceptance tests for this issue before writing any implementation.`, + ` Tests must:`, + ` - Use only the public interface of the module under test.`, + ` - Assert on observable behaviour, not internal state.`, + ` - Cover every acceptance criterion listed for this issue:`, + ...issue.acceptance_criteria.map((ac) => ` - ${ac}`), + ...(issue.test_command + ? [` Run: ${issue.test_command}`, ` Confirm all new tests FAIL before proceeding.`] + : [` Mark tests as expected-to-fail (todo) if no runner is configured yet.`]), + `` + ) + + lines.push( + ` - id: code-green-${issue.id}`, + ` name: "Green — ${issue.title}"`, + ` type: prompt`, + ` depends_on: [code-red-${issue.id}]`, + ` prompt: |`, + ` You are running the unic-archon-dlc build workflow — code-green node for issue ${issue.id}.`, + ``, + ` Issue: ${issue.title}`, + ` Summary: ${issue.summary}`, + ``, + ` Write the MINIMUM implementation to make the failing tests from code-red-${issue.id} pass.`, + ` Rules:`, + ` - Do not modify the tests.`, + ` - Only write enough code to pass the current tests.`, + ` - Do not add features not covered by the acceptance criteria.`, + ...(issue.test_command + ? [` Run: ${issue.test_command}`, ` Confirm all tests PASS before proceeding.`] + : []), + `` + ) + } + + return lines.join('\n') +} diff --git a/apps/claude-code/unic-archon-dlc/lib/findings-writer.mjs b/apps/claude-code/unic-archon-dlc/lib/findings-writer.mjs new file mode 100644 index 0000000..700109d --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/findings-writer.mjs @@ -0,0 +1,72 @@ +// @ts-check +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * @typedef {Object} FindingsSections + * @property {string} stack - Technology stack findings + * @property {string} features - Key features findings + * @property {string} architecture - Architecture findings + * @property {string} pitfalls - Known pitfalls and gotchas + * @property {string} brief - Integrated brief synthesizing all dimensions + */ + +/** + * Initialise the findings directory for a given slug. + * Creates `docs/workflow/<slug>/` under projectDir if absent. + * Safe to call multiple times — never destroys existing contents. + * @param {string} projectDir + * @param {string} slug + * @returns {string} absolute path to the findings directory + */ +export function initFindingsDir(projectDir, slug) { + const findingsDir = join(projectDir, 'docs', 'workflow', slug) + mkdirSync(findingsDir, { recursive: true }) + return findingsDir +} + +/** + * Write findings.md into the given findings directory. + * Sections: Stack, Features, Architecture, Pitfalls, Integrated Brief. + * @param {string} findingsDir - path returned by initFindingsDir + * @param {FindingsSections} sections + */ +export function writeFindingsMd(findingsDir, sections) { + const content = `# Research Findings + +_Generated by unic-archon-dlc explore workflow._ + +## Stack + +${sections.stack} + +## Features + +${sections.features} + +## Architecture + +${sections.architecture} + +## Pitfalls + +${sections.pitfalls} + +## Integrated Brief + +${sections.brief} +` + writeFileSync(join(findingsDir, 'findings.md'), content) +} + +/** + * Read findings.md from the given findings directory. + * Returns null if the file does not exist. + * @param {string} findingsDir + * @returns {string | null} + */ +export function readFindingsMd(findingsDir) { + const filePath = join(findingsDir, 'findings.md') + if (!existsSync(filePath)) return null + return readFileSync(filePath, 'utf8') +} diff --git a/apps/claude-code/unic-archon-dlc/lib/handoff-generator.mjs b/apps/claude-code/unic-archon-dlc/lib/handoff-generator.mjs new file mode 100644 index 0000000..9dea0f6 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/handoff-generator.mjs @@ -0,0 +1,94 @@ +// @ts-check +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const ROADMAP_BEGIN = '<!-- unic-archon-dlc:begin -->' +const ROADMAP_END = '<!-- unic-archon-dlc:end -->' + +/** + * Archon DLC lifecycle phases. + * @typedef {'triage' | 'explore' | 'plan' | 'build' | 'qa' | 'cleanup'} WorkflowPhase + */ + +/** + * @typedef {Object} HandoffSnapshot + * @property {WorkflowPhase} phase - current lifecycle phase + * @property {Record<string, string[]>} openIssues - state label → issue titles + * @property {string[]} blockers - blocker descriptions + * @property {string[]} recentDecisions - ADR file basenames + */ + +/** + * Build the HANDOFF.md content from a workflow snapshot. + * Returns a Markdown string with four sections: current phase, open issues, + * blockers, and recent decisions. + * @param {HandoffSnapshot} snapshot + * @returns {string} + */ +export function buildHandoff(snapshot) { + const issueLines = Object.entries(snapshot.openIssues) + .flatMap(([state, titles]) => titles.map((t) => ` - [${state}] ${t}`)) + .join('\n') + + const blockerLines = snapshot.blockers.length ? snapshot.blockers.map((b) => `- ${b}`).join('\n') : '_No blockers._' + + const decisionLines = snapshot.recentDecisions.length + ? snapshot.recentDecisions.map((d) => `- ${d}`).join('\n') + : '_No recent decisions._' + + return `# HANDOFF + +_Generated by unic-archon-dlc triage workflow._ + +## Current Phase + +**Phase:** ${snapshot.phase} + +## Open Issues by State + +${issueLines || '_No open issues._'} + +## Blockers + +${blockerLines} + +## Recent Decisions + +${decisionLines} +` +} + +/** + * Idempotent ROADMAP.md update using marker-delimited auto-generated region. + * Human-written content outside the markers is preserved. + * @param {string} projectDir + * @param {WorkflowPhase} phase - current lifecycle phase + */ +export function updateRoadmap(projectDir, phase) { + const roadmapDir = join(projectDir, 'docs', 'workflow') + mkdirSync(roadmapDir, { recursive: true }) + const roadmapPath = join(roadmapDir, 'ROADMAP.md') + + const timestamp = new Date().toISOString().slice(0, 10) + const statusBlock = `**Last updated:** ${timestamp} \n**Current phase:** ${phase}` + const markerBlock = `${ROADMAP_BEGIN}\n${statusBlock}\n${ROADMAP_END}` + + if (!existsSync(roadmapPath)) { + writeFileSync(roadmapPath, `# Project Roadmap\n\n${markerBlock}\n`) + return + } + + let content = readFileSync(roadmapPath, 'utf8') + const beginIdx = content.indexOf(ROADMAP_BEGIN) + const endIdx = content.indexOf(ROADMAP_END) + + if (beginIdx !== -1 && endIdx !== -1) { + content = `${content.slice(0, beginIdx) + ROADMAP_BEGIN}\n${statusBlock}\n${content.slice(endIdx)}` + writeFileSync(roadmapPath, content) + return + } + + // No markers yet — append the block + const separator = content.endsWith('\n') ? '\n' : '\n\n' + writeFileSync(roadmapPath, `${content}${separator}${markerBlock}\n`) +} diff --git a/apps/claude-code/unic-archon-dlc/lib/issues-schema.mjs b/apps/claude-code/unic-archon-dlc/lib/issues-schema.mjs new file mode 100644 index 0000000..de6f28e --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/issues-schema.mjs @@ -0,0 +1,149 @@ +// @ts-check +/** + * Issues schema — pure data transformation functions for decomposed issues. + * + * All functions are side-effect-free; they only transform data in memory. + */ + +/** + * Canonical issue type values. + * @typedef {'feature' | 'bug' | 'spike' | 'tech-debt' | 'docs'} IssueType + */ + +/** + * Canonical issue priority values. + * @typedef {'p0' | 'p1' | 'p2' | 'p3'} IssuePriority + */ + +/** + * @typedef {Object} Issue + * @property {string} id - Unique identifier for this issue within the file + * @property {string} title - Short, descriptive title + * @property {IssueType} type - Canonical type label + * @property {IssuePriority} priority - Canonical priority label + * @property {string[]} blocked_by - Array of issue IDs this issue depends on + * @property {string[]} acceptance_criteria - Non-empty array of independently demonstrable criteria + * @property {string} summary - One-paragraph description of the work + * @property {string} [test_command] - Exact shell command to verify this issue + * @property {true} [test_command_planned] - Set to true when no test command exists yet + */ + +/** + * @typedef {Object} ValidationResult + * @property {boolean} valid - true if all mandatory fields are present and valid + * @property {string[]} errors - list of human-readable error messages + */ + +const MANDATORY_FIELDS = /** @type {const} */ ([ + 'id', + 'title', + 'type', + 'priority', + 'blocked_by', + 'acceptance_criteria', + 'summary', +]) + +/** + * Validate that an issue object has all mandatory fields present and valid. + * @param {Partial<Issue>} issue + * @returns {ValidationResult} + */ +export function validateIssue(issue) { + /** @type {string[]} */ + const errors = [] + + for (const field of MANDATORY_FIELDS) { + if (issue[field] === undefined || issue[field] === null || issue[field] === '') { + errors.push(`Missing mandatory field: '${field}'`) + } + } + + // acceptance_criteria must be a non-empty array + if ( + issue.acceptance_criteria !== undefined && + issue.acceptance_criteria !== null && + (!Array.isArray(issue.acceptance_criteria) || issue.acceptance_criteria.length === 0) + ) { + errors.push(`'acceptance_criteria' must be a non-empty array`) + } + + // blocked_by must be an array (may be empty) + if (issue.blocked_by !== undefined && issue.blocked_by !== null && !Array.isArray(issue.blocked_by)) { + errors.push(`'blocked_by' must be an array`) + } + + return { valid: errors.length === 0, errors } +} + +/** + * Topological sort of issues by their blocked_by dependency edges. + * Issues with no dependencies (blocked_by: []) come first. + * Throws if a circular dependency is detected. + * @param {Issue[]} issues + * @returns {Issue[]} sorted array — dependencies before dependants + */ +export function sortByDependency(issues) { + // Kahn's algorithm (BFS-based topological sort) + /** @type {Map<string, Issue>} */ + const byId = new Map(issues.map((i) => [i.id, i])) + + // in-degree: how many unresolved dependencies each issue has + /** @type {Map<string, number>} */ + const inDegree = new Map(issues.map((i) => [i.id, 0])) + + // adjacency list: dep → list of issues that depend on dep + /** @type {Map<string, string[]>} */ + const dependants = new Map(issues.map((i) => [i.id, []])) + + for (const issue of issues) { + for (const dep of issue.blocked_by) { + if (dep === issue.id) { + throw new Error(`Circular dependency detected: issue '${issue.id}' depends on itself`) + } + // dep must exist in the set; if not treat as external (skip counting) + if (!byId.has(dep)) continue + inDegree.set(issue.id, (inDegree.get(issue.id) ?? 0) + 1) + dependants.get(dep)?.push(issue.id) + } + } + + // Start with all issues that have no dependencies + /** @type {string[]} */ + const queue = [] + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id) + } + + /** @type {Issue[]} */ + const sorted = [] + + while (queue.length > 0) { + const id = /** @type {string} */ (queue.shift()) + const issue = byId.get(id) + if (issue) sorted.push(issue) + + for (const dependantId of dependants.get(id) ?? []) { + const newDeg = (inDegree.get(dependantId) ?? 0) - 1 + inDegree.set(dependantId, newDeg) + if (newDeg === 0) queue.push(dependantId) + } + } + + if (sorted.length !== issues.length) { + // Not all issues were processed → circular dependency + const remaining = issues.filter((i) => !sorted.find((s) => s.id === i.id)).map((i) => i.id) + throw new Error(`Circular dependency detected among issues: ${remaining.join(', ')}`) + } + + return sorted +} + +/** + * Serialise an issues array to a JSON string with 2-space indentation. + * @param {Issue[]} issues + * @returns {string} + */ +export function buildIssuesJson(issues) { + return JSON.stringify(issues, null, 2) +} diff --git a/apps/claude-code/unic-archon-dlc/lib/labels-config.mjs b/apps/claude-code/unic-archon-dlc/lib/labels-config.mjs new file mode 100644 index 0000000..0a2eb14 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/labels-config.mjs @@ -0,0 +1,43 @@ +// @ts-check + +/** @type {readonly string[]} */ +export const STATE_LABELS = [ + 'needs-triage', + 'needs-info', + 'needs-specs', + 'ready-for-agent', + 'ready-for-human', + 'resolved', + 'closed', + 'rejected', +] + +/** @type {readonly string[]} */ +export const TYPE_LABELS = ['feature', 'bug', 'spike', 'tech-debt', 'docs'] + +/** @type {readonly string[]} */ +export const PRIORITY_LABELS = ['p0', 'p1', 'p2', 'p3'] + +/** + * @typedef {Object} LabelMapping + * @property {Record<string, string>} state + * @property {Record<string, string>} type + * @property {Record<string, string>} priority + */ + +/** + * Build default label mappings for a given tracker. + * For all v1 backends, canonical names equal tracker strings by default. + * Users can override these mappings in .archon/unic-dlc.config.json. + * @param {string} _tracker + * @returns {LabelMapping} + */ +export function getDefaultLabels(_tracker) { + const identity = (/** @type {readonly string[]} */ keys) => Object.fromEntries(keys.map((k) => [k, k])) + + return { + state: identity(STATE_LABELS), + type: identity(TYPE_LABELS), + priority: identity(PRIORITY_LABELS), + } +} diff --git a/apps/claude-code/unic-archon-dlc/lib/prd-writer.mjs b/apps/claude-code/unic-archon-dlc/lib/prd-writer.mjs new file mode 100644 index 0000000..5ca2eae --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/prd-writer.mjs @@ -0,0 +1,102 @@ +// @ts-check +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * @typedef {Object} PrdSections + * @property {string} problemStatement - Problem Statement section body + * @property {string} solution - Solution section body + * @property {string} userStories - User Stories section body + * @property {string} implementationDecisions - Implementation Decisions section body + * @property {string} testingDecisions - Testing Decisions section body + * @property {string} outOfScope - Out of Scope section body + * @property {string} furtherNotes - Further Notes section body + */ + +/** + * @typedef {Object} PrdValidationResult + * @property {boolean} valid - true if all 7 headings are present + * @property {string[]} missingSections - list of missing section headings + */ + +const REQUIRED_HEADINGS = [ + 'Problem Statement', + 'Solution', + 'User Stories', + 'Implementation Decisions', + 'Testing Decisions', + 'Out of Scope', + 'Further Notes', +] + +/** + * Write PRD.md for the given slug under docs/workflow/<slug>/PRD.md. + * Creates the directory if absent. Overwrites any existing PRD.md. + * @param {string} projectDir + * @param {string} slug + * @param {PrdSections} sections + */ +export function writePrd(projectDir, slug, sections) { + const prdDir = join(projectDir, 'docs', 'workflow', slug) + mkdirSync(prdDir, { recursive: true }) + + const content = `# Product Requirements Document + +_Generated by unic-archon-dlc plan workflow._ + +## Problem Statement + +${sections.problemStatement} + +## Solution + +${sections.solution} + +## User Stories + +${sections.userStories} + +## Implementation Decisions + +${sections.implementationDecisions} + +## Testing Decisions + +${sections.testingDecisions} + +## Out of Scope + +${sections.outOfScope} + +## Further Notes + +${sections.furtherNotes} +` + writeFileSync(join(prdDir, 'PRD.md'), content) +} + +/** + * Read PRD.md for the given slug. + * Returns null if the file does not exist. + * @param {string} projectDir + * @param {string} slug + * @returns {string | null} + */ +export function readPrd(projectDir, slug) { + const prdPath = join(projectDir, 'docs', 'workflow', slug, 'PRD.md') + if (!existsSync(prdPath)) return null + return readFileSync(prdPath, 'utf8') +} + +/** + * Validate that a PRD.md string contains all 7 required section headings. + * @param {string} content + * @returns {PrdValidationResult} + */ +export function validatePrdSections(content) { + const missingSections = REQUIRED_HEADINGS.filter((heading) => !content.includes(heading)) + return { + valid: missingSections.length === 0, + missingSections, + } +} diff --git a/apps/claude-code/unic-archon-dlc/lib/setup-explorer.mjs b/apps/claude-code/unic-archon-dlc/lib/setup-explorer.mjs new file mode 100644 index 0000000..b4761a7 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/setup-explorer.mjs @@ -0,0 +1,99 @@ +// @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/lib/slopcheck.mjs b/apps/claude-code/unic-archon-dlc/lib/slopcheck.mjs new file mode 100644 index 0000000..4c3ddbb --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/slopcheck.mjs @@ -0,0 +1,63 @@ +// @ts-check + +/** + * A flat map of package name → version specifier (e.g. from package.json dependencies). + * @typedef {{ [name: string]: string }} DepsMap + */ + +/** + * Result of classifying a single package name against a registry. + * @typedef {Object} PackageVerdict + * @property {string} name - npm package name + * @property {boolean} assumed - true when existence could not be confirmed via registry + */ + +/** + * Optional async registry lookup function. + * Returns true when the package is confirmed to exist in the registry. + * Pass null or undefined to skip registry checks (all packages are assumed). + * @typedef {((name: string) => Promise<boolean>) | null | undefined} RegistryFn + */ + +/** + * Compare two dependency maps and return the names that are in `next` but not in `prev`. + * Both arguments are plain objects mapping package name → version specifier. + * @param {DepsMap | null | undefined} prev + * @param {DepsMap | null | undefined} next + * @returns {string[]} + */ +export function parseNewPackages(prev, next) { + const prevKeys = new Set(Object.keys(prev ?? {})) + return Object.keys(next ?? {}).filter((k) => !prevKeys.has(k)) +} + +/** + * Classify package names as assumed (unverifiable) or confirmed (registry check passed). + * Falls back to assumed when: + * - no registryFn is provided + * - the registryFn returns false + * - the registryFn throws + * @param {string[]} names + * @param {RegistryFn} registryFn + * @returns {Promise<PackageVerdict[]>} + */ +export async function classifyPackages(names, registryFn) { + if (!registryFn) return names.map((name) => ({ name, assumed: true })) + + return Promise.all( + names.map(async (name) => { + try { + const exists = await registryFn(name) + return { name, assumed: !exists } + } catch (err) { + // Registry check failed (network error, timeout, etc.). + // Log the reason so operators can distinguish transient failures + // from legitimate "unknown package" verdicts. + process.stderr.write( + `[unic-archon-dlc] Warning: registry check failed for '${name}' — marking as [ASSUMED]. Reason: ${/** @type {Error} */ (err).message}\n` + ) + return { name, assumed: true } + } + }) + ) +} diff --git a/apps/claude-code/unic-archon-dlc/lib/spike-verdicts.mjs b/apps/claude-code/unic-archon-dlc/lib/spike-verdicts.mjs new file mode 100644 index 0000000..0f0b4b0 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/spike-verdicts.mjs @@ -0,0 +1,102 @@ +// @ts-check +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * @typedef {'VALIDATED' | 'INVALIDATED' | 'PARTIAL'} VerdictType + */ + +/** + * @typedef {Object} SpikeVerdict + * @property {string} title - Short title of the experiment + * @property {VerdictType} verdict - Outcome: VALIDATED, INVALIDATED, or PARTIAL + * @property {string} notes - Evidence or reason for the verdict + */ + +const SECTION_HEADING = '## Spike verdicts' + +/** + * Build the Markdown block for the spike verdicts section. + * @param {SpikeVerdict[]} verdicts + * @returns {string} + */ +function buildSpikeVerdictsBlock(verdicts) { + const entries = verdicts + .map((v, i) => `### Experiment ${i + 1}: ${v.title}\n**Verdict:** ${v.verdict}\n${v.notes.trim()}`) + .join('\n\n') + return `${SECTION_HEADING}\n\n${entries}\n` +} + +/** + * Append (or replace) the `## Spike verdicts` section in findings.md. + * If the section already exists it is replaced in-place — never duplicated. + * @param {string} findingsDir - Directory containing findings.md + * @param {SpikeVerdict[]} verdicts + */ +export function appendSpikeVerdicts(findingsDir, verdicts) { + const filePath = join(findingsDir, 'findings.md') + if (!existsSync(filePath)) { + throw new Error(`appendSpikeVerdicts: findings.md not found at ${filePath}. Call writeFindingsMd() first.`) + } + const existing = readFileSync(filePath, 'utf8') + + const block = buildSpikeVerdictsBlock(verdicts) + + let updated + if (existing.includes(SECTION_HEADING)) { + // Replace from the existing section heading to end-of-file (or next ## heading) + const idx = existing.indexOf(SECTION_HEADING) + const afterSection = existing.slice(idx + SECTION_HEADING.length) + const nextSectionMatch = afterSection.match(/\n(?=## )/) + if (nextSectionMatch && nextSectionMatch.index !== undefined) { + // There is a section after spike verdicts — preserve it + const beforeBlock = existing.slice(0, idx) + const afterBlock = afterSection.slice(nextSectionMatch.index) + updated = `${beforeBlock}${block}${afterBlock}` + } else { + // Spike verdicts is the last section — replace to end + updated = `${existing.slice(0, idx)}${block}` + } + } else { + // No existing section — append (ensure a blank line separator) + const separator = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n' + updated = `${existing}${separator}${block}` + } + + writeFileSync(filePath, updated) +} + +/** + * Parse spike verdicts from findings.md content. + * Returns an empty array if no `## Spike verdicts` section is present. + * @param {string} content - Raw findings.md file content + * @returns {SpikeVerdict[]} + */ +export function parseSpikeVerdicts(content) { + const sectionIdx = content.indexOf(SECTION_HEADING) + if (sectionIdx === -1) return [] + + // Extract from section heading onward (stop at next top-level heading) + const sectionBody = content.slice(sectionIdx + SECTION_HEADING.length) + const nextSectionMatch = sectionBody.match(/\n(?=## )/) + const body = + nextSectionMatch && nextSectionMatch.index !== undefined + ? sectionBody.slice(0, nextSectionMatch.index) + : sectionBody + + /** @type {SpikeVerdict[]} */ + const verdicts = [] + + // Match each ### Experiment N: <title> block + const experimentPattern = + /###\s+Experiment\s+\d+:\s+(.+?)\n\*\*Verdict:\*\*\s+(VALIDATED|INVALIDATED|PARTIAL)\n([\s\S]*?)(?=\n###\s+Experiment|\s*$)/g + for (const match of body.matchAll(experimentPattern)) { + verdicts.push({ + title: match[1].trim(), + verdict: /** @type {VerdictType} */ (match[2]), + notes: match[3].trim(), + }) + } + + return verdicts +} diff --git a/apps/claude-code/unic-archon-dlc/lib/stub-detector.mjs b/apps/claude-code/unic-archon-dlc/lib/stub-detector.mjs new file mode 100644 index 0000000..fcb4d52 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/stub-detector.mjs @@ -0,0 +1,56 @@ +// @ts-check + +/** + * @typedef {{ line: number, pattern: string, text: string }} StubFinding + */ + +// Patterns that match stub annotations in any source language +const COMMENT_PATTERNS = [ + { re: /\bTODO\b/, pattern: 'TODO' }, + { re: /\bFIXME\b/, pattern: 'FIXME' }, +] + +// Sentinel values that indicate an unimplemented return +const SENTINEL_RE = /^\s*return\s+(null|undefined|None)\s*;?\s*$/ + +// Empty return (no value at all) +const EMPTY_RETURN_RE = /^\s*return\s*;?\s*$/ + +// Python pass statement (only meaningful content on the line) +const PASS_RE = /^\s*pass\s*$/ + +/** + * Detect stub patterns in source code text. + * Returns one finding per detected instance. + * @param {string} source + * @returns {StubFinding[]} + */ +export function detectStubs(source) { + if (!source) return [] + + const lines = source.split('\n') + + /** @type {StubFinding[]} */ + const findings = [] + + for (let i = 0; i < lines.length; i++) { + const lineNo = i + 1 + const line = lines[i] + + for (const { re, pattern } of COMMENT_PATTERNS) { + if (re.test(line)) { + findings.push({ line: lineNo, pattern, text: line.trim() }) + } + } + + if (PASS_RE.test(line)) { + findings.push({ line: lineNo, pattern: 'pass', text: line.trim() }) + } else if (EMPTY_RETURN_RE.test(line)) { + findings.push({ line: lineNo, pattern: 'empty-return', text: line.trim() }) + } else if (SENTINEL_RE.test(line)) { + findings.push({ line: lineNo, pattern: 'sentinel', text: line.trim() }) + } + } + + return findings +} diff --git a/apps/claude-code/unic-archon-dlc/lib/tracker-adapter.mjs b/apps/claude-code/unic-archon-dlc/lib/tracker-adapter.mjs new file mode 100644 index 0000000..64d346c --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/lib/tracker-adapter.mjs @@ -0,0 +1,95 @@ +// @ts-check +/** + * Tracker adapter — pure functions that translate canonical label names to + * tracker-specific strings and generate CLI command strings for each backend. + * + * All tracker-specific knowledge is encapsulated here. Callers always use + * canonical names; this module translates at write time. + */ + +/** + * @typedef {import('./labels-config.mjs').LabelMapping} LabelMapping + * @typedef {import('./issues-schema.mjs').IssueType} IssueType + * @typedef {import('./issues-schema.mjs').IssuePriority} IssuePriority + */ + +/** + * Supported tracker backend identifiers. + * @typedef {'github' | 'ado' | 'jira' | 'local-markdown'} TrackerBackend + */ + +/** + * Translate a canonical label name to the tracker-specific string. + * Falls back to the canonical name if the key is not in any tier. + * @param {string} canonical + * @param {LabelMapping} labels + * @returns {string} + */ +export function translateLabel(canonical, labels) { + return labels.state[canonical] ?? labels.type[canonical] ?? labels.priority[canonical] ?? canonical +} + +/** + * Generate a CLI command string to create a new issue in the configured tracker. + * @param {TrackerBackend | string} tracker + * @param {string} title + * @param {IssueType | string} type - canonical type label + * @param {IssuePriority | string} priority - canonical priority label + * @param {LabelMapping} labels + * @returns {string} + */ +export function buildCreateCommand(tracker, title, type, priority, labels) { + const typeStr = translateLabel(type, labels) + const priorityStr = translateLabel(priority, labels) + + switch (tracker) { + case 'github': + return `gh issue create --title "${title}" --label "${typeStr}" --label "${priorityStr}"` + + case 'ado': + return `az boards work-item create --title "${title}" --type "Issue" --fields "Tags=${typeStr};${priorityStr}"` + + case 'jira': + return `jira issue create --summary "${title}" --issuetype "${typeStr}" --label "${priorityStr}"` + + case 'local-markdown': { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + return `Create docs/issues/${slug}/index.md with:\nStatus: needs-triage\nType: ${typeStr}\nPriority: ${priorityStr}` + } + + default: + return `gh issue create --title "${title}" --label "${typeStr}" --label "${priorityStr}"` + } +} + +/** + * Generate a CLI command string to update an issue's state label. + * @param {TrackerBackend | string} tracker + * @param {string} issueId - issue number or key + * @param {string} newState - canonical state label (e.g. 'resolved') + * @param {LabelMapping} labels + * @returns {string} + */ +export function buildUpdateCommand(tracker, issueId, newState, labels) { + const stateStr = translateLabel(newState, labels) + + switch (tracker) { + case 'github': + return `gh issue edit ${issueId} --add-label "${stateStr}"` + + case 'ado': + return `az boards work-item update --id ${issueId} --fields "Tags=${stateStr}"` + + case 'jira': + return `jira issue edit ${issueId} --label "${stateStr}"` + + case 'local-markdown': + return `Edit Status: line in docs/issues/<slug>/index.md to: Status: ${stateStr}` + + default: + return `gh issue edit ${issueId} --add-label "${stateStr}"` + } +} diff --git a/apps/claude-code/unic-archon-dlc/package.json b/apps/claude-code/unic-archon-dlc/package.json new file mode 100644 index 0000000..ba01ad1 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/package.json @@ -0,0 +1,26 @@ +{ + "name": "unic-archon-dlc", + "version": "0.1.0", + "private": true, + "license": "LGPL-3.0-or-later", + "type": "module", + "packageManager": "pnpm@10.33.0", + "engines": { + "node": ">=22", + "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", + "typecheck": "tsc --noEmit --project tsconfig.json", + "bump": "unic-bump", + "sync-version": "unic-sync-version", + "tag": "unic-tag", + "verify:changelog": "unic-verify-changelog" + }, + "devDependencies": { + "@types/node": "catalog:", + "@unic/release-tools": "workspace:*", + "@unic/tsconfig": "workspace:*", + "typescript": "catalog:" + } +} 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 new file mode 100644 index 0000000..970542b --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/config-loader.test.mjs @@ -0,0 +1,57 @@ +// @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/dag-builder.test.mjs b/apps/claude-code/unic-archon-dlc/test/dag-builder.test.mjs new file mode 100644 index 0000000..1d5ceed --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/dag-builder.test.mjs @@ -0,0 +1,112 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildYaml, detectCircular } from '../lib/dag-builder.mjs' + +/** + * @param {string} id + * @param {string[]} blocked_by + * @returns {import('../lib/issues-schema.mjs').Issue} + */ +function issue(id, blocked_by = []) { + return { + id, + title: `Issue ${id}`, + type: 'feature', + priority: 'p1', + blocked_by, + acceptance_criteria: ['done'], + summary: 'x', + } +} + +test('detectCircular: returns null for issues with no dependencies', () => { + const issues = [issue('a'), issue('b'), issue('c')] + assert.equal(detectCircular(issues), null) +}) + +test('detectCircular: returns null for a valid linear chain', () => { + const issues = [issue('a'), issue('b', ['a']), issue('c', ['b'])] + assert.equal(detectCircular(issues), null) +}) + +test('detectCircular: returns null for a diamond dependency', () => { + // a → b, a → c, b → d, c → d + const issues = [issue('a'), issue('b', ['a']), issue('c', ['a']), issue('d', ['b', 'c'])] + assert.equal(detectCircular(issues), null) +}) + +test('detectCircular: detects a direct cycle', () => { + const issues = [issue('a', ['b']), issue('b', ['a'])] + const result = detectCircular(issues) + assert.notEqual(result, null, 'should detect cycle') +}) + +test('detectCircular: detects a self-reference cycle', () => { + const issues = [issue('a', ['a'])] + const result = detectCircular(issues) + assert.notEqual(result, null, 'should detect self-reference') +}) + +test('detectCircular: detects a longer cycle (a→b→c→a)', () => { + const issues = [issue('a', ['c']), issue('b', ['a']), issue('c', ['b'])] + const result = detectCircular(issues) + assert.notEqual(result, null, 'should detect 3-node cycle') +}) + +test('buildYaml: throws on circular dependencies', () => { + const issues = [issue('a', ['b']), issue('b', ['a'])] + assert.throws(() => buildYaml('my-slug', issues), /circular/i) +}) + +test('buildYaml: produces valid YAML string', () => { + const issues = [issue('a'), issue('b')] + const yaml = buildYaml('my-slug', issues) + assert.ok(typeof yaml === 'string', 'should return a string') + assert.ok(yaml.includes('name:') || yaml.includes('id:'), 'should contain YAML node fields') +}) + +test('buildYaml: independent issues produce parallel code-red nodes (no shared depends_on)', () => { + const issues = [issue('x'), issue('y')] + const yaml = buildYaml('test', issues) + // Both code-red nodes should have empty depends_on + const redXMatch = yaml.match(/id: code-red-x[\s\S]*?depends_on: \[\]/) + const redYMatch = yaml.match(/id: code-red-y[\s\S]*?depends_on: \[\]/) + assert.ok(redXMatch, 'code-red-x should have empty depends_on') + assert.ok(redYMatch, 'code-red-y should have empty depends_on') +}) + +test('buildYaml: code-green depends on code-red for same issue', () => { + const issues = [issue('a')] + const yaml = buildYaml('test', issues) + assert.ok(yaml.includes('code-red-a'), 'should reference code-red-a') + assert.ok(yaml.includes('code-green-a'), 'should reference code-green-a') + // code-green-a must depend on code-red-a + const greenSection = yaml.slice(yaml.indexOf('id: code-green-a')) + assert.ok(greenSection.includes('code-red-a'), 'code-green-a depends_on must include code-red-a') +}) + +test('buildYaml: chained issues produce serial code-red nodes', () => { + // b blocked_by a → code-red-b must depend on code-green-a + const issues = [issue('a'), issue('b', ['a'])] + const yaml = buildYaml('test', issues) + const redBSection = yaml.slice(yaml.indexOf('id: code-red-b')) + assert.ok(redBSection.includes('code-green-a'), 'code-red-b must depend on code-green-a') +}) + +test('buildYaml: diamond dependency produces correct depends_on on leaf', () => { + // a → b, a → c, b+c → d + // code-red-d must depend on code-green-b AND code-green-c + const issues = [issue('a'), issue('b', ['a']), issue('c', ['a']), issue('d', ['b', 'c'])] + const yaml = buildYaml('test', issues) + const redDSection = yaml.slice(yaml.indexOf('id: code-red-d')) + assert.ok(redDSection.includes('code-green-b'), 'code-red-d depends on code-green-b') + assert.ok(redDSection.includes('code-green-c'), 'code-red-d depends on code-green-c') +}) + +test('buildYaml: output includes workflow name derived from slug', () => { + const issues = [issue('a')] + const yaml = buildYaml('my-feature', issues) + assert.ok(yaml.includes('my-feature'), 'slug should appear in workflow YAML') +}) diff --git a/apps/claude-code/unic-archon-dlc/test/findings-writer.test.mjs b/apps/claude-code/unic-archon-dlc/test/findings-writer.test.mjs new file mode 100644 index 0000000..5468b2d --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/findings-writer.test.mjs @@ -0,0 +1,100 @@ +// @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 { initFindingsDir, readFindingsMd, writeFindingsMd } from '../lib/findings-writer.mjs' + +let _seq = 0 +function tempDir() { + const p = join(tmpdir(), `unic-dlc-fw-${Date.now()}-${++_seq}`) + mkdirSync(p, { recursive: true }) + return p +} + +// --- initFindingsDir --- + +test('initFindingsDir creates docs/workflow/<slug>/ on first call and returns path', () => { + const projectDir = tempDir() + const slug = 'my-feature' + const result = initFindingsDir(projectDir, slug) + + assert.equal(result, join(projectDir, 'docs', 'workflow', slug)) + assert.ok(existsSync(result), 'directory should exist after initFindingsDir') +}) + +test('initFindingsDir second call with same slug returns same path without destroying contents', () => { + const projectDir = tempDir() + const slug = 'existing-feature' + + const first = initFindingsDir(projectDir, slug) + + // Write a sentinel file inside the directory + const sentinel = join(first, 'sentinel.txt') + writeFileSync(sentinel, 'do not destroy me') + + const second = initFindingsDir(projectDir, slug) + + assert.equal(first, second, 'should return the same path both times') + assert.ok(existsSync(sentinel), 'sentinel file must still exist after second call') + assert.equal(readFileSync(sentinel, 'utf8'), 'do not destroy me', 'file contents must be unchanged') +}) + +// --- writeFindingsMd --- + +test('writeFindingsMd writes a findings.md with all required sections', () => { + const projectDir = tempDir() + const slug = 'new-project' + const findingsDir = initFindingsDir(projectDir, slug) + + writeFindingsMd(findingsDir, { + stack: 'Node.js 22, pnpm 10, Biome 2', + features: 'Issue tracking, HANDOFF.md generation', + architecture: 'Pure function modules, no external deps', + pitfalls: 'Windows path separators, large monorepos', + brief: 'Minimal plugin that bridges Archon and Claude Code', + }) + + const content = readFileSync(join(findingsDir, 'findings.md'), 'utf8') + + assert.ok(content.includes('Stack') || content.includes('stack'), 'should have Stack section') + assert.ok(content.includes('Node.js 22'), 'should include stack content') + assert.ok(content.includes('Features') || content.includes('features'), 'should have Features section') + assert.ok(content.includes('Issue tracking'), 'should include features content') + assert.ok(content.includes('Architecture') || content.includes('architecture'), 'should have Architecture section') + assert.ok(content.includes('Pure function'), 'should include architecture content') + assert.ok(content.includes('Pitfalls') || content.includes('pitfalls'), 'should have Pitfalls section') + assert.ok(content.includes('Windows path'), 'should include pitfalls content') + assert.ok( + content.includes('Brief') || content.includes('brief') || content.includes('Integrated'), + 'should have Integrated Brief section' + ) + assert.ok(content.includes('Minimal plugin'), 'should include brief content') +}) + +// --- readFindingsMd --- + +test('readFindingsMd returns null if findings.md does not exist', () => { + const projectDir = tempDir() + const slug = 'no-findings' + const findingsDir = initFindingsDir(projectDir, slug) + + const result = readFindingsMd(findingsDir) + + assert.equal(result, null, 'should return null when findings.md is absent') +}) + +test('readFindingsMd returns file content if findings.md exists', () => { + const projectDir = tempDir() + const slug = 'with-findings' + const findingsDir = initFindingsDir(projectDir, slug) + + const expected = '# Findings\n\nSome content here.' + writeFileSync(join(findingsDir, 'findings.md'), expected) + + const result = readFindingsMd(findingsDir) + + assert.equal(result, expected, 'should return the exact findings.md content') +}) diff --git a/apps/claude-code/unic-archon-dlc/test/handoff-generator.test.mjs b/apps/claude-code/unic-archon-dlc/test/handoff-generator.test.mjs new file mode 100644 index 0000000..6d03517 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/handoff-generator.test.mjs @@ -0,0 +1,75 @@ +// @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 { buildHandoff, updateRoadmap } from '../lib/handoff-generator.mjs' + +let _seq = 0 +function tempDir() { + const p = join(tmpdir(), `unic-dlc-hf-${Date.now()}-${++_seq}`) + mkdirSync(p, { recursive: true }) + return p +} + +test('buildHandoff produces string with all four required sections', () => { + const snapshot = /** @type {import('../lib/handoff-generator.mjs').HandoffSnapshot} */ ({ + phase: 'build', + openIssues: { + 'needs-triage': ['feat: add login'], + 'ready-for-agent': ['fix: null pointer in handler'], + }, + blockers: ['fix: null pointer in handler (blocked by feat: add login)'], + recentDecisions: ['ADR-0001-use-gitflow.md', 'ADR-0002-local-markdown.md'], + }) + + const handoff = buildHandoff(snapshot) + + assert.ok( + handoff.includes('Current Phase') || handoff.includes('current phase') || handoff.includes('phase'), + 'should have phase section' + ) + assert.ok(handoff.includes('build'), 'should include the phase value') + assert.ok( + handoff.includes('needs-triage') || handoff.includes('Open Issues') || handoff.includes('open issues'), + 'should have open issues section' + ) + assert.ok(handoff.includes('feat: add login'), 'should include open issue title') + assert.ok(handoff.includes('Blockers') || handoff.includes('blockers'), 'should have blockers section') + assert.ok(handoff.includes('null pointer'), 'should include blocker description') + assert.ok( + handoff.includes('Decisions') || handoff.includes('decisions') || handoff.includes('ADR'), + 'should have decisions section' + ) + assert.ok(handoff.includes('ADR-0001'), 'should include ADR reference') +}) + +test('updateRoadmap creates ROADMAP.md on first run', () => { + const dir = tempDir() + mkdirSync(join(dir, 'docs', 'workflow'), { recursive: true }) + + updateRoadmap(dir, 'build') + + const roadmap = readFileSync(join(dir, 'docs', 'workflow', 'ROADMAP.md'), 'utf8') + assert.ok(roadmap.includes('build'), 'ROADMAP.md should mention the current phase') +}) + +test('updateRoadmap re-run updates marker region without clobbering human content', () => { + const dir = tempDir() + mkdirSync(join(dir, 'docs', 'workflow'), { recursive: true }) + + // Write ROADMAP.md with some human content + writeFileSync(join(dir, 'docs', 'workflow', 'ROADMAP.md'), '# Project Roadmap\n\nHuman notes here.\n') + + updateRoadmap(dir, 'plan') + updateRoadmap(dir, 'build') + + const roadmap = readFileSync(join(dir, 'docs', 'workflow', 'ROADMAP.md'), 'utf8') + assert.ok(roadmap.includes('Human notes here.'), 'human content must be preserved') + assert.ok(roadmap.includes('build'), 'latest phase should be reflected') + // Status block should appear only once + const blockMatches = (roadmap.match(/unic-archon-dlc:begin/g) ?? []).length + assert.equal(blockMatches, 1, 'marker block should appear exactly once') +}) 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 new file mode 100644 index 0000000..01800a3 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/install-agent-docs.test.mjs @@ -0,0 +1,47 @@ +// @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 { getDefaultLabels } from '../lib/labels-config.mjs' + +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, { + tracker: 'local-markdown', + pr_strategy: 'merge', + branching: 'gitflow', + repo_layout: 'single-context', + labels: getDefaultLabels('local-markdown'), + }) + + // 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') + + // workflow.md mentions all 6 workflow phases + const workflow = readFileSync(join(dir, 'docs', 'agents', 'workflow.md'), 'utf8') + for (const phase of ['explore', 'plan', 'build', 'qa', 'cleanup', 'triage']) { + assert.ok(workflow.toLowerCase().includes(phase), `workflow.md should mention ${phase}`) + } +}) 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 new file mode 100644 index 0000000..47e96c0 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/install-claude-md.test.mjs @@ -0,0 +1,44 @@ +// @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' + +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') +}) 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 new file mode 100644 index 0000000..b8ea147 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/install-helpers.test.mjs @@ -0,0 +1,71 @@ +// @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 '../hooks/install.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/issues-schema.test.mjs b/apps/claude-code/unic-archon-dlc/test/issues-schema.test.mjs new file mode 100644 index 0000000..15ba201 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/issues-schema.test.mjs @@ -0,0 +1,168 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildIssuesJson, sortByDependency, validateIssue } from '../lib/issues-schema.mjs' + +// --- helpers --- + +/** @returns {import('../lib/issues-schema.mjs').Issue} */ +function makeIssue(overrides = {}) { + return { + id: 'issue-1', + title: 'Implement login flow', + type: 'feature', + priority: 'p1', + blocked_by: [], + acceptance_criteria: ['User can log in with email and password'], + summary: 'Builds the authentication login page and backend handler.', + ...overrides, + } +} + +// --- validateIssue --- + +test('validateIssue: returns valid=true for a complete issue object', () => { + const result = validateIssue(makeIssue()) + assert.equal(result.valid, true, 'a complete issue should be valid') + assert.deepEqual(result.errors, [], 'no errors for a complete issue') +}) + +test('validateIssue: returns errors listing missing mandatory fields', () => { + const incomplete = { + id: 'issue-2', + title: 'Missing several fields', + // type, priority, blocked_by, acceptance_criteria, summary all missing + } + const result = validateIssue(incomplete) + assert.equal(result.valid, false, 'incomplete issue should not be valid') + assert.ok(result.errors.length > 0, 'should have at least one error') + assert.ok( + result.errors.some((e) => e.includes('type')), + `errors should mention 'type', got: ${result.errors}` + ) + assert.ok( + result.errors.some((e) => e.includes('priority')), + `errors should mention 'priority', got: ${result.errors}` + ) + assert.ok( + result.errors.some((e) => e.includes('blocked_by')), + `errors should mention 'blocked_by', got: ${result.errors}` + ) + assert.ok( + result.errors.some((e) => e.includes('acceptance_criteria')), + `errors should mention 'acceptance_criteria', got: ${result.errors}` + ) + assert.ok( + result.errors.some((e) => e.includes('summary')), + `errors should mention 'summary', got: ${result.errors}` + ) +}) + +test('validateIssue: missing id is reported as an error', () => { + const noId = makeIssue({ id: undefined }) + const result = validateIssue(noId) + assert.equal(result.valid, false) + assert.ok( + result.errors.some((e) => e.includes('id')), + `errors should mention 'id', got: ${result.errors}` + ) +}) + +test('validateIssue: missing title is reported as an error', () => { + const noTitle = makeIssue({ title: undefined }) + const result = validateIssue(noTitle) + assert.equal(result.valid, false) + assert.ok( + result.errors.some((e) => e.includes('title')), + `errors should mention 'title', got: ${result.errors}` + ) +}) + +test('validateIssue: acceptance_criteria must be a non-empty array', () => { + const emptyAc = makeIssue({ acceptance_criteria: [] }) + const result = validateIssue(emptyAc) + assert.equal(result.valid, false, 'empty acceptance_criteria array should be invalid') + assert.ok( + result.errors.some((e) => e.includes('acceptance_criteria')), + `errors should mention 'acceptance_criteria', got: ${result.errors}` + ) +}) + +// --- sortByDependency --- + +test('sortByDependency: returns single-element array unchanged', () => { + const issues = [makeIssue({ id: 'a', blocked_by: [] })] + const sorted = sortByDependency(issues) + assert.equal(sorted.length, 1) + assert.equal(sorted[0].id, 'a') +}) + +test('sortByDependency: returns correct order for a simple linear chain [A←B←C] → [A, B, C]', () => { + // B blocked_by A means A must come before B + // C blocked_by B means B must come before C + const issues = [ + makeIssue({ id: 'C', blocked_by: ['B'] }), + makeIssue({ id: 'A', blocked_by: [] }), + makeIssue({ id: 'B', blocked_by: ['A'] }), + ] + const sorted = sortByDependency(issues) + assert.equal(sorted.length, 3, 'all issues should be in result') + + const ids = sorted.map((i) => i.id) + const idxA = ids.indexOf('A') + const idxB = ids.indexOf('B') + const idxC = ids.indexOf('C') + + assert.ok(idxA < idxB, `A must come before B, got order: ${ids}`) + assert.ok(idxB < idxC, `B must come before C, got order: ${ids}`) +}) + +test('sortByDependency: handles independent issues with no dependencies', () => { + const issues = [ + makeIssue({ id: 'X', blocked_by: [] }), + makeIssue({ id: 'Y', blocked_by: [] }), + makeIssue({ id: 'Z', blocked_by: [] }), + ] + const sorted = sortByDependency(issues) + assert.equal(sorted.length, 3, 'all independent issues should be returned') + const ids = sorted.map((i) => i.id) + assert.ok(ids.includes('X') && ids.includes('Y') && ids.includes('Z'), 'all IDs present') +}) + +test('sortByDependency: throws on circular dependency', () => { + const issues = [makeIssue({ id: 'A', blocked_by: ['B'] }), makeIssue({ id: 'B', blocked_by: ['A'] })] + assert.throws(() => sortByDependency(issues), /circular/i, 'should throw an error mentioning circular dependency') +}) + +test('sortByDependency: throws on self-referencing dependency', () => { + const issues = [makeIssue({ id: 'A', blocked_by: ['A'] })] + assert.throws(() => sortByDependency(issues), /circular/i, 'should throw an error mentioning circular dependency') +}) + +// --- buildIssuesJson --- + +test('buildIssuesJson: produces valid JSON string', () => { + const issues = [makeIssue(), makeIssue({ id: 'issue-2', title: 'Second issue' })] + const json = buildIssuesJson(issues) + assert.doesNotThrow(() => JSON.parse(json), 'output should be parseable JSON') +}) + +test('buildIssuesJson: uses 2-space indent', () => { + const issues = [makeIssue()] + const json = buildIssuesJson(issues) + assert.ok(json.includes('\n '), 'output should use 2-space indentation') +}) + +test('buildIssuesJson: round-trips an issues array correctly', () => { + const issues = [ + makeIssue({ id: 'i1', title: 'First', blocked_by: [] }), + makeIssue({ id: 'i2', title: 'Second', blocked_by: ['i1'] }), + ] + const json = buildIssuesJson(issues) + const parsed = JSON.parse(json) + assert.equal(parsed.length, 2, 'should have 2 issues after round-trip') + assert.equal(parsed[0].id, 'i1') + assert.equal(parsed[1].id, 'i2') + assert.deepEqual(parsed[1].blocked_by, ['i1']) +}) diff --git a/apps/claude-code/unic-archon-dlc/test/labels-config.test.mjs b/apps/claude-code/unic-archon-dlc/test/labels-config.test.mjs new file mode 100644 index 0000000..d23e406 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/labels-config.test.mjs @@ -0,0 +1,42 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { getDefaultLabels } from '../lib/labels-config.mjs' + +test('local-markdown: canonical names equal tracker strings for all tiers', () => { + const labels = getDefaultLabels('local-markdown') + assert.equal(labels.state['needs-triage'], 'needs-triage') + assert.equal(labels.state['ready-for-agent'], 'ready-for-agent') + assert.equal(labels.state.rejected, 'rejected') + assert.equal(labels.type.feature, 'feature') + assert.equal(labels.type['tech-debt'], 'tech-debt') + assert.equal(labels.priority.p0, 'p0') + assert.equal(labels.priority.p3, 'p3') +}) + +test('github: default mapping has all canonical keys and uses canonical as tracker strings', () => { + const labels = getDefaultLabels('github') + // All state labels present + for (const k of [ + 'needs-triage', + 'needs-info', + 'needs-specs', + 'ready-for-agent', + 'ready-for-human', + 'resolved', + 'closed', + 'rejected', + ]) { + assert.ok(k in labels.state, `state.${k} should be present`) + assert.equal(labels.state[k], k, `github default: state.${k} should equal canonical key`) + } + // All type labels present + for (const k of ['feature', 'bug', 'spike', 'tech-debt', 'docs']) { + assert.ok(k in labels.type, `type.${k} should be present`) + } + // All priority labels present + for (const k of ['p0', 'p1', 'p2', 'p3']) { + assert.ok(k in labels.priority, `priority.${k} should be present`) + } +}) diff --git a/apps/claude-code/unic-archon-dlc/test/prd-writer.test.mjs b/apps/claude-code/unic-archon-dlc/test/prd-writer.test.mjs new file mode 100644 index 0000000..a971763 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/prd-writer.test.mjs @@ -0,0 +1,147 @@ +// @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 { readPrd, validatePrdSections, writePrd } from '../lib/prd-writer.mjs' + +let _seq = 0 +function tempDir() { + const p = join(tmpdir(), `unic-dlc-prd-${Date.now()}-${++_seq}`) + mkdirSync(p, { recursive: true }) + return p +} + +const FULL_SECTIONS = { + problemStatement: 'The current setup requires manual steps.', + solution: 'Automate via a workflow YAML DAG.', + userStories: 'As a developer, I want to run a single command.', + implementationDecisions: 'Use pure ESM modules, no external deps.', + testingDecisions: 'node:test with tmp dirs; no mocks.', + outOfScope: 'GUI tooling, cloud deployments.', + furtherNotes: 'Revisit after v1 ships.', +} + +// --- writePrd --- + +test('writePrd creates docs/workflow/<slug>/PRD.md with all 7 sections', () => { + const projectDir = tempDir() + writePrd(projectDir, 'my-feature', FULL_SECTIONS) + + const prdPath = join(projectDir, 'docs', 'workflow', 'my-feature', 'PRD.md') + assert.ok(existsSync(prdPath), 'PRD.md must exist after writePrd') + + const content = readFileSync(prdPath, 'utf8') + + assert.ok(content.includes('Problem Statement'), 'should contain Problem Statement heading') + assert.ok(content.includes('Solution'), 'should contain Solution heading') + assert.ok(content.includes('User Stories'), 'should contain User Stories heading') + assert.ok(content.includes('Implementation Decisions'), 'should contain Implementation Decisions heading') + assert.ok(content.includes('Testing Decisions'), 'should contain Testing Decisions heading') + assert.ok(content.includes('Out of Scope'), 'should contain Out of Scope heading') + assert.ok(content.includes('Further Notes'), 'should contain Further Notes heading') +}) + +test('writePrd writes actual section content into PRD.md', () => { + const projectDir = tempDir() + writePrd(projectDir, 'content-check', FULL_SECTIONS) + + const prdPath = join(projectDir, 'docs', 'workflow', 'content-check', 'PRD.md') + const content = readFileSync(prdPath, 'utf8') + + assert.ok(content.includes('The current setup requires manual steps.'), 'problemStatement body present') + assert.ok(content.includes('Automate via a workflow YAML DAG.'), 'solution body present') + assert.ok(content.includes('As a developer, I want to run a single command.'), 'userStories body present') + assert.ok(content.includes('Use pure ESM modules, no external deps.'), 'implementationDecisions body present') + assert.ok(content.includes('node:test with tmp dirs; no mocks.'), 'testingDecisions body present') + assert.ok(content.includes('GUI tooling, cloud deployments.'), 'outOfScope body present') + assert.ok(content.includes('Revisit after v1 ships.'), 'furtherNotes body present') +}) + +test('writePrd creates intermediate directories if absent', () => { + const projectDir = tempDir() + const slug = 'deep-feature' + // Directory does not exist yet + assert.ok(!existsSync(join(projectDir, 'docs', 'workflow', slug)), 'dir should not exist before call') + + writePrd(projectDir, slug, FULL_SECTIONS) + + const prdPath = join(projectDir, 'docs', 'workflow', slug, 'PRD.md') + assert.ok(existsSync(prdPath), 'PRD.md should be created including intermediate dirs') +}) + +// --- readPrd --- + +test('readPrd returns null if PRD.md does not exist', () => { + const projectDir = tempDir() + const result = readPrd(projectDir, 'no-prd') + assert.equal(result, null, 'should return null when PRD.md is absent') +}) + +test('readPrd returns PRD.md content if it exists', () => { + const projectDir = tempDir() + const slug = 'existing-prd' + const prdDir = join(projectDir, 'docs', 'workflow', slug) + mkdirSync(prdDir, { recursive: true }) + const expected = '# PRD\n\nSome content.' + writeFileSync(join(prdDir, 'PRD.md'), expected) + + const result = readPrd(projectDir, slug) + assert.equal(result, expected, 'should return exact PRD.md content') +}) + +// --- validatePrdSections --- + +test('validatePrdSections returns valid=true for a PRD with all 7 headings', () => { + const content = `# Product Requirements Document + +## Problem Statement +The current setup is manual. + +## Solution +Automate it. + +## User Stories +As a user... + +## Implementation Decisions +Use ESM modules. + +## Testing Decisions +Use node:test. + +## Out of Scope +GUI tooling. + +## Further Notes +Revisit later. +` + const result = validatePrdSections(content) + assert.equal(result.valid, true, 'should be valid') + assert.deepEqual(result.missingSections, [], 'no missing sections') +}) + +test('validatePrdSections returns valid=false and lists missing sections when headings absent', () => { + const content = `# Product Requirements Document + +## Problem Statement +Only this section. +` + const result = validatePrdSections(content) + assert.equal(result.valid, false, 'should not be valid') + assert.ok(result.missingSections.includes('Solution'), 'Solution should be missing') + assert.ok(result.missingSections.includes('User Stories'), 'User Stories should be missing') + assert.ok(result.missingSections.includes('Implementation Decisions'), 'Implementation Decisions should be missing') + assert.ok(result.missingSections.includes('Testing Decisions'), 'Testing Decisions should be missing') + assert.ok(result.missingSections.includes('Out of Scope'), 'Out of Scope should be missing') + assert.ok(result.missingSections.includes('Further Notes'), 'Further Notes should be missing') + assert.ok(!result.missingSections.includes('Problem Statement'), 'Problem Statement should not be listed as missing') +}) + +test('validatePrdSections returns valid=false with all sections missing for empty content', () => { + const result = validatePrdSections('') + assert.equal(result.valid, false, 'empty content is not valid') + assert.equal(result.missingSections.length, 7, 'all 7 sections should be missing') +}) 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 new file mode 100644 index 0000000..6762704 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/setup-explorer.test.mjs @@ -0,0 +1,36 @@ +// @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/apps/claude-code/unic-archon-dlc/test/slopcheck.test.mjs b/apps/claude-code/unic-archon-dlc/test/slopcheck.test.mjs new file mode 100644 index 0000000..390b19e --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/slopcheck.test.mjs @@ -0,0 +1,58 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { classifyPackages, parseNewPackages } from '../lib/slopcheck.mjs' + +test('parseNewPackages: all packages are new when prev is empty', () => { + const result = parseNewPackages({}, { react: '^18.0.0', lodash: '^4.0.0' }) + assert.deepEqual(result.sort(), ['lodash', 'react']) +}) + +test('parseNewPackages: returns empty array when deps are unchanged', () => { + const deps = { react: '^18.0.0' } + assert.deepEqual(parseNewPackages(deps, deps), []) +}) + +test('parseNewPackages: returns only packages not in prev', () => { + const prev = { react: '^18.0.0', lodash: '^4.0.0' } + const next = { react: '^18.0.0', lodash: '^4.0.0', zod: '^3.0.0' } + assert.deepEqual(parseNewPackages(prev, next), ['zod']) +}) + +test('parseNewPackages: treats null/undefined prev as empty', () => { + const result = parseNewPackages(null, { express: '^4.0.0' }) + assert.deepEqual(result, ['express']) +}) + +test('classifyPackages: package is not assumed when registry returns true', async () => { + const result = await classifyPackages(['my-pkg'], async () => true) + assert.equal(result.length, 1) + assert.equal(result[0].name, 'my-pkg') + assert.equal(result[0].assumed, false) +}) + +test('classifyPackages: package is assumed when registry returns false', async () => { + const result = await classifyPackages(['fake-pkg-xyz'], async () => false) + assert.equal(result[0].assumed, true) +}) + +test('classifyPackages: package is assumed when registry check throws', async () => { + const result = await classifyPackages(['boom-pkg'], async () => { + throw new Error('network error') + }) + assert.equal(result[0].assumed, true) +}) + +test('classifyPackages: all packages assumed when no registryFn provided', async () => { + const result = await classifyPackages(['pkg-a', 'pkg-b'], null) + assert.ok( + result.every((r) => r.assumed === true), + 'all should be assumed' + ) +}) + +test('classifyPackages: returns empty array for empty input', async () => { + const result = await classifyPackages([], async () => true) + assert.deepEqual(result, []) +}) diff --git a/apps/claude-code/unic-archon-dlc/test/spike-verdicts.test.mjs b/apps/claude-code/unic-archon-dlc/test/spike-verdicts.test.mjs new file mode 100644 index 0000000..89a0588 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/spike-verdicts.test.mjs @@ -0,0 +1,155 @@ +// @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 { appendSpikeVerdicts, parseSpikeVerdicts } from '../lib/spike-verdicts.mjs' + +let _seq = 0 +function tempDir() { + const p = join(tmpdir(), `unic-dlc-sv-${Date.now()}-${++_seq}`) + mkdirSync(p, { recursive: true }) + return p +} + +// --- appendSpikeVerdicts --- + +test('appendSpikeVerdicts on new findings.md adds ## Spike verdicts section', () => { + const dir = tempDir() + writeFileSync(join(dir, 'findings.md'), '# Research Findings\n\n## Stack\n\nNode.js 22\n') + + const verdicts = [ + { + title: 'Try streaming parser', + verdict: /** @type {'VALIDATED'} */ ('VALIDATED'), + notes: 'Works within memory budget.', + }, + ] + appendSpikeVerdicts(dir, verdicts) + + const content = readFileSync(join(dir, 'findings.md'), 'utf8') + assert.ok(content.includes('## Spike verdicts'), 'should have ## Spike verdicts heading') + assert.ok(content.includes('### Experiment 1: Try streaming parser'), 'should have experiment heading') + assert.ok(content.includes('**Verdict:** VALIDATED'), 'should include the verdict') + assert.ok(content.includes('Works within memory budget.'), 'should include the notes') +}) + +test('appendSpikeVerdicts called twice replaces existing section, does not duplicate', () => { + const dir = tempDir() + writeFileSync(join(dir, 'findings.md'), '# Research Findings\n\n## Stack\n\nNode.js 22\n') + + const first = [ + { title: 'First experiment', verdict: /** @type {'INVALIDATED'} */ ('INVALIDATED'), notes: 'Too slow.' }, + ] + appendSpikeVerdicts(dir, first) + + const second = [ + { title: 'Second experiment', verdict: /** @type {'PARTIAL'} */ ('PARTIAL'), notes: 'Needs more work.' }, + ] + appendSpikeVerdicts(dir, second) + + const content = readFileSync(join(dir, 'findings.md'), 'utf8') + + // Only one ## Spike verdicts heading + const sectionCount = (content.match(/^## Spike verdicts/gm) ?? []).length + assert.equal(sectionCount, 1, 'should have exactly one ## Spike verdicts section after two calls') + + // Second experiment present, first not + assert.ok(content.includes('Second experiment'), 'should contain second experiment title') + assert.ok(!content.includes('First experiment'), 'should NOT contain first experiment title after replacement') +}) + +test('appendSpikeVerdicts handles multiple verdicts with correct numbering', () => { + const dir = tempDir() + writeFileSync(join(dir, 'findings.md'), '# Research Findings\n\n## Integrated Brief\n\nSome brief.\n') + + const verdicts = [ + { title: 'Alpha', verdict: /** @type {'VALIDATED'} */ ('VALIDATED'), notes: 'Alpha notes.' }, + { title: 'Beta', verdict: /** @type {'INVALIDATED'} */ ('INVALIDATED'), notes: 'Beta notes.' }, + { title: 'Gamma', verdict: /** @type {'PARTIAL'} */ ('PARTIAL'), notes: 'Gamma notes.' }, + ] + appendSpikeVerdicts(dir, verdicts) + + const content = readFileSync(join(dir, 'findings.md'), 'utf8') + assert.ok(content.includes('### Experiment 1: Alpha'), 'first experiment numbered 1') + assert.ok(content.includes('### Experiment 2: Beta'), 'second experiment numbered 2') + assert.ok(content.includes('### Experiment 3: Gamma'), 'third experiment numbered 3') + assert.ok(content.includes('**Verdict:** VALIDATED'), 'VALIDATED verdict present') + assert.ok(content.includes('**Verdict:** INVALIDATED'), 'INVALIDATED verdict present') + assert.ok(content.includes('**Verdict:** PARTIAL'), 'PARTIAL verdict present') +}) + +// --- parseSpikeVerdicts --- + +test('parseSpikeVerdicts returns empty array when no ## Spike verdicts section exists', () => { + const content = '# Research Findings\n\n## Stack\n\nNode.js 22\n' + const result = parseSpikeVerdicts(content) + assert.deepEqual(result, []) +}) + +test('parseSpikeVerdicts parses verdicts from findings.md content', () => { + const content = `# Research Findings + +## Stack + +Node.js 22 + +## Spike verdicts + +### Experiment 1: Try streaming parser +**Verdict:** VALIDATED +Works within memory budget. + +### Experiment 2: Naive approach +**Verdict:** INVALIDATED +Exceeded 512 MB limit. +` + const result = parseSpikeVerdicts(content) + + assert.equal(result.length, 2, 'should parse two verdicts') + assert.equal(result[0].title, 'Try streaming parser') + assert.equal(result[0].verdict, 'VALIDATED') + assert.ok(result[0].notes.includes('Works within memory budget.')) + assert.equal(result[1].title, 'Naive approach') + assert.equal(result[1].verdict, 'INVALIDATED') + assert.ok(result[1].notes.includes('Exceeded 512 MB limit.')) +}) + +test('parseSpikeVerdicts handles PARTIAL verdict', () => { + const content = `## Spike verdicts + +### Experiment 1: Hybrid approach +**Verdict:** PARTIAL +Some parts work, needs refinement. +` + const result = parseSpikeVerdicts(content) + assert.equal(result.length, 1) + assert.equal(result[0].verdict, 'PARTIAL') + assert.equal(result[0].title, 'Hybrid approach') +}) + +test('each verdict in parseSpikeVerdicts result has exactly one of VALIDATED | INVALIDATED | PARTIAL', () => { + const content = `## Spike verdicts + +### Experiment 1: A +**Verdict:** VALIDATED +Notes A. + +### Experiment 2: B +**Verdict:** INVALIDATED +Notes B. + +### Experiment 3: C +**Verdict:** PARTIAL +Notes C. +` + const valid = new Set(['VALIDATED', 'INVALIDATED', 'PARTIAL']) + const result = parseSpikeVerdicts(content) + + assert.equal(result.length, 3) + for (const v of result) { + assert.ok(valid.has(v.verdict), `verdict '${v.verdict}' must be one of VALIDATED|INVALIDATED|PARTIAL`) + } +}) diff --git a/apps/claude-code/unic-archon-dlc/test/stub-detector.test.mjs b/apps/claude-code/unic-archon-dlc/test/stub-detector.test.mjs new file mode 100644 index 0000000..4fa39ad --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/stub-detector.test.mjs @@ -0,0 +1,84 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { detectStubs } from '../lib/stub-detector.mjs' + +// --- Positive cases (should be flagged as stubs) --- + +test('detectStubs: flags TODO comment', () => { + const result = detectStubs('const x = 1 // TODO: implement this') + assert.equal(result.length, 1) + assert.ok(result[0].pattern === 'TODO', `expected TODO, got ${result[0].pattern}`) +}) + +test('detectStubs: flags FIXME comment', () => { + const result = detectStubs('// FIXME: broken\nconst y = 2') + assert.equal(result.length, 1) + assert.equal(result[0].pattern, 'FIXME') +}) + +test('detectStubs: flags empty function body (only return with no value)', () => { + const src = `function doWork() {\n return\n}` + const result = detectStubs(src) + assert.equal(result.length, 1) + assert.ok(result[0].pattern.includes('empty-return'), `expected empty-return, got ${result[0].pattern}`) +}) + +test('detectStubs: flags hardcoded sentinel (return null as only body statement)', () => { + const src = `function getUser() {\n return null\n}` + const result = detectStubs(src) + assert.equal(result.length, 1) + assert.ok(result[0].pattern.includes('sentinel'), `expected sentinel, got ${result[0].pattern}`) +}) + +test('detectStubs: flags hardcoded sentinel (return undefined as only body)', () => { + const src = `function init() {\n return undefined\n}` + const result = detectStubs(src) + assert.equal(result.length, 1) + assert.ok(result[0].pattern.includes('sentinel'), `expected sentinel, got ${result[0].pattern}`) +}) + +test('detectStubs: flags pass (Python stub)', () => { + const src = `def do_work():\n pass` + const result = detectStubs(src) + assert.equal(result.length, 1) + assert.ok(result[0].pattern.includes('pass'), `expected pass, got ${result[0].pattern}`) +}) + +// --- Negative cases (should NOT be flagged) --- + +test('detectStubs: does not flag a function with real logic', () => { + const src = `function add(a, b) {\n return a + b\n}` + const result = detectStubs(src) + assert.equal(result.length, 0) +}) + +test('detectStubs: does not flag return true (non-sentinel)', () => { + const src = `function isReady() {\n return true\n}` + const result = detectStubs(src) + assert.equal(result.length, 0) +}) + +test('detectStubs: does not flag return with a real expression', () => { + const src = `function count(arr) {\n return arr.length\n}` + const result = detectStubs(src) + assert.equal(result.length, 0) +}) + +test('detectStubs: returns line number for each finding', () => { + const src = `// TODO: fix me\nconst x = 1` + const result = detectStubs(src) + assert.equal(result.length, 1) + assert.equal(result[0].line, 1) +}) + +test('detectStubs: detects multiple stubs in one file', () => { + const src = `// TODO: first\nconst a = 1\n// FIXME: second` + const result = detectStubs(src) + assert.equal(result.length, 2) +}) + +test('detectStubs: returns empty array for empty string', () => { + assert.deepEqual(detectStubs(''), []) +}) diff --git a/apps/claude-code/unic-archon-dlc/test/tracker-adapter.test.mjs b/apps/claude-code/unic-archon-dlc/test/tracker-adapter.test.mjs new file mode 100644 index 0000000..1d795b0 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/test/tracker-adapter.test.mjs @@ -0,0 +1,80 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { getDefaultLabels } from '../lib/labels-config.mjs' +import { buildCreateCommand, buildUpdateCommand, translateLabel } from '../lib/tracker-adapter.mjs' + +const labels = getDefaultLabels('github') + +test('translateLabel: returns canonical string as tracker string (default mapping)', () => { + assert.equal(translateLabel('needs-triage', labels), 'needs-triage') + assert.equal(translateLabel('ready-for-agent', labels), 'ready-for-agent') + assert.equal(translateLabel('feature', labels), 'feature') + assert.equal(translateLabel('p0', labels), 'p0') +}) + +test('translateLabel: missing key falls back to canonical name unchanged', () => { + const empty = { state: {}, type: {}, priority: {} } + assert.equal(translateLabel('needs-triage', empty), 'needs-triage') + assert.equal(translateLabel('some-unknown-label', empty), 'some-unknown-label') +}) + +test('github: buildCreateCommand produces a valid gh CLI string', () => { + const cmd = buildCreateCommand('github', 'Fix login bug', 'bug', 'p1', labels) + assert.ok(cmd.startsWith('gh issue create'), `expected gh issue create, got: ${cmd}`) + assert.ok(cmd.includes('--title'), 'should include --title flag') + assert.ok(cmd.includes('Fix login bug'), 'should include the title') + assert.ok(cmd.includes('--label'), 'should include --label flag') +}) + +test('ado: buildCreateCommand produces a valid az boards CLI string', () => { + const adoLabels = getDefaultLabels('ado') + const cmd = buildCreateCommand('ado', 'Add dashboard', 'feature', 'p2', adoLabels) + assert.ok(cmd.startsWith('az boards work-item create'), `expected az boards work-item create, got: ${cmd}`) + assert.ok(cmd.includes('Add dashboard'), 'should include the title') +}) + +test('jira: buildCreateCommand produces a valid jira CLI string', () => { + const jiraLabels = getDefaultLabels('jira') + const cmd = buildCreateCommand('jira', 'Spike authentication', 'spike', 'p0', jiraLabels) + assert.ok(cmd.startsWith('jira issue create'), `expected jira issue create, got: ${cmd}`) + assert.ok(cmd.includes('Spike authentication'), 'should include the title') +}) + +test('local-markdown: buildCreateCommand produces a file write instruction', () => { + const lmLabels = getDefaultLabels('local-markdown') + const cmd = buildCreateCommand('local-markdown', 'Tech debt cleanup', 'tech-debt', 'p3', lmLabels) + assert.ok(cmd.includes('docs/issues') || cmd.includes('Status:'), `expected file write instruction, got: ${cmd}`) +}) + +test('github: buildUpdateCommand produces a valid gh CLI string', () => { + const cmd = buildUpdateCommand('github', '42', 'resolved', labels) + assert.ok(cmd.startsWith('gh issue edit'), `expected gh issue edit, got: ${cmd}`) + assert.ok(cmd.includes('42'), 'should include the issue number') +}) + +test('ado: buildUpdateCommand produces a valid az boards CLI string', () => { + const adoLabels = getDefaultLabels('ado') + const cmd = buildUpdateCommand('ado', '99', 'resolved', adoLabels) + assert.ok(cmd.startsWith('az boards work-item update'), `expected az boards work-item update, got: ${cmd}`) + assert.ok(cmd.includes('99'), 'should include the issue id') +}) + +test('jira: buildUpdateCommand produces a valid jira CLI string', () => { + const jiraLabels = getDefaultLabels('jira') + const cmd = buildUpdateCommand('jira', 'PROJ-42', 'ready-for-agent', jiraLabels) + assert.ok(cmd.startsWith('jira issue edit'), `expected jira issue edit, got: ${cmd}`) + assert.ok(cmd.includes('PROJ-42'), 'should include the issue key') +}) + +test('local-markdown: buildUpdateCommand produces a human-readable instruction', () => { + const lmLabels = getDefaultLabels('local-markdown') + const cmd = buildUpdateCommand('local-markdown', 'fix-login', 'resolved', lmLabels) + assert.ok(cmd.includes('Status:'), `expected Status: instruction, got: ${cmd}`) +}) + +test('default tracker: buildUpdateCommand falls back to gh issue edit', () => { + const cmd = buildUpdateCommand('unknown-tracker', '7', 'closed', labels) + assert.ok(cmd.startsWith('gh issue edit'), `expected gh fallback, got: ${cmd}`) +}) diff --git a/apps/claude-code/unic-archon-dlc/tsconfig.json b/apps/claude-code/unic-archon-dlc/tsconfig.json new file mode 100644 index 0000000..5e97fd1 --- /dev/null +++ b/apps/claude-code/unic-archon-dlc/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@unic/tsconfig/tsconfig.base.json", + "include": ["lib/**/*.mjs", "hooks/**/*.mjs", "test/**/*.mjs"] +} diff --git a/docs/issues/unic-archon-dlc/15-fix-interactive-loop-fresh-context.md b/docs/issues/unic-archon-dlc/15-fix-interactive-loop-fresh-context.md new file mode 100644 index 0000000..ec426fd --- /dev/null +++ b/docs/issues/unic-archon-dlc/15-fix-interactive-loop-fresh-context.md @@ -0,0 +1,31 @@ +# Fix interactive loop nodes: add fresh_context to prevent session expiry crashes + +**Status:** ready-for-agent +**Category:** bug + +## Parent + +`docs/issues/unic-archon-dlc/PRD.md` + +## What to build + +All interactive loop nodes in the six workflows (`explore.yaml`, `plan.yaml`, `cleanup.yaml`) crash on iteration 2+ when a human responds to a gate. The root cause is a known Archon bug (coleam00/Archon#1208): the DAG executor tries to resume an expired Claude SDK session from the previous iteration instead of starting a fresh one. The fix is to add `fresh_context: true` to every node that is both `interactive: true` and uses a `loop:` block, so each iteration gets a clean session. + +Affected nodes across the six workflows: + +- `explore.yaml` → `code-preserve-gate` +- `plan.yaml` → `specs` (grill loop), `prd-gate`, `plan-gate`, `stall-gate` +- `cleanup.yaml` → `adr-consolidation` +- `qa.yaml` → `uat-gate` + +No lib modules, tests, or install hook are touched — this is a YAML-only patch. + +## Acceptance criteria + +- [ ] Every `interactive: true` loop node in all six workflow YAMLs has `fresh_context: true` set. +- [ ] No non-interactive loop node (e.g. `plan-checker`) receives `fresh_context: true` — only interactive gates get it. +- [ ] `pnpm check` passes at repo root after the change. + +## Blocked by + +None — can start immediately. diff --git a/docs/issues/unic-archon-dlc/16-qa-verify-pr-base.md b/docs/issues/unic-archon-dlc/16-qa-verify-pr-base.md new file mode 100644 index 0000000..5903439 --- /dev/null +++ b/docs/issues/unic-archon-dlc/16-qa-verify-pr-base.md @@ -0,0 +1,35 @@ +# QA workflow: add verify-pr-base guard after merge-pr + +**Status:** ready-for-agent +**Category:** feature + +## Parent + +`docs/issues/unic-archon-dlc/PRD.md` + +## What to build + +`qa.yaml` currently calls `merge-pr` (which runs the configured tracker's merge CLI command) but does not verify the PR targets the correct base branch first. If the PR was opened against the wrong target — e.g. `main` instead of `develop` on a Gitflow project — the merge silently ships to the wrong branch. + +Add a `verify-pr-base` bash node that runs before `merge-pr`: + +1. Reads the configured branching strategy from `.archon/unic-dlc.config.json`. +2. Derives the expected base branch (`develop` for Gitflow, `main` for GitHub Flow). +3. For GitHub tracker: calls `gh pr view --json baseRefName` and compares. +4. For ADO tracker: calls `az repos pr show` and compares `targetRefName`. +5. For Jira / local: logs a warning and continues (no CLI to query PR base). +6. If the base is wrong: logs a clear error with the expected vs actual branch names and exits non-zero, preventing `merge-pr` from running. + +This is a YAML-only addition of one `bash` node with an inline shell snippet — no lib modules or tests are added. + +## Acceptance criteria + +- [ ] `qa.yaml` has a `verify-pr-base` bash node with `depends_on: [uat-gate]`. +- [ ] `merge-pr` has `depends_on: [verify-pr-base]`. +- [ ] For GitHub and ADO trackers, the node exits non-zero with a clear message if the PR base does not match the expected branch. +- [ ] For Jira and local trackers, the node logs a warning and exits zero (no-op). +- [ ] `pnpm check` passes at repo root after the change. + +## Blocked by + +None — can start immediately. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24dd64c..d616ec3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,21 @@ importers: specifier: workspace:* version: link:../../../packages/release-tools + apps/claude-code/unic-archon-dlc: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.12.2 + '@unic/release-tools': + specifier: workspace:* + version: link:../../../packages/release-tools + '@unic/tsconfig': + specifier: workspace:* + version: link:../../../packages/tsconfig + typescript: + specifier: 'catalog:' + version: 5.8.3 + apps/claude-code/unic-confluence: dependencies: marked: