@sabaiway/agent-workflow-memory 8.1.4 → 8.2.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 CHANGED
@@ -4,6 +4,29 @@ 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 `4.0.0`).
6
6
 
7
+ ## 8.2.0 — the docs navigator stamps a cap it honours past 80 lines (AD-182)
8
+
9
+ **`references/scripts/check-docs-size.mjs` now writes a `docs/ai/index.md` that its own default run and `--check-index`
10
+ accept, however many docs the project has.** Before, the navigator always stamped `maxLines: 80` while it renders one
11
+ row per document outside the collapsed ADR and spec stores, so a project with enough docs (measured: 67 docs, an 87-line navigator) got a navigator its own
12
+ pre-commit hook refused.
13
+
14
+ - The stamp is `maxLines: 80` while the rendered navigator has 80 lines or fewer, and its own line count past that,
15
+ counted as the gate counts (a trailing newline closes the last line and opens none). A navigator that fits is
16
+ byte-identical to before. There is no ceiling, as for the ADR navigator (AD-167).
17
+ - A hand-edited cap line still reads as stale under `--check-index`, as before.
18
+ - `SKILL.md`: a deployed spec-layer script with an edited body is named by the composition root's upgrade and replaced
19
+ only on the user's yes; this substrate still never overwrites a deployed script.
20
+ - MINOR: the navigator's stamped cap changes past 80 lines. The deployed `check-docs-size.test.mjs` body is unchanged.
21
+
22
+ ## 8.1.5 — the gate migration names the `lcovProducer` marker (AD-175)
23
+
24
+ `references/scripts/migrate-gates.mjs`: the four hints that print the paste-ready suite cmd (the
25
+ customized `unit-tests` recovery, the INERT row of a vendored checker, and the withheld-checker and inert-checker
26
+ warnings) also name the other producer form: `add "lcovProducer": true to that gate in docs/ai/gates.json`, for a suite cmd
27
+ that already writes the lcov in a form the migration cannot recognise. The guarded `coverage-producer canon` and
28
+ `checker-claim canon` regions are unchanged and stay byte-identical to the kit's. PATCH: no migration result changes.
29
+
7
30
  ## 8.1.4 — the templates carry the four-session story flow (AD-170)
8
31
 
9
32
  `references/templates/agent_rules.md`: §2.6 is the engine 5.12.0 lens render (the Finding scope, Spec-first, Fold
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: '8.1.4'
6
+ version: '8.2.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
@@ -251,7 +251,8 @@ Fill strategy:
251
251
  is what the pre-commit hook runs, and an older or edited one renders the store row by row and
252
252
  reds its own index check. Otherwise seed NO store root and report which file differs: a
253
253
  file of either pair still on a body a release shipped is refreshed by the composition root's own upgrade
254
- (it carries the catalog of shipped bodies), an edited one is the user's to refresh by hand — this
254
+ (it carries the catalog of shipped bodies), and an edited one is named by that upgrade and replaced
255
+ only on the user's yes — this
255
256
  substrate carries no such catalog and never overwrites a deployed script. **Same gate, also
256
257
  stamp-independent — ensure the NAVIGATOR:** `docs/ai/index.md` is a GENERATED artifact the entry
257
258
  point declares always-loaded, so a deployment that never had one must gain it even at head — run
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-memory",
5
5
  "kind": "memory-substrate",
6
- "version": "8.1.4",
6
+ "version": "8.2.0",
7
7
  "provides": ["context"],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-memory",
3
- "version": "8.1.4",
3
+ "version": "8.2.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",
@@ -58,6 +58,51 @@ maxLines: 80
58
58
  | [\`a.md\`](./a.md) | state | 10/10 | 2026-07-18 | never |
59
59
  `;
60
60
 
61
+ describe('spec:docs-navigator/S1 the navigator stamps a cap it honours', () => {
62
+ const createNames = (count) => Array.from({ length: count }, (_, index) => `n${String(index + 1).padStart(2, '0')}`);
63
+ const buildDocs = (names) => ({ docs }) => {
64
+ for (const name of names) writeFileSync(join(docs, `${name}.md`), DOC(name));
65
+ };
66
+ const countLines = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
67
+
68
+ it("a tree whose navigator renders exactly 80 lines stamps maxLines 80, byte-identical to today's render", async () => {
69
+ const names = createNames(57);
70
+ await withRoot(buildDocs(names), async ({ root }) => {
71
+ assert.equal((await cli(['--write-index', `--root=${root}`, '--today=2026-07-18'])).code, 0);
72
+ const index = indexOf(root);
73
+ const rows = names.map((name) => `| [\`${name}.md\`](./${name}.md) | state | 10/10 | 2026-07-18 | never |\n`);
74
+ assert.equal(countLines(index), 80);
75
+ assert.equal(index, EXPECTED_INDEX + rows.join(''));
76
+ });
77
+ });
78
+
79
+ it('a tree of 81 lines stamps maxLines 81 and both checker runs exit 0', async () => {
80
+ await withRoot(buildDocs(createNames(58)), async ({ root }) => {
81
+ assert.equal((await cli(['--write-index', `--root=${root}`, '--today=2026-07-18'])).code, 0);
82
+ const index = indexOf(root);
83
+ const checked = await runCli([`--root=${root}`]);
84
+ const fresh = await runCli(['--check-index', `--root=${root}`]);
85
+ assert.equal(countLines(index), 81);
86
+ assert.equal(index.match(/^maxLines: \d+$/m)?.[0], 'maxLines: 81', `default: ${checked.code}, --check-index: ${fresh.code}`);
87
+ assert.equal(checked.code, 0, checked.stdout + checked.stderr);
88
+ assert.equal(fresh.code, 0, fresh.stdout + fresh.stderr);
89
+ });
90
+ });
91
+
92
+ it('a hand-edited cap line is stale under --check-index', async () => {
93
+ await withRoot(buildDocs(createNames(58)), async ({ root, docs }) => {
94
+ assert.equal((await cli(['--write-index', `--root=${root}`, '--today=2026-07-18'])).code, 0);
95
+ const index = indexOf(root);
96
+ const edited = index.replace(/^maxLines: \d+$/m, 'maxLines: 90');
97
+ assert.notEqual(edited, index);
98
+ writeFileSync(join(docs, 'index.md'), edited);
99
+ const { code, stdout, stderr } = await runCli(['--check-index', `--root=${root}`]);
100
+ assert.equal(code, 1);
101
+ assert.match(stdout + stderr, /is stale/);
102
+ });
103
+ });
104
+ });
105
+
61
106
  describe('check-docs-size runCli — refusal branches', () => {
62
107
  it('an unknown argument exits 2 naming it', async () => {
63
108
  const { code, stderr } = await cli(['--bogus']);
@@ -11,6 +11,9 @@ import { runCli } from './check-docs-size.mjs';
11
11
  const DOC = (title) =>
12
12
  `---\ntype: state\nlastUpdated: 2026-08-15\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# ${title}\n`;
13
13
 
14
+ const NAVIGATOR_LINE_FLOOR = 80;
15
+ const EXTRA_DOC_COUNT = 85;
16
+
14
17
  const makeTree = (prefix) => {
15
18
  const dir = mkdtempSync(join(tmpdir(), prefix));
16
19
  mkdirSync(join(dir, 'docs', 'ai'), { recursive: true });
@@ -330,3 +333,28 @@ describe('check-docs-size --ensure-index', () => {
330
333
  }
331
334
  });
332
335
  });
336
+
337
+ describe('spec:docs-navigator/S2 --ensure-index past the 80-line floor', () => {
338
+ it('writes a navigator past 80 lines that the default run and --check-index accept', async () => {
339
+ const root = makeTree('cds-ensure-past-floor-');
340
+ try {
341
+ for (const number of Array.from({ length: EXTRA_DOC_COUNT }, (_, index) => index)) {
342
+ writeFileSync(join(root, 'docs', 'ai', `z-extra-${number}.md`), DOC(`extra ${number}`));
343
+ }
344
+ const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`]);
345
+ assert.equal(code, 0);
346
+ assert.match(stdout, /ensure-index: regenerated/);
347
+ const navigator = readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8');
348
+ const lineCount = navigator.split('\n').length - (navigator.endsWith('\n') ? 1 : 0);
349
+ const maxLines = Number(navigator.match(/^maxLines: (\d+)$/m)?.[1]);
350
+ assert.ok(lineCount > NAVIGATOR_LINE_FLOOR);
351
+ const defaultRun = await runCli([`--root=${root}`]);
352
+ const indexCheck = await runCli(['--check-index', `--root=${root}`]);
353
+ assert.equal(defaultRun.code, 0, defaultRun.stdout);
354
+ assert.equal(maxLines, lineCount);
355
+ assert.equal(indexCheck.code, 0, indexCheck.stdout);
356
+ } finally {
357
+ drop(root);
358
+ }
359
+ });
360
+ });
@@ -45,6 +45,8 @@ const pathsFor = (root) => {
45
45
  };
46
46
 
47
47
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
48
+ const NAVIGATOR_CAP_FLOOR = 80;
49
+ const countLines = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
48
50
 
49
51
  // The one token every `--ensure-index` outcome line opens with — deploy/upgrade prose relays it
50
52
  // verbatim and the kit's ensure op reads it, so it is a contract, not a message.
@@ -263,7 +265,7 @@ export const inspectFile = async (found, today, root = ROOT) => {
263
265
  return { path: rel, lineCount: 0, frontmatter: null, spec: null, refused: true, errors: [refusal], warnings: [] };
264
266
  }
265
267
  const text = await readFile(filePath, 'utf8');
266
- const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
268
+ const lineCount = countLines(text);
267
269
  const fm = parseFrontmatter(text);
268
270
  const spec = readSpecVerdict(rel, text);
269
271
 
@@ -332,7 +334,7 @@ lastUpdated: __TODAY__
332
334
  scope: permanent
333
335
  staleAfter: 30d
334
336
  owner: none
335
- maxLines: 80
337
+ maxLines: __CAP__
336
338
  ---
337
339
 
338
340
  # Memory Map — __PROJECT__ \`docs/ai/\`
@@ -398,7 +400,6 @@ export const buildIndex = (rows, todayStr, meta = {}) => {
398
400
  const projectName = meta.projectName ?? DEFAULT_PROJECT_NAME;
399
401
  const onDemandLinks = meta.onDemandLinks ?? [];
400
402
  const hierarchicalLinks = meta.hierarchicalLinks ?? [];
401
- const header = INDEX_HEADER.replace('__TODAY__', todayStr).replace('__PROJECT__', projectName);
402
403
  const tableHeader = `| File | Type | Lines/Max | Updated | Stale after |\n|------|------|-----------|---------|-------------|`;
403
404
  const singles = [];
404
405
  const grouped = COLLAPSE_GROUPS.map(() => []);
@@ -421,7 +422,8 @@ export const buildIndex = (rows, todayStr, meta = {}) => {
421
422
  hierarchicalLinks.length > 0
422
423
  ? `\n\n## Subdirectory \`AGENTS.md\` (hierarchical)\n\n${hierarchicalLinks.map((link) => `- ${link}`).join('\n')}`
423
424
  : '';
424
- return `${header}${tableHeader}\n${tableRows}${onDemandSection}${hierarchicalSection}\n`;
425
+ const render = (cap) => `${INDEX_HEADER.replace('__CAP__', cap).replace('__TODAY__', todayStr).replace('__PROJECT__', projectName)}${tableHeader}\n${tableRows}${onDemandSection}${hierarchicalSection}\n`;
426
+ return render(Math.max(NAVIGATOR_CAP_FLOOR, countLines(render(NAVIGATOR_CAP_FLOOR))));
425
427
  };
426
428
 
427
429
  // Freshness regenerates the index with the on-disk header's OWN `lastUpdated`, so a mere day-rollover
@@ -461,12 +461,14 @@ export const resultingGates = (plan) => {
461
461
  return [...kept, ...moved, ...added]; // move/add go LAST — the checker ends up the last declared gate
462
462
  };
463
463
 
464
+ const MARKER_LANE = 'or, if your suite cmd already writes that lcov in a form this check cannot recognise, add "lcovProducer": true to that gate in docs/ai/gates.json';
465
+
464
466
  // The per-entry customized recovery: a unit-tests-class entry gets the FULL canonical flag set
465
467
  // (the intent is known — the cmd shape just cannot be verified); anything else gets the
466
468
  // dead-tool recovery.
467
469
  const customizedRecovery = (gate) =>
468
470
  gate.id === 'unit-tests'
469
- ? `declare the canonical suite gate by hand so the coverage contract is verifiable: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`
471
+ ? `declare the canonical suite gate by hand so the coverage contract is verifiable: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths> — ${MARKER_LANE}`
470
472
  : 'remove the entry, or repoint it at a living check — the review-ledger / fold-completeness tools no longer exist.';
471
473
 
472
474
  const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable, hasProducer = false, checkerWithheld = false, canonicalCheckerInert = false, duplicateCheckers = 0, externalCoreChecks = [], reviewStateCandidate }) => {
@@ -495,7 +497,7 @@ const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable,
495
497
  } else if (inert && hasProducer) {
496
498
  lines.push(' it is also INERT as declared: a gate DOES produce the lcov, but it runs AFTER this entry, so this checker reads nothing (or stale bytes) and passes while verifying nothing — a checker belongs LAST, after its producer.');
497
499
  } else if (inert) {
498
- lines.push(` it is also INERT as declared: no declared gate PRODUCES the lcov it reads, so it passes while verifying nothing — declare the suite gate: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
500
+ lines.push(` it is also INERT as declared: no declared gate PRODUCES the lcov it reads, so it passes while verifying nothing — declare the suite gate: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths> — ${MARKER_LANE}`);
499
501
  }
500
502
  }
501
503
  if (externalCoreChecks.length) {
@@ -506,12 +508,14 @@ const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable,
506
508
  if (checkerWithheld) {
507
509
  lines.push(' WARNING: the canonical coverage-check gate was NOT added — no declared gate would PRODUCE the lcov it reads, and a checker with no producer passes while verifying nothing. Declare the suite gate first, then re-run this migration:');
508
510
  lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
511
+ lines.push(` ${MARKER_LANE}, then re-run this migration`);
509
512
  }
510
513
  // The CANONICAL checker's inertness only. An external row's is said on the row itself above, with
511
514
  // the edit that fits that row — this block would otherwise add a second, contradictory one.
512
515
  if (canonicalCheckerInert) {
513
516
  lines.push(' WARNING: the DECLARED coverage-check gate is INERT — no declared gate PRODUCES the lcov it reads, so it passes while verifying nothing. Nothing is removed for you; declare the suite gate:');
514
517
  lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
518
+ lines.push(` ${MARKER_LANE}`);
515
519
  }
516
520
  if (duplicateCheckers > 1) {
517
521
  lines.push(` WARNING: ${duplicateCheckers} declared gates are the canonical coverage checker — run-gates --final accepts EXACTLY ONE, so the result is NOT final-run-capable. Nothing is removed for you; keep a single checker and delete the rest by hand.`);
@@ -122,6 +122,7 @@ describe('migrate-gates — the pure migration plan', () => {
122
122
  const preview = formatPreview(analysis, 'APPLY');
123
123
  assert.match(preview, /CUSTOMIZED \(untouched\): unit-tests/);
124
124
  assert.match(preview, /declare the canonical suite gate by hand/, 'the paste-ready recovery rides along');
125
+ assert.match(preview, /recovery \(apply by hand[^\n]*"lcovProducer": true/, 'the recovery names the marker as the other lane');
125
126
  });
126
127
 
127
128
  it('adds the coverage-check gate LAST with the RESOLVED quoted path; never a second one', () => {
@@ -143,6 +144,7 @@ describe('migrate-gates — the pure migration plan', () => {
143
144
  const preview = formatPreview(analysis, 'APPLY');
144
145
  assert.match(preview, /coverage-check/, 'the withheld checker is named');
145
146
  assert.match(preview, /produce/i, 'the preview says WHY — no gate produces the lcov it would read');
147
+ assert.match(preview, /WARNING: the canonical coverage-check gate was NOT added[^\n]*\n[^\n]*\n[^\n]*"lcovProducer": true/, 'the withheld block names the marker as the other lane');
146
148
  });
147
149
 
148
150
  it('an ALREADY-declared checker over no producer is reported INERT, is never removed, and is not final-run-capable', () => {
@@ -154,6 +156,7 @@ describe('migrate-gates — the pure migration plan', () => {
154
156
  const preview = formatPreview(analysis, 'APPLY');
155
157
  assert.match(preview, /INERT/, 'the inert pair is named');
156
158
  assert.match(preview, /--experimental-test-coverage/, 'the paste-ready suite cmd is carried');
159
+ assert.match(preview, /WARNING: the DECLARED coverage-check gate is INERT[^\n]*\n[^\n]*\n[^\n]*"lcovProducer": true/, 'the inert block names the marker as the other lane');
157
160
  assert.doesNotMatch(preview, /already final-run-capable/);
158
161
  });
159
162
 
@@ -352,6 +355,7 @@ describe('migrate-gates — a VENDORED core check is the tool, from a copy --kit
352
355
  const absent = formatPreview(buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored], KIT_TOOLS, PROJECT), 'APPLY');
353
356
  assert.match(absent, /no declared gate PRODUCES the lcov/, 'with nothing producing, the old sentence still holds');
354
357
  assert.match(absent, /declare the suite gate/);
358
+ assert.match(absent, /no declared gate PRODUCES the lcov[^\n]*"lcovProducer": true/, 'the INERT row names the marker as the other lane');
355
359
  });
356
360
 
357
361
  it('a mixed declaration renders ONE edit, never a removal and a reorder at once', () => {