@sabaiway/agent-workflow-kit 5.8.0 → 5.9.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 +35 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +6 -2
- package/references/modes/upgrade.md +8 -5
- package/references/scripts/check-docs-size-cli.test.mjs +5 -4
- package/references/scripts/check-docs-size-ensure.test.mjs +332 -0
- package/references/scripts/check-docs-size.mjs +181 -30
- package/references/shared/composition-handoff.md +10 -0
- package/tools/doc-parity.mjs +5 -1
- package/tools/ensure-configs.mjs +33 -15
- package/tools/ensure-ops.mjs +79 -1
- package/tools/ensure-vocabulary.mjs +17 -3
- package/tools/known-footprint.mjs +10 -0
- package/tools/upgrade-runlist.mjs +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ 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
|
+
## 5.9.0 — the always-loaded navigator becomes something every deploy path actually creates (AD-096; memory 4.4.0)
|
|
8
|
+
|
|
9
|
+
**A fresh deployment's entry point declared `docs/ai/index.md` always-loaded, and no step ever
|
|
10
|
+
created it.** The navigator is generated, not templated, and nothing in bootstrap ran the generator:
|
|
11
|
+
a Node project was born with a pre-commit hook failing its own index check, and a project without
|
|
12
|
+
Node stayed quietly broken, its entry point pointing at a file nothing writes. The kit's own
|
|
13
|
+
fallback bootstrap carried the same gap. Every deploy and upgrade path now finishes by materializing
|
|
14
|
+
it, and two new guards make "the entry point only points at things a deploy creates" a test rather
|
|
15
|
+
than an intention.
|
|
16
|
+
|
|
17
|
+
- **A fifth project-configuration ensure: `index`.** `ensure-configs --reconcile` now regenerates
|
|
18
|
+
`docs/ai/index.md` whenever it is missing or stale, so an existing deployment gains its navigator
|
|
19
|
+
without a lineage bump — exactly like the config seeds. It never skips a No-Node project: the
|
|
20
|
+
generator runs from the kit's own bundled copy, not the project's `scripts/`.
|
|
21
|
+
- **The LATE run is the authoritative one.** The agent-rules lens rewrites `docs/ai` after the
|
|
22
|
+
step-3 reconcile, so `upgrade.md` documents a late `--reconcile --only index` rung at BOTH exits —
|
|
23
|
+
after the lens block on the equal-head exit, at the end of step 7 on the migrated one — and the
|
|
24
|
+
step-4/8 reports relay THAT line.
|
|
25
|
+
- **`ensure-configs --only <op>`** runs exactly one ensure. An unknown, missing or repeated
|
|
26
|
+
selector is a usage error with zero writes: a run that cannot be narrowed as asked must not widen.
|
|
27
|
+
- **Failure says how far it got.** The index op classifies `generator-unlaunchable` (nothing ran),
|
|
28
|
+
`generator-failed`, `index-probe-failed` and `index-stale-after-write`; every cause that can only
|
|
29
|
+
arise after the generator ran DISCLOSES that a write may already have landed, and a claimed
|
|
30
|
+
regeneration is verified by a re-probe that must ANSWER — an exit code alone would turn a failed
|
|
31
|
+
probe into a false "still stale". Every relayed cause is now doc-parity-bound into `upgrade.md`.
|
|
32
|
+
- **The bundled generator's write is contained and atomic** (mirrored from the memory canon): a
|
|
33
|
+
symlink at any level of `docs/ai/index.md` refuses, the body is published through an
|
|
34
|
+
exclusive-create temp + rename, and the containment guard runs BEFORE the freshness read — a
|
|
35
|
+
symlink whose target happens to hold current bytes is refused, not reported "already current".
|
|
36
|
+
- **Hidden mode now names the whole payload.** `KIT_OWN_PATHS` listed 14 of the 21 files a deploy
|
|
37
|
+
copies into `scripts/`, so six shipped scripts besides the new one stayed visible in a hidden
|
|
38
|
+
project's `git status`. The registry is complete (20 → 27) and a new test derives the expected set
|
|
39
|
+
from the deploy payload itself. Known residual: a registered path is still hidden by NAME rather
|
|
40
|
+
than proven ownership (`known_issues.md` → Issue-018).
|
|
41
|
+
|
|
7
42
|
## 5.8.0 — the bundled agy bridge speaks the catalog the installed CLI actually serves, and grounding stops needing a shell (AD-095; antigravity-cli-bridge 5.2.0)
|
|
8
43
|
|
|
9
44
|
**A model display string is matched exactly, so a stale one is not a documentation bug — it is a
|
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: '5.
|
|
6
|
+
version: '5.9.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": "5.
|
|
3
|
+
"version": "5.9.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",
|
|
@@ -22,7 +22,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
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
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 each non-`AGENTS.md`, non-`adr-record.md` template — `adr-record.md` is a skill-home ADR authoring reference, 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 `.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
|
-
7. **Fill templates** per the table below.
|
|
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).
|
|
28
28
|
- **visible** — wire the `package.json` scripts + add the minimal `.gitignore`. **Do not run the hide tool.**
|
|
@@ -32,7 +32,11 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
32
32
|
- If it reports a **leftover machine-wide ignore block** from an older deployment, **ask before removing it** — it could affect another of the user's repos that relies on the same machine-wide rules; on a yes, re-run with `--remove-global` (prints a restorable backup). Otherwise it is kept (harmless — the project-local rules win).
|
|
33
33
|
- Report the result plainly (what is now hidden). **No Node on the agent host** → write the one managed block into `.git/info/exclude` by hand from the contract's path list, and report the manual step. **Windows is supported.**
|
|
34
34
|
- **Do not edit `package.json`** in hidden mode — a tracked change leaks the whole system.
|
|
35
|
-
10. **Stamp the deployment lineage.**
|
|
35
|
+
10. **Stamp the deployment lineage.** **First re-run the step-7 navigator finalizer**
|
|
36
|
+
(`--ensure-index`) whenever a later step touched `docs/ai` — the reconciles that follow the
|
|
37
|
+
template copy do (the agent-rules lens,
|
|
38
|
+
`${CLAUDE_SKILL_DIR}/references/shared/composition-handoff.md`); it is idempotent, so an
|
|
39
|
+
untouched tree simply reports `already-current`. Then write the **deployment-lineage head** into
|
|
36
40
|
`docs/ai/.workflow-version` (one semver line). The lineage head is **`3.0.0`** — the shared
|
|
37
41
|
`agent-workflow` deployment lineage, **NOT** this kit's npm package version (see
|
|
38
42
|
`package.json` / `CHANGELOG.md`). The two are
|
|
@@ -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` / `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` / `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 five** ensures described below — orchestration config · gate declaration · autonomy declaration · enforcement scripts · 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 two refresh-class ops are named apart — the orchestration onboarding note (refreshed only while it still matches a canonical this kit shipped) 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`; 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 five reach an equal-head deployment **without a lineage-head bump or a migration file** (they are `.json` / `scripts/` / a generated artifact, 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
|
|
|
@@ -40,6 +40,8 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
40
40
|
|
|
41
41
|
**What the enforcement-script ensure does.** A deployment older than the ADR-cascade feature has no `scripts/archive-decisions.mjs`, and an equal-head exit would otherwise never deliver it. The pairs must exist in the project's `scripts/`: `archive-decisions.mjs` + `archive-decisions.test.mjs` and `markdown-blocks.mjs` + `markdown-blocks.test.mjs`, copied from `${CLAUDE_SKILL_DIR}/references/scripts/` if missing; **an existing file is preserved, never overwritten** (drift repair belongs to a lineage migration). Nothing else is seeded — the other tokenizer-era tests red beside OLD archivers. **OLD ADR-store layout — DETECTED FIRST, never auto-migrated (AD-051, Decision 13):** on a `docs/ai/history/decisions-archive*.md` monolith (`old`), or no monolith but a deployed rotator predating the store (`old-unrotated`), the project is on the RETIRED 3-tier cascade, so the ensure writes NOTHING and reports `old-adr-layout-migration-instructed` (the new `archive-decisions.mjs` beside un-migrated monoliths would red their ADR gate). Relay it as the LOUD instruct it is: the fix is the opt-in **`/agent-workflow-kit migrate-adr-store`** (consent-gated; previews first, never commits), and the seed lands on the next upgrade. A layout the ensure cannot READ is `failed`, not a seed — it never writes on an unverifiable tree. The seed applies ONLY to a clean layout (neither signal), and a project with no `package.json` at its root reports `skipped-no-node` (these are Node enforcement scripts) while the three config ensures still run. The deployed pre-commit hook gains the `archive-decisions.mjs --check` line only when the hook itself is next refreshed (re-run `node scripts/install-git-hooks.mjs` after the ensure and it will refuse a non-marker hook as always); an OLD hook without the line stays consistent-safe — the decisions gate is simply not enforced yet, never a broken hook.
|
|
42
42
|
|
|
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
|
+
|
|
43
45
|
**`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).
|
|
44
46
|
|
|
45
47
|
**`bridges` — placed-bridge refresh — stamp-independent, same gate, BEFORE the equal-head short-circuit.** Run
|
|
@@ -82,8 +84,9 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
82
84
|
verbatim**: it validates the deployed host settings file's keys against the bundled manifests and
|
|
83
85
|
**NEVER writes** it (the file lives outside every kit tree — D2), so an unknown/retired key is
|
|
84
86
|
flagged + preserved, never edited. Runs on **every** upgrade; exit 0 covers every outcome.
|
|
87
|
+
**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.
|
|
85
88
|
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:
|
|
86
|
-
- **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 **
|
|
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 **five project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (orchestration config, gate declaration, autonomy declaration, enforcement scripts, 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`).
|
|
87
90
|
- **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*.
|
|
88
91
|
- **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).
|
|
89
92
|
- **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`.
|
|
@@ -91,5 +94,5 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
91
94
|
- **Then ask before committing — never auto-commit.** If step 3 added the slot (or anything else changed), report it and ask. If step 3 was a pure zero-diff no-op and nothing else changed, give the plain **settings already current — no update needed** message (the *Success state* contract in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`) and still print the read-only version block (installed package versions) + backend line — but **no `docs/ai` structure version and no two-axes note** (nothing changed, so the number is inert here).
|
|
92
95
|
5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
|
|
93
96
|
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.
|
|
94
|
-
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.)
|
|
95
|
-
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 **
|
|
97
|
+
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 **five 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.
|
|
@@ -28,12 +28,13 @@ describe('check-docs-size runCli — refusal branches', () => {
|
|
|
28
28
|
join(root, 'docs', 'ai', 'a.md'),
|
|
29
29
|
'---\ntype: state\nlastUpdated: 2026-07-18\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# a\n',
|
|
30
30
|
);
|
|
31
|
-
// The index path is a symlink into /dev/null: the write
|
|
32
|
-
// the
|
|
33
|
-
|
|
31
|
+
// The index path is a symlink into /dev/null: the contained write refuses to publish THROUGH
|
|
32
|
+
// the link (it would clobber the link target), names the path, and writes nothing.
|
|
33
|
+
const indexPath = join(root, 'docs', 'ai', 'index.md');
|
|
34
|
+
symlinkSync('/dev/null', indexPath);
|
|
34
35
|
const { code, stderr } = await cli(['--write-index', `--root=${root}`]);
|
|
35
36
|
assert.equal(code, 2);
|
|
36
|
-
assert.match(stderr,
|
|
37
|
+
assert.match(stderr, new RegExp(`${indexPath} is a symlink`));
|
|
37
38
|
} finally {
|
|
38
39
|
rmSync(root, { recursive: true, force: true });
|
|
39
40
|
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// check-docs-size-ensure.test.mjs — the navigator WRITE contract (containment + atomic publication)
|
|
2
|
+
// and the idempotent `--ensure-index` finalizer mode. A separate file because the main spec pair is
|
|
3
|
+
// suite-parity-frozen; single responsibility: what the write refuses, and what the mode reports.
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, symlinkSync, rmSync, existsSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { runCli } from './check-docs-size.mjs';
|
|
10
|
+
|
|
11
|
+
const DOC = (title) =>
|
|
12
|
+
`---\ntype: state\nlastUpdated: 2026-08-15\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# ${title}\n`;
|
|
13
|
+
|
|
14
|
+
const makeTree = (prefix) => {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
16
|
+
mkdirSync(join(dir, 'docs', 'ai'), { recursive: true });
|
|
17
|
+
writeFileSync(join(dir, 'docs', 'ai', 'a.md'), DOC('a'));
|
|
18
|
+
return dir;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const drop = (...dirs) => {
|
|
22
|
+
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const tempFiles = (docsAi) => readdirSync(docsAi).filter((name) => name.endsWith('.tmp'));
|
|
26
|
+
|
|
27
|
+
describe('check-docs-size navigator write — containment', () => {
|
|
28
|
+
it('refuses a symlinked index.md, exit 2, naming the path', async () => {
|
|
29
|
+
const root = makeTree('cds-leaf-');
|
|
30
|
+
const target = mkdtempSync(join(tmpdir(), 'cds-leaf-target-'));
|
|
31
|
+
try {
|
|
32
|
+
const indexPath = join(root, 'docs', 'ai', 'index.md');
|
|
33
|
+
symlinkSync(join(target, 'sink.md'), indexPath);
|
|
34
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${root}`]);
|
|
35
|
+
assert.equal(code, 2);
|
|
36
|
+
assert.match(stderr, /symlink/);
|
|
37
|
+
assert.ok(stderr.includes(indexPath), `refusal must name ${indexPath}, got: ${stderr}`);
|
|
38
|
+
} finally {
|
|
39
|
+
drop(root, target);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('refuses a symlinked docs/ai, exit 2, naming the path', async () => {
|
|
44
|
+
const root = mkdtempSync(join(tmpdir(), 'cds-docsai-'));
|
|
45
|
+
const real = mkdtempSync(join(tmpdir(), 'cds-docsai-real-'));
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(join(root, 'docs'), { recursive: true });
|
|
48
|
+
writeFileSync(join(real, 'a.md'), DOC('a'));
|
|
49
|
+
symlinkSync(real, join(root, 'docs', 'ai'));
|
|
50
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${root}`]);
|
|
51
|
+
assert.equal(code, 2);
|
|
52
|
+
assert.ok(stderr.includes(join(root, 'docs', 'ai')), `refusal must name the symlinked dir, got: ${stderr}`);
|
|
53
|
+
} finally {
|
|
54
|
+
drop(root, real);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('refuses a symlinked docs, exit 2, naming the path', async () => {
|
|
59
|
+
const root = mkdtempSync(join(tmpdir(), 'cds-docs-'));
|
|
60
|
+
const real = mkdtempSync(join(tmpdir(), 'cds-docs-real-'));
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(join(real, 'ai'), { recursive: true });
|
|
63
|
+
writeFileSync(join(real, 'ai', 'a.md'), DOC('a'));
|
|
64
|
+
symlinkSync(real, join(root, 'docs'));
|
|
65
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${root}`]);
|
|
66
|
+
assert.equal(code, 2);
|
|
67
|
+
assert.ok(stderr.includes(join(root, 'docs')), `refusal must name the symlinked dir, got: ${stderr}`);
|
|
68
|
+
} finally {
|
|
69
|
+
drop(root, real);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('refuses a symlinked project root, exit 2, naming the path', async () => {
|
|
74
|
+
const real = makeTree('cds-root-real-');
|
|
75
|
+
const host = mkdtempSync(join(tmpdir(), 'cds-root-host-'));
|
|
76
|
+
const link = join(host, 'project');
|
|
77
|
+
try {
|
|
78
|
+
symlinkSync(real, link);
|
|
79
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${link}`]);
|
|
80
|
+
assert.equal(code, 2);
|
|
81
|
+
assert.ok(stderr.includes(link), `refusal must name the symlinked root, got: ${stderr}`);
|
|
82
|
+
} finally {
|
|
83
|
+
drop(real, host);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('writes an absent index rather than refusing it', async () => {
|
|
88
|
+
const root = makeTree('cds-absent-');
|
|
89
|
+
try {
|
|
90
|
+
const { code } = await runCli(['--write-index', `--root=${root}`]);
|
|
91
|
+
assert.equal(code, 0);
|
|
92
|
+
assert.ok(existsSync(join(root, 'docs', 'ai', 'index.md')));
|
|
93
|
+
} finally {
|
|
94
|
+
drop(root);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The temp name is exclusive-create for a reason: a collision means the name is SOMEONE ELSE's
|
|
99
|
+
// file, which this run never wrote and must never delete.
|
|
100
|
+
it('never removes a temp name it lost the exclusive-create race for', async () => {
|
|
101
|
+
const root = makeTree('cds-collision-');
|
|
102
|
+
const removed = [];
|
|
103
|
+
try {
|
|
104
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${root}`], {
|
|
105
|
+
writeFile: () => {
|
|
106
|
+
throw Object.assign(new Error('EEXIST: file already exists'), { code: 'EEXIST' });
|
|
107
|
+
},
|
|
108
|
+
rm: (target) => { removed.push(target); },
|
|
109
|
+
});
|
|
110
|
+
assert.equal(code, 2);
|
|
111
|
+
assert.match(stderr, /EEXIST/);
|
|
112
|
+
assert.deepEqual(removed, [], 'a collision is not this run\'s file to discard');
|
|
113
|
+
} finally {
|
|
114
|
+
drop(root);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('discards the temp when the write itself dies mid-flight', async () => {
|
|
119
|
+
const root = makeTree('cds-partial-');
|
|
120
|
+
const removed = [];
|
|
121
|
+
try {
|
|
122
|
+
const { code } = await runCli(['--write-index', `--root=${root}`], {
|
|
123
|
+
writeFile: () => {
|
|
124
|
+
throw Object.assign(new Error('EIO: i/o error'), { code: 'EIO' });
|
|
125
|
+
},
|
|
126
|
+
rm: (target) => { removed.push(target); },
|
|
127
|
+
});
|
|
128
|
+
assert.equal(code, 2);
|
|
129
|
+
assert.equal(removed.length, 1, 'the partially-created temp is the run\'s own to discard');
|
|
130
|
+
assert.match(removed[0], /\.tmp$/);
|
|
131
|
+
} finally {
|
|
132
|
+
drop(root);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('names the temp it could not remove instead of swallowing the cleanup failure', async () => {
|
|
137
|
+
const root = makeTree('cds-cleanup-');
|
|
138
|
+
try {
|
|
139
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${root}`], {
|
|
140
|
+
rename: () => {
|
|
141
|
+
throw Object.assign(new Error('EXDEV: cross-device link'), { code: 'EXDEV' });
|
|
142
|
+
},
|
|
143
|
+
rm: () => {
|
|
144
|
+
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
assert.equal(code, 2);
|
|
148
|
+
assert.match(stderr, /could not be removed/);
|
|
149
|
+
assert.match(stderr, /\.tmp/);
|
|
150
|
+
} finally {
|
|
151
|
+
drop(root);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('leaves no temp file and no index behind when the publication fails', async () => {
|
|
156
|
+
const root = makeTree('cds-tmp-');
|
|
157
|
+
try {
|
|
158
|
+
const { code } = await runCli(['--write-index', `--root=${root}`], {
|
|
159
|
+
rename: () => {
|
|
160
|
+
throw Object.assign(new Error('injected publication failure'), { code: 'EIO' });
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
assert.equal(code, 2);
|
|
164
|
+
assert.deepEqual(tempFiles(join(root, 'docs', 'ai')), []);
|
|
165
|
+
assert.equal(existsSync(join(root, 'docs', 'ai', 'index.md')), false);
|
|
166
|
+
} finally {
|
|
167
|
+
drop(root);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('check-docs-size --ensure-index', () => {
|
|
173
|
+
it('reports regenerated and materializes the navigator on a fresh tree', async () => {
|
|
174
|
+
const root = makeTree('cds-ensure-fresh-');
|
|
175
|
+
try {
|
|
176
|
+
const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
177
|
+
assert.equal(code, 0);
|
|
178
|
+
assert.match(stdout, /ensure-index: regenerated/);
|
|
179
|
+
const { code: checkCode } = await runCli(['--check-index', `--root=${root}`]);
|
|
180
|
+
assert.equal(checkCode, 0);
|
|
181
|
+
} finally {
|
|
182
|
+
drop(root);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('reports already-current on a second run and leaves the bytes identical', async () => {
|
|
187
|
+
const root = makeTree('cds-ensure-idem-');
|
|
188
|
+
try {
|
|
189
|
+
await runCli(['--ensure-index', `--root=${root}`]);
|
|
190
|
+
const first = readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8');
|
|
191
|
+
const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`, '--today=2027-01-01']);
|
|
192
|
+
assert.equal(code, 0);
|
|
193
|
+
assert.match(stdout, /ensure-index: already-current/);
|
|
194
|
+
assert.equal(readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8'), first);
|
|
195
|
+
} finally {
|
|
196
|
+
drop(root);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// A symlink whose TARGET happens to hold the current bytes reads as fresh through the link — so a
|
|
201
|
+
// freshness-first finalizer would report `already-current` over a file it would never write
|
|
202
|
+
// through: an exit 0 that proves nothing about the deployment's own navigator.
|
|
203
|
+
it('refuses a symlinked navigator even when its target holds the CURRENT bytes', async () => {
|
|
204
|
+
const root = makeTree('cds-ensure-symlink-fresh-');
|
|
205
|
+
try {
|
|
206
|
+
const indexPath = join(root, 'docs', 'ai', 'index.md');
|
|
207
|
+
await runCli(['--ensure-index', `--root=${root}`]);
|
|
208
|
+
const current = readFileSync(indexPath, 'utf8');
|
|
209
|
+
rmSync(indexPath);
|
|
210
|
+
const target = join(root, 'elsewhere-index.md');
|
|
211
|
+
writeFileSync(target, current);
|
|
212
|
+
symlinkSync(target, indexPath);
|
|
213
|
+
|
|
214
|
+
const { code, stdout, stderr } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
215
|
+
assert.equal(code, 2, `a symlinked navigator must be refused, got: ${stdout}${stderr}`);
|
|
216
|
+
assert.match(stderr, /ensure-index: write-refused/);
|
|
217
|
+
assert.equal(readFileSync(target, 'utf8'), current, 'the link target is untouched');
|
|
218
|
+
} finally {
|
|
219
|
+
drop(root);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// The two refusals name STAGES: a reader told the PROBE failed will go looking at the tree, while
|
|
224
|
+
// a write that died may have left the navigator half-published.
|
|
225
|
+
it('reports a WRITE failure as write-refused, never as a failed probe', async () => {
|
|
226
|
+
const root = makeTree('cds-write-stage-');
|
|
227
|
+
try {
|
|
228
|
+
const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`], {
|
|
229
|
+
writeFile: () => {
|
|
230
|
+
throw Object.assign(new Error('EIO: i/o error'), { code: 'EIO' });
|
|
231
|
+
},
|
|
232
|
+
rm: () => {},
|
|
233
|
+
});
|
|
234
|
+
assert.equal(code, 2);
|
|
235
|
+
assert.match(stderr, /ensure-index: write-refused/);
|
|
236
|
+
assert.match(stderr, /EIO/);
|
|
237
|
+
} finally {
|
|
238
|
+
drop(root);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// "Could not read" is not "nothing there": a finalizer that swallowed the difference would publish
|
|
243
|
+
// a navigator missing whatever it failed to see, and report success over it.
|
|
244
|
+
it('refuses a tree it cannot fully read instead of writing an incomplete navigator', async () => {
|
|
245
|
+
const root = makeTree('cds-strict-meta-');
|
|
246
|
+
try {
|
|
247
|
+
const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`], {
|
|
248
|
+
readdir: () => {
|
|
249
|
+
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
assert.equal(code, 2);
|
|
253
|
+
assert.match(stderr, /ensure-index: probe-failed/);
|
|
254
|
+
assert.match(stderr, /EACCES/);
|
|
255
|
+
assert.equal(existsSync(join(root, 'docs', 'ai', 'index.md')), false, 'nothing written over a tree it could not read');
|
|
256
|
+
} finally {
|
|
257
|
+
drop(root);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// "Unknown" must not read as "empty" either: a reader that throws WITHOUT an fs code is still not
|
|
262
|
+
// evidence that the tree is absent, so the finalizer refuses rather than publishing a short index.
|
|
263
|
+
it('refuses a code-less read failure too, instead of treating it as an absence', async () => {
|
|
264
|
+
const root = makeTree('cds-codeless-');
|
|
265
|
+
try {
|
|
266
|
+
const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`], {
|
|
267
|
+
readdir: () => {
|
|
268
|
+
throw new Error('the injected reader failed without an fs code');
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
assert.equal(code, 2);
|
|
272
|
+
assert.match(stderr, /ensure-index: probe-failed/);
|
|
273
|
+
assert.equal(existsSync(join(root, 'docs', 'ai', 'index.md')), false);
|
|
274
|
+
} finally {
|
|
275
|
+
drop(root);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('a MALFORMED package.json stays the documented basename fallback, never a refusal', async () => {
|
|
280
|
+
const root = makeTree('cds-badpkg-');
|
|
281
|
+
try {
|
|
282
|
+
writeFileSync(join(root, 'package.json'), '{ not json');
|
|
283
|
+
const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
284
|
+
assert.equal(code, 0, 'authored content that is malformed is not an unreadable tree');
|
|
285
|
+
assert.match(stdout, /ensure-index: regenerated/);
|
|
286
|
+
} finally {
|
|
287
|
+
drop(root);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('closes with ONE named probe-failed line when the tree cannot be read', async () => {
|
|
292
|
+
const root = mkdtempSync(join(tmpdir(), 'cds-ensure-nodocs-'));
|
|
293
|
+
try {
|
|
294
|
+
const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
295
|
+
assert.equal(code, 2);
|
|
296
|
+
assert.match(stderr, /ensure-index: probe-failed/);
|
|
297
|
+
assert.equal(/at .*check-docs-size/.test(stderr), false, 'a named refusal, never a stack trace');
|
|
298
|
+
} finally {
|
|
299
|
+
drop(root);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('reports a named write-refused, exit 2, when the write cannot be published', async () => {
|
|
304
|
+
const root = makeTree('cds-ensure-refused-');
|
|
305
|
+
const target = mkdtempSync(join(tmpdir(), 'cds-ensure-refused-target-'));
|
|
306
|
+
try {
|
|
307
|
+
const indexPath = join(root, 'docs', 'ai', 'index.md');
|
|
308
|
+
symlinkSync(join(target, 'sink.md'), indexPath);
|
|
309
|
+
const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
310
|
+
assert.equal(code, 2);
|
|
311
|
+
assert.match(stderr, /ensure-index: write-refused/);
|
|
312
|
+
assert.ok(stderr.includes(indexPath), `the refusal must name ${indexPath}, got: ${stderr}`);
|
|
313
|
+
} finally {
|
|
314
|
+
drop(root, target);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('reports regenerated when the on-disk navigator went stale', async () => {
|
|
319
|
+
const root = makeTree('cds-ensure-stale-');
|
|
320
|
+
try {
|
|
321
|
+
await runCli(['--ensure-index', `--root=${root}`]);
|
|
322
|
+
writeFileSync(join(root, 'docs', 'ai', 'b.md'), DOC('b'));
|
|
323
|
+
const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
324
|
+
assert.equal(code, 0);
|
|
325
|
+
assert.match(stdout, /ensure-index: regenerated/);
|
|
326
|
+
const { code: checkCode } = await runCli(['--check-index', `--root=${root}`]);
|
|
327
|
+
assert.equal(checkCode, 0);
|
|
328
|
+
} finally {
|
|
329
|
+
drop(root);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
// --check-index verify docs/ai/index.md is in sync with source frontmatter;
|
|
13
13
|
// exit 1 (and print how to fix) if stale. Catches the silent
|
|
14
14
|
// drift `--write-index` is supposed to prevent.
|
|
15
|
+
// --ensure-index the idempotent finalizer every deploy/upgrade path runs after its last
|
|
16
|
+
// docs/ai mutation: probe, write only when the navigator is missing or stale,
|
|
17
|
+
// print ONE outcome line (`ensure-index: regenerated|already-current` on
|
|
18
|
+
// stdout; `ensure-index: write-refused|probe-failed — <path>: …` on stderr).
|
|
19
|
+
// Exit 0 on either written state, 2 on a named refusal — never a stack trace.
|
|
15
20
|
//
|
|
16
21
|
// CLI overrides:
|
|
17
22
|
// --today=YYYY-MM-DD (default today UTC) — useful for tests / reproducible runs
|
|
@@ -19,10 +24,11 @@
|
|
|
19
24
|
// hook passes it so a rotation regenerates the right project's index
|
|
20
25
|
// --quiet print only failures (and final summary)
|
|
21
26
|
|
|
22
|
-
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
|
23
|
-
import { existsSync } from 'node:fs';
|
|
24
|
-
import { dirname, resolve, relative, join, basename } from 'node:path';
|
|
27
|
+
import { readFile, writeFile, readdir, stat, rename, rm } from 'node:fs/promises';
|
|
28
|
+
import { existsSync, lstatSync } from 'node:fs';
|
|
29
|
+
import { dirname, resolve, relative, join, basename, sep } from 'node:path';
|
|
25
30
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
import { randomBytes } from 'node:crypto';
|
|
26
32
|
|
|
27
33
|
const __filename = fileURLToPath(import.meta.url);
|
|
28
34
|
const __dirname = dirname(__filename);
|
|
@@ -34,10 +40,17 @@ const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
|
|
|
34
40
|
// (this deployment's own root); `--root=<dir>` and the exported `regenerateIndex(root, today)`
|
|
35
41
|
// override them so the ADR-rotation hook (archive-decisions.mjs) and hermetic tests can regenerate
|
|
36
42
|
// an arbitrary root's index without ever touching the real repo tree.
|
|
37
|
-
const pathsFor = (root) =>
|
|
43
|
+
const pathsFor = (root) => {
|
|
44
|
+
const base = resolve(root);
|
|
45
|
+
return { root: base, docsDir: resolve(base, 'docs/ai'), indexPath: resolve(base, 'docs/ai/index.md') };
|
|
46
|
+
};
|
|
38
47
|
|
|
39
48
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
40
49
|
|
|
50
|
+
// The one token every `--ensure-index` outcome line opens with — deploy/upgrade prose relays it
|
|
51
|
+
// verbatim and the kit's ensure op reads it, so it is a contract, not a message.
|
|
52
|
+
const ENSURE_INDEX_PREFIX = 'ensure-index:';
|
|
53
|
+
|
|
41
54
|
// Project-name + footer links for the index are auto-discovered (no hardcoding):
|
|
42
55
|
// project name ← package.json "name" (fallback: repo dir basename)
|
|
43
56
|
// hierarchical ← every AGENTS.md / CLAUDE.md below the repo root
|
|
@@ -45,18 +58,30 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
|
45
58
|
const DEFAULT_PROJECT_NAME = 'this project';
|
|
46
59
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-ssr', 'coverage', 'build', '.next']);
|
|
47
60
|
|
|
48
|
-
|
|
61
|
+
// `strict` is the finalizer's lens on the SAME walk: for a report, an unreadable subtree is fairly
|
|
62
|
+
// skipped, but a run that WRITES the navigator may not silently treat "could not read" as "nothing
|
|
63
|
+
// there" — it would publish an index missing whatever it could not see and call that success. Only
|
|
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".
|
|
67
|
+
const rethrowUnlessAbsent = (err, strict) => {
|
|
68
|
+
if (strict && err?.code !== 'ENOENT') throw err;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const walkForName = async (dir, name, acc = [], depth = 0, strict = false, deps = {}) => {
|
|
49
72
|
if (depth > 6) return acc;
|
|
73
|
+
const readDir = deps.readdir ?? readdir;
|
|
50
74
|
let entries;
|
|
51
75
|
try {
|
|
52
|
-
entries = await
|
|
53
|
-
} catch {
|
|
76
|
+
entries = await readDir(dir, { withFileTypes: true });
|
|
77
|
+
} catch (err) {
|
|
78
|
+
rethrowUnlessAbsent(err, strict);
|
|
54
79
|
return acc;
|
|
55
80
|
}
|
|
56
81
|
for (const entry of entries) {
|
|
57
82
|
if (entry.isDirectory()) {
|
|
58
83
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
59
|
-
await walkForName(join(dir, entry.name), name, acc, depth + 1);
|
|
84
|
+
await walkForName(join(dir, entry.name), name, acc, depth + 1, strict, deps);
|
|
60
85
|
} else if (entry.isFile() && entry.name === name) {
|
|
61
86
|
acc.push(join(dir, entry.name));
|
|
62
87
|
}
|
|
@@ -64,16 +89,29 @@ const walkForName = async (dir, name, acc = [], depth = 0) => {
|
|
|
64
89
|
return acc;
|
|
65
90
|
};
|
|
66
91
|
|
|
67
|
-
export const discoverMeta = async (root = ROOT) => {
|
|
92
|
+
export const discoverMeta = async (root = ROOT, { strict = false, deps = {} } = {}) => {
|
|
93
|
+
const read = deps.readFile ?? readFile;
|
|
94
|
+
const readDir = deps.readdir ?? readdir;
|
|
68
95
|
let projectName = basename(root);
|
|
96
|
+
// The READ and the PARSE are separate on purpose: an unreadable package.json is a tree this run
|
|
97
|
+
// could not see (strict propagates it), while a MALFORMED one is authored content — the basename
|
|
98
|
+
// fallback, under strict too.
|
|
99
|
+
let manifest = null;
|
|
69
100
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
/* no package.json — keep dir basename */
|
|
101
|
+
manifest = await read(resolve(root, 'package.json'), 'utf8');
|
|
102
|
+
} catch (err) {
|
|
103
|
+
rethrowUnlessAbsent(err, strict);
|
|
74
104
|
}
|
|
75
|
-
|
|
76
|
-
|
|
105
|
+
if (manifest !== null) {
|
|
106
|
+
try {
|
|
107
|
+
const pkg = JSON.parse(manifest);
|
|
108
|
+
if (pkg.name) projectName = pkg.name;
|
|
109
|
+
} catch {
|
|
110
|
+
/* malformed package.json — keep the dir basename */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const agentsFiles = await walkForName(root, 'AGENTS.md', [], 0, strict, deps);
|
|
114
|
+
const claudeFiles = await walkForName(root, 'CLAUDE.md', [], 0, strict, deps);
|
|
77
115
|
const rootAgents = resolve(root, 'AGENTS.md');
|
|
78
116
|
const rootClaude = resolve(root, 'CLAUDE.md');
|
|
79
117
|
// A subdir typically holds AGENTS.md plus a CLAUDE.md symlink to it — list each
|
|
@@ -91,26 +129,28 @@ export const discoverMeta = async (root = ROOT) => {
|
|
|
91
129
|
.map((rel) => `[\`${rel}\`](../../${rel})`);
|
|
92
130
|
let onDemandLinks = [];
|
|
93
131
|
try {
|
|
94
|
-
const skillDirs = await
|
|
132
|
+
const skillDirs = await readDir(resolve(root, '.agents/skills'), { withFileTypes: true });
|
|
95
133
|
onDemandLinks = skillDirs
|
|
96
134
|
.filter((dirent) => dirent.isDirectory() && /-(patterns|commands)$/.test(dirent.name))
|
|
97
135
|
.map((dirent) => dirent.name)
|
|
98
136
|
.sort()
|
|
99
137
|
.map((name) => `[\`${name}\`](../../.agents/skills/${name}/SKILL.md)`);
|
|
100
|
-
} catch {
|
|
101
|
-
|
|
138
|
+
} catch (err) {
|
|
139
|
+
// No .agents/skills — omit the section (under strict, only a real absence may omit it).
|
|
140
|
+
rethrowUnlessAbsent(err, strict);
|
|
102
141
|
}
|
|
103
142
|
return { projectName, hierarchicalLinks, onDemandLinks };
|
|
104
143
|
};
|
|
105
144
|
|
|
106
145
|
// Pure argv parser (no I/O, no exit): `help` / `error` ride out as data for runCli to render.
|
|
107
146
|
const parseArgs = (argv) => {
|
|
108
|
-
const flags = { report: false, writeIndex: false, checkIndex: false, quiet: false };
|
|
147
|
+
const flags = { report: false, writeIndex: false, checkIndex: false, ensureIndex: false, quiet: false };
|
|
109
148
|
const opts = { today: null, root: null };
|
|
110
149
|
for (const arg of argv) {
|
|
111
150
|
if (arg === '--report') flags.report = true;
|
|
112
151
|
else if (arg === '--write-index') flags.writeIndex = true;
|
|
113
152
|
else if (arg === '--check-index') flags.checkIndex = true;
|
|
153
|
+
else if (arg === '--ensure-index') flags.ensureIndex = true;
|
|
114
154
|
else if (arg === '--quiet') flags.quiet = true;
|
|
115
155
|
else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
|
|
116
156
|
else if (arg.startsWith('--root=')) opts.root = arg.slice('--root='.length);
|
|
@@ -334,9 +374,74 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
|
|
|
334
374
|
return { fresh: expected === onDiskText, expected };
|
|
335
375
|
};
|
|
336
376
|
|
|
337
|
-
|
|
377
|
+
// The navigator is a GENERATED artifact, so its write must land on the deployment's own file and
|
|
378
|
+
// nowhere else: every component of <root>/docs/ai/index.md is lstat'ed no-follow (a symlinked root,
|
|
379
|
+
// `docs`, `docs/ai` or leaf REFUSES — publishing through one would clobber whatever it points at),
|
|
380
|
+
// the body goes out through a unique exclusive-create temp renamed into place with the chain
|
|
381
|
+
// re-checked immediately before the rename, and the temp never survives a failure. The kit runs the
|
|
382
|
+
// same discipline in atomic-write.mjs; this deployment script ships dependency-free, so the
|
|
383
|
+
// semantics are REIMPLEMENTED here rather than imported.
|
|
384
|
+
export const INDEX_WRITE_REFUSED = 'INDEX_WRITE_REFUSED';
|
|
385
|
+
const refuse = (message) => Object.assign(new Error(message), { code: INDEX_WRITE_REFUSED });
|
|
386
|
+
|
|
387
|
+
const lstatNoFollow = (target, lstat) => {
|
|
388
|
+
try {
|
|
389
|
+
return lstat(target);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
if (err && err.code === 'ENOENT') return null;
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// The target is always DERIVED from `root` here (the navigator and its temp sibling), never handed
|
|
397
|
+
// in by a caller, so there is no escape arm to guard: what remains is the no-follow walk.
|
|
398
|
+
const assertContainedNoSymlink = (root, target, lstat) => {
|
|
399
|
+
const rel = relative(root, target);
|
|
400
|
+
if (lstatNoFollow(root, lstat)?.isSymbolicLink()) {
|
|
401
|
+
throw refuse(`${root} is a symlink — refusing to write the navigator through it`);
|
|
402
|
+
}
|
|
403
|
+
rel.split(sep).filter(Boolean).reduce((walked, part) => {
|
|
404
|
+
const current = join(walked, part);
|
|
405
|
+
if (lstatNoFollow(current, lstat)?.isSymbolicLink()) {
|
|
406
|
+
throw refuse(`${current} is a symlink — refusing to write the navigator through it`);
|
|
407
|
+
}
|
|
408
|
+
return current;
|
|
409
|
+
}, root);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const writeIndex = async (rows, today, meta, { root = ROOT, indexPath = INDEX_PATH, deps = {} } = {}) => {
|
|
413
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
414
|
+
const write = deps.writeFile ?? writeFile;
|
|
415
|
+
const publish = deps.rename ?? rename;
|
|
416
|
+
const remove = deps.rm ?? rm;
|
|
417
|
+
const uniqueSuffix = deps.rand ?? (() => randomBytes(6).toString('hex'));
|
|
338
418
|
const body = buildIndex(rows, today.toISOString().slice(0, 10), meta);
|
|
339
|
-
|
|
419
|
+
assertContainedNoSymlink(root, indexPath, lstat);
|
|
420
|
+
const tmp = `${indexPath}.${uniqueSuffix()}.tmp`;
|
|
421
|
+
assertContainedNoSymlink(root, tmp, lstat);
|
|
422
|
+
const discardTemp = async (err) => {
|
|
423
|
+
try {
|
|
424
|
+
await remove(tmp, { force: true });
|
|
425
|
+
} catch (cleanupErr) {
|
|
426
|
+
throw refuse(`${err.message} — and its temp file could not be removed, delete it by hand: ${tmp} (${cleanupErr.message})`);
|
|
427
|
+
}
|
|
428
|
+
throw err;
|
|
429
|
+
};
|
|
430
|
+
try {
|
|
431
|
+
await write(tmp, body, { encoding: 'utf8', flag: 'wx' });
|
|
432
|
+
} catch (err) {
|
|
433
|
+
// EEXIST means the name is SOMEONE ELSE's file: exclusive-create refused, this run wrote
|
|
434
|
+
// nothing, and removing it would delete a file we never made. Every other failure can leave a
|
|
435
|
+
// partial temp behind, and that one is ours to discard.
|
|
436
|
+
if (err && err.code === 'EEXIST') throw err;
|
|
437
|
+
await discardTemp(err);
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
assertContainedNoSymlink(root, indexPath, lstat);
|
|
441
|
+
await publish(tmp, indexPath);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
await discardTemp(err);
|
|
444
|
+
}
|
|
340
445
|
};
|
|
341
446
|
|
|
342
447
|
// regenerateIndex(root, todayStr) — the ONE reused generator, root-parameterized (item (h)). It runs
|
|
@@ -344,15 +449,46 @@ const writeIndex = async (rows, today, meta, indexPath = INDEX_PATH) => {
|
|
|
344
449
|
// (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
|
|
345
450
|
// hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
|
|
346
451
|
// index path + row count. No second index implementation exists.
|
|
347
|
-
export const regenerateIndex = async (root, todayStr = null) => {
|
|
348
|
-
const
|
|
452
|
+
export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
|
|
453
|
+
const paths = pathsFor(root);
|
|
349
454
|
const today = computeToday(todayStr);
|
|
350
|
-
const files = (await walkMarkdownFiles(docsDir)).sort();
|
|
351
|
-
const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
|
|
455
|
+
const files = (await walkMarkdownFiles(paths.docsDir)).sort();
|
|
456
|
+
const inspected = await Promise.all(files.map((f) => inspectFile(f, today, paths.root)));
|
|
352
457
|
const rows = inspected.map(formatRow);
|
|
353
|
-
const meta = await discoverMeta(root);
|
|
354
|
-
await writeIndex(rows, today, meta, indexPath);
|
|
355
|
-
return { indexPath, files: rows.length };
|
|
458
|
+
const meta = await discoverMeta(paths.root);
|
|
459
|
+
await writeIndex(rows, today, meta, { root: paths.root, indexPath: paths.indexPath, deps });
|
|
460
|
+
return { indexPath: paths.indexPath, files: rows.length };
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// The finalizer promises its caller EXACTLY ONE outcome line, so every step it owns — the walk, the
|
|
464
|
+
// metadata discovery, the freshness read and the write — runs inside one classified error path: an
|
|
465
|
+
// unreadable docs/ai is a NAMED refusal, never a stack trace. The containment guard runs BEFORE the
|
|
466
|
+
// freshness read for the same reason `already-present` needs a kind probe: a symlinked navigator
|
|
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.
|
|
469
|
+
const runEnsureIndex = async ({ root, docsDir, indexPath, today, deps }) => {
|
|
470
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
471
|
+
const read = deps.readFile ?? readFile;
|
|
472
|
+
const line = (token) => `${ENSURE_INDEX_PREFIX} ${token} — ${relative(root, indexPath)}`;
|
|
473
|
+
// The two refusals name STAGES, not error codes: once the write has been entered, ANY failure —
|
|
474
|
+
// a containment refusal, EIO, EACCES — is a write refusal, because that is what the reader has to
|
|
475
|
+
// act on. A raw fs error reported as a failed PROBE would send them to the wrong half of the run.
|
|
476
|
+
let writing = false;
|
|
477
|
+
try {
|
|
478
|
+
assertContainedNoSymlink(root, indexPath, lstat);
|
|
479
|
+
const files = (await walkMarkdownFiles(docsDir)).sort();
|
|
480
|
+
const inspected = await Promise.all(files.map((file) => inspectFile(file, today, root)));
|
|
481
|
+
const rows = inspected.map(formatRow);
|
|
482
|
+
const meta = await discoverMeta(root, { strict: true, deps });
|
|
483
|
+
const onDisk = existsSync(indexPath) ? await read(indexPath, 'utf8') : null;
|
|
484
|
+
if (checkIndexFreshness(rows, onDisk, meta).fresh) return { code: 0, out: line('already-current') };
|
|
485
|
+
writing = true;
|
|
486
|
+
await writeIndex(rows, today, meta, { root, indexPath, deps });
|
|
487
|
+
return { code: 0, out: line('regenerated') };
|
|
488
|
+
} catch (err) {
|
|
489
|
+
const cause = writing || err?.code === INDEX_WRITE_REFUSED ? 'write-refused' : 'probe-failed';
|
|
490
|
+
return { code: 2, err: `${ENSURE_INDEX_PREFIX} ${cause} — ${indexPath}: ${err.message}` };
|
|
491
|
+
}
|
|
356
492
|
};
|
|
357
493
|
|
|
358
494
|
// The return-code entry point (no process.argv / process.exit / console inside): argv[] →
|
|
@@ -370,7 +506,7 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
370
506
|
|
|
371
507
|
const { flags, opts, help, error } = parseArgs(argv);
|
|
372
508
|
if (help) {
|
|
373
|
-
log('Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]');
|
|
509
|
+
log('Usage: check-docs-size.mjs [--report|--write-index|--check-index|--ensure-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]');
|
|
374
510
|
return result(0);
|
|
375
511
|
}
|
|
376
512
|
if (error) {
|
|
@@ -379,6 +515,16 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
379
515
|
}
|
|
380
516
|
const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : (deps.root ?? ROOT));
|
|
381
517
|
const today = computeToday(opts.today);
|
|
518
|
+
|
|
519
|
+
// The finalizer owns its whole pipeline (above), so it returns BEFORE the shared walk: a tree the
|
|
520
|
+
// walk would throw on must still close with one outcome line.
|
|
521
|
+
if (flags.ensureIndex) {
|
|
522
|
+
const { code, out, err } = await runEnsureIndex({ root, docsDir, indexPath, today, deps });
|
|
523
|
+
if (out) log(out);
|
|
524
|
+
if (err) logError(err);
|
|
525
|
+
return result(code);
|
|
526
|
+
}
|
|
527
|
+
|
|
382
528
|
const files = (await walkMarkdownFiles(docsDir)).sort();
|
|
383
529
|
const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
|
|
384
530
|
const rows = inspected.map(formatRow);
|
|
@@ -386,7 +532,12 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
386
532
|
const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta(root) : null;
|
|
387
533
|
|
|
388
534
|
if (flags.writeIndex) {
|
|
389
|
-
|
|
535
|
+
try {
|
|
536
|
+
await writeIndex(rows, today, meta, { root, indexPath, deps });
|
|
537
|
+
} catch (err) {
|
|
538
|
+
logError(`[check-docs-size] FAIL: ${indexPath}: ${err.message}`);
|
|
539
|
+
return result(2);
|
|
540
|
+
}
|
|
390
541
|
log(`Wrote ${relative(root, indexPath)}`);
|
|
391
542
|
const after = await stat(indexPath);
|
|
392
543
|
if (after.size === 0) {
|
|
@@ -59,3 +59,13 @@ a body matching the current canon is reported *already current*; a body matching
|
|
|
59
59
|
is refreshed; a custom body is preserved + noted; an absent section is a stated note (never an
|
|
60
60
|
insert); an over-cap refresh is refused; and an unreadable bundled template canon is its own
|
|
61
61
|
loud STOP naming the kit reinstall command.
|
|
62
|
+
|
|
63
|
+
**Navigator finalizer (runs in BOTH paths, AFTER the lens reconcile above).** `docs/ai/index.md` is
|
|
64
|
+
a GENERATED artifact the entry point declares always-loaded, and the reconcile above may have just
|
|
65
|
+
rewritten a `docs/ai` file — so the finalizer runs at the LAST `docs/ai` mutation of the deploy,
|
|
66
|
+
before the stamp and the report. ONE command:
|
|
67
|
+
`node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<project>`.
|
|
68
|
+
Relay its one outcome line (*regenerated* / *already current*); a `write-refused` (the write) or
|
|
69
|
+
`probe-failed` (the tree could not be read) line names the offending path and is a loud STOP,
|
|
70
|
+
never a note. It is idempotent, so an earlier run in the
|
|
71
|
+
delegated path (the substrate's own fill step) is never a reason to skip it here.
|
package/tools/doc-parity.mjs
CHANGED
|
@@ -56,7 +56,7 @@ import { COVERAGE_PRODUCER_BODY } from './coverage-producer.mjs';
|
|
|
56
56
|
// tool renames or drops must fail here rather than leave the doc teaching a vocabulary nobody emits.
|
|
57
57
|
// Imported from the VOCABULARY leaf, never from the ops: a read-only lint must not pull the ensure
|
|
58
58
|
// implementation — and through it the orchestration writer — into its import graph.
|
|
59
|
-
import { RELAYED_ENSURE_TOKENS } from './ensure-vocabulary.mjs';
|
|
59
|
+
import { RELAYED_ENSURE_TOKENS, RELAYED_FAILURE_CAUSES } from './ensure-vocabulary.mjs';
|
|
60
60
|
|
|
61
61
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
62
62
|
|
|
@@ -138,6 +138,10 @@ export const BINDINGS = Object.freeze([
|
|
|
138
138
|
// free. Now one command performs them and the doc enumerates its tokens — backticked, so a bare
|
|
139
139
|
// word in a sentence cannot pass for the pinned outcome.
|
|
140
140
|
...RELAYED_ENSURE_TOKENS.map((token) => valueBinding(`ensure-outcome:${token}`, token, `\`${token}\``, [UPGRADE_DOC])),
|
|
141
|
+
// And the CAUSE half of the same promise (index-navigator hotfix / D9): the doc says a `failed`
|
|
142
|
+
// line OPENS with its cause, so every word that can open one is pinned into the doc that relays it
|
|
143
|
+
// — otherwise a new cause ships with no doc anyone could have read. Backticked, same reason.
|
|
144
|
+
...RELAYED_FAILURE_CAUSES.map((cause) => valueBinding(`ensure-cause:${cause}`, cause, `\`${cause}\``, [UPGRADE_DOC])),
|
|
141
145
|
// The "the tool knows and does not say" contract: a clean-tree PASS must still name a latent arm.
|
|
142
146
|
// It was a prose-only bar a doc could silently drop, so it is pinned to the live string the tool
|
|
143
147
|
// actually emits — a reworded doc dropping the notice fails this pin plus the gate.
|
package/tools/ensure-configs.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// ensure-configs.mjs — ONE runnable command for the
|
|
2
|
+
// ensure-configs.mjs — ONE runnable command for the five stamp-independent upgrade ensures:
|
|
3
3
|
//
|
|
4
4
|
// orchestration docs/ai/orchestration.json seed, or refresh a still-canonical onboarding note
|
|
5
5
|
// gates docs/ai/gates.json seed-if-missing (an existing declaration is authored content)
|
|
6
6
|
// autonomy docs/ai/autonomy.json seed-if-missing (same)
|
|
7
7
|
// scripts scripts/<ADR enforcement> seed-if-missing, ADR-layout detect FIRST
|
|
8
|
+
// index docs/ai/index.md regenerate-if-missing-or-stale (a GENERATED artifact)
|
|
8
9
|
//
|
|
9
10
|
// Each was prose in references/modes/upgrade.md that an agent performed by hand. One command instead
|
|
10
11
|
// of four is deliberate: four independent runs would be four chances to skip one, and the mode doc now
|
|
@@ -39,20 +40,25 @@ const EXIT_USAGE = 2;
|
|
|
39
40
|
const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
40
41
|
|
|
41
42
|
const EMPTY_CWD = '--cwd needs a path argument (an empty value would silently mean the current directory)';
|
|
43
|
+
const EMPTY_ONLY = `--only needs one operation name (${ENSURE_OPS.join(' | ')}) — an empty value would silently widen the run`;
|
|
44
|
+
const REPEATED_ONLY = '--only was passed more than once — this selector names exactly ONE operation';
|
|
45
|
+
const unknownOp = (value) => `--only ${value}: no such operation (${ENSURE_OPS.join(' | ')}) — nothing was run`;
|
|
42
46
|
|
|
43
|
-
const HELP = `ensure-configs — the
|
|
47
|
+
const HELP = `ensure-configs — the five stamp-independent upgrade ensures, as ONE command.
|
|
44
48
|
|
|
45
49
|
Usage:
|
|
46
|
-
node ensure-configs.mjs --reconcile [--dry-run] [--cwd <project>]
|
|
50
|
+
node ensure-configs.mjs --reconcile [--dry-run] [--only <op>] [--cwd <project>]
|
|
47
51
|
|
|
48
|
-
--reconcile required — run the
|
|
52
|
+
--reconcile required — run the five ensures (${ENSURE_OPS.join(', ')})
|
|
49
53
|
--dry-run report what each ensure WOULD do; write nothing
|
|
54
|
+
--only <op> run EXACTLY ONE of them (an unknown, missing or repeated value is a usage error)
|
|
50
55
|
--cwd <dir> the target project (default: the current directory)
|
|
51
56
|
--help, -h this help
|
|
52
57
|
|
|
53
|
-
Every
|
|
54
|
-
refreshed in place.
|
|
55
|
-
matches a canonical the kit shipped
|
|
58
|
+
Every SEED is CREATE-ONLY: an existing file is preserved byte-for-byte, never clobbered and never
|
|
59
|
+
refreshed in place. Two ops refresh instead: the orchestration onboarding note, only while it still
|
|
60
|
+
matches a canonical the kit shipped (your own wording is preserved verbatim), and the navigator
|
|
61
|
+
index — a GENERATED artifact, regenerated whenever it is missing or stale. The enforcement-script
|
|
56
62
|
ensure detects an older ADR-store layout FIRST and instructs the opt-in migration instead of seeding.
|
|
57
63
|
|
|
58
64
|
Exit codes: 0 every op fine; 1 an op failed (its line says so) or there is no deployment here; 2 usage.`;
|
|
@@ -60,13 +66,24 @@ Exit codes: 0 every op fine; 1 an op failed (its line says so) or there is no de
|
|
|
60
66
|
// argv → { reconcile, dryRun, cwd, help }. Order-independent; an unknown flag or a missing --cwd
|
|
61
67
|
// value is a usage error, never a silently-ignored argument.
|
|
62
68
|
export const parseArgs = (argv) => {
|
|
63
|
-
const out = { reconcile: false, dryRun: false, cwd: undefined, help: false };
|
|
69
|
+
const out = { reconcile: false, dryRun: false, cwd: undefined, only: undefined, help: false };
|
|
64
70
|
for (let i = 0; i < argv.length; i += 1) {
|
|
65
71
|
const a = argv[i];
|
|
66
72
|
if (a === '--help' || a === '-h') out.help = true;
|
|
67
73
|
else if (a === '--reconcile') out.reconcile = true;
|
|
68
74
|
else if (a === '--dry-run') out.dryRun = true;
|
|
69
|
-
else if (a === '--
|
|
75
|
+
else if (a === '--only' || a.startsWith('--only=')) {
|
|
76
|
+
// A selector that cannot be honoured EXACTLY as asked is a usage error, never a wider run:
|
|
77
|
+
// narrowing is the whole point, so a missing value, a repeat, or an op that does not exist
|
|
78
|
+
// must stop the run before any op writes.
|
|
79
|
+
if (out.only !== undefined) throw fail(EXIT_USAGE, REPEATED_ONLY);
|
|
80
|
+
const inline = a.startsWith('--only=');
|
|
81
|
+
const value = inline ? a.slice('--only='.length) : argv[i + 1];
|
|
82
|
+
if (value === undefined || value === '' || (!inline && value.startsWith('-'))) throw fail(EXIT_USAGE, EMPTY_ONLY);
|
|
83
|
+
if (!ENSURE_OPS.includes(value)) throw fail(EXIT_USAGE, unknownOp(value));
|
|
84
|
+
out.only = value;
|
|
85
|
+
if (!inline) i += 1;
|
|
86
|
+
} else if (a === '--cwd') {
|
|
70
87
|
// An EMPTY value resolves to the ambient cwd — a writing CLI would then act on a different
|
|
71
88
|
// project than the caller named, silently. Both spellings refuse it.
|
|
72
89
|
const next = argv[i + 1];
|
|
@@ -88,8 +105,8 @@ export const parseArgs = (argv) => {
|
|
|
88
105
|
// Run every op in ENSURE_OPS order. A throw from one op becomes THAT op's failed outcome — the
|
|
89
106
|
// remaining ops still run, because a project missing its gate declaration should not also be left
|
|
90
107
|
// without its autonomy seed just because the first ensure hit an unreadable file.
|
|
91
|
-
export const runEnsures = ({ cwd, kitRoot, dryRun, deps }) =>
|
|
92
|
-
ENSURE_OPS.map((op) => {
|
|
108
|
+
export const runEnsures = ({ cwd, kitRoot, dryRun, deps, only }) =>
|
|
109
|
+
(only ? [only] : ENSURE_OPS).map((op) => {
|
|
93
110
|
try {
|
|
94
111
|
return ENSURE_IMPLEMENTATIONS[op]({ cwd, kitRoot, dryRun, deps });
|
|
95
112
|
} catch (err) {
|
|
@@ -97,10 +114,11 @@ export const runEnsures = ({ cwd, kitRoot, dryRun, deps }) =>
|
|
|
97
114
|
}
|
|
98
115
|
});
|
|
99
116
|
|
|
100
|
-
const render = (outcomes, dryRun) => {
|
|
117
|
+
const render = (outcomes, dryRun, only) => {
|
|
101
118
|
// The banner names the tool + the flag it ran under (both machine tokens the L2 rule exempts);
|
|
102
119
|
// the failure footer is a user-grade sentence — the composed-lines guard scans both.
|
|
103
|
-
const
|
|
120
|
+
const scope = only ? `, --only ${only}` : '';
|
|
121
|
+
const lines = [dryRun ? `ensure-configs (--reconcile${scope}, dry run — nothing written)` : `ensure-configs (--reconcile${scope})`];
|
|
104
122
|
for (const o of outcomes) {
|
|
105
123
|
lines.push(` ${o.op}: ${o.token}`);
|
|
106
124
|
for (const detail of o.lines) lines.push(` ${detail}`);
|
|
@@ -122,10 +140,10 @@ export const main = (argv = [], ctx = {}) => {
|
|
|
122
140
|
// ONE deployment gate for the whole run (see the header): with no docs/ai there is nothing to
|
|
123
141
|
// reconcile, and four identical STOPs would read as four separate problems.
|
|
124
142
|
assertDocsAiDeployment(cwd, deps, { noun: 'the project configuration', rel: 'under docs/ai' });
|
|
125
|
-
const outcomes = runEnsures({ cwd, kitRoot: ctx.kitRoot ?? KIT_ROOT, dryRun: args.dryRun, deps });
|
|
143
|
+
const outcomes = runEnsures({ cwd, kitRoot: ctx.kitRoot ?? KIT_ROOT, dryRun: args.dryRun, deps, only: args.only });
|
|
126
144
|
return {
|
|
127
145
|
code: outcomes.some((o) => o.failed) ? EXIT_FAILED : EXIT_OK,
|
|
128
|
-
stdout: render(outcomes, args.dryRun),
|
|
146
|
+
stdout: render(outcomes, args.dryRun, args.only),
|
|
129
147
|
stderr: '',
|
|
130
148
|
};
|
|
131
149
|
} catch (err) {
|
package/tools/ensure-ops.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// ensure-ops.mjs — the
|
|
1
|
+
// ensure-ops.mjs — the FIVE upgrade ensure operations, one function each, behind one shared outcome
|
|
2
2
|
// shape. The CLI that orders and runs them is ensure-configs.mjs; this module owns what each ensure
|
|
3
3
|
// DOES and, more importantly, what it is allowed to CLAIM.
|
|
4
4
|
//
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
|
|
24
24
|
|
|
25
25
|
import { readFileSync, lstatSync } from 'node:fs';
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
26
27
|
import { join } from 'node:path';
|
|
27
28
|
import { CANON_README, CONFIG_REL, SEED_CONFIG, loadConfig, normalizeCanonical, refreshReadme } from './orchestration-config.mjs';
|
|
28
29
|
import { seedConfig, writeConfig } from './orchestration-write.mjs';
|
|
@@ -41,6 +42,7 @@ export {
|
|
|
41
42
|
DRY_RUN_TOKENS,
|
|
42
43
|
FAILURE_CAUSES,
|
|
43
44
|
RELAYED_ENSURE_TOKENS,
|
|
45
|
+
RELAYED_FAILURE_CAUSES,
|
|
44
46
|
SEED_SCRIPTS,
|
|
45
47
|
WRITE_TOKENS,
|
|
46
48
|
} from './ensure-vocabulary.mjs';
|
|
@@ -275,10 +277,86 @@ export const ensureScripts = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
|
|
|
275
277
|
return outcome('scripts', anyCreated ? 'seeded' : 'already-present', lines, false);
|
|
276
278
|
};
|
|
277
279
|
|
|
280
|
+
// ── 5. docs/ai/index.md — the GENERATED navigator, regenerated when missing or stale ───────────────
|
|
281
|
+
|
|
282
|
+
// The only ensure whose target is generated rather than authored: there is nothing to preserve, and
|
|
283
|
+
// nothing to seed from either — the bundled generator IS the writer, driven through its idempotent
|
|
284
|
+
// finalizer mode. Sync-over-async by the same precedent the ADR rotator uses (spawnSync the CLI):
|
|
285
|
+
// the ensure framework is synchronous, and a second index implementation here would be the drift
|
|
286
|
+
// the one-generator rule exists to prevent.
|
|
287
|
+
const INDEX_REL = 'docs/ai/index.md';
|
|
288
|
+
const GENERATOR_PATH = ['references', 'scripts', 'check-docs-size.mjs'];
|
|
289
|
+
const ENSURE_INDEX_OUTCOME = /^ensure-index: (regenerated|already-current)\b/m;
|
|
290
|
+
const ENSURE_INDEX_REFUSAL = /^ensure-index: (write-refused|probe-failed)\b/m;
|
|
291
|
+
// The probe has no machine line, so its two ANSWERS are matched against the canonical sentences it
|
|
292
|
+
// composes — anchored, not a loose substring: a failure that merely CONTAINS "is stale" (a path, an
|
|
293
|
+
// error quoting the checker's own advice) would otherwise pass for a stale verdict and fail OPEN.
|
|
294
|
+
const PROBE_FRESH = /^\[check-docs-size\] OK — .+ is in sync with source frontmatter\./m;
|
|
295
|
+
const PROBE_STALE = /^\[check-docs-size\] FAIL: .+ is stale \(out of sync with source frontmatter\)\./m;
|
|
296
|
+
// Only the LAUNCH and the pre-write probe are provably zero-write. Once the generator has run, a
|
|
297
|
+
// failure must say that a write may already have landed — the reader's next step depends on it.
|
|
298
|
+
const MAY_HAVE_WRITTEN = 'the navigator may already have been written — re-read it before re-running';
|
|
299
|
+
|
|
300
|
+
const describeExit = (result) => (result.signal ? `on signal ${result.signal}` : `with code ${result.status}`);
|
|
301
|
+
const bothStreams = (result) => `${String(result.stdout ?? '')}${String(result.stderr ?? '')}`.trim();
|
|
302
|
+
|
|
303
|
+
export const ensureIndex = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
|
|
304
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
305
|
+
const spawn = deps.spawnSync ?? spawnSync;
|
|
306
|
+
const generator = join(kitRoot, ...GENERATOR_PATH);
|
|
307
|
+
const drive = (mode) => spawn(process.execPath, [generator, mode, `--root=${cwd}`], { encoding: 'utf8' });
|
|
308
|
+
const unlaunchable = (result) =>
|
|
309
|
+
loud('index', 'generator-unlaunchable', `${INDEX_REL}: the bundled generator could not be started, so nothing was probed or written — reinstall the kit. ${causeOf(result.error)}`);
|
|
310
|
+
|
|
311
|
+
const probe = probeSeedTarget(join(cwd, INDEX_REL), lstat);
|
|
312
|
+
if (probe.wrongKind) {
|
|
313
|
+
return loud('index', 'wrong-node-kind', `${INDEX_REL}: exists but is ${probe.wrongKind} — the navigator is a generated file and this is not one; nothing was read or written, resolve it by hand and re-run`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (dryRun) {
|
|
317
|
+
const check = drive('--check-index');
|
|
318
|
+
if (check.error) return unlaunchable(check);
|
|
319
|
+
const out = bothStreams(check);
|
|
320
|
+
if (check.status === 0 && PROBE_FRESH.test(out)) return ok('index', 'already-current', `${INDEX_REL}: in sync with the source frontmatter — nothing would be written`);
|
|
321
|
+
if (check.status === 1 && PROBE_STALE.test(out)) return ok('index', 'would-regenerate', `${INDEX_REL}: missing or stale — would be written from the source frontmatter`);
|
|
322
|
+
return loud('index', 'index-probe-failed', `${INDEX_REL}: the freshness probe answered neither fresh nor stale (exited ${describeExit(check)}), so nothing was written. ${out}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const run = drive('--ensure-index');
|
|
326
|
+
if (run.error) return unlaunchable(run);
|
|
327
|
+
const outcome = String(run.stdout ?? '').match(ENSURE_INDEX_OUTCOME);
|
|
328
|
+
const refusal = String(run.stderr ?? '').match(ENSURE_INDEX_REFUSAL);
|
|
329
|
+
if (run.status === 2 && refusal) {
|
|
330
|
+
// The generator's OWN closed refusals: a write it would not publish, and a probe that could not
|
|
331
|
+
// answer. Each keeps its identity here rather than collapsing into "the generator failed".
|
|
332
|
+
const cause = refusal[1] === 'write-refused' ? 'write-refused' : 'index-probe-failed';
|
|
333
|
+
return loud('index', cause, `${INDEX_REL}: ${bothStreams(run)}`);
|
|
334
|
+
}
|
|
335
|
+
if (run.status !== 0 || !outcome) {
|
|
336
|
+
return loud('index', 'generator-failed', `${INDEX_REL}: the generator exited ${describeExit(run)} without a recognized outcome line — ${MAY_HAVE_WRITTEN}. ${bothStreams(run)}`);
|
|
337
|
+
}
|
|
338
|
+
if (outcome[1] === 'already-current') return ok('index', 'already-current', `${INDEX_REL}: in sync with the source frontmatter — nothing written`);
|
|
339
|
+
|
|
340
|
+
// A claimed regeneration is not a verified one: re-probe, so `regenerated` names a state this run
|
|
341
|
+
// PROVED rather than a line the generator printed. The verdict is read from the probe's own two
|
|
342
|
+
// ANSWERS — an exit code alone would turn a probe that merely FAILED (exit 1 on an unreadable
|
|
343
|
+
// tree) into a false "still stale after the write".
|
|
344
|
+
const verify = drive('--check-index');
|
|
345
|
+
const verdict = verify.error ? '' : bothStreams(verify);
|
|
346
|
+
if (!verify.error && verify.status === 0 && PROBE_FRESH.test(verdict)) {
|
|
347
|
+
return ok('index', 'regenerated', `${INDEX_REL}: written from the source frontmatter — the navigator is in sync again`);
|
|
348
|
+
}
|
|
349
|
+
if (!verify.error && verify.status === 1 && PROBE_STALE.test(verdict)) {
|
|
350
|
+
return loud('index', 'index-stale-after-write', `${INDEX_REL}: the generator reported a regeneration, but the re-probe still reads the navigator as missing or stale — ${MAY_HAVE_WRITTEN}. ${verdict}`);
|
|
351
|
+
}
|
|
352
|
+
return loud('index', 'index-probe-failed', `${INDEX_REL}: the generator reported a regeneration, but the verifying probe answered neither fresh nor stale — ${MAY_HAVE_WRITTEN}. ${verify.error ? causeOf(verify.error) : verdict}`);
|
|
353
|
+
};
|
|
354
|
+
|
|
278
355
|
// The op table the CLI walks — name → implementation, in ENSURE_OPS order.
|
|
279
356
|
export const ENSURE_IMPLEMENTATIONS = Object.freeze({
|
|
280
357
|
orchestration: ensureOrchestration,
|
|
281
358
|
gates: ensureGates,
|
|
282
359
|
autonomy: ensureAutonomy,
|
|
283
360
|
scripts: ensureScripts,
|
|
361
|
+
index: ensureIndex,
|
|
284
362
|
});
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
// read-only tool's import graph. Vocabulary here, behaviour in ensure-ops.mjs.
|
|
9
9
|
|
|
10
10
|
// The FIXED order the CLI runs them in — the order references/modes/upgrade.md already prescribed.
|
|
11
|
-
export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', 'scripts']);
|
|
11
|
+
export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', 'scripts', 'index']);
|
|
12
12
|
|
|
13
13
|
// Tokens that assert a WRITE happened. --dry-run may never emit one of these (the CLI's contract test
|
|
14
14
|
// walks this set), and each has exactly one `would-` counterpart below.
|
|
15
|
-
export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed']);
|
|
16
|
-
export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note']);
|
|
15
|
+
export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'regenerated']);
|
|
16
|
+
export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-regenerate']);
|
|
17
17
|
|
|
18
18
|
// The CLOSED outcome vocabulary. Closed at RUNTIME, not by convention: composing an outcome with a
|
|
19
19
|
// token outside this list throws, so an op cannot quietly invent a word the mode doc has never heard
|
|
@@ -43,8 +43,21 @@ export const FAILURE_CAUSES = Object.freeze([
|
|
|
43
43
|
'wrong-node-kind',
|
|
44
44
|
'write-refused',
|
|
45
45
|
'unexpected-error',
|
|
46
|
+
// The navigator ensure drives a SEPARATE PROCESS (the bundled generator), so its failures split by
|
|
47
|
+
// how far that process got: it never launched · it launched and did not succeed · the freshness
|
|
48
|
+
// probe itself could not answer · it claimed a regeneration the re-probe still finds stale. Only
|
|
49
|
+
// the first and third are provably pre-mutation; the other two DISCLOSE a possible partial write.
|
|
50
|
+
'generator-unlaunchable',
|
|
51
|
+
'generator-failed',
|
|
52
|
+
'index-probe-failed',
|
|
53
|
+
'index-stale-after-write',
|
|
46
54
|
]);
|
|
47
55
|
|
|
56
|
+
// The causes the mode doc must TEACH, so an agent relaying a `failed` line knows every word that can
|
|
57
|
+
// open one. Bound into references/modes/upgrade.md by doc-parity — the executable half of "a failed
|
|
58
|
+
// line names its cause": a cause the tool can print but the doc never named fails the lint.
|
|
59
|
+
export const RELAYED_FAILURE_CAUSES = FAILURE_CAUSES;
|
|
60
|
+
|
|
48
61
|
// The subset references/modes/upgrade.md enumerates, so the agent relaying an upgrade knows every
|
|
49
62
|
// outcome by name. doc-parity binds each of these into that doc: a reworded doc that drops one fails
|
|
50
63
|
// the check instead of silently teaching an outcome set the tool no longer has. The dry-run pair is
|
|
@@ -52,6 +65,7 @@ export const FAILURE_CAUSES = Object.freeze([
|
|
|
52
65
|
export const RELAYED_ENSURE_TOKENS = Object.freeze([
|
|
53
66
|
'seeded',
|
|
54
67
|
'note-refreshed',
|
|
68
|
+
'regenerated',
|
|
55
69
|
'already-current',
|
|
56
70
|
'customized-preserved',
|
|
57
71
|
'malformed-preserved',
|
|
@@ -36,6 +36,9 @@ export const stop = (message, fields = {}) =>
|
|
|
36
36
|
// never a silent un-track. `/docs/plans/` + both `.claude/settings*.json` are listed because a pure
|
|
37
37
|
// hidden deploy has no tracked `.gitignore`; the classifier drops any candidate a tracked `.gitignore`
|
|
38
38
|
// already covers, so in a repo that DOES track those ignores they are never re-written.
|
|
39
|
+
// The enumeration must cover EVERY file the deploy copies into `scripts/` (bootstrap step 8 copies
|
|
40
|
+
// `references/scripts/*.mjs` + `*.test.mjs` wholesale): a name missing here is a file a hidden
|
|
41
|
+
// deployment leaves visible in `git status` — the exact leak this registry exists to prevent.
|
|
39
42
|
export const KIT_OWN_PATHS = [
|
|
40
43
|
'/AGENTS.md',
|
|
41
44
|
'/CLAUDE.md',
|
|
@@ -49,11 +52,18 @@ export const KIT_OWN_PATHS = [
|
|
|
49
52
|
'/scripts/archive-issues.mjs',
|
|
50
53
|
'/scripts/archive-issues.test.mjs',
|
|
51
54
|
'/scripts/archiver-structure.test.mjs',
|
|
55
|
+
'/scripts/check-docs-size-cli.test.mjs',
|
|
56
|
+
'/scripts/check-docs-size-ensure.test.mjs',
|
|
52
57
|
'/scripts/check-docs-size.mjs',
|
|
53
58
|
'/scripts/check-docs-size.test.mjs',
|
|
59
|
+
'/scripts/install-git-hooks-repo-exec.test.mjs',
|
|
54
60
|
'/scripts/install-git-hooks.mjs',
|
|
61
|
+
'/scripts/install-git-hooks.test.mjs',
|
|
55
62
|
'/scripts/markdown-blocks.mjs',
|
|
56
63
|
'/scripts/markdown-blocks.test.mjs',
|
|
64
|
+
'/scripts/migrate-gates-branches.test.mjs',
|
|
65
|
+
'/scripts/migrate-gates.mjs',
|
|
66
|
+
'/scripts/migrate-gates.test.mjs',
|
|
57
67
|
'/docs/plans/',
|
|
58
68
|
'/.claude/settings.local.json',
|
|
59
69
|
'/.claude/settings.json',
|