@sabaiway/agent-workflow-memory 4.3.0 → 4.5.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 +54 -0
- package/SKILL.md +22 -4
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/check-docs-size-cli.test.mjs +7 -6
- package/references/scripts/check-docs-size-ensure.test.mjs +332 -0
- package/references/scripts/check-docs-size.mjs +181 -30
- package/references/templates/agent_rules.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,60 @@ All notable changes to the memory substrate. Versions are this **package's** npm
|
|
|
4
4
|
they are distinct from the **deployment-lineage** stamp written into a project's
|
|
5
5
|
`docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `3.0.0`).
|
|
6
6
|
|
|
7
|
+
## 4.5.0 — the closing state block gets a canon rule, and its slot labels are declared English (AD-098; ships with kit 5.10.0)
|
|
8
|
+
|
|
9
|
+
**Three slots that answer three different questions, or one restatement written three times.** The
|
|
10
|
+
deployed rules template already asked for a closing state block; what it never said was what belongs
|
|
11
|
+
in each slot — so *now* drifted into a report of finished work, and the block collapsed into a
|
|
12
|
+
summary the reader had already read.
|
|
13
|
+
|
|
14
|
+
- **`agent_rules.md` gains the rule, in §2.5.** *Now* is the state at this instant — what is running
|
|
15
|
+
or what the work is stopped on, never a report of what was completed (that belongs in the message
|
|
16
|
+
body, above the block). *From you* is the real unblocker, named; a turn that is ending always has
|
|
17
|
+
one. *Next* is what follows.
|
|
18
|
+
- **The slot LABELS stay English; the values take the dialogue language.** An English label is what
|
|
19
|
+
lets a state-block checker find the block and its slots at all; everything written into a slot is
|
|
20
|
+
in the project's own language, and the checker's English phrase sets do not judge those values.
|
|
21
|
+
This is the substrate half of the same decision the kit's guard ships.
|
|
22
|
+
- **One test point renamed to describe the check it actually runs** — the `--write-index` refusal is
|
|
23
|
+
a pre-write symlink refusal on the index path, which is what the body has pinned since 4.4.0.
|
|
24
|
+
|
|
25
|
+
## 4.4.0 — the deploy finishes by writing the navigator its entry point declares (AD-096; ships with kit 5.9.0)
|
|
26
|
+
|
|
27
|
+
**The substrate deployed an `AGENTS.md` that calls `docs/ai/index.md` always-loaded, and no step
|
|
28
|
+
ever wrote it.** The navigator is generated — `check-docs-size.mjs` is its only writer — and neither
|
|
29
|
+
bootstrap nor upgrade ran the generator, so a fresh Node deployment started life with a pre-commit
|
|
30
|
+
hook failing its own index check, and a project without Node stayed silently broken. Fixed at the
|
|
31
|
+
source: the generator gains an idempotent finalizer, and every documented path now runs it.
|
|
32
|
+
|
|
33
|
+
- **`check-docs-size.mjs --ensure-index [--root=<dir>]`** — probe first, write only when the
|
|
34
|
+
navigator is missing or stale, and close with exactly ONE outcome line: `ensure-index:
|
|
35
|
+
regenerated` / `already-current` on stdout, or `write-refused` / `probe-failed` on stderr with
|
|
36
|
+
exit 2. It reuses the existing generator and freshness check — there is no second index
|
|
37
|
+
implementation, and no seed template that could drift from it.
|
|
38
|
+
- **The bootstrap and upgrade prose run it at the LAST `docs/ai` mutation of each path:** after the
|
|
39
|
+
template fill (which covers a No-Node target — the generator runs from the skill home, so the
|
|
40
|
+
step-8 script copy is not a precondition), stamp-independently BEFORE the equal-head short-circuit
|
|
41
|
+
on upgrade, and again after the migrations before the re-stamp. Either refusal is a STOP.
|
|
42
|
+
- **The write is contained and atomic.** Every component of `<root>/docs/ai/index.md` is lstat'ed
|
|
43
|
+
no-follow — a symlinked root, `docs`, `docs/ai` or leaf refuses by name — the body is published
|
|
44
|
+
through an exclusive-create temp renamed into place with the chain re-checked immediately before
|
|
45
|
+
the rename, and a failure ATTEMPTS the temp discard — never a name this run did not create, and a
|
|
46
|
+
cleanup that itself fails names the temp it could not remove instead of swallowing it. The
|
|
47
|
+
containment guard runs BEFORE the freshness read, so a symlink whose target happens to hold
|
|
48
|
+
current bytes is refused rather than reported "already current".
|
|
49
|
+
- **"Could not read" never passes for "nothing there".** Under the finalizer, only a genuine
|
|
50
|
+
`ENOENT` counts as an absence; any other read failure refuses instead of publishing an index
|
|
51
|
+
missing whatever the run could not see. A malformed `package.json` stays the documented
|
|
52
|
+
project-name fallback.
|
|
53
|
+
- **A closure guard** asserts every `docs/ai` reference in the entry-point template's Memory Map
|
|
54
|
+
resolves to a shipped template, a generated artifact whose finalizer the prose documents, or an
|
|
55
|
+
exact exception list — and the deploy fixture EXTRACTS the documented command from `SKILL.md` and
|
|
56
|
+
runs it, so a step that loses its finalizer line fails as a broken deployment.
|
|
57
|
+
|
|
58
|
+
Known residual: a project that already carries `scripts/check-docs-size.mjs` keeps its un-hardened
|
|
59
|
+
copy (the scripts ensure is create-only by contract) — repair rides the next lineage migration.
|
|
60
|
+
|
|
7
61
|
## 4.3.0 — the migration emits a destination that refuses by name, accepts the producer marker, and preserves a vendored core check (AD-092 + AD-093 + AD-094; ships with kit 5.7.0)
|
|
8
62
|
|
|
9
63
|
The `migrate-gates.mjs` canon moves in lockstep with the kit it is byte-twinned to. Three changes,
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-memory
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory substrate in any project — an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) and a structured `docs/ai/` context store with cap/archive/index enforcement. Use when the user wants to bootstrap `docs/ai/`, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-memory` / `/agent-workflow-memory upgrade`. Triggers on "set up the memory system", "deploy the AI memory here", "bootstrap docs/ai", "upgrade the memory substrate". This is the substrate only — the workflow methodology (plan→execute→review, queue, Cleanup) is owned elsewhere and injected into AGENTS.md by the family composition root.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '4.
|
|
6
|
+
version: '4.5.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-memory
|
|
@@ -113,7 +113,15 @@ bootstrapping over a live system, but the user makes the final call.
|
|
|
113
113
|
`docs/ai/autonomy.json` — the per-project autonomy policy, seeded SPARSE (the onboarding note
|
|
114
114
|
only, defaults-equivalent — behavior changes only when the user declares levels). Keep each
|
|
115
115
|
`.md` file's frontmatter.
|
|
116
|
-
7. **Fill templates** per the table below.
|
|
116
|
+
7. **Fill templates** per the table below. **Then materialize the navigator, as the last `docs/ai`
|
|
117
|
+
step:** the entry point declares `docs/ai/index.md` always-loaded and it is a GENERATED artifact
|
|
118
|
+
with no template, so run
|
|
119
|
+
`node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<target>`
|
|
120
|
+
and relay its one outcome line (`ensure-index: regenerated` / `already-current`, or a
|
|
121
|
+
`write-refused` / `probe-failed` line naming the offending path — either is a STOP: resolve it
|
|
122
|
+
before reporting the deploy, since the navigator the entry point declares does not exist yet). The
|
|
123
|
+
generator runs from the **skill home**, so a **No-Node target** is covered too: the operator's own
|
|
124
|
+
host runs it, and step 8's copy is not a precondition.
|
|
117
125
|
8. **Install enforcement (Node projects).** Copy `${CLAUDE_SKILL_DIR}/references/scripts/*.mjs`
|
|
118
126
|
(+ `*.test.mjs`) into the project's `scripts/`. **No Node runtime** → skip this + the hook;
|
|
119
127
|
follow the cap/archive/index policy manually.
|
|
@@ -214,7 +222,14 @@ Fill strategy:
|
|
|
214
222
|
the tree untouched and report the pending migration. The deployed pre-commit
|
|
215
223
|
hook gains its `archive-decisions.mjs --check` line only when the hook is next refreshed via
|
|
216
224
|
`node scripts/install-git-hooks.mjs`; an old hook without the line stays consistent-safe (the
|
|
217
|
-
decisions gate is simply not enforced yet — never a broken hook). **
|
|
225
|
+
decisions gate is simply not enforced yet — never a broken hook). **Same gate, also
|
|
226
|
+
stamp-independent — ensure the NAVIGATOR:** `docs/ai/index.md` is a GENERATED artifact the entry
|
|
227
|
+
point declares always-loaded, so a deployment that never had one must gain it even at head — run
|
|
228
|
+
`node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<target>`
|
|
229
|
+
and relay its outcome line. It writes only when the navigator is missing or stale
|
|
230
|
+
(`already-current` otherwise), so a re-run changes nothing; a `write-refused` (the write) or
|
|
231
|
+
`probe-failed` (the tree could not be read) line names the offending path and STOPs this
|
|
232
|
+
reconcile like any other failed ensure. **Then**, if the stamp **equals** the head → the substrate is
|
|
218
233
|
current (no structure migration is due), and stop after reporting. Report **in the user's
|
|
219
234
|
conversational language**: if step 2's reconcile just **changed something** (a footprint move /
|
|
220
235
|
config seed), say **what changed** in plain terms and ask before committing; if **nothing changed at
|
|
@@ -242,7 +257,10 @@ Fill strategy:
|
|
|
242
257
|
absent (a legacy `AGENTS.md`), gracefully **no-op** on that slot (adding a slot to
|
|
243
258
|
already-deployed files is the composition root's reconcile, not this substrate's job). On any
|
|
244
259
|
malformed marker state (single, reversed, nested, or duplicate pair), **no-op with an error** —
|
|
245
|
-
never edit.
|
|
260
|
+
never edit. **Then, before the stamp, re-run the navigator finalizer:** steps 4–6 may have added
|
|
261
|
+
or changed `docs/ai` files, so run
|
|
262
|
+
`node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<target>`
|
|
263
|
+
once more (idempotent — an untouched tree reports `already-current`) and relay its outcome line.
|
|
246
264
|
7. **Re-stamp** `docs/ai/.memory-version` to the lineage head (atomic write — mechanics unchanged).
|
|
247
265
|
Report changes **in plain human terms** (which parts of the deployment are now different);
|
|
248
266
|
**omit the raw structure number**, and do not recite the two-axes note here (it belongs to
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-memory",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"description": "Portable, cross-agent memory substrate for AI coding agents — an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement, deployable standalone or as part of the agent-workflow family. The memory layer of the agent-workflow family.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// check-docs-size-cli.test.mjs — runCli branch pins the subprocess smokes cannot reach
|
|
2
2
|
// in-process (Phase-5 coverage fill; the main spec file is parity-frozen, so these ride a
|
|
3
|
-
// colocated file): the unknown-argument refusal and the
|
|
3
|
+
// colocated file): the unknown-argument refusal and the pre-write symlink refusal on the index path.
|
|
4
4
|
import { describe, it } from 'node:test';
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
6
|
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
|
|
@@ -20,7 +20,7 @@ describe('check-docs-size runCli — refusal branches', () => {
|
|
|
20
20
|
assert.match(stderr, /Unknown argument: --bogus/);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it('--write-index
|
|
23
|
+
it('--write-index refuses a symlinked index path BEFORE writing, naming the path', async () => {
|
|
24
24
|
const root = mkdtempSync(join(tmpdir(), 'cds-cli-'));
|
|
25
25
|
try {
|
|
26
26
|
mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
|
|
@@ -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) {
|
|
@@ -73,6 +73,7 @@ Apply this as part of §2 before any user-facing summary:
|
|
|
73
73
|
- **No condescension, no filler.** Own a miss plainly and fix it in the same message.
|
|
74
74
|
- **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.
|
|
75
75
|
- **Live host/session facts are tool-composed only.** Any claim 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**; 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.
|
|
76
|
+
- **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.
|
|
76
77
|
|
|
77
78
|
### 2.6. Planning, review & process-fidelity invariants
|
|
78
79
|
Apply these when authoring a plan, reviewing, folding a finding, or editing code — the layer read **before any code change**. (Full canon: the project's planning / workflow-methodology + orchestration canon. This section is rendered from that canon and refreshed on upgrade; a custom edit is preserved verbatim, but flagged.)
|