@sabaiway/agent-workflow-memory 4.1.0 → 4.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,37 @@ 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.2.0 — the gates migration stops handing you a coverage checker with nothing to read (AD-089)
8
+
9
+ `migrate-gates.mjs` added the canonical `coverage-check` gate to any legacy declaration that lacked
10
+ one — whether or not anything in that declaration would ever WRITE the lcov the checker reads. The
11
+ result passes: the gate reports `skipped-no-lcov` and exits 0, so a migrated project came out with a
12
+ green matrix that certified nothing. This release stops the migration creating that pair, and makes
13
+ it say so when it finds one already there.
14
+
15
+ - **The checker is added only over a declaration that produces the lcov.** With no producer it is
16
+ WITHHELD, loudly, with the exact suite-gate line to declare first and an invitation to re-run the
17
+ migration afterwards.
18
+ - **An already-declared checker over no producer is reported as INERT** — same dead pair, made by an
19
+ earlier deployment rather than by this migration. Nothing is removed for you; the warning names
20
+ the remedy, and the result is no longer called final-run-capable.
21
+ - **`finalCapable` now means what it says.** It was computed from the review-state check alone, so a
22
+ withheld or inert checker still reported a final-run-capable result. The review-state warning is
23
+ now keyed on its own condition instead of riding that flag.
24
+ - **The "no canonical `unit-tests` entry" note stops firing over a working producer.** It was keyed
25
+ on the entry ID, but a producer is recognised under any id — the note was sending people to fix
26
+ something that was already correct.
27
+ - **The producer vocabulary is exported** (`COVERAGE_PRODUCER_BODY`, `matchesCoverageProducer`) as a
28
+ CLOSED set of the full command forms this family emits, never a substring probe. Its tail rule is
29
+ a positive path-shaped grammar rather than an operator blocklist, because
30
+ `node --test <flags> && rm -f <lcov>` runs the suite and then deletes the file. The scope is
31
+ stated in the source: recognising a producer means "configured with the reporters", never "the
32
+ lcov survives the command".
33
+
34
+ This package stays standalone — it imports nothing from the kit, and the kit imports nothing from
35
+ here. The kit carries its own byte-identical copy of the block above, held equal by a text drift
36
+ guard rather than by an import.
37
+
7
38
  ## 4.1.0 — the ADR rotation carries your inbound links with it (AD-087)
8
39
 
9
40
  Rotating `decisions.md` used to be a link-breaking event: `archive-decisions.mjs` moved ADR
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.1.0'
6
+ version: '4.2.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
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": "4.1.0",
6
+ "version": "4.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": "4.1.0",
3
+ "version": "4.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",
@@ -5,7 +5,9 @@
5
5
  // COMPLETELY in one consented step: it REMOVES the canonical legacy entries, EXTENDS the
6
6
  // canonical `unit-tests` cmd with the built-in lcov reporters (the D3(d) coverage source), and
7
7
  // ADDS the coverage-check gate LAST (removal alone is not a migration — the result must satisfy
8
- // `run-gates --final` and carry a working commit path).
8
+ // `run-gates --final` and carry a working commit path). The checker is added ONLY over a
9
+ // declaration that PRODUCES the lcov it reads; with no producer it is WITHHELD with a loud
10
+ // warning, because a checker with no producer passes (`skipped-no-lcov`) while verifying nothing.
9
11
  //
10
12
  // Matching is by the DOCUMENTED cmd forms ONLY (hand-wired history included — never by seed
11
13
  // provenance): a legacy entry is `node <path>/review-ledger.mjs --check` or
@@ -45,12 +47,69 @@ export const LEGACY_FORMS = Object.freeze([
45
47
  { name: 'fold-completeness', re: legacyRe('fold-completeness\\.mjs') },
46
48
  ]);
47
49
 
48
- // The D3(d) reporter flags the canonical unit-tests cmd gains lcov at the fixed git-dir path
49
- // run-gates --final exports, plus an explicit stdout reporter (without it the lcov reporter
50
- // swallows the human TAP/spec stream).
50
+ // coverage-producer canon >>> BEGIN drift-guarded region
51
+ // Authored TWICE, byte-identically: in the memory substrate's references/scripts/migrate-gates.mjs
52
+ // and in the composition root's tools/coverage-producer.mjs. Neither side imports the other — the
53
+ // substrate is standalone and must not depend on the root, and the root must not import mirrored
54
+ // bytes — so a TEXT drift guard beside the root's copy holds them equal. Edit BOTH, then re-run the
55
+ // mirror sync.
56
+ //
57
+ // The destination is written against AW_GIT_DIR, which run-gates exports to every gate child on a
58
+ // plain run AND on --final (AW_LCOV_FILE is --final only), so one cmd survives the unmet
59
+ // producer-variable preflight in both modes. The explicit stdout reporter is not decoration:
60
+ // without it the lcov reporter swallows the human TAP/spec stream.
51
61
  export const UNIT_TESTS_COVERAGE_FLAGS =
52
62
  '--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
53
63
 
64
+ // The ONE suite body that produces that lcov with no extra dependency.
65
+ export const COVERAGE_PRODUCER_BODY = `node --test ${UNIT_TESTS_COVERAGE_FLAGS}`;
66
+
67
+ // The per-PM exec wrappers a fill offer puts that body behind. Recognition must cover every form
68
+ // the kit has EMITTED, so the prefixes are matched literally; gates-init's execCmdFor stays the one
69
+ // EMITTER and is bound to this list by a named acceptance test, never by a second grammar.
70
+ const PRODUCER_EXEC_PREFIXES = Object.freeze([
71
+ 'COREPACK_ENABLE_NETWORK=0 npm exec --offline --script-shell /bin/sh -- ',
72
+ 'COREPACK_ENABLE_NETWORK=0 pnpm exec -- ',
73
+ 'COREPACK_ENABLE_NETWORK=0 yarn exec -- ',
74
+ ]);
75
+
76
+ // A trailing suffix is the project's own test paths; a leading one would mean the body is not what
77
+ // this cmd runs. The tail passes a POSITIVE closed grammar — every whitespace-separated token must
78
+ // be path-shaped — never an operator blocklist: `node --test <flags> && rm -f <lcov>` runs the
79
+ // suite and then DELETES the file, so an open-ended tail would certify a producer that leaves
80
+ // nothing behind, and scanning for operator BYTES would put the incomplete-scan failure on the
81
+ // unsafe side (a missed operator is a dead pair) instead of the mild one (an unrecognised
82
+ // legitimate tail merely withholds the offer — add the gate by hand).
83
+ // The token set is everything that appears in a PATH or a glob and can never sequence, redirect or
84
+ // substitute a command — quoting and `~` included, `( ) $ ` ; & | < > # \` excluded. SCOPE, stated
85
+ // exactly: the screen judges each token's SOURCE bytes. Quote removal adds none, but brace SEQUENCE
86
+ // expansion does — `{Y..a}` yields ``[ \ ] ^ _ ` `` (probed) — so "no new bytes" would be a false
87
+ // claim. What holds is the property that matters: bash does not re-scan an expansion result as
88
+ // syntax, so a byte arriving that way is literal argument DATA, never an operator. The leading-`-`
89
+ // exclusion is weaker still — a FIRST-ORDER screen only, defeated by `'--flag'` and
90
+ // `{path,--flag}`. It is kept because the tail is the project's test PATHS and it costs only a loud
91
+ // withhold. Deciding an argument's post-expansion identity needs a shell lexer, which this family
92
+ // deliberately has NOWHERE (AD-079). So the claim is "configured with the reporters", never "the
93
+ // lcov survives the command"; a run that produces none is caught honestly at runtime as
94
+ // `skipped-no-lcov`.
95
+ const PRODUCER_PATH_TOKEN = /^(?!-)[A-Za-z0-9_./*{},:@+=~?[\]!'"-]+$/;
96
+ const pathShapedTail = (tail) => tail === '' || tail.split(/[ \t]+/).every((token) => PRODUCER_PATH_TOKEN.test(token));
97
+ const carriesProducerBody = (text) =>
98
+ text === COVERAGE_PRODUCER_BODY ||
99
+ (text.startsWith(`${COVERAGE_PRODUCER_BODY} `) && pathShapedTail(text.slice(COVERAGE_PRODUCER_BODY.length).trim()));
100
+
101
+ // matchesCoverageProducer(cmd) → CLOSED-WORLD over the full command forms the kit emits, never a
102
+ // substring probe: `echo "$AW_GIT_DIR/agent-workflow-lcov.info"`, a half-written reporter flag set,
103
+ // or the path as a bare substring must all read as NOT a producer — otherwise the checker is
104
+ // declared over a gate that writes nothing and then PASSES while certifying nothing.
105
+ export const matchesCoverageProducer = (cmd) => {
106
+ if (typeof cmd !== 'string') return false;
107
+ const trimmed = cmd.trim();
108
+ if (carriesProducerBody(trimmed)) return true;
109
+ return PRODUCER_EXEC_PREFIXES.some((prefix) => trimmed.startsWith(prefix) && carriesProducerBody(trimmed.slice(prefix.length)));
110
+ };
111
+ // coverage-producer canon <<< END drift-guarded region
112
+
54
113
  // The RETIRED kit-owned git-dir stores the deleted machinery wrote — dead data a consumer's
55
114
  // upgrade would otherwise strand forever. The migration cleans them (consented via the preview;
56
115
  // ENOENT is a silent no-op; any other unlink error is reported loudly but never fails the
@@ -147,13 +206,20 @@ export const buildMigrationPlan = (gates, kitToolsDir) => {
147
206
  plan.push({ action: 'keep', entry: gate, reason: null });
148
207
  }
149
208
  const kept = plan.filter((r) => r.action === 'keep' || r.action === 'extend');
209
+ // The checker READS an lcov; something has to WRITE it. Adding the checker over a declaration
210
+ // with no producer creates the dead pair — the gate PASSES (`skipped-no-lcov`) and certifies
211
+ // nothing, so the migration withholds it and says why instead.
212
+ const hasProducer = kept.some((r) => matchesCoverageProducer(r.entry.cmd));
150
213
  let collision = null;
214
+ let checkerWithheld = false;
151
215
  if (checkerRow === null) {
152
216
  // A surviving NON-canonical entry already holding the checker's id blocks the add — two
153
217
  // `coverage-check` rows would be ambiguous; the customized entry must be resolved by hand
154
218
  // FIRST (the caller turns this into a loud STOP on preview and apply alike).
155
219
  if (kept.some((r) => r.entry.id === 'coverage-check')) {
156
220
  collision = 'coverage-check';
221
+ } else if (!hasProducer) {
222
+ checkerWithheld = true;
157
223
  } else {
158
224
  plan.push({
159
225
  action: 'add',
@@ -165,8 +231,23 @@ export const buildMigrationPlan = (gates, kitToolsDir) => {
165
231
  checkerRow.action = 'move';
166
232
  checkerRow.reason = 'the canonical checker must be the LAST declared gate (nothing may run after it consumed the lcov)';
167
233
  }
234
+ // An ALREADY-declared checker over no producer is the same dead pair the withhold prevents — an
235
+ // earlier deployment could have created it. The migration removes no declared gate, so it reports
236
+ // the inertness and refuses to call the result final-run-capable.
237
+ const checkerInert = checkerRow !== null && !hasProducer;
168
238
  const reviewStateCandidate = `{ "id": "review-state", "title": "Review receipts converged (D3(b))", "cmd": "node \\"${join(kitToolsDir, 'review-state.mjs')}\\" --check" }`;
169
- return { plan, customized, unitTestsExtended, finalCapable: hasReviewState, reviewStateCandidate, collision };
239
+ return {
240
+ plan,
241
+ customized,
242
+ unitTestsExtended,
243
+ finalCapable: hasReviewState && !checkerWithheld && !checkerInert,
244
+ hasProducer,
245
+ hasReviewState,
246
+ checkerWithheld,
247
+ checkerInert,
248
+ reviewStateCandidate,
249
+ collision,
250
+ };
170
251
  };
171
252
 
172
253
  export const resultingGates = (plan) => {
@@ -184,7 +265,7 @@ const customizedRecovery = (gate) =>
184
265
  ? `declare the canonical suite gate by hand so the coverage contract is verifiable: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`
185
266
  : 'remove the entry, or repoint it at a living check — the review-ledger / fold-completeness tools no longer exist.';
186
267
 
187
- const warningLines = ({ customized, finalCapable, reviewStateCandidate }) => {
268
+ const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable, checkerWithheld = false, checkerInert = false, reviewStateCandidate }) => {
188
269
  const lines = [];
189
270
  for (const gate of customized) {
190
271
  lines.push(` CUSTOMIZED (untouched): ${gate.id}: ${gate.cmd}`);
@@ -193,7 +274,15 @@ const warningLines = ({ customized, finalCapable, reviewStateCandidate }) => {
193
274
  if (customized.length) {
194
275
  lines.push(' IMPORTANT: do NOT install the commit guard until every customized entry above is resolved — a declaration that cannot pass run-gates --final would block every commit.');
195
276
  }
196
- if (!finalCapable) {
277
+ if (checkerWithheld) {
278
+ 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:');
279
+ lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
280
+ }
281
+ if (checkerInert) {
282
+ 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:');
283
+ lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
284
+ }
285
+ if (!hasReviewState) {
197
286
  lines.push(' WARNING: the result is NOT final-run-capable — no canonical review-state check is declared. Add it (paste-ready), then run-gates --final can mint the receipt:');
198
287
  lines.push(` ${reviewStateCandidate}`);
199
288
  }
@@ -201,7 +290,7 @@ const warningLines = ({ customized, finalCapable, reviewStateCandidate }) => {
201
290
  };
202
291
 
203
292
  export const formatPreview = (analysis, applyHint) => {
204
- const { plan, unitTestsExtended, finalCapable, retiredStores = [] } = analysis;
293
+ const { plan, unitTestsExtended, finalCapable, hasProducer = unitTestsExtended, retiredStores = [] } = analysis;
205
294
  const lines = ['[agent-workflow] legacy gates.json migration preview (dry-run — nothing was written):'];
206
295
  const acted = plan.filter((r) => r.action !== 'keep');
207
296
  for (const r of acted) {
@@ -217,7 +306,9 @@ export const formatPreview = (analysis, applyHint) => {
217
306
  : ' nothing to migrate mechanically — no canonical legacy entries and no retired stores; the warnings below still need a hand.',
218
307
  );
219
308
  }
220
- if (!unitTestsExtended && !plan.some((r) => r.entry.id === 'unit-tests')) {
309
+ // Keyed on the ID, but a PRODUCER is recognized under any id repeating this advice over a
310
+ // working producer sends the user to fix nothing.
311
+ if (!hasProducer && !unitTestsExtended && !plan.some((r) => r.entry.id === 'unit-tests')) {
221
312
  lines.push(' note: no canonical `unit-tests` entry found — declare your suite gate with the lcov reporters by hand (the coverage-check gate reads the file it produces).');
222
313
  }
223
314
  lines.push(...warningLines(analysis));
@@ -317,7 +408,8 @@ Usage:
317
408
  Default is a dry-run PREVIEW (writes nothing). --apply rewrites ${GATES_REL} atomically:
318
409
  canonical legacy entries (review-ledger / fold-completeness --check, matched by their documented
319
410
  single-invocation forms) are REMOVED; the canonical unit-tests cmd gains the built-in lcov
320
- reporters; the coverage-check gate is ADDED last (resolved, QUOTED path). Customized entries are
411
+ reporters; the coverage-check gate is ADDED last (resolved, QUOTED path) and WITHHELD, loudly,
412
+ when no declared gate produces the lcov it reads. Customized entries are
321
413
  NEVER auto-touched — the preview names each with a paste-ready recovery, and the commit guard
322
414
  must not be installed until they are resolved.`;
323
415
 
@@ -12,6 +12,7 @@ import { spawnSync } from 'node:child_process';
12
12
  import {
13
13
  LEGACY_FORMS,
14
14
  UNIT_TESTS_COVERAGE_FLAGS,
15
+ COVERAGE_PRODUCER_BODY,
15
16
  RETIRED_STORE_BASENAMES,
16
17
  findRetiredStores,
17
18
  buildMigrationPlan,
@@ -71,6 +72,42 @@ describe('migrate-gates — the pure migration plan', () => {
71
72
  assert.ok(!again.plan.some((r) => r.action === 'add'), 'a declaration already carrying the checker gains no duplicate');
72
73
  });
73
74
 
75
+ it('a declaration with NO producer never GAINS the checker — the pair is declared together or not at all', () => {
76
+ const npmSuite = { id: 'suite', title: 'S', cmd: 'npm test' };
77
+ const analysis = buildMigrationPlan([LEGACY_LEDGER, npmSuite], KIT_TOOLS);
78
+ assert.ok(!analysis.plan.some((r) => r.action === 'add'), 'no checker is added over a declaration that produces no lcov');
79
+ assert.deepEqual(resultingGates(analysis.plan).map((g) => g.id), ['suite'], 'the legacy entry still goes, nothing dead arrives');
80
+ assert.equal(analysis.finalCapable, false, 'a declaration with no checker is not final-run-capable');
81
+ const preview = formatPreview(analysis, 'APPLY');
82
+ assert.match(preview, /coverage-check/, 'the withheld checker is named');
83
+ assert.match(preview, /produce/i, 'the preview says WHY — no gate produces the lcov it would read');
84
+ });
85
+
86
+ it('an ALREADY-declared checker over no producer is reported INERT, is never removed, and is not final-run-capable', () => {
87
+ const checker = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` };
88
+ const reviewState = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` };
89
+ const analysis = buildMigrationPlan([{ id: 'suite', title: 'S', cmd: 'npm test' }, reviewState, checker], KIT_TOOLS);
90
+ assert.equal(analysis.finalCapable, false, 'a review-state present must NOT make an inert pair read as final-run-capable');
91
+ assert.ok(resultingGates(analysis.plan).some((g) => g.id === 'coverage-check'), 'the declared checker is never removed');
92
+ const preview = formatPreview(analysis, 'APPLY');
93
+ assert.match(preview, /INERT/, 'the inert pair is named');
94
+ assert.match(preview, /--experimental-test-coverage/, 'the paste-ready suite cmd is carried');
95
+ assert.doesNotMatch(preview, /already final-run-capable/);
96
+ });
97
+
98
+ it('a gates-init-shaped producer (the exec-wrapped offer form) is recognized — the checker IS added over it', () => {
99
+ const offered = {
100
+ id: 'test',
101
+ title: 'T',
102
+ cmd: `COREPACK_ENABLE_NETWORK=0 npm exec --offline --script-shell /bin/sh -- ${COVERAGE_PRODUCER_BODY}`,
103
+ };
104
+ const analysis = buildMigrationPlan([offered], KIT_TOOLS);
105
+ assert.deepEqual(resultingGates(analysis.plan).map((g) => g.id), ['test', 'coverage-check']);
106
+ // The `no canonical unit-tests entry` advice is keyed on the ID, but a producer is recognized
107
+ // under ANY id — repeating the advice over a working producer sends the user to fix nothing.
108
+ assert.doesNotMatch(formatPreview(analysis, 'APPLY'), /declare your suite gate with the lcov reporters by hand/);
109
+ });
110
+
74
111
  it('a CUSTOMIZED dead-tool reference (compound form) is kept untouched and reported', () => {
75
112
  const analysis = buildMigrationPlan([CUSTOM], KIT_TOOLS);
76
113
  assert.equal(analysis.plan.find((r) => r.entry.id === 'my-ledger-wrap').action, 'keep');