@sabaiway/agent-workflow-kit 7.2.0 → 7.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +1 -1
- package/references/modes/upgrade.md +6 -4
- package/references/scripts/check-docs-size.mjs +59 -62
- package/references/scripts/check-docs-size.test.mjs +79 -15
- package/references/scripts/spec-schema.mjs +355 -0
- package/references/scripts/spec-schema.test.mjs +212 -0
- package/references/templates/AGENTS.md +2 -2
- package/references/templates/SPEC_TEMPLATE.md +45 -0
- package/references/templates/agent_rules.md +4 -3
- package/references/templates/specs/index.md +21 -0
- package/tools/ensure-configs.mjs +21 -12
- package/tools/ensure-ops.mjs +11 -8
- package/tools/ensure-specs.mjs +214 -0
- package/tools/ensure-vocabulary.mjs +9 -4
- package/tools/known-footprint.mjs +2 -0
- package/tools/script-priors.mjs +41 -0
- package/tools/upgrade-runlist.mjs +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,67 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 7.4.0 — the spec layer reaches existing deployments: known-prior refresh, seed-behind-checker, the `specs` ensure (AD-113)
|
|
8
|
+
|
|
9
|
+
7.3.0 made a FRESH bootstrap deliver the feature-spec layer whole; this release gives every EXISTING
|
|
10
|
+
deployment the same layer on an equal-head upgrade, with no lineage bump. The shaping fact: the kit's
|
|
11
|
+
`index` ensure drives the BUNDLED navigator generator, which collapses `docs/ai/specs/` into one
|
|
12
|
+
counted row, while the project's pre-commit hook runs ITS deployed `check-docs-size.mjs` — a store
|
|
13
|
+
root seeded behind an older checker renders row by row and reds `--check-index` on the next commit.
|
|
14
|
+
|
|
15
|
+
- **`tools/script-priors.mjs` — a deployed script is refreshed only when its bytes are a body a
|
|
16
|
+
release shipped.** An append-only catalog of sha256 digests of every `check-docs-size.mjs` body
|
|
17
|
+
since memory 4.0.0 (three checker bodies + the one test body); `classifyDeployedScript` answers
|
|
18
|
+
`current` · `prior` · `custom`. The fixtures are the shipped bytes verbatim
|
|
19
|
+
(`test/fixtures/script-priors/<firstShipped>/<file>.txt`, outside the test glob, the source-size
|
|
20
|
+
scope and `files[]`); the test holds catalog and fixtures equal BOTH ways and pins the rows as a
|
|
21
|
+
literal immutable prefix — a count alone would let a row and its fixture be replaced together,
|
|
22
|
+
turning a shipped body into `custom` on every deployment that carries it. No other deployed script
|
|
23
|
+
is refreshed; a custom body is preserved verbatim and said so.
|
|
24
|
+
- **`tools/ensure-specs.mjs` — the sixth ensure op, and the store root seeds ONLY behind a checker
|
|
25
|
+
pair proven current after the run.** `specs` sits after `scripts` and before `index`. It surveys
|
|
26
|
+
the four script files and the store root into current | prior | custom | absent | wrong-kind
|
|
27
|
+
BEFORE writing and admits each write through ONE conjunction: an absent reader file seeds
|
|
28
|
+
create-only (nothing depends on it); the checker pair refreshes or seeds only when BOTH reader
|
|
29
|
+
files are current after the seed and NEITHER checker file is custom; the store root only when the
|
|
30
|
+
checker pair is then current. A custom file preserves itself AND withholds every dependent write,
|
|
31
|
+
and the line names the remedy (copy the pair from `references/scripts/` by hand, re-run). A seed
|
|
32
|
+
that loses the create-only race is re-PROVEN — the appeared file must classify `current`, else
|
|
33
|
+
`race-unresolved` and nothing further; every line composes from the FATE of its write (written ·
|
|
34
|
+
stood · would · withheld · stopped); the run token follows one precedence — `seeded` >
|
|
35
|
+
`refreshed` > `customized-preserved` > `already-present`. The no-Node skip stays; there is NO
|
|
36
|
+
legacy-ADR early return (the store has no ADR-layout dependency).
|
|
37
|
+
- **Vocabulary + wiring.** The closed vocabulary gains the relayed pair `refreshed` /
|
|
38
|
+
`would-refresh` (doc-parity binds the `upgrade.md` step-3 line, the run-list registry mirrors the
|
|
39
|
+
op, the composed-lines guard witnesses the tokens); `ENSURE_IMPLEMENTATIONS` moved from
|
|
40
|
+
`ensure-ops.mjs` to `ensure-configs.mjs`, its only consumer, keeping the tools graph acyclic;
|
|
41
|
+
`--only specs` works and the CLI counts 6 slots.
|
|
42
|
+
- **Proof.** The state table runs as a table-driven test over every cell with injected fs
|
|
43
|
+
(`--dry-run` emits only `would-*`); the integration E2E upgrades a 4.5.4-byte deployment with
|
|
44
|
+
`--reconcile` and the real installed hook — seeded/refreshed on the plain and legacy-ADR layouts,
|
|
45
|
+
and the custom-checker variant keeps the checker verbatim, seeds NO store root and prints
|
|
46
|
+
`customized-preserved`. Package-content pins 245 -> 247.
|
|
47
|
+
|
|
48
|
+
## 7.3.0 — the spec layer's delivery half: hidden from day one, mirrored, bootstrapped (AD-112)
|
|
49
|
+
|
|
50
|
+
The memory substrate ships the feature-spec layer in 4.6.0 and the engine its canon in 3.3.0; this
|
|
51
|
+
release is what makes a FRESH kit bootstrap deliver it whole and hidden.
|
|
52
|
+
|
|
53
|
+
- **Hidden mode, no new machinery.** `KIT_OWN_PATHS` gains `/scripts/spec-schema.mjs` and
|
|
54
|
+
`/scripts/spec-schema.test.mjs` (sentinel 28 -> 30) — the reader the navigator checker now imports
|
|
55
|
+
and its deployed test; `/docs/ai/` already subsumes the store. The integration suite proves it on a
|
|
56
|
+
bootstrapped layout with real `git check-ignore` on both files and on `docs/ai/specs/x.md`: nothing
|
|
57
|
+
of the layer is visible to git.
|
|
58
|
+
- **Mirrors.** `references/scripts/` carries the reader pair and the GROUPS-collapse checker
|
|
59
|
+
byte-identical to the memory canon; `references/templates/` carries `specs/index.md` and
|
|
60
|
+
`SPEC_TEMPLATE.md` (the mirror manifest names both; template-parity reverse-pins them). The kit's
|
|
61
|
+
own fallback bootstrap excludes `SPEC_TEMPLATE.md` by NAME in its prose AND its E2E copy loop, and
|
|
62
|
+
the finalized navigator carries ONE counted `specs/` row.
|
|
63
|
+
- **Templates.** `agent_rules.md` §1.2/§1.3 reworded (governing specs, adoption shim, the revision
|
|
64
|
+
lands with the code) and its lens region re-rendered with the engine's `Spec-first` bullet;
|
|
65
|
+
`AGENTS.md` routes `technical_specification.md` readers to the contracts under `docs/ai/specs/`.
|
|
66
|
+
- Package-content pins: 241 -> 245 (the reader pair + the two templates), each by NAME.
|
|
67
|
+
|
|
7
68
|
## 7.2.0 — `.mcp.json` joins the hidden-mode footprint, and a worktree never owns it (AD-111)
|
|
8
69
|
|
|
9
70
|
7.0.0 taught the kit to register its MCP server. It did not teach hidden mode about the file that
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '7.
|
|
6
|
+
version: '7.4.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.4.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -21,7 +21,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
21
21
|
3. **Choose conversational language — answered in the step-2 batch.** Which language should the agent *talk to them* in — questions, explanations, summaries, status updates? Offer the language they're already writing in as the default. Carry the answer into the `{{COMM_LANGUAGE}}` slot of the *Communication language* block when `AGENTS.md` is created (step 5). See [Communication contract](${CLAUDE_SKILL_DIR}/references/contracts.md#communication-contract). This sets the **dialogue** language only — never the files.
|
|
22
22
|
4. **Choose agent attribution — answered in the step-2 batch.** May the agent attribute work to itself / to AI — `Co-Authored-By` trailers, "Generated with …" footers, "AI"/agent/model mentions in code, comments, commit messages, PR titles/bodies, or docs? **Default to `off`** (no agent/AI mention anywhere) unless they opt in — people are routinely surprised to find an AI listed as a repo contributor. Carry the answer into the `{{AGENT_ATTRIBUTION}}` slot of the *Attribution* block when `AGENTS.md` is created (step 5). **If `off` and the project uses Claude Code**, also set `"includeCoAuthoredBy": false` in the project's `.claude/settings.json` (create it if absent) — the trailer is added by the harness, so a doc directive alone won't stop it. See [Attribution contract](${CLAUDE_SKILL_DIR}/references/contracts.md#attribution-contract).
|
|
23
23
|
5. **Entry-point doc.** If `AGENTS.md` / `CLAUDE.md` already exist (step-1 recon), do **not** overwrite — show the user and ask whether to merge or replace. Otherwise create `AGENTS.md` (the cross-agent standard — Codex / Cursor / Devin Desktop / Copilot read it natively) from `${CLAUDE_SKILL_DIR}/references/templates/AGENTS.md`, and symlink `CLAUDE.md -> AGENTS.md` (`ln -s AGENTS.md CLAUDE.md`) for Claude Code — single source, no duplication. For nested context, add a subdir `AGENTS.md` (+ a `CLAUDE.md` symlink beside it for Claude Code).
|
|
24
|
-
6. **Deploy `docs/ai/`.** Create every `docs/ai/` file + `pages/` + the seed `adr/` store from `${CLAUDE_SKILL_DIR}/references/templates/` (the template loop deploys
|
|
24
|
+
6. **Deploy `docs/ai/`.** Create every `docs/ai/` file + `pages/` + the seed `adr/` store + the seed `specs/` store from `${CLAUDE_SKILL_DIR}/references/templates/` (the template loop deploys every template except `AGENTS.md`, `adr-record.md` and `SPEC_TEMPLATE.md` — the latter two are skill-home authoring references, never deployed). This deploys the HOT ADR window **`docs/ai/decisions.md`** and the seed navigator **`docs/ai/adr/log.md`** (its `adr/` subdir copies in wholesale), the spec store root **`docs/ai/specs/index.md`** (its `specs/` subdir copies in wholesale), the `.md` docs, **and** the three seeded, user-editable strict-JSON configs: **`docs/ai/orchestration.json`** (the per-project recipe defaults the `procedures` advisor reads), **`docs/ai/gates.json`** (the project's gate declaration — seeded as the no-consent EMPTY list; FILLING it is the consented `gates-init` preview, `${CLAUDE_SKILL_DIR}/references/modes/gates.md`), and **`docs/ai/autonomy.json`** (the per-project autonomy policy — seeded SPARSE with the onboarding note only, so it is exactly the computed defaults until the user declares levels via `/agent-workflow-kit set-autonomy` or by hand). Keep each `.md` file's frontmatter (`type / lastUpdated / scope / staleAfter / owner / maxLines`); the `.json` seeds carry no frontmatter (the docs cap-validator globs `*.md` only, so they are inherently skipped).
|
|
25
25
|
7. **Fill templates** per the table below. **Then materialize the navigator, as the last `docs/ai` step:** the entry point declares `docs/ai/index.md` always-loaded and it is a GENERATED artifact with no template, so run `node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<project>` and relay its one outcome line (`ensure-index: regenerated` / `already-current`, or a `write-refused` / `probe-failed` line naming the offending path — either is a STOP: resolve it before reporting the deploy, since the navigator the entry point declares does not exist yet). The generator runs from **this skill's own directory**, so a project with **no Node runtime** is covered too: the agent host runs it, and step 8's copy is not a precondition.
|
|
26
26
|
8. **Install enforcement (Node projects).** Copy `${CLAUDE_SKILL_DIR}/references/scripts/*.mjs` (+ `*.test.mjs`) into the project's `scripts/`. They self-configure (project name from `package.json`, hierarchical/on-demand sections auto-discovered). **If the project has no Node runtime** (step-1 recon), skip this step and the hook in step 9 — follow the cap/archive/index policy manually, or port the scripts to the project's language.
|
|
27
27
|
9. **Wire / hide** per visibility (see [Visibility contract](${CLAUDE_SKILL_DIR}/references/contracts.md#visibility-contract)). Install the pre-commit hook (Node projects): `node scripts/install-git-hooks.mjs`. If the installer reports a pre-existing non-marker hook, stop and ask the user to merge it manually rather than overwriting. Once the gate declaration is final-run-capable, the installer's optional consented `--commit-guard <kit commit-guard.mjs path>` arm binds commits to the green `run-gates --final` receipt (the Recommendations advisor surfaces the exact one-liner).
|
|
@@ -10,7 +10,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
10
10
|
|
|
11
11
|
1. `pointers` — `node ${CLAUDE_SKILL_DIR}/tools/inject-methodology.mjs reconcile <project>/AGENTS.md` → per pointer: added · already present · skipped (reported) · a hard STOP.
|
|
12
12
|
2. `footprint` — `node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile --dry-run` → visibility: visible · ambiguous · hidden — consent (conditional): ambiguous → ask which it is BEFORE anything; hidden → the conditional re-run without `--dry-run` (its surfaced paths ask per bootstrap step 9).
|
|
13
|
-
3. `configs` — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>` → one line per ensure: `seeded` / `note-refreshed` / `regenerated` / `already-current` / `customized-preserved` / `malformed-preserved` / `already-present` / `skipped-no-node` / `old-adr-layout-migration-instructed` / `failed`.
|
|
13
|
+
3. `configs` — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>` → one line per ensure: `seeded` / `note-refreshed` / `refreshed` / `regenerated` / `already-current` / `customized-preserved` / `malformed-preserved` / `already-present` / `skipped-no-node` / `old-adr-layout-migration-instructed` / `failed`.
|
|
14
14
|
4. `gates-migration` — `node ${CLAUDE_SKILL_DIR}/references/scripts/migrate-gates.mjs --kit-tools ${CLAUDE_SKILL_DIR}/tools --cwd <project>` → the preview plan · INERT checker · CUSTOMIZED entries, each named — consent: apply only on an explicit yes, re-run with `--apply`.
|
|
15
15
|
5. `bridges` — `node ${CLAUDE_SKILL_DIR}/tools/setup-backends.mjs --refresh-placed` → per-bridge lines: refreshed · already current · skipped — with its stated reason (not placed / newer than the bundle / unsupported host) · `skipped-readonly` · could not refresh.
|
|
16
16
|
6. `lens` — `node ${CLAUDE_SKILL_DIR}/tools/lens-region.mjs reconcile <project>/docs/ai/agent_rules.md` → per section: refreshed · already current · custom edit preserved · file absent / engine too old — skipped · over the line cap — refused · section absent — noted · a fully absent/invalid engine → hard STOP.
|
|
@@ -30,7 +30,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
30
30
|
|
|
31
31
|
**`footprint` — hidden-mode footprint reconcile (D9 / AD-014).** A deployment does not record whether it chose `hidden`, so first **infer visibility** — the dry-run writes **zero bytes** and reports one of — **visible** (the entry point is tracked) → nothing to do; **ambiguous** (untracked but not ignored — could be a fresh uncommitted repo, or a hide that broke) → **ASK** the user which it is, never guess; **hidden** → re-run without `--dry-run` to migrate any older **machine-global** hide to the **project-local** `.git/info/exclude` (one managed block; folds in the legacy `.claude/skills/` line), idempotently (a clean re-run is zero-diff). Handle its surfaced paths exactly as bootstrap step 9 (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`) (already-committed → show `git rm --cached`, ask before `--include`; generic-name present file → ask; **leftover machine-wide ignore block → ASK before `--remove-global`**, default keep + report). No Node on the agent host / Windows → as bootstrap step 9 (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`). This runs on **every** hidden upgrade, like the methodology slot — no lineage-head bump, no migration file.
|
|
32
32
|
|
|
33
|
-
**`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all
|
|
33
|
+
**`configs` — the project-configuration ensures, ONE run.** The ONE command performs **all six** ensures described below — orchestration config · gate declaration · autonomy declaration · enforcement scripts · spec layer · navigator index — in a fixed order, and prints **one outcome line per ensure**: paste those lines into the step 4 / step 8 success report. Every SEED is **create-only** (an existing file is preserved byte-for-byte); the three refresh-class ops are named apart — the orchestration onboarding note (refreshed only while it still matches a canonical this kit shipped), the spec-layer checker pair (below) and the **navigator index**, a GENERATED artifact regenerated whenever it is missing or stale (never authored content, so there is nothing to preserve). One ensure failing **never** skips the others: each reports its own outcome and the run exits non-zero when any of them `failed`. The outcome tokens, by ensure: orchestration → `seeded` / `note-refreshed` / `already-current` / `customized-preserved` / `malformed-preserved`; gates and autonomy → `seeded` / `already-present`; scripts → `seeded` / `already-present` / `old-adr-layout-migration-instructed` / `skipped-no-node`; specs → `seeded` / `refreshed` / `already-present` / `customized-preserved` / `skipped-no-node`; index → `regenerated` / `already-current`; and any ensure may report `failed`, whose line OPENS with the cause (relay it with that cause — never soften it into a skip; an op that copies file by file also states when it stopped partway). The cause vocabulary is CLOSED — one of `race-unresolved`, `template-unreadable`, `bundle-unreadable`, `adr-layout-unverifiable`, `wrong-node-kind`, `write-refused`, `unexpected-error`, `generator-unlaunchable`, `generator-failed`, `index-probe-failed`, `index-stale-after-write` — and every cause that can only arise AFTER the generator ran (`generator-failed`, and `index-probe-failed` / `index-stale-after-write` when they follow a reported regeneration) DISCLOSES in its own line that a write may already have landed. **A non-zero exit STOPs this upgrade** — report the failed line and stop there, before the equal-head exit, the migrations and the re-stamp. Add `--dry-run` to preview without writing a byte. Like the pointer slots + the footprint reconcile, all six reach an equal-head deployment **without a lineage-head bump or a migration file** (they are `.json` / `scripts/` / a generated artifact / a seeded store root, inherently outside the docs cap-validator).
|
|
34
34
|
|
|
35
35
|
**What the orchestration-config ensure does.** `docs/ai/orchestration.json` must exist **and its onboarding note must be current**: created from the canonical seed if missing; if it already exists, **every activity/slot the user set is preserved** and ONLY the `_README` note is refreshed, and only when the existing one still matches a known prior canonical — the tested `refreshIfCanonical` / `refreshReadme` in `tools/orchestration-config.mjs` is the source of truth for that decision (it normalizes CRLF/whitespace before comparing; a *customized* `_README` is preserved verbatim → `customized-preserved`; a *malformed* existing config is **preserved untouched + LOUD** → `malformed-preserved`, never clobbered and never silently skipped). The current note points at `/agent-workflow-kit set-recipe`. **Kit-owned:** in the **delegated** path memory only seeds/preserves the file (memory upgrade step 2) and this ensure applies the `_README` refresh; in the **fallback** path it does both. (Memory stays standalone.)
|
|
36
36
|
|
|
@@ -42,6 +42,8 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
42
42
|
|
|
43
43
|
**What the navigator ensure does.** `docs/ai/index.md` is the always-loaded navigator the entry point declares, and it is GENERATED — no template ships it, so a deployment that never ran the generator boots from a broken entry point (and, on a Node project, carries a pre-commit hook that fails its own index check). The ensure runs the bundled generator's finalizer and reports `regenerated` (it was missing or stale — it was written) or `already-current` (nothing written). It never skips a No-Node project: the generator runs from `${CLAUDE_SKILL_DIR}/references/scripts/` on the agent host, not from the project's `scripts/`. **Its position in the run-list is EARLY and therefore NOT authoritative** — `lens` (and, on the migrated path, steps 6–7) still change `docs/ai` afterwards — so the authoritative run is the LATE `--only index` rung documented at both exits; the early one is idempotent and costs at most an `already-current` line.
|
|
44
44
|
|
|
45
|
+
**What the spec-layer ensure does.** A deployment older than the feature-spec layer lacks `scripts/spec-schema.mjs` and `docs/ai/specs/index.md`, and its deployed `check-docs-size.mjs` predates the store collapse. In ONE order: the reader pair is seeded (create-only); the checker pair is REFRESHED only while its bytes are a body a release shipped (an append-only digest catalog → `refreshed`; an edited body is preserved verbatim and withholds the writes that depend on it); the store root is seeded from the bundled template, date rendered, ONLY behind a checker pair current after the run — an older or edited checker renders the store row by row and reds the hook's `--check-index`, so behind a custom checker the store root is NOT seeded and the line names the remedy (copy the pair from `${CLAUDE_SKILL_DIR}/references/scripts/` by hand, re-run). One token by precedence: `seeded` > `refreshed` > `customized-preserved` (an edited pair, and this run wrote nothing) > `already-present`; `skipped-no-node` without a `package.json`; every line states what this run did, and a write that stops partway names what landed. The legacy-ADR instruct never withholds it.
|
|
46
|
+
|
|
45
47
|
**`gates-migration` — legacy gates.json migration (consented preview — D8).** An EXISTING declaration may still carry the retired review-ledger / fold-completeness checks. Run the preview (dry-run — writes NOTHING), show the user the exact plan, and only on an explicit yes re-run it with `--apply`: canonical legacy entries (matched by their documented single-invocation cmd forms) are REMOVED, the canonical `unit-tests` cmd gains the built-in lcov reporters, and the coverage-check gate is ADDED last — atomic and COMPLETE, so the migrated declaration satisfies `run-gates --final`. **The checker rides a PRODUCER or is not declared at all** (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`): with no gate producing the lcov it reads, the migration does NOT add it, an already-declared one is reported INERT, the result is not called final-run-capable, and the preview prints the paste-ready suite cmd to declare by hand — nothing is ever removed for you. CUSTOMIZED entries are NEVER auto-touched: the preview names each with a paste-ready recovery, and the commit guard must NOT be installed until they are resolved. This is the ONLY gates.json writer at upgrade (the consented FILL preview runs at init).
|
|
46
48
|
|
|
47
49
|
**`bridges` — placed-bridge refresh — stamp-independent, same gate, BEFORE the equal-head short-circuit.** Run
|
|
@@ -86,7 +88,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
86
88
|
flagged + preserved, never edited. Runs on **every** upgrade; exit 0 covers every outcome.
|
|
87
89
|
**The LATE navigator finalizer — the AUTHORITATIVE run, after the last `docs/ai` mutation.** `lens` above rewrites `docs/ai/agent_rules.md`, so the step-3 index ensure is already behind by the time the run-list ends. Re-run that ONE op here, before the step-4 report: `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only index --cwd <project>`. Relay **this** line in the report (it supersedes the early one; an untouched tree reports `already-current`, a failure STOPs the upgrade like any other ensure). On the migrated path the same rung runs again at the END of step 7 — after the migrations, before the step-8 re-stamp.
|
|
88
90
|
4. **Equal-head exit — a real successful-exit report, not a bare stop.** If the stamp **equals** the head, the lineage is up to date — but step 3 (the stamp-independent reconciles) ran first and may have changed things, so this is a proper exit report, not a no-op:
|
|
89
|
-
- **Report step 3's outcome in plain language** — for **each** `pointers` slot (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); the **
|
|
91
|
+
- **Report step 3's outcome in plain language** — for **each** `pointers` slot (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); the **six project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (orchestration config, gate declaration, autonomy declaration, enforcement scripts, spec layer, navigator index — their outcome tokens are enumerated in step 3), each rendered in plain language: what was created, what was left exactly as the user wrote it, and — for a `failed` line — what stopped it; for the navigator, relay the **late** `--only index` line, not the early one; the **`gates-migration`** result — *nothing to migrate*, the shown plan *applied* on your explicit yes, or the plan *left unapplied* (consent not given), with any INERT checker or CUSTOMIZED entries named; the **placed-bridge refresh** (`bridges`) outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` with its re-scan verdict / *could not refresh* + recovery); the **agent-rules lens** (`lens`) outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*) and the **Communication-section** outcome (its own set: refreshed / already current / custom preserved + note / section absent — noted / over the cap — refused); the **bridge-settings reconcile** (`bridge-settings`) outcome (paste the tool's line(s) verbatim); and, for a hidden deployment, whether the hidden-mode footprint (`footprint`) was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
|
|
90
92
|
- **Never surface the structure number on this exit.** Whatever step 3 did, do **not** recite the `docs/ai` structure version, the internal versioning vocabulary, or the two-axes note here — the number is inert on an equal-head exit; it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md` (shown at the never-downgrade STOP, the explicit status view, or on an explicit ask). Frame the success itself per the final bullet: if step 3 changed anything, say **what changed** in plain human terms; only a pure zero-diff no-op is *settings already current — no update needed*.
|
|
91
93
|
- **Render the mandatory Recommendations section — on this exit too, BEFORE the footer.** Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language: every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; show the raw tool block on request. The section is present-even-when-empty (with everything optimal the body is exactly `no recommendations — flow optimal.`) and VERDICT-FIRST — the composed verdict line renders from the frozen templates `{K} item(s) need attention` / `nothing is broken` / `{N} optional recommendation(s), apply any you want` / `optimality NOT attested — {M} probe check(s) skipped`. Then OFFER the consent-gated applies: the user picks items in plain language; surface each picked item's posture note, get the explicit confirm, then run EXACTLY the rendered one-liners (a HAND-APPLY item is never run by you) — the full lane in `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`. Pinned order on this exit: Recommendations block → optional applies → report footer → the commit ask (the advisor/apply lane never lands after the commit ask).
|
|
92
94
|
- **Live host/session facts are tool-composed only.** Any claim this report makes about the current host or session state — prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts — must trace to **live tool output** from **this session** (the lines you just composed, or a probe you ran this run); a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection. Full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`.
|
|
@@ -95,4 +97,4 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
95
97
|
5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
|
|
96
98
|
6. **Collect the migration answers FIRST, then apply.** If `AGENTS.md` is missing BOTH the *Communication language* and *Attribution* blocks — i.e. both blocks are missing (a pre-1.1.0 deployment) — ask the two questions as ONE structured multi-question prompt; record each answer individually, write nothing until ALL are answered, and carry the answers into the migrations below: a migration whose answer was already collected never re-asks (its own "Ask the user" step is the standalone fallback); a single missing block keeps its single ask (step 7). Then apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**, only those newer than the project's stamp. Migrations are **idempotent** — safe to re-run.
|
|
97
99
|
7. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. If `AGENTS.md` has no *Communication language* block (pre-1.1.0 deployment), **ask the user their conversational language** and insert the block — see `migrations/1.1.0-communication-language.md`. If it has no *Attribution* block (pre-1.2.0 deployment), **ask whether the agent may attribute work to itself / AI** and insert the block (defaulting to `off`) — see `migrations/1.2.0-agent-attribution.md`. (An answer already collected by the step-6 batched prompt is carried in — never re-asked here.) **Then, before the stamp, re-run the LATE navigator finalizer:** the migrations of steps 6–7 may have added or changed `docs/ai` files, so run the one op again — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only index --cwd <project>` — and relay THAT line in the step-8 report (idempotent: an untouched tree reports `already-current`; a failure STOPs this upgrade like any other ensure).
|
|
98
|
-
8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`3.0.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 run-list outcomes, each as step 4 words it: the `pointers` slots, the `footprint` visibility verdict (and what a consented re-run migrated), the **
|
|
100
|
+
8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`3.0.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 run-list outcomes, each as step 4 words it: the `pointers` slots, the `footprint` visibility verdict (and what a consented re-run migrated), the **six project-configuration ensure** (`configs`) lines — the navigator one being the late `--only index` run at the end of step 7, the **`gates-migration`** result, the **placed-bridge refresh** (`bridges`) lines (pasted verbatim), the **agent-rules lens** (`lens`) + **Communication-section** outcomes (same outcome sets as step 4), and the **bridge-settings reconcile** (`bridge-settings`) outcome (line(s) pasted verbatim) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **render the mandatory Recommendations section**: run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language (every fact, count and item, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request; present-even-when-empty: `no recommendations — flow optimal.`), then OFFER the consent-gated applies (per picked item: posture note → explicit confirm → run EXACTLY the rendered one-liner; a HAND-APPLY item is never run by you — `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`). **Every current host/session claim in this report is tool-composed only** — prompts fired, sandbox scope, whether a bypass was needed, network reachability and approval counts must trace to **live tool output** from **this session**, a memory/handover snapshot is **context, never report facts**, and an unbacked claim is **omitted or explicitly marked unverified** (full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing** — the pinned order on this exit is: Recommendations block → optional applies → report footer → the commit ask.
|
|
@@ -29,6 +29,7 @@ import { existsSync, lstatSync, realpathSync } from 'node:fs';
|
|
|
29
29
|
import { dirname, resolve, relative, join, basename, sep } from 'node:path';
|
|
30
30
|
import { fileURLToPath } from 'node:url';
|
|
31
31
|
import { randomBytes } from 'node:crypto';
|
|
32
|
+
import { readSpecDocument, SPECS_COLLAPSE } from './spec-schema.mjs';
|
|
32
33
|
|
|
33
34
|
const __filename = fileURLToPath(import.meta.url);
|
|
34
35
|
const __dirname = dirname(__filename);
|
|
@@ -36,10 +37,8 @@ const ROOT = resolve(__dirname, '..');
|
|
|
36
37
|
const DOCS_DIR = resolve(ROOT, 'docs/ai');
|
|
37
38
|
const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
|
|
38
39
|
|
|
39
|
-
//
|
|
40
|
-
// (
|
|
41
|
-
// override them so the ADR-rotation hook (archive-decisions.mjs) and hermetic tests can regenerate
|
|
42
|
-
// an arbitrary root's index without ever touching the real repo tree.
|
|
40
|
+
// The module ROOT constants are the CLI DEFAULT (this deployment's own root); `--root=<dir>` and the
|
|
41
|
+
// exported `regenerateIndex(root, today)` target an arbitrary root (the ADR-rotation hook, hermetic tests).
|
|
43
42
|
const pathsFor = (root) => {
|
|
44
43
|
const base = resolve(root);
|
|
45
44
|
return { root: base, docsDir: resolve(base, 'docs/ai'), indexPath: resolve(base, 'docs/ai/index.md') };
|
|
@@ -58,12 +57,9 @@ const ENSURE_INDEX_PREFIX = 'ensure-index:';
|
|
|
58
57
|
const DEFAULT_PROJECT_NAME = 'this project';
|
|
59
58
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-ssr', 'coverage', 'build', '.next']);
|
|
60
59
|
|
|
61
|
-
// `strict` is the finalizer's lens on the SAME walk:
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// a genuine ENOENT stays an absence; every other fs error propagates.
|
|
65
|
-
// Only a genuine ENOENT is an absence. A code-LESS throw (an injected reader, a wrapped client) is
|
|
66
|
-
// not evidence of absence either, so it propagates too — "unknown" must never read as "empty".
|
|
60
|
+
// `strict` is the finalizer's lens on the SAME walk: a run that WRITES the navigator may not treat
|
|
61
|
+
// "could not read" as "nothing there". Only a genuine ENOENT is an absence — a code-LESS throw (an
|
|
62
|
+
// injected reader, a wrapped client) propagates too: "unknown" must never read as "empty".
|
|
67
63
|
const rethrowUnlessAbsent = (err, strict) => {
|
|
68
64
|
if (strict && err?.code !== 'ENOENT') throw err;
|
|
69
65
|
};
|
|
@@ -204,24 +200,25 @@ export const computeToday = (todayStr) =>
|
|
|
204
200
|
? new Date(`${todayStr}T00:00:00Z`)
|
|
205
201
|
: new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
|
|
206
202
|
|
|
203
|
+
// A file under the spec store is ALSO read through the shared reader (already-read text, no second
|
|
204
|
+
// read): its verdict rides the row as `spec` and decides collapse eligibility; a refusal is ADVISORY
|
|
205
|
+
// here — surfaced as warnings, never a cap error — the structural checker owns the refusal.
|
|
206
|
+
const readSpecVerdict = (rel, text) =>
|
|
207
|
+
rel.startsWith(SPECS_COLLAPSE.prefix) ? readSpecDocument(text, rel.slice(SPECS_COLLAPSE.prefix.length)) : null;
|
|
208
|
+
|
|
207
209
|
export const inspectFile = async (filePath, today, root = ROOT) => {
|
|
208
210
|
const text = await readFile(filePath, 'utf8');
|
|
209
211
|
const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
210
212
|
const fm = parseFrontmatter(text);
|
|
211
213
|
const rel = relative(root, filePath);
|
|
214
|
+
const spec = readSpecVerdict(rel, text);
|
|
212
215
|
|
|
213
216
|
if (!fm) {
|
|
214
|
-
return {
|
|
215
|
-
path: rel,
|
|
216
|
-
lineCount,
|
|
217
|
-
frontmatter: null,
|
|
218
|
-
errors: [`missing YAML frontmatter`],
|
|
219
|
-
warnings: [],
|
|
220
|
-
};
|
|
217
|
+
return { path: rel, lineCount, frontmatter: null, spec, errors: [`missing YAML frontmatter`], warnings: [] };
|
|
221
218
|
}
|
|
222
219
|
|
|
223
220
|
const errors = [];
|
|
224
|
-
const warnings = [];
|
|
221
|
+
const warnings = (spec === null ? [] : [...spec.errors, ...spec.warnings]).map((finding) => `spec ${finding.rule}: ${finding.message}`);
|
|
225
222
|
|
|
226
223
|
const maxLines = fm.maxLines ? Number(fm.maxLines) : null;
|
|
227
224
|
if (maxLines === null || Number.isNaN(maxLines)) {
|
|
@@ -241,7 +238,7 @@ export const inspectFile = async (filePath, today, root = ROOT) => {
|
|
|
241
238
|
}
|
|
242
239
|
}
|
|
243
240
|
|
|
244
|
-
return { path: rel, lineCount, frontmatter: fm, errors, warnings };
|
|
241
|
+
return { path: rel, lineCount, frontmatter: fm, spec, errors, warnings };
|
|
245
242
|
};
|
|
246
243
|
|
|
247
244
|
const formatRow = (row) => {
|
|
@@ -304,19 +301,16 @@ const formatIndexRow = (row) => {
|
|
|
304
301
|
return `| ${link} | ${fm.type ?? '—'} | ${row.lineCount}/${fm.maxLines ?? '—'} | ${fm.lastUpdated ?? '—'} | ${fm.staleAfter ?? '—'} |`;
|
|
305
302
|
};
|
|
306
303
|
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
304
|
+
// Two stores grow O(n) forever and would blow the index's own 80-line cap: the one-file-per-ADR store
|
|
305
|
+
// (docs/ai/adr/) and the spec store (docs/ai/specs/). Each is a GROUP that COLLAPSES to a single
|
|
306
|
+
// counted row — while walkMarkdownFiles still finds + cap-checks every individual body (only the
|
|
307
|
+
// index RENDERING is collapsed). Membership is by PROOF: a genuine ADR record / the adr navigator,
|
|
308
|
+
// or a spec-store file the shared reader accepts. Anything else under either dir (a stray README.md,
|
|
309
|
+
// a malformed spec) renders as its OWN visible row — never silently hidden by the collapse.
|
|
311
310
|
const ADR_DIR_PREFIX = 'docs/ai/adr/';
|
|
312
311
|
const ADR_RECORD_RE = /\/AD-(\d{3,})-[^/]*\.md$/;
|
|
313
312
|
const ADR_NAV_PATH = 'docs/ai/adr/log.md';
|
|
314
313
|
|
|
315
|
-
// Only genuine records + the navigator collapse into the aggregate row; an UNEXPECTED file under
|
|
316
|
-
// adr/ (a stray README.md, AD-foo.md) renders as its OWN visible index row — never silently hidden
|
|
317
|
-
// by the collapse (it also fails archive-decisions' own store-integrity check).
|
|
318
|
-
const isCollapsibleAdr = (path) => path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(path) || path === ADR_NAV_PATH);
|
|
319
|
-
|
|
320
314
|
const formatAdrCollapseRow = (adrRows) => {
|
|
321
315
|
const recs = adrRows
|
|
322
316
|
.map((r) => {
|
|
@@ -329,23 +323,37 @@ const formatAdrCollapseRow = (adrRows) => {
|
|
|
329
323
|
return `| [\`adr/\`](./adr/log.md) | adr | ${recs.length} records | ${range} | — |`;
|
|
330
324
|
};
|
|
331
325
|
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
326
|
+
// The live counts make the row change when a spec is added or removed, so --check-index sees it.
|
|
327
|
+
const formatSpecsCollapseRow = (specRows) => {
|
|
328
|
+
const count = (kind) => specRows.filter((r) => r.spec.kind === kind).length;
|
|
329
|
+
const link = `[\`${SPECS_COLLAPSE.label}\`](./${SPECS_COLLAPSE.navPath.replace(/^docs\/ai\//, '')})`;
|
|
330
|
+
return `| ${link} | ${SPECS_COLLAPSE.type} | ${count('spec')} specs | ${count('part')} parts · ${count('index')} indexes | — |`;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const COLLAPSE_GROUPS = [
|
|
334
|
+
{ sortPath: ADR_DIR_PREFIX, isMember: (r) => r.path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(r.path) || r.path === ADR_NAV_PATH), format: formatAdrCollapseRow },
|
|
335
|
+
{ sortPath: SPECS_COLLAPSE.prefix, isMember: (r) => Boolean(r.spec) && r.spec.errors.length === 0, format: formatSpecsCollapseRow },
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
// Pure index renderer — inspected rows + the header date → the exact bytes `docs/ai/index.md` should
|
|
339
|
+
// contain. Shared by `--write-index` (writes it) and `--check-index` (diffs against on-disk).
|
|
335
340
|
export const buildIndex = (rows, todayStr, meta = {}) => {
|
|
336
341
|
const projectName = meta.projectName ?? DEFAULT_PROJECT_NAME;
|
|
337
342
|
const onDemandLinks = meta.onDemandLinks ?? [];
|
|
338
343
|
const hierarchicalLinks = meta.hierarchicalLinks ?? [];
|
|
339
344
|
const header = INDEX_HEADER.replace('__TODAY__', todayStr).replace('__PROJECT__', projectName);
|
|
340
345
|
const tableHeader = `| File | Type | Lines/Max | Updated | Stale after |\n|------|------|-----------|---------|-------------|`;
|
|
341
|
-
const
|
|
342
|
-
const
|
|
346
|
+
const singles = [];
|
|
347
|
+
const grouped = COLLAPSE_GROUPS.map(() => []);
|
|
343
348
|
for (const r of rows) {
|
|
344
349
|
if (r.path === 'docs/ai/index.md') continue;
|
|
345
|
-
(
|
|
350
|
+
const group = COLLAPSE_GROUPS.findIndex((g) => g.isMember(r));
|
|
351
|
+
(group === -1 ? singles : grouped[group]).push(r);
|
|
346
352
|
}
|
|
347
|
-
const tableEntries =
|
|
348
|
-
|
|
353
|
+
const tableEntries = singles.map((r) => ({ sortPath: r.path, md: formatIndexRow(r) }));
|
|
354
|
+
COLLAPSE_GROUPS.forEach((g, i) => {
|
|
355
|
+
if (grouped[i].length > 0) tableEntries.push({ sortPath: g.sortPath, md: g.format(grouped[i]) });
|
|
356
|
+
});
|
|
349
357
|
tableEntries.sort((a, b) => a.sortPath.localeCompare(b.sortPath));
|
|
350
358
|
const tableRows = tableEntries.map((e) => e.md).join('\n');
|
|
351
359
|
const onDemandSection =
|
|
@@ -359,11 +367,8 @@ export const buildIndex = (rows, todayStr, meta = {}) => {
|
|
|
359
367
|
return `${header}${tableHeader}\n${tableRows}${onDemandSection}${hierarchicalSection}\n`;
|
|
360
368
|
};
|
|
361
369
|
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// for the header, so a mere day-rollover (no content change) is NOT flagged —
|
|
365
|
-
// only genuine drift in the file table (added/removed files, changed
|
|
366
|
-
// type/cap/lastUpdated/staleAfter, or a changed line count) makes it stale.
|
|
370
|
+
// Freshness regenerates the index with the on-disk header's OWN `lastUpdated`, so a mere day-rollover
|
|
371
|
+
// is NOT flagged — only genuine drift in the file table makes it stale.
|
|
367
372
|
export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
|
|
368
373
|
if (onDiskText === null || onDiskText === undefined || onDiskText === '') {
|
|
369
374
|
return { fresh: false, expected: buildIndex(rows, 'unknown', meta) };
|
|
@@ -374,13 +379,11 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
|
|
|
374
379
|
return { fresh: expected === onDiskText, expected };
|
|
375
380
|
};
|
|
376
381
|
|
|
377
|
-
// The navigator
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
// the
|
|
381
|
-
//
|
|
382
|
-
// same discipline in atomic-write.mjs; this deployment script ships dependency-free, so the
|
|
383
|
-
// semantics are REIMPLEMENTED here rather than imported.
|
|
382
|
+
// The navigator write lands on the deployment's own file and nowhere else: every component of
|
|
383
|
+
// <root>/docs/ai/index.md is lstat'ed no-follow (a symlink anywhere REFUSES — publishing through one
|
|
384
|
+
// would clobber its target), the body goes out through an exclusive-create temp renamed into place
|
|
385
|
+
// with the chain re-checked before the rename, and the temp never survives a failure. The kit's
|
|
386
|
+
// atomic-write.mjs runs the same discipline; this script ships dependency-free, so it is REIMPLEMENTED.
|
|
384
387
|
export const INDEX_WRITE_REFUSED = 'INDEX_WRITE_REFUSED';
|
|
385
388
|
const refuse = (message) => Object.assign(new Error(message), { code: INDEX_WRITE_REFUSED });
|
|
386
389
|
|
|
@@ -444,11 +447,8 @@ const writeIndex = async (rows, today, meta, { root = ROOT, indexPath = INDEX_PA
|
|
|
444
447
|
}
|
|
445
448
|
};
|
|
446
449
|
|
|
447
|
-
// regenerateIndex(root, todayStr) — the ONE reused generator
|
|
448
|
-
//
|
|
449
|
-
// (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
|
|
450
|
-
// hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
|
|
451
|
-
// index path + row count. No second index implementation exists.
|
|
450
|
+
// regenerateIndex(root, todayStr) — the ONE reused generator: the SAME walk → inspect → discoverMeta →
|
|
451
|
+
// writeIndex pipeline as `--write-index`, against `root`. `todayStr` is 'YYYY-MM-DD' (null → today).
|
|
452
452
|
export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
|
|
453
453
|
const paths = pathsFor(root);
|
|
454
454
|
const today = computeToday(todayStr);
|
|
@@ -460,12 +460,10 @@ export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
|
|
|
460
460
|
return { indexPath: paths.indexPath, files: rows.length };
|
|
461
461
|
};
|
|
462
462
|
|
|
463
|
-
// The finalizer promises
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// whose target happens to hold current bytes would otherwise report `already-current` over a file
|
|
468
|
-
// this mode refuses to write through — an exit 0 proving nothing about the deployment's own file.
|
|
463
|
+
// The finalizer promises EXACTLY ONE outcome line, so every step it owns runs inside one classified
|
|
464
|
+
// error path: an unreadable docs/ai is a NAMED refusal, never a stack trace. The containment guard
|
|
465
|
+
// runs BEFORE the freshness read: a symlinked navigator whose target holds current bytes would
|
|
466
|
+
// otherwise report `already-current` over a file this mode refuses to write through.
|
|
469
467
|
const runEnsureIndex = async ({ root, docsDir, indexPath, today, deps }) => {
|
|
470
468
|
const lstat = deps.lstat ?? lstatSync;
|
|
471
469
|
const read = deps.readFile ?? readFile;
|
|
@@ -571,9 +569,8 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
571
569
|
return result(errorCount > 0 && !flags.report ? 1 : 0);
|
|
572
570
|
};
|
|
573
571
|
|
|
574
|
-
// Run main() only when executed directly, never on import
|
|
575
|
-
// reached through a symlink
|
|
576
|
-
// different and the CLI never runs. realpathSync collapses the link so both sides match.
|
|
572
|
+
// Run main() only when executed directly, never on import — compared by REAL path, so an entry point
|
|
573
|
+
// reached through a symlink still runs.
|
|
577
574
|
const isDirectRun = (() => {
|
|
578
575
|
const invoked = process.argv[1];
|
|
579
576
|
if (!invoked) return false;
|
|
@@ -178,11 +178,7 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
it('a stray adr/ markdown file renders as its OWN visible row (never collapsed/hidden)', () => {
|
|
181
|
-
const rows = [
|
|
182
|
-
makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
183
|
-
makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
|
|
184
|
-
makeRow('docs/ai/adr/notes.md', { frontmatter: { type: 'reference', maxLines: '100' } }),
|
|
185
|
-
];
|
|
181
|
+
const rows = [makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }), makeRow('docs/ai/adr/notes.md', { frontmatter: { type: 'reference', maxLines: '100' } })];
|
|
186
182
|
const out = buildIndex(rows, '2026-07-09');
|
|
187
183
|
expect(out).toMatch(/adr\/notes\.md/); // the stray is a visible row, not swallowed by the collapse
|
|
188
184
|
const aggregateRows = out.split('\n').filter((l) => l.includes('](./adr/log.md)'));
|
|
@@ -190,11 +186,7 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
|
|
|
190
186
|
});
|
|
191
187
|
|
|
192
188
|
it('the aggregate row shows the record count and a NUMERIC id range (AD-200 … AD-1000)', () => {
|
|
193
|
-
const rows = [
|
|
194
|
-
makeRow('docs/ai/adr/AD-200-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
195
|
-
makeRow('docs/ai/adr/AD-1000-b.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
196
|
-
makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
|
|
197
|
-
];
|
|
189
|
+
const rows = [makeRow('docs/ai/adr/AD-200-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/AD-1000-b.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } })];
|
|
198
190
|
const out = buildIndex(rows, '2026-07-09');
|
|
199
191
|
expect(out).toMatch(/\[`adr\/`\]\(\.\/adr\/log\.md\) \| adr \| 2 records \| AD-200 … AD-1000/);
|
|
200
192
|
});
|
|
@@ -219,13 +211,70 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
|
|
|
219
211
|
});
|
|
220
212
|
});
|
|
221
213
|
|
|
214
|
+
// The spec store (docs/ai/specs/**) joins the collapse as a second GROUP: every file whose reader
|
|
215
|
+
// verdict is clean folds into ONE counted `specs/` row; a file the reader refuses keeps its own row.
|
|
216
|
+
const SPEC_FRONT = (kind, maxLines, extra = '') =>
|
|
217
|
+
`---\ntype: spec\nlastUpdated: 2026-08-23\nscope: permanent\nstaleAfter: 90d\nowner: none\nmaxLines: ${maxLines}\nkind: ${kind}\n${extra}---\n`;
|
|
218
|
+
const specBody = (slug) =>
|
|
219
|
+
`\n# Spec: ${slug}\n\n## Contract\n\nc\n\n## Scenarios\n\n- S1 a :: test/${slug}.test.mjs :: spec:${slug}/S1\n\n## Out of scope\n\n- b\n\n## Module\n\n- src/${slug}/\n`;
|
|
220
|
+
const specRow = (path, kind, errors = []) => makeRow(path, { frontmatter: { type: 'spec', maxLines: '150' }, spec: { kind, status: 'draft', revision: 1, errors, warnings: [] } });
|
|
221
|
+
const SPECS_ROW_RE = /\| \[`specs\/`\]\(\.\/specs\/index\.md\) \| spec \| (\d+) specs \| (\d+) parts · (\d+) indexes \| — \|/;
|
|
222
|
+
|
|
223
|
+
describe('buildIndex — docs/ai/specs/ store collapse (spec layer 1a)', () => {
|
|
224
|
+
it('1000 valid specs + their indexes render ONE counted specs/ row; the index stays <= 80 lines', () => {
|
|
225
|
+
const specs = Array.from({ length: 1000 }, (_, i) => specRow(`docs/ai/specs/d${Math.floor(i / 30)}/s${i}.md`, 'spec'));
|
|
226
|
+
const indexes = Array.from({ length: 34 }, (_, i) => specRow(`docs/ai/specs/d${i}/index.md`, 'index'));
|
|
227
|
+
const rows = [makeRow('docs/ai/handover.md'), specRow('docs/ai/specs/index.md', 'index'), ...indexes, ...specs];
|
|
228
|
+
const out = buildIndex(rows, '2026-08-23');
|
|
229
|
+
const lines = out.split('\n');
|
|
230
|
+
expect(lines.filter((l) => l.includes('](./specs/index.md)')).length).toBe(1);
|
|
231
|
+
expect(out).not.toMatch(/s999\.md/);
|
|
232
|
+
expect(lines.length <= 80).toBe(true);
|
|
233
|
+
expect(out.match(SPECS_ROW_RE).slice(1)).toEqual(['1000', '0', '35']);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('the counted row carries live counts per kind; a malformed file under specs/ keeps its OWN visible row beside it', () => {
|
|
237
|
+
const rows = [specRow('docs/ai/specs/index.md', 'index'), specRow('docs/ai/specs/a/index.md', 'spec'), specRow('docs/ai/specs/a/p.md', 'part'), specRow('docs/ai/specs/b.md', 'spec'), specRow('docs/ai/specs/broken.md', null, [{ rule: 'kind', message: 'x' }])];
|
|
238
|
+
const out = buildIndex(rows, '2026-08-23');
|
|
239
|
+
expect(out).toMatch(/specs\/broken\.md/);
|
|
240
|
+
expect(out.match(SPECS_ROW_RE).slice(1)).toEqual(['2', '1', '1']);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('adding or removing one valid spec drifts the counted row -> the on-disk index is stale', () => {
|
|
244
|
+
const base = [specRow('docs/ai/specs/index.md', 'index'), specRow('docs/ai/specs/a.md', 'spec')];
|
|
245
|
+
const onDisk = buildIndex(base, '2026-08-23');
|
|
246
|
+
expect(checkIndexFreshness([...base, specRow('docs/ai/specs/b.md', 'spec')], onDisk).fresh).toBe(false);
|
|
247
|
+
expect(checkIndexFreshness(base.slice(0, 1), onDisk).fresh).toBe(false);
|
|
248
|
+
expect(checkIndexFreshness(base, onDisk).fresh).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('inspectFile reads a file under docs/ai/specs/ through the reader: clean -> spec verdict, malformed -> advisory warnings only', async () => {
|
|
252
|
+
const root = await mkdtemp(join(tmpdir(), 'specs-inspect-'));
|
|
253
|
+
try {
|
|
254
|
+
await mkdir(join(root, 'docs', 'ai', 'specs'), { recursive: true });
|
|
255
|
+
const clean = join(root, 'docs', 'ai', 'specs', 'login.md');
|
|
256
|
+
await writeFile(clean, `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
|
|
257
|
+
const ok = await inspectFile(clean, computeToday('2026-08-23'), root);
|
|
258
|
+
expect(ok.errors).toEqual([]);
|
|
259
|
+
expect(ok.spec.kind).toBe('spec');
|
|
260
|
+
expect(ok.spec.errors).toEqual([]);
|
|
261
|
+
const malformed = join(root, 'docs', 'ai', 'specs', 'broken.md');
|
|
262
|
+
await writeFile(malformed, `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
|
|
263
|
+
const bad = await inspectFile(malformed, computeToday('2026-08-23'), root);
|
|
264
|
+
expect(bad.errors).toEqual([]);
|
|
265
|
+
expect(bad.spec.errors.map((e) => e.rule)).toEqual(['scenario-marker']);
|
|
266
|
+
expect(bad.warnings.some((w) => /spec scenario-marker/.test(w))).toBe(true);
|
|
267
|
+
await writeFile(join(root, 'docs', 'ai', 'handover.md'), `${SPEC_FRONT('spec', 150)}\n# outside the store\n`);
|
|
268
|
+
expect((await inspectFile(join(root, 'docs', 'ai', 'handover.md'), computeToday('2026-08-23'), root)).spec).toBe(null);
|
|
269
|
+
} finally {
|
|
270
|
+
await rm(root, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
222
275
|
describe('buildIndex', () => {
|
|
223
276
|
it('is deterministic, sorts rows by path, and excludes index.md itself', () => {
|
|
224
|
-
const rows = [
|
|
225
|
-
makeRow('docs/ai/index.md'),
|
|
226
|
-
makeRow('docs/ai/b.md'),
|
|
227
|
-
makeRow('docs/ai/a.md'),
|
|
228
|
-
];
|
|
277
|
+
const rows = [makeRow('docs/ai/index.md'), makeRow('docs/ai/b.md'), makeRow('docs/ai/a.md')];
|
|
229
278
|
const out = buildIndex(rows, '2026-05-29');
|
|
230
279
|
expect(out).toBe(buildIndex(rows, '2026-05-29')); // deterministic
|
|
231
280
|
expect(out).not.toMatch(/\[`index\.md`\]/); // index.md row excluded
|
|
@@ -328,4 +377,19 @@ describe('root parameterization (item (h))', () => {
|
|
|
328
377
|
expect(stale.status).toBe(1);
|
|
329
378
|
expect(stale.stderr).toMatch(/stale/);
|
|
330
379
|
});
|
|
380
|
+
|
|
381
|
+
it('the hook path over a real specs/ store: ONE counted row in the written index, a new spec makes --check-index exit 1', async () => {
|
|
382
|
+
const store = join(root, 'docs', 'ai', 'specs');
|
|
383
|
+
await mkdir(store, { recursive: true });
|
|
384
|
+
await writeFile(join(store, 'index.md'), `${SPEC_FRONT('index', 80)}\n# Specs\n\n> Up: [technical_specification.md](../technical_specification.md)\n\n## Children\n\n- [login](./login.md)\n`);
|
|
385
|
+
await writeFile(join(store, 'login.md'), `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
|
|
386
|
+
expect(runCli(['--write-index', `--root=${root}`]).status).toBe(0);
|
|
387
|
+
const index = await readFile(join(root, 'docs', 'ai', 'index.md'), 'utf8');
|
|
388
|
+
expect(index.match(SPECS_ROW_RE).slice(1)).toEqual(['1', '0', '1']);
|
|
389
|
+
expect(index).not.toMatch(/specs\/login\.md/);
|
|
390
|
+
expect(runCli(['--check-index', `--root=${root}`]).status).toBe(0);
|
|
391
|
+
await writeFile(join(store, 'signup.md'), `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('signup')}`);
|
|
392
|
+
expect(runCli(['--check-index', `--root=${root}`]).status).toBe(1);
|
|
393
|
+
expect(runCli([`--root=${root}`]).status).toBe(0);
|
|
394
|
+
});
|
|
331
395
|
});
|