* chore(#3560): delete two unreachable workflows, gate reachability in lint discovery-phase.md and plan-milestone-gaps.md shipped to all 19 runtime install trees with no command, agent, or skill referencing them. plan-milestone-gaps' command was deleted by #2790 and the workflow was left behind; discovery-phase's own header claimed a caller in plan-phase.md's mandatory_discovery step, and that step does not exist — plan-phase.md contains zero occurrences of "discovery". docs/INVENTORY.md asserted discovery-phase.md was an alternate entry for /gsd-new-project. new-project.md never referenced it. The row and the matching note sentence are removed across all five locales rather than corrected. Adds rule 6 to lint-command-contract: every shipped workflow must be reachable from a loader, walking the transitive closure over the three reference shapes this repo uses. The closure seeds ONLY from commands/agents/skills, so a workflow that references only itself and a pair that reference only each other are both correctly reported rather than satisfying themselves; a visited set makes reference cycles terminate. The measure is a mention in a LOADER — docs/ and install-tree fixtures deliberately do not count, because scan.md proved a file can be documented and shipped while entirely unreached. Ships blocking, not report-only: #3561 is in this branch's base, so the tree reports 0 unreachable from the start. Closes #3560 * test(#3560): drive rule 6 end-to-end, sweep a stale allowlist, update ADR-0002 Review findings. Rule 6 had no end-to-end coverage: the tests exercised the pure closure with in-memory data, so the wiring — file collection, exit code, diagnostic — was unproven, and #3560's acceptance list explicitly wants a fixture showing the rule FAILS on a planted orphan. Adds an optional --root to lint-command-contract (default behavior unchanged) and four tests driving the real CLI through the process seam against a temp fixture: clean=0, planted orphan=1, orphan referenced only from docs/=1, orphan reachable transitively=0. The docs/ case is what pins the Goodhart defense — a mention outside a loader must not confer reachability. Deletes two tests that were byte-identical to a third and could not assert anything loader-specific, since the closure is source-agnostic by design; that distinction lives in the lint script's file collection and is now covered above. Removes a stale ALLOWLIST entry for discovery-phase.md in planner-language-regression — the exact sweep-miss class rule 6 exists to catch, found in the PR that adds the rule. ADR-0002 described five per-file frontmatter checks; rule 6 is a repo-level reachability graph, so the Decision section now says so. Refs #3560 * test(#3560): cut the bug-3298 test pin on the deleted plan-milestone-gaps workflow The remote runner went red with four failures: tests/phase.test.cjs asserted the plan-milestone-gaps workflow exists and checked its mkdir patterns, so deleting the file broke the test that pinned it. This is the fence the epic describes — the content-sync test IS what keeps an unreachable file alive — and cutting the coupling is what makes the deletion safe. Removes only that arm. The bug-3298 block guards three workflows against phase-dir prefix drift; the import and add-backlog arms and both shared mkdir-pattern helpers are untouched. Worth recording where the sweep failed: my reachability walk covered commands, agents, skills, gsd-core and docs, and lint-removed-but-needed covers .github/workflows, gsd-core, docs and package.json. Neither looks at tests/, so a test-pinned deletion is invisible to both and surfaces only on the remote runner. The how-to added by this PR names that gap explicitly so the next deletion searches tests/ by hand. Refs #3560 * docs(#3560): add a how-to for resolving unreachable-workflow findings * chore(#3560): backfill changeset pr number to 3564 --------- Co-authored-by: sim <sim@local>
46 lines
4.7 KiB
Markdown
46 lines
4.7 KiB
Markdown
# Command Contract Validation Module
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-05-05
|
|
|
|
We decided to centralize the `commands/gsd/*.md` file contract into a single validation seam enforced at two layers: a fast lint script (`scripts/lint-command-contract.cjs`) that runs as a pre-test CI step, and a behavioral regression test (`tests/command-contract.test.cjs`) that validates the full contract against the live filesystem.
|
|
|
|
## Decision
|
|
|
|
The command file contract defines what makes a valid `commands/gsd/*.md`:
|
|
|
|
- `name:` field present, non-empty, matches `gsd:*` or `gsd-*` (ns- commands use `gsd-`)
|
|
- `description:` field present and non-empty
|
|
- `allowed-tools:` block present and non-empty, all entries from the canonical tool set
|
|
- Every `@`-reference inside `<execution_context>` blocks resolves to an existing file on disk
|
|
- `@`-references inside `<execution_context>` blocks appear on their own line (no trailing prose)
|
|
- Every `gsd-core/workflows/*.md` file is reachable from at least one `commands/`, `agents/`, or `skills/` loader, transitively through `gsd-core/**` (#3560)
|
|
|
|
The first five checks are per-file frontmatter/body rules. The sixth is a different concern in kind: a repo-level reachability graph, computed once per run rather than per command file. It walks every markdown file under `commands/`, `agents/`, and `skills/` as loader seeds, then follows references transitively through `gsd-core/**` (not just `gsd-core/workflows/`, since a `references/` or `templates/` file can itself name a workflow path) until every reachable workflow file is marked. Any `gsd-core/workflows/*.md` file left unmarked is reported as an orphan.
|
|
|
|
A reference is recognized in three shapes: an eager `@`-include (the same `<execution_context>` syntax the first five checks already parse), a lazy path named only in prose or code that a command reads on demand rather than inlines, and a parent-relative sub-file path (`execute-phase/steps/post-merge-gate.md`) implicitly rooted under `workflows/`.
|
|
|
|
Reachability is seeded only from loaders, never from a workflow file's own content — a workflow that references itself, or two workflows that reference only each other, must still be reported as unreachable, since no command, agent, or skill loader ever actually opens them. `docs/` and test fixtures deliberately do not count as loaders: a path merely mentioned in documentation or a test does not make it reachable by any runtime — only `commands/`, `agents/`, and `skills/` do.
|
|
|
|
## Context
|
|
|
|
Before this ADR, the command contract was enforced inconsistently:
|
|
- `tests/skill-frontmatter-contract.test.cjs` (folds former `enh-2790-skill-consolidation`, consolidation epic #1969) checked existence and frontmatter of specific post-consolidation commands
|
|
- `tests/docs-update.test.cjs` (folds former `bug-3135-capture-backlog-workflow`, consolidation epic #1969) checked `execution_context` @-ref resolution (added 2026-05-05)
|
|
- No test checked `allowed-tools` validity, `name:` convention, or `description:` non-emptiness across all commands simultaneously
|
|
|
|
This meant any PR touching a command file could break the contract without a single test catching it. The `add-backlog.md` gap (#3135) is a concrete example: the workflow file was missing for the full consolidation cycle before a targeted regression test was written.
|
|
|
|
Additionally, 40 of 65 command files contained redundant prose @-references — the same path appearing once in `<execution_context>` (which loads the file) and again in `<process>` body text (inert). This added ~900 tokens of dead weight per invocation and created a drift seam where prose refs could go stale independently of the executable `execution_context` ref.
|
|
|
|
The two largest commands (`debug.md`, `thread.md`) embedded their full implementation inline rather than delegating to workflow files, causing ~4,400 tokens of implementation detail to load as part of the skills index description on every session regardless of whether those commands are used.
|
|
|
|
## Consequences
|
|
|
|
- A single `lint-command-contract.cjs` script enforces frontmatter invariants across all 65 commands in milliseconds, runs before the test suite in CI
|
|
- `tests/command-contract.test.cjs` replaces the scattered contract coverage in `enh-2790` and `bug-3135`, becoming the authoritative behavioral contract test for the entire command surface
|
|
- Redundant prose @-refs removed from 40 command files (~900 tokens/invocation recovered)
|
|
- `debug.md` and `thread.md` refactored to the workflow-delegation pattern (~4,400 tokens removed from eager system-prompt load)
|
|
- `workflows/extract_learnings.md` renamed to `workflows/extract-learnings.md` to align with the hyphen convention used by all other workflow files
|
|
- The `execution_context` block is the single authoritative declaration of what a command loads — no duplication in prose
|