@sabaiway/agent-workflow-kit 7.1.0 → 7.3.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 +73 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/contracts.md +1 -1
- package/references/modes/bootstrap.md +1 -1
- package/references/modes/mcp.md +2 -0
- package/references/modes/worktrees.md +16 -1
- package/references/scripts/check-docs-size.mjs +59 -62
- package/references/scripts/check-docs-size.test.mjs +79 -15
- package/references/scripts/spec-schema.mjs +355 -0
- package/references/scripts/spec-schema.test.mjs +212 -0
- package/references/templates/AGENTS.md +2 -2
- package/references/templates/SPEC_TEMPLATE.md +45 -0
- package/references/templates/agent_rules.md +5 -3
- package/references/templates/specs/index.md +21 -0
- package/tools/known-footprint.mjs +9 -1
- package/tools/mcp-registration.mjs +70 -0
- package/tools/mcp.mjs +26 -5
- package/tools/worktrees.mjs +105 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,79 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 7.3.0 — the spec layer's delivery half: hidden from day one, mirrored, bootstrapped (AD-112)
|
|
8
|
+
|
|
9
|
+
The memory substrate ships the feature-spec layer in 4.6.0 and the engine its canon in 3.3.0; this
|
|
10
|
+
release is what makes a FRESH kit bootstrap deliver it whole and hidden.
|
|
11
|
+
|
|
12
|
+
- **Hidden mode, no new machinery.** `KIT_OWN_PATHS` gains `/scripts/spec-schema.mjs` and
|
|
13
|
+
`/scripts/spec-schema.test.mjs` (sentinel 28 -> 30) — the reader the navigator checker now imports
|
|
14
|
+
and its deployed test; `/docs/ai/` already subsumes the store. The integration suite proves it on a
|
|
15
|
+
bootstrapped layout with real `git check-ignore` on both files and on `docs/ai/specs/x.md`: nothing
|
|
16
|
+
of the layer is visible to git.
|
|
17
|
+
- **Mirrors.** `references/scripts/` carries the reader pair and the GROUPS-collapse checker
|
|
18
|
+
byte-identical to the memory canon; `references/templates/` carries `specs/index.md` and
|
|
19
|
+
`SPEC_TEMPLATE.md` (the mirror manifest names both; template-parity reverse-pins them). The kit's
|
|
20
|
+
own fallback bootstrap excludes `SPEC_TEMPLATE.md` by NAME in its prose AND its E2E copy loop, and
|
|
21
|
+
the finalized navigator carries ONE counted `specs/` row.
|
|
22
|
+
- **Templates.** `agent_rules.md` §1.2/§1.3 reworded (governing specs, adoption shim, the revision
|
|
23
|
+
lands with the code) and its lens region re-rendered with the engine's `Spec-first` bullet;
|
|
24
|
+
`AGENTS.md` routes `technical_specification.md` readers to the contracts under `docs/ai/specs/`.
|
|
25
|
+
- Package-content pins: 241 -> 245 (the reader pair + the two templates), each by NAME.
|
|
26
|
+
|
|
27
|
+
## 7.2.0 — `.mcp.json` joins the hidden-mode footprint, and a worktree never owns it (AD-111)
|
|
28
|
+
|
|
29
|
+
7.0.0 taught the kit to register its MCP server. It did not teach hidden mode about the file that
|
|
30
|
+
registration writes — so a hidden deployment that ran `/agent-workflow-kit mcp` left `.mcp.json`
|
|
31
|
+
sitting in `git status`, one `git add -A` away from a commit. That is exactly the leak the footprint
|
|
32
|
+
registry exists to prevent, and it shipped anyway.
|
|
33
|
+
|
|
34
|
+
- **`/.mcp.json` is in `KIT_OWN_PATHS`.** It joins `/.claude/settings.json` as a SHARED container the
|
|
35
|
+
kit merges into rather than owns, on the same terms: the tracked→ASK arm means a project that
|
|
36
|
+
already commits its own registration is asked, never silently un-tracked. Candidacy is
|
|
37
|
+
unconditional on purpose — the path is CONSIDERED before a later `mcp --apply` writes the file
|
|
38
|
+
rather than only after it appears, and is then hidden, dropped as already covered by a tracked
|
|
39
|
+
`.gitignore`, or surfaced as an ASK. That is what removes the follow-up reconcile the bug depended
|
|
40
|
+
on. The `contracts.md` prose mirror names it.
|
|
41
|
+
- **A worktree is never HANDED one, and never owns one.** The scope is the untracked lanes this tool
|
|
42
|
+
drives — the registry copy set and `--include`; a TRACKED `.mcp.json` still arrives with
|
|
43
|
+
`git worktree add`, because that is the checkout, not a copy. One frozen never-provisioned set,
|
|
44
|
+
read at three sites because each needs it for a different reason: the copy set would hand a
|
|
45
|
+
satellite a launcher pinned to a path consented for the MAIN checkout; the containment sweep would
|
|
46
|
+
STOP the whole provision on an escaping symlink at that path (a device-node mask realpaths inside
|
|
47
|
+
the repo and reaches the later special-file refusal instead); and `registryRoots` decides what
|
|
48
|
+
`cleanup` may REMOVE. That third one was measured — without the guard, `cleanup` on a landed
|
|
49
|
+
worktree carrying a satellite's own `.mcp.json` exited 0 and took the file with it. `--include` is
|
|
50
|
+
refused pre-mutation for the same paths: it is the one door that bypasses the copy set, and it
|
|
51
|
+
would have recorded the launcher as ours.
|
|
52
|
+
- **A settings copy this tool wrote never enables a server the worktree cannot declare.** The
|
|
53
|
+
guarantee is exactly that narrow. `.claude/settings.json` rides `REBASE_TARGETS` into a worktree
|
|
54
|
+
carrying `enabledMcpjsonServers`, which `mcp.md` itself calls a client error on every startup. An
|
|
55
|
+
untracked copy still equal to MAIN loses our enable and our two derived allow rules — and ONLY
|
|
56
|
+
those; every foreign server, rule and key survives, as do the file's EOL and the order of its
|
|
57
|
+
non-index keys (`withoutRegistration`, the token-removal complement of `mergeSettings`, not its
|
|
58
|
+
exact inverse — a JSON round-trip still hoists integer-like keys, as JS object semantics require).
|
|
59
|
+
**Where a rewrite would LOSE something the kit does not own, it is refused whole and says why.**
|
|
60
|
+
Three ways, each detected before anything is written: a number past double precision comes back a
|
|
61
|
+
different value; a duplicate key at any depth collapses to the last one (both found on the source
|
|
62
|
+
TEXT, because after the parse the evidence is gone); and invalid UTF-8 would become U+FFFD, so both
|
|
63
|
+
rewriting lanes — the pin rebase and the token strip — decode with FATAL UTF-8 and refuse
|
|
64
|
+
separately. The MAIN-equality proof compares BYTES, with the BOM preserved through the decode, so
|
|
65
|
+
a BOM-carrying original and a BOM-less copy can never pass for the same file.
|
|
66
|
+
The rewrite is admitted by ONE positive conjunction — untracked, bytes still MAIN's or their
|
|
67
|
+
rebased form, and a launcher proven ABSENT — so a tracked or user-modified settings file keeps its
|
|
68
|
+
registration tokens on purpose, as does one standing beside a launcher that is present or that
|
|
69
|
+
cannot be read. Those states get a neutral line naming the tokens; orphanhood is only CLAIMED where
|
|
70
|
+
it is proven.
|
|
71
|
+
- **All three output arms name the reconcile.** Applied, masked hand-off, and already-registered —
|
|
72
|
+
the last one matters most, because a deployment registered under 7.0.0 learns about the reconcile
|
|
73
|
+
on its next run. A settings write that fails after the entry landed carries the note too: that
|
|
74
|
+
path never reaches the report, and it strands a standing registration.
|
|
75
|
+
|
|
76
|
+
**Known residual, stated:** a VISIBLE deployment still commits a launcher carrying an absolute
|
|
77
|
+
machine path. The fix is not symmetric with hidden mode — a visible `.mcp.json` may legitimately hold
|
|
78
|
+
a team's own committable servers — so it is its own queued decision, not a line in this release.
|
|
79
|
+
|
|
7
80
|
## 7.1.0 — `fold-scope`: the fold channel gets a checker that refuses a claim whose reference does not resolve (AD-110)
|
|
8
81
|
|
|
9
82
|
The engine canon now asks every finding to NAME the invariant its fix enforces before the edit
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '7.
|
|
6
|
+
version: '7.3.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.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",
|
package/references/contracts.md
CHANGED
|
@@ -42,7 +42,7 @@ diverge:
|
|
|
42
42
|
| `/.aider.input.history` | Aider | file | no | input history |
|
|
43
43
|
| `/.continue/` | Continue | dir | no | project config dir |
|
|
44
44
|
|
|
45
|
-
The kit's OWN footprint (`KIT_OWN_PATHS`) — `AGENTS.md`, `CLAUDE.md`, `docs/ai/` (subsumes the stamp), the added `scripts/*.mjs`, `docs/plans/`, `.claude/settings.local.json`,
|
|
45
|
+
The kit's OWN footprint (`KIT_OWN_PATHS`) — `AGENTS.md`, `CLAUDE.md`, `docs/ai/` (subsumes the stamp), the added `scripts/*.mjs`, `docs/plans/`, `.claude/settings.local.json`, `.claude/settings.json` (hidden-only — visible mode commits it), and `.mcp.json` (the `mcp` mode's registration; hidden-only for the same reason, and it carries an absolute machine path) — is always a candidate in hidden mode.
|
|
46
46
|
|
|
47
47
|
Not in this version: a fully-external hidden mode (artifacts relocated outside the repo tree).
|
|
48
48
|
Deferred to a later release + migration.
|
|
@@ -21,7 +21,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
21
21
|
3. **Choose conversational language — answered in the step-2 batch.** Which language should the agent *talk to them* in — questions, explanations, summaries, status updates? Offer the language they're already writing in as the default. Carry the answer into the `{{COMM_LANGUAGE}}` slot of the *Communication language* block when `AGENTS.md` is created (step 5). See [Communication contract](${CLAUDE_SKILL_DIR}/references/contracts.md#communication-contract). This sets the **dialogue** language only — never the files.
|
|
22
22
|
4. **Choose agent attribution — answered in the step-2 batch.** May the agent attribute work to itself / to AI — `Co-Authored-By` trailers, "Generated with …" footers, "AI"/agent/model mentions in code, comments, commit messages, PR titles/bodies, or docs? **Default to `off`** (no agent/AI mention anywhere) unless they opt in — people are routinely surprised to find an AI listed as a repo contributor. Carry the answer into the `{{AGENT_ATTRIBUTION}}` slot of the *Attribution* block when `AGENTS.md` is created (step 5). **If `off` and the project uses Claude Code**, also set `"includeCoAuthoredBy": false` in the project's `.claude/settings.json` (create it if absent) — the trailer is added by the harness, so a doc directive alone won't stop it. See [Attribution contract](${CLAUDE_SKILL_DIR}/references/contracts.md#attribution-contract).
|
|
23
23
|
5. **Entry-point doc.** If `AGENTS.md` / `CLAUDE.md` already exist (step-1 recon), do **not** overwrite — show the user and ask whether to merge or replace. Otherwise create `AGENTS.md` (the cross-agent standard — Codex / Cursor / Devin Desktop / Copilot read it natively) from `${CLAUDE_SKILL_DIR}/references/templates/AGENTS.md`, and symlink `CLAUDE.md -> AGENTS.md` (`ln -s AGENTS.md CLAUDE.md`) for Claude Code — single source, no duplication. For nested context, add a subdir `AGENTS.md` (+ a `CLAUDE.md` symlink beside it for Claude Code).
|
|
24
|
-
6. **Deploy `docs/ai/`.** Create every `docs/ai/` file + `pages/` + the seed `adr/` store from `${CLAUDE_SKILL_DIR}/references/templates/` (the template loop deploys
|
|
24
|
+
6. **Deploy `docs/ai/`.** Create every `docs/ai/` file + `pages/` + the seed `adr/` store + the seed `specs/` store from `${CLAUDE_SKILL_DIR}/references/templates/` (the template loop deploys every template except `AGENTS.md`, `adr-record.md` and `SPEC_TEMPLATE.md` — the latter two are skill-home authoring references, never deployed). This deploys the HOT ADR window **`docs/ai/decisions.md`** and the seed navigator **`docs/ai/adr/log.md`** (its `adr/` subdir copies in wholesale), the spec store root **`docs/ai/specs/index.md`** (its `specs/` subdir copies in wholesale), the `.md` docs, **and** the three seeded, user-editable strict-JSON configs: **`docs/ai/orchestration.json`** (the per-project recipe defaults the `procedures` advisor reads), **`docs/ai/gates.json`** (the project's gate declaration — seeded as the no-consent EMPTY list; FILLING it is the consented `gates-init` preview, `${CLAUDE_SKILL_DIR}/references/modes/gates.md`), and **`docs/ai/autonomy.json`** (the per-project autonomy policy — seeded SPARSE with the onboarding note only, so it is exactly the computed defaults until the user declares levels via `/agent-workflow-kit set-autonomy` or by hand). Keep each `.md` file's frontmatter (`type / lastUpdated / scope / staleAfter / owner / maxLines`); the `.json` seeds carry no frontmatter (the docs cap-validator globs `*.md` only, so they are inherently skipped).
|
|
25
25
|
7. **Fill templates** per the table below. **Then materialize the navigator, as the last `docs/ai` step:** the entry point declares `docs/ai/index.md` always-loaded and it is a GENERATED artifact with no template, so run `node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<project>` and relay its one outcome line (`ensure-index: regenerated` / `already-current`, or a `write-refused` / `probe-failed` line naming the offending path — either is a STOP: resolve it before reporting the deploy, since the navigator the entry point declares does not exist yet). The generator runs from **this skill's own directory**, so a project with **no Node runtime** is covered too: the agent host runs it, and step 8's copy is not a precondition.
|
|
26
26
|
8. **Install enforcement (Node projects).** Copy `${CLAUDE_SKILL_DIR}/references/scripts/*.mjs` (+ `*.test.mjs`) into the project's `scripts/`. They self-configure (project name from `package.json`, hierarchical/on-demand sections auto-discovered). **If the project has no Node runtime** (step-1 recon), skip this step and the hook in step 9 — follow the cap/archive/index policy manually, or port the scripts to the project's language.
|
|
27
27
|
9. **Wire / hide** per visibility (see [Visibility contract](${CLAUDE_SKILL_DIR}/references/contracts.md#visibility-contract)). Install the pre-commit hook (Node projects): `node scripts/install-git-hooks.mjs`. If the installer reports a pre-existing non-marker hook, stop and ask the user to merge it manually rather than overwriting. Once the gate declaration is final-run-capable, the installer's optional consented `--commit-guard <kit commit-guard.mjs path>` arm binds commits to the green `run-gates --final` receipt (the Recommendations advisor surfaces the exact one-liner).
|
package/references/modes/mcp.md
CHANGED
|
@@ -17,6 +17,8 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/mcp.mjs [--dry-run | --apply] [--cwd <dir>]`
|
|
|
17
17
|
2. **Ask consent** via **`AskUserQuestion` where supported**, the no-change option first: keep the shell lanes, or register the typed channel — presenting the trust posture above in plain language.
|
|
18
18
|
3. **Only on an explicit yes**, re-run with `--apply`. It writes **`.mcp.json` FIRST, then `.claude/settings.json`** — settings that enable a server whose entry is not yet there is a client error on every startup. Both writes are merge-don't-clobber (foreign servers, foreign keys and existing allow rules preserved; a re-apply adds nothing twice) and keep each file's existing EOL. New MCP servers are picked up when the client next starts — unlike the `hook` mode, this is not a hot reload.
|
|
19
19
|
|
|
20
|
+
4. **Hidden-mode deployments:** after apply, run the hide-footprint reconcile (`node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile`) so the registration stays invisible to `git status` — `/.mcp.json` is in the known-footprint registry; every output arm reminds you.
|
|
21
|
+
|
|
20
22
|
**The two arms that are not a write:**
|
|
21
23
|
|
|
22
24
|
- **DIFFERING** — `.mcp.json` already carries an `"agent-workflow"` entry that **structurally differs** from ours (another kit copy, a hand-edited path, an added `env`, a `null`). The comparison is deliberately key-order-independent, so a re-serialized identical entry is the SAME registration, while any real difference in what would be launched is a difference. The run **STOPs unwritten, on both lanes**: silently changing what an MCP server launches is exactly what consent must not slide past. The recovery is named — review that entry, then remove or rename it and re-run. The kit never resolves this for you.
|
|
@@ -14,10 +14,25 @@ The ONE stored-metadata exception is the PREPARED OID recorded in the handoff: l
|
|
|
14
14
|
Git ≥ 2.36 is required for NUL-terminated worktree porcelain; an older Git fails closed with its
|
|
15
15
|
own verbatim error through the existing Git-error surface.
|
|
16
16
|
|
|
17
|
+
**The MCP registration is never HANDED to a worktree, and never owned by one.** The scope is the
|
|
18
|
+
untracked lanes this tool drives — the registry copy set and `--include` (refused pre-mutation, and
|
|
19
|
+
a recorded include from an older handoff is filtered out too); a TRACKED `.mcp.json` still arrives
|
|
20
|
+
with `git worktree add`, because that is the checkout, not a copy. `/.mcp.json` names an absolute
|
|
21
|
+
machine path and its consent is per checkout, so it is excluded from the copy set, from the provision
|
|
22
|
+
containment sweep, and from what `cleanup` may remove — a satellite's own `.mcp.json` therefore stays
|
|
23
|
+
classified as foreign work and blocks cleanup, rather than being deleted as ours. The settings half
|
|
24
|
+
is rewritten only under ONE conjunction of proven facts: untracked, bytes still MAIN's (or their
|
|
25
|
+
rebased form), and a launcher proven ABSENT — then the copy loses our `enabledMcpjsonServers`
|
|
26
|
+
membership and our two derived allow rules and nothing else. Every other state keeps its registration
|
|
27
|
+
TOKENS — not necessarily its bytes, since `rebasePins` may still rewrite absolute pins in that same
|
|
28
|
+
file on its own lane — and, where the settings file is readable, well-formed and actually carries
|
|
29
|
+
tokens, gets a neutral line naming them; orphanhood is claimed only where it is proven.
|
|
30
|
+
|
|
17
31
|
- `provision <slug> --plan <path> [--as <name>.md] [--dir <path>] [--branch <name>] [--include <path>]... [--install] [--resume]`
|
|
18
32
|
— create a feature worktree (default: the visible sibling `<repoParent>/<repoName>--<slug>`,
|
|
19
33
|
branch `aw/<slug>`) and populate it: the registry-derived footprint copy-if-missing (a tracked
|
|
20
|
-
file is NEVER overwritten
|
|
34
|
+
file is NEVER overwritten, and `/.mcp.json` is never copied or owned — see below), EXACTLY ONE seeded
|
|
35
|
+
feature plan, the `handoff-<slug>.md` stub
|
|
21
36
|
(written at provision — the tool's own record; `list` and `cleanup` read it), a
|
|
22
37
|
`node_modules` symlink when main has one and the link stays ignored — a SHARED MUTABLE cache:
|
|
23
38
|
writes through it hit MAIN's node_modules; for isolation RUN the printed isolated-install
|
|
@@ -29,6 +29,7 @@ import { existsSync, lstatSync, realpathSync } from 'node:fs';
|
|
|
29
29
|
import { dirname, resolve, relative, join, basename, sep } from 'node:path';
|
|
30
30
|
import { fileURLToPath } from 'node:url';
|
|
31
31
|
import { randomBytes } from 'node:crypto';
|
|
32
|
+
import { readSpecDocument, SPECS_COLLAPSE } from './spec-schema.mjs';
|
|
32
33
|
|
|
33
34
|
const __filename = fileURLToPath(import.meta.url);
|
|
34
35
|
const __dirname = dirname(__filename);
|
|
@@ -36,10 +37,8 @@ const ROOT = resolve(__dirname, '..');
|
|
|
36
37
|
const DOCS_DIR = resolve(ROOT, 'docs/ai');
|
|
37
38
|
const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
|
|
38
39
|
|
|
39
|
-
//
|
|
40
|
-
// (
|
|
41
|
-
// override them so the ADR-rotation hook (archive-decisions.mjs) and hermetic tests can regenerate
|
|
42
|
-
// an arbitrary root's index without ever touching the real repo tree.
|
|
40
|
+
// The module ROOT constants are the CLI DEFAULT (this deployment's own root); `--root=<dir>` and the
|
|
41
|
+
// exported `regenerateIndex(root, today)` target an arbitrary root (the ADR-rotation hook, hermetic tests).
|
|
43
42
|
const pathsFor = (root) => {
|
|
44
43
|
const base = resolve(root);
|
|
45
44
|
return { root: base, docsDir: resolve(base, 'docs/ai'), indexPath: resolve(base, 'docs/ai/index.md') };
|
|
@@ -58,12 +57,9 @@ const ENSURE_INDEX_PREFIX = 'ensure-index:';
|
|
|
58
57
|
const DEFAULT_PROJECT_NAME = 'this project';
|
|
59
58
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-ssr', 'coverage', 'build', '.next']);
|
|
60
59
|
|
|
61
|
-
// `strict` is the finalizer's lens on the SAME walk:
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// a genuine ENOENT stays an absence; every other fs error propagates.
|
|
65
|
-
// Only a genuine ENOENT is an absence. A code-LESS throw (an injected reader, a wrapped client) is
|
|
66
|
-
// not evidence of absence either, so it propagates too — "unknown" must never read as "empty".
|
|
60
|
+
// `strict` is the finalizer's lens on the SAME walk: a run that WRITES the navigator may not treat
|
|
61
|
+
// "could not read" as "nothing there". Only a genuine ENOENT is an absence — a code-LESS throw (an
|
|
62
|
+
// injected reader, a wrapped client) propagates too: "unknown" must never read as "empty".
|
|
67
63
|
const rethrowUnlessAbsent = (err, strict) => {
|
|
68
64
|
if (strict && err?.code !== 'ENOENT') throw err;
|
|
69
65
|
};
|
|
@@ -204,24 +200,25 @@ export const computeToday = (todayStr) =>
|
|
|
204
200
|
? new Date(`${todayStr}T00:00:00Z`)
|
|
205
201
|
: new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
|
|
206
202
|
|
|
203
|
+
// A file under the spec store is ALSO read through the shared reader (already-read text, no second
|
|
204
|
+
// read): its verdict rides the row as `spec` and decides collapse eligibility; a refusal is ADVISORY
|
|
205
|
+
// here — surfaced as warnings, never a cap error — the structural checker owns the refusal.
|
|
206
|
+
const readSpecVerdict = (rel, text) =>
|
|
207
|
+
rel.startsWith(SPECS_COLLAPSE.prefix) ? readSpecDocument(text, rel.slice(SPECS_COLLAPSE.prefix.length)) : null;
|
|
208
|
+
|
|
207
209
|
export const inspectFile = async (filePath, today, root = ROOT) => {
|
|
208
210
|
const text = await readFile(filePath, 'utf8');
|
|
209
211
|
const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
210
212
|
const fm = parseFrontmatter(text);
|
|
211
213
|
const rel = relative(root, filePath);
|
|
214
|
+
const spec = readSpecVerdict(rel, text);
|
|
212
215
|
|
|
213
216
|
if (!fm) {
|
|
214
|
-
return {
|
|
215
|
-
path: rel,
|
|
216
|
-
lineCount,
|
|
217
|
-
frontmatter: null,
|
|
218
|
-
errors: [`missing YAML frontmatter`],
|
|
219
|
-
warnings: [],
|
|
220
|
-
};
|
|
217
|
+
return { path: rel, lineCount, frontmatter: null, spec, errors: [`missing YAML frontmatter`], warnings: [] };
|
|
221
218
|
}
|
|
222
219
|
|
|
223
220
|
const errors = [];
|
|
224
|
-
const warnings = [];
|
|
221
|
+
const warnings = (spec === null ? [] : [...spec.errors, ...spec.warnings]).map((finding) => `spec ${finding.rule}: ${finding.message}`);
|
|
225
222
|
|
|
226
223
|
const maxLines = fm.maxLines ? Number(fm.maxLines) : null;
|
|
227
224
|
if (maxLines === null || Number.isNaN(maxLines)) {
|
|
@@ -241,7 +238,7 @@ export const inspectFile = async (filePath, today, root = ROOT) => {
|
|
|
241
238
|
}
|
|
242
239
|
}
|
|
243
240
|
|
|
244
|
-
return { path: rel, lineCount, frontmatter: fm, errors, warnings };
|
|
241
|
+
return { path: rel, lineCount, frontmatter: fm, spec, errors, warnings };
|
|
245
242
|
};
|
|
246
243
|
|
|
247
244
|
const formatRow = (row) => {
|
|
@@ -304,19 +301,16 @@ const formatIndexRow = (row) => {
|
|
|
304
301
|
return `| ${link} | ${fm.type ?? '—'} | ${row.lineCount}/${fm.maxLines ?? '—'} | ${fm.lastUpdated ?? '—'} | ${fm.staleAfter ?? '—'} |`;
|
|
305
302
|
};
|
|
306
303
|
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
304
|
+
// Two stores grow O(n) forever and would blow the index's own 80-line cap: the one-file-per-ADR store
|
|
305
|
+
// (docs/ai/adr/) and the spec store (docs/ai/specs/). Each is a GROUP that COLLAPSES to a single
|
|
306
|
+
// counted row — while walkMarkdownFiles still finds + cap-checks every individual body (only the
|
|
307
|
+
// index RENDERING is collapsed). Membership is by PROOF: a genuine ADR record / the adr navigator,
|
|
308
|
+
// or a spec-store file the shared reader accepts. Anything else under either dir (a stray README.md,
|
|
309
|
+
// a malformed spec) renders as its OWN visible row — never silently hidden by the collapse.
|
|
311
310
|
const ADR_DIR_PREFIX = 'docs/ai/adr/';
|
|
312
311
|
const ADR_RECORD_RE = /\/AD-(\d{3,})-[^/]*\.md$/;
|
|
313
312
|
const ADR_NAV_PATH = 'docs/ai/adr/log.md';
|
|
314
313
|
|
|
315
|
-
// Only genuine records + the navigator collapse into the aggregate row; an UNEXPECTED file under
|
|
316
|
-
// adr/ (a stray README.md, AD-foo.md) renders as its OWN visible index row — never silently hidden
|
|
317
|
-
// by the collapse (it also fails archive-decisions' own store-integrity check).
|
|
318
|
-
const isCollapsibleAdr = (path) => path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(path) || path === ADR_NAV_PATH);
|
|
319
|
-
|
|
320
314
|
const formatAdrCollapseRow = (adrRows) => {
|
|
321
315
|
const recs = adrRows
|
|
322
316
|
.map((r) => {
|
|
@@ -329,23 +323,37 @@ const formatAdrCollapseRow = (adrRows) => {
|
|
|
329
323
|
return `| [\`adr/\`](./adr/log.md) | adr | ${recs.length} records | ${range} | — |`;
|
|
330
324
|
};
|
|
331
325
|
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
326
|
+
// The live counts make the row change when a spec is added or removed, so --check-index sees it.
|
|
327
|
+
const formatSpecsCollapseRow = (specRows) => {
|
|
328
|
+
const count = (kind) => specRows.filter((r) => r.spec.kind === kind).length;
|
|
329
|
+
const link = `[\`${SPECS_COLLAPSE.label}\`](./${SPECS_COLLAPSE.navPath.replace(/^docs\/ai\//, '')})`;
|
|
330
|
+
return `| ${link} | ${SPECS_COLLAPSE.type} | ${count('spec')} specs | ${count('part')} parts · ${count('index')} indexes | — |`;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const COLLAPSE_GROUPS = [
|
|
334
|
+
{ sortPath: ADR_DIR_PREFIX, isMember: (r) => r.path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(r.path) || r.path === ADR_NAV_PATH), format: formatAdrCollapseRow },
|
|
335
|
+
{ sortPath: SPECS_COLLAPSE.prefix, isMember: (r) => Boolean(r.spec) && r.spec.errors.length === 0, format: formatSpecsCollapseRow },
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
// Pure index renderer — inspected rows + the header date → the exact bytes `docs/ai/index.md` should
|
|
339
|
+
// contain. Shared by `--write-index` (writes it) and `--check-index` (diffs against on-disk).
|
|
335
340
|
export const buildIndex = (rows, todayStr, meta = {}) => {
|
|
336
341
|
const projectName = meta.projectName ?? DEFAULT_PROJECT_NAME;
|
|
337
342
|
const onDemandLinks = meta.onDemandLinks ?? [];
|
|
338
343
|
const hierarchicalLinks = meta.hierarchicalLinks ?? [];
|
|
339
344
|
const header = INDEX_HEADER.replace('__TODAY__', todayStr).replace('__PROJECT__', projectName);
|
|
340
345
|
const tableHeader = `| File | Type | Lines/Max | Updated | Stale after |\n|------|------|-----------|---------|-------------|`;
|
|
341
|
-
const
|
|
342
|
-
const
|
|
346
|
+
const singles = [];
|
|
347
|
+
const grouped = COLLAPSE_GROUPS.map(() => []);
|
|
343
348
|
for (const r of rows) {
|
|
344
349
|
if (r.path === 'docs/ai/index.md') continue;
|
|
345
|
-
(
|
|
350
|
+
const group = COLLAPSE_GROUPS.findIndex((g) => g.isMember(r));
|
|
351
|
+
(group === -1 ? singles : grouped[group]).push(r);
|
|
346
352
|
}
|
|
347
|
-
const tableEntries =
|
|
348
|
-
|
|
353
|
+
const tableEntries = singles.map((r) => ({ sortPath: r.path, md: formatIndexRow(r) }));
|
|
354
|
+
COLLAPSE_GROUPS.forEach((g, i) => {
|
|
355
|
+
if (grouped[i].length > 0) tableEntries.push({ sortPath: g.sortPath, md: g.format(grouped[i]) });
|
|
356
|
+
});
|
|
349
357
|
tableEntries.sort((a, b) => a.sortPath.localeCompare(b.sortPath));
|
|
350
358
|
const tableRows = tableEntries.map((e) => e.md).join('\n');
|
|
351
359
|
const onDemandSection =
|
|
@@ -359,11 +367,8 @@ export const buildIndex = (rows, todayStr, meta = {}) => {
|
|
|
359
367
|
return `${header}${tableHeader}\n${tableRows}${onDemandSection}${hierarchicalSection}\n`;
|
|
360
368
|
};
|
|
361
369
|
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// for the header, so a mere day-rollover (no content change) is NOT flagged —
|
|
365
|
-
// only genuine drift in the file table (added/removed files, changed
|
|
366
|
-
// type/cap/lastUpdated/staleAfter, or a changed line count) makes it stale.
|
|
370
|
+
// Freshness regenerates the index with the on-disk header's OWN `lastUpdated`, so a mere day-rollover
|
|
371
|
+
// is NOT flagged — only genuine drift in the file table makes it stale.
|
|
367
372
|
export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
|
|
368
373
|
if (onDiskText === null || onDiskText === undefined || onDiskText === '') {
|
|
369
374
|
return { fresh: false, expected: buildIndex(rows, 'unknown', meta) };
|
|
@@ -374,13 +379,11 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
|
|
|
374
379
|
return { fresh: expected === onDiskText, expected };
|
|
375
380
|
};
|
|
376
381
|
|
|
377
|
-
// The navigator
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
// the
|
|
381
|
-
//
|
|
382
|
-
// same discipline in atomic-write.mjs; this deployment script ships dependency-free, so the
|
|
383
|
-
// semantics are REIMPLEMENTED here rather than imported.
|
|
382
|
+
// The navigator write lands on the deployment's own file and nowhere else: every component of
|
|
383
|
+
// <root>/docs/ai/index.md is lstat'ed no-follow (a symlink anywhere REFUSES — publishing through one
|
|
384
|
+
// would clobber its target), the body goes out through an exclusive-create temp renamed into place
|
|
385
|
+
// with the chain re-checked before the rename, and the temp never survives a failure. The kit's
|
|
386
|
+
// atomic-write.mjs runs the same discipline; this script ships dependency-free, so it is REIMPLEMENTED.
|
|
384
387
|
export const INDEX_WRITE_REFUSED = 'INDEX_WRITE_REFUSED';
|
|
385
388
|
const refuse = (message) => Object.assign(new Error(message), { code: INDEX_WRITE_REFUSED });
|
|
386
389
|
|
|
@@ -444,11 +447,8 @@ const writeIndex = async (rows, today, meta, { root = ROOT, indexPath = INDEX_PA
|
|
|
444
447
|
}
|
|
445
448
|
};
|
|
446
449
|
|
|
447
|
-
// regenerateIndex(root, todayStr) — the ONE reused generator
|
|
448
|
-
//
|
|
449
|
-
// (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
|
|
450
|
-
// hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
|
|
451
|
-
// index path + row count. No second index implementation exists.
|
|
450
|
+
// regenerateIndex(root, todayStr) — the ONE reused generator: the SAME walk → inspect → discoverMeta →
|
|
451
|
+
// writeIndex pipeline as `--write-index`, against `root`. `todayStr` is 'YYYY-MM-DD' (null → today).
|
|
452
452
|
export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
|
|
453
453
|
const paths = pathsFor(root);
|
|
454
454
|
const today = computeToday(todayStr);
|
|
@@ -460,12 +460,10 @@ export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
|
|
|
460
460
|
return { indexPath: paths.indexPath, files: rows.length };
|
|
461
461
|
};
|
|
462
462
|
|
|
463
|
-
// The finalizer promises
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// whose target happens to hold current bytes would otherwise report `already-current` over a file
|
|
468
|
-
// this mode refuses to write through — an exit 0 proving nothing about the deployment's own file.
|
|
463
|
+
// The finalizer promises EXACTLY ONE outcome line, so every step it owns runs inside one classified
|
|
464
|
+
// error path: an unreadable docs/ai is a NAMED refusal, never a stack trace. The containment guard
|
|
465
|
+
// runs BEFORE the freshness read: a symlinked navigator whose target holds current bytes would
|
|
466
|
+
// otherwise report `already-current` over a file this mode refuses to write through.
|
|
469
467
|
const runEnsureIndex = async ({ root, docsDir, indexPath, today, deps }) => {
|
|
470
468
|
const lstat = deps.lstat ?? lstatSync;
|
|
471
469
|
const read = deps.readFile ?? readFile;
|
|
@@ -571,9 +569,8 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
571
569
|
return result(errorCount > 0 && !flags.report ? 1 : 0);
|
|
572
570
|
};
|
|
573
571
|
|
|
574
|
-
// Run main() only when executed directly, never on import
|
|
575
|
-
// reached through a symlink
|
|
576
|
-
// different and the CLI never runs. realpathSync collapses the link so both sides match.
|
|
572
|
+
// Run main() only when executed directly, never on import — compared by REAL path, so an entry point
|
|
573
|
+
// reached through a symlink still runs.
|
|
577
574
|
const isDirectRun = (() => {
|
|
578
575
|
const invoked = process.argv[1];
|
|
579
576
|
if (!invoked) return false;
|
|
@@ -178,11 +178,7 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
it('a stray adr/ markdown file renders as its OWN visible row (never collapsed/hidden)', () => {
|
|
181
|
-
const rows = [
|
|
182
|
-
makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
183
|
-
makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
|
|
184
|
-
makeRow('docs/ai/adr/notes.md', { frontmatter: { type: 'reference', maxLines: '100' } }),
|
|
185
|
-
];
|
|
181
|
+
const rows = [makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }), makeRow('docs/ai/adr/notes.md', { frontmatter: { type: 'reference', maxLines: '100' } })];
|
|
186
182
|
const out = buildIndex(rows, '2026-07-09');
|
|
187
183
|
expect(out).toMatch(/adr\/notes\.md/); // the stray is a visible row, not swallowed by the collapse
|
|
188
184
|
const aggregateRows = out.split('\n').filter((l) => l.includes('](./adr/log.md)'));
|
|
@@ -190,11 +186,7 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
|
|
|
190
186
|
});
|
|
191
187
|
|
|
192
188
|
it('the aggregate row shows the record count and a NUMERIC id range (AD-200 … AD-1000)', () => {
|
|
193
|
-
const rows = [
|
|
194
|
-
makeRow('docs/ai/adr/AD-200-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
195
|
-
makeRow('docs/ai/adr/AD-1000-b.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
196
|
-
makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
|
|
197
|
-
];
|
|
189
|
+
const rows = [makeRow('docs/ai/adr/AD-200-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/AD-1000-b.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } })];
|
|
198
190
|
const out = buildIndex(rows, '2026-07-09');
|
|
199
191
|
expect(out).toMatch(/\[`adr\/`\]\(\.\/adr\/log\.md\) \| adr \| 2 records \| AD-200 … AD-1000/);
|
|
200
192
|
});
|
|
@@ -219,13 +211,70 @@ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
|
|
|
219
211
|
});
|
|
220
212
|
});
|
|
221
213
|
|
|
214
|
+
// The spec store (docs/ai/specs/**) joins the collapse as a second GROUP: every file whose reader
|
|
215
|
+
// verdict is clean folds into ONE counted `specs/` row; a file the reader refuses keeps its own row.
|
|
216
|
+
const SPEC_FRONT = (kind, maxLines, extra = '') =>
|
|
217
|
+
`---\ntype: spec\nlastUpdated: 2026-08-23\nscope: permanent\nstaleAfter: 90d\nowner: none\nmaxLines: ${maxLines}\nkind: ${kind}\n${extra}---\n`;
|
|
218
|
+
const specBody = (slug) =>
|
|
219
|
+
`\n# Spec: ${slug}\n\n## Contract\n\nc\n\n## Scenarios\n\n- S1 a :: test/${slug}.test.mjs :: spec:${slug}/S1\n\n## Out of scope\n\n- b\n\n## Module\n\n- src/${slug}/\n`;
|
|
220
|
+
const specRow = (path, kind, errors = []) => makeRow(path, { frontmatter: { type: 'spec', maxLines: '150' }, spec: { kind, status: 'draft', revision: 1, errors, warnings: [] } });
|
|
221
|
+
const SPECS_ROW_RE = /\| \[`specs\/`\]\(\.\/specs\/index\.md\) \| spec \| (\d+) specs \| (\d+) parts · (\d+) indexes \| — \|/;
|
|
222
|
+
|
|
223
|
+
describe('buildIndex — docs/ai/specs/ store collapse (spec layer 1a)', () => {
|
|
224
|
+
it('1000 valid specs + their indexes render ONE counted specs/ row; the index stays <= 80 lines', () => {
|
|
225
|
+
const specs = Array.from({ length: 1000 }, (_, i) => specRow(`docs/ai/specs/d${Math.floor(i / 30)}/s${i}.md`, 'spec'));
|
|
226
|
+
const indexes = Array.from({ length: 34 }, (_, i) => specRow(`docs/ai/specs/d${i}/index.md`, 'index'));
|
|
227
|
+
const rows = [makeRow('docs/ai/handover.md'), specRow('docs/ai/specs/index.md', 'index'), ...indexes, ...specs];
|
|
228
|
+
const out = buildIndex(rows, '2026-08-23');
|
|
229
|
+
const lines = out.split('\n');
|
|
230
|
+
expect(lines.filter((l) => l.includes('](./specs/index.md)')).length).toBe(1);
|
|
231
|
+
expect(out).not.toMatch(/s999\.md/);
|
|
232
|
+
expect(lines.length <= 80).toBe(true);
|
|
233
|
+
expect(out.match(SPECS_ROW_RE).slice(1)).toEqual(['1000', '0', '35']);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('the counted row carries live counts per kind; a malformed file under specs/ keeps its OWN visible row beside it', () => {
|
|
237
|
+
const rows = [specRow('docs/ai/specs/index.md', 'index'), specRow('docs/ai/specs/a/index.md', 'spec'), specRow('docs/ai/specs/a/p.md', 'part'), specRow('docs/ai/specs/b.md', 'spec'), specRow('docs/ai/specs/broken.md', null, [{ rule: 'kind', message: 'x' }])];
|
|
238
|
+
const out = buildIndex(rows, '2026-08-23');
|
|
239
|
+
expect(out).toMatch(/specs\/broken\.md/);
|
|
240
|
+
expect(out.match(SPECS_ROW_RE).slice(1)).toEqual(['2', '1', '1']);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('adding or removing one valid spec drifts the counted row -> the on-disk index is stale', () => {
|
|
244
|
+
const base = [specRow('docs/ai/specs/index.md', 'index'), specRow('docs/ai/specs/a.md', 'spec')];
|
|
245
|
+
const onDisk = buildIndex(base, '2026-08-23');
|
|
246
|
+
expect(checkIndexFreshness([...base, specRow('docs/ai/specs/b.md', 'spec')], onDisk).fresh).toBe(false);
|
|
247
|
+
expect(checkIndexFreshness(base.slice(0, 1), onDisk).fresh).toBe(false);
|
|
248
|
+
expect(checkIndexFreshness(base, onDisk).fresh).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('inspectFile reads a file under docs/ai/specs/ through the reader: clean -> spec verdict, malformed -> advisory warnings only', async () => {
|
|
252
|
+
const root = await mkdtemp(join(tmpdir(), 'specs-inspect-'));
|
|
253
|
+
try {
|
|
254
|
+
await mkdir(join(root, 'docs', 'ai', 'specs'), { recursive: true });
|
|
255
|
+
const clean = join(root, 'docs', 'ai', 'specs', 'login.md');
|
|
256
|
+
await writeFile(clean, `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
|
|
257
|
+
const ok = await inspectFile(clean, computeToday('2026-08-23'), root);
|
|
258
|
+
expect(ok.errors).toEqual([]);
|
|
259
|
+
expect(ok.spec.kind).toBe('spec');
|
|
260
|
+
expect(ok.spec.errors).toEqual([]);
|
|
261
|
+
const malformed = join(root, 'docs', 'ai', 'specs', 'broken.md');
|
|
262
|
+
await writeFile(malformed, `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
|
|
263
|
+
const bad = await inspectFile(malformed, computeToday('2026-08-23'), root);
|
|
264
|
+
expect(bad.errors).toEqual([]);
|
|
265
|
+
expect(bad.spec.errors.map((e) => e.rule)).toEqual(['scenario-marker']);
|
|
266
|
+
expect(bad.warnings.some((w) => /spec scenario-marker/.test(w))).toBe(true);
|
|
267
|
+
await writeFile(join(root, 'docs', 'ai', 'handover.md'), `${SPEC_FRONT('spec', 150)}\n# outside the store\n`);
|
|
268
|
+
expect((await inspectFile(join(root, 'docs', 'ai', 'handover.md'), computeToday('2026-08-23'), root)).spec).toBe(null);
|
|
269
|
+
} finally {
|
|
270
|
+
await rm(root, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
222
275
|
describe('buildIndex', () => {
|
|
223
276
|
it('is deterministic, sorts rows by path, and excludes index.md itself', () => {
|
|
224
|
-
const rows = [
|
|
225
|
-
makeRow('docs/ai/index.md'),
|
|
226
|
-
makeRow('docs/ai/b.md'),
|
|
227
|
-
makeRow('docs/ai/a.md'),
|
|
228
|
-
];
|
|
277
|
+
const rows = [makeRow('docs/ai/index.md'), makeRow('docs/ai/b.md'), makeRow('docs/ai/a.md')];
|
|
229
278
|
const out = buildIndex(rows, '2026-05-29');
|
|
230
279
|
expect(out).toBe(buildIndex(rows, '2026-05-29')); // deterministic
|
|
231
280
|
expect(out).not.toMatch(/\[`index\.md`\]/); // index.md row excluded
|
|
@@ -328,4 +377,19 @@ describe('root parameterization (item (h))', () => {
|
|
|
328
377
|
expect(stale.status).toBe(1);
|
|
329
378
|
expect(stale.stderr).toMatch(/stale/);
|
|
330
379
|
});
|
|
380
|
+
|
|
381
|
+
it('the hook path over a real specs/ store: ONE counted row in the written index, a new spec makes --check-index exit 1', async () => {
|
|
382
|
+
const store = join(root, 'docs', 'ai', 'specs');
|
|
383
|
+
await mkdir(store, { recursive: true });
|
|
384
|
+
await writeFile(join(store, 'index.md'), `${SPEC_FRONT('index', 80)}\n# Specs\n\n> Up: [technical_specification.md](../technical_specification.md)\n\n## Children\n\n- [login](./login.md)\n`);
|
|
385
|
+
await writeFile(join(store, 'login.md'), `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('login')}`);
|
|
386
|
+
expect(runCli(['--write-index', `--root=${root}`]).status).toBe(0);
|
|
387
|
+
const index = await readFile(join(root, 'docs', 'ai', 'index.md'), 'utf8');
|
|
388
|
+
expect(index.match(SPECS_ROW_RE).slice(1)).toEqual(['1', '0', '1']);
|
|
389
|
+
expect(index).not.toMatch(/specs\/login\.md/);
|
|
390
|
+
expect(runCli(['--check-index', `--root=${root}`]).status).toBe(0);
|
|
391
|
+
await writeFile(join(store, 'signup.md'), `${SPEC_FRONT('spec', 150, 'status: draft\nrevision: 1\n')}${specBody('signup')}`);
|
|
392
|
+
expect(runCli(['--check-index', `--root=${root}`]).status).toBe(1);
|
|
393
|
+
expect(runCli([`--root=${root}`]).status).toBe(0);
|
|
394
|
+
});
|
|
331
395
|
});
|