@sabaiway/agent-workflow-memory 4.6.1 → 5.0.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,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
+ ## 5.0.0 — the scenario floor: a contract can no longer pin NOTHING (AD-117)
8
+
9
+ The reader enforced a minimum on `## Out of scope` and none on `## Scenarios`. A `kind: spec`
10
+ document could therefore carry an EMPTY scenario section and pass everything — the reader, both
11
+ `spec-check` lanes, the gate row. Measured cost, from writing the layer's first real specs: three
12
+ stub specs would have satisfied every mechanical check of that slice and delivered nothing.
13
+
14
+ > ### ⚠ BREAKING — the reader refuses a document it used to accept
15
+ >
16
+ > `SPEC_SCHEMA.rules` gains ONE frozen rule, **`scenarios-empty`**, in table position between
17
+ > `scenario-path` and `out-of-scope`: a `## Scenarios` section carrying no scenario line is now an
18
+ > error. The rule list is a frozen ordered contract, so a consumer that deep-equals it sees a 34th
19
+ > id, and a deployment's spec gate can turn red with no edit of its own. The remedy is one line per
20
+ > scenario — `- S<N> <name> :: unbound` while no test pins it.
21
+
22
+ - **`*(empty)*` is deliberately NOT an escape here.** Written under `## Scenarios` it still refuses,
23
+ as `scenario-line`, and a fixture now pins that. The asymmetry with `## Out of scope` (where the
24
+ marker IS the decision) is the point: an empty exclusion list is a claim, an empty scenario list is
25
+ an absence, and the grammar already carries the honest form for the absence.
26
+ - **`unbound` accepts on every status** — `draft`, `live` and `retired` alike — so the floor costs a
27
+ spec nothing but the statement that a scenario exists.
28
+ - Measured before shipping: no document in the fixture corpus, the live store or the shipped
29
+ templates carries an empty section, so the floor refuses nothing that already exists.
30
+ - The other 33 rule ids, every existing refusal and the whole `structure` extraction stay
31
+ behaviour-identical. The new arm was red-proofed against the pre-change reader.
32
+
33
+
34
+ ## 4.7.0 — the reader's verdict gains the additive `structure` extraction (AD-114)
35
+
36
+ The slice-2 checker (`spec-check`, next release) must read a document's structure through the SAME
37
+ module that defines "malformed" — a second parser of the frozen grammar would fork that definition.
38
+ `readSpecDocument` therefore now returns, beside the untouched errors and warnings, an additive
39
+ `structure` field in a shape frozen at plan time:
40
+
41
+ - **`{scenarios: [{ordinal, binding: {file, marker} | null}], children: [{name, target}], parts:
42
+ [{name, target}], module: {form: 'root' | 'fileSet', paths: [...]} | null}`** — every target
43
+ VERBATIM as written (`./x.md` and `./x/index.md` stay distinct strings).
44
+ - **Null ONLY on the early refusals** (missing frontmatter, a frontmatter-key defect, an unknown
45
+ kind); for a known kind with errors it is the DETERMINISTIC extraction of what parsed: a
46
+ grammar-malformed scenario/child/part line is simply absent (valid lines before and after it
47
+ extract), while a line that parses but breaks a rule still extracts verbatim beside its error.
48
+ - **The module is a conjunction** — ONE `dir/` root or an all-file list; prose, a refused path, a
49
+ dir/file mix or `*(empty)*` extracts `module: null`. `## Links` stays free prose, never
50
+ extracted.
51
+ - The 33 rule ids, every refusal and every existing verdict field stay behaviour-identical (the
52
+ engine corpus suite is green untouched; engine stays 3.3.0). The deep-equal suite pins the shape
53
+ per kind, both module forms, every early-refusal branch and the partial extractions; the new
54
+ tests were red-proofed against the pre-change reader.
55
+
56
+ MINOR: a shipped script gains a capability. One prose alignment rides along: the upgrade's
57
+ refresh-lane sentence now names a file of EITHER deployed pair (reader or checker) on a shipped
58
+ body as refreshed by the composition root's upgrade — this substrate still carries no catalog and
59
+ never overwrites a deployed script itself.
60
+
7
61
  ## 4.6.1 — the standalone upgrade delivers the spec layer behind a checker it can prove (AD-113)
8
62
 
9
63
  4.6.0 shipped the feature-spec layer for FRESH bootstraps; an EXISTING deployment at lineage head
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.1'
6
+ version: '5.0.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
@@ -236,7 +236,7 @@ Fill strategy:
236
236
  written) **byte-equal** to this skill's bundled copies (compare with `cmp`): the deployed checker
237
237
  is what the pre-commit hook runs, and an older or edited one renders the store row by row and
238
238
  reds its own index check. Otherwise seed NO store root and report which file differs: a
239
- checker still on a body a release shipped is refreshed by the composition root's own upgrade
239
+ file of either pair still on a body a release shipped is refreshed by the composition root's own upgrade
240
240
  (it carries the catalog of shipped bodies), an edited one is the user's to refresh by hand — this
241
241
  substrate carries no such catalog and never overwrites a deployed script. **Same gate, also
242
242
  stamp-independent — ensure the NAVIGATOR:** `docs/ai/index.md` is a GENERATED artifact the entry
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.6.1",
6
+ "version": "5.0.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.6.1",
3
+ "version": "5.0.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",
@@ -38,7 +38,7 @@ export const SPEC_SCHEMA = Object.freeze({
38
38
  'frontmatter', 'frontmatter-key', 'substrate-key', 'type', 'kind', 'maxlines', 'status', 'revision',
39
39
  'root-owns', 'slug', 'kind-path', 'root-uplink', 'title', 'section-missing', 'section-order',
40
40
  'section-forbidden', 'fence', 'children-link', 'children-duplicate', 'fan-out', 'scenario-line',
41
- 'scenario-number', 'scenario-marker', 'scenario-path', 'out-of-scope', 'module-line', 'module-empty',
41
+ 'scenario-number', 'scenario-marker', 'scenario-path', 'scenarios-empty', 'out-of-scope', 'module-line', 'module-empty',
42
42
  'module-traversal', 'module-absolute', 'module-backslash', 'module-glob', 'module-mix', 'parts',
43
43
  ]),
44
44
  });
@@ -238,6 +238,10 @@ const checkScenarios = (parsed, slug, status, errors, warnings) => {
238
238
  }
239
239
  scenarios.push(scenario);
240
240
  }
241
+ if (scenarios.length === 0) {
242
+ errors.push({ rule: 'scenarios-empty', message: `at least one scenario line (\`${SPEC_SCHEMA.unboundMarker}\` while no test pins it)` });
243
+ return;
244
+ }
241
245
  const gap = scenarios.findIndex((scenario, i) => scenario.n !== i + 1);
242
246
  if (gap !== -1) errors.push({ rule: 'scenario-number', message: `scenario ${gap + 1} is numbered S${scenarios[gap].n} — N runs contiguously from 1` });
243
247
  for (const scenario of scenarios) {
@@ -310,13 +314,45 @@ const checkParts = (parsed, at, errors) => {
310
314
  if (duplicate !== undefined) errors.push({ rule: 'parts', message: `part "${duplicate}" is listed twice` });
311
315
  };
312
316
 
313
- // The verdict: { kind, status, revision, errors: [{ rule, message }], warnings: [{ rule, message }] }.
314
- // Errors are collected past the first defect wherever later checks stay meaningful; a missing or
315
- // defective frontmatter and an unknown kind each end the read, because no shape can be judged without them.
317
+ // The structure verdict (additive, slice 2a): the DETERMINISTIC extraction of what parsed, per
318
+ // section, targets VERBATIM as written (`./x.md` and `./x/index.md` stay distinct strings). The
319
+ // grammar is per line for scenarios/children/parts a malformed line is simply absent while the
320
+ // module is a conjunction (ONE `dir/` root or an all-file list): prose, a refused path, a mix or
321
+ // `*(empty)*` extracts null. `## Links` is free prose and is never extracted.
322
+ const scenarioEntry = (line) => {
323
+ const scenario = parseScenario(line);
324
+ if (scenario === null) return null;
325
+ return { ordinal: scenario.n, binding: scenario.bound ? { file: scenario.path, marker: scenario.marker } : null };
326
+ };
327
+ const linkEntry = (re) => (line) => {
328
+ const m = line.match(re);
329
+ return m && SLUG_RE.test(m[2]) ? { name: m[1], target: `./${m[2]}${m[3] ?? ''}.md` } : null;
330
+ };
331
+ const entriesOf = (parsed, heading, entryOf) =>
332
+ contentOf(sectionLines(parsed, heading) ?? []).map(entryOf).filter((entry) => entry !== null);
333
+ const extractModule = (parsed) => {
334
+ const content = contentOf(sectionLines(parsed, '## Module') ?? []);
335
+ const paths = bulletsOf(content);
336
+ if (paths.length === 0 || content.some((line) => !isBullet(line))) return null;
337
+ const kinds = paths.map(classifyPath);
338
+ if (paths.length === 1 && kinds[0] === 'dir') return { form: 'root', paths };
339
+ return kinds.every((kind) => kind === 'file') ? { form: 'fileSet', paths } : null;
340
+ };
341
+ const extractStructure = (parsed) => ({
342
+ scenarios: entriesOf(parsed, '## Scenarios', scenarioEntry),
343
+ children: entriesOf(parsed, '## Children', linkEntry(CHILD_LINK_RE)),
344
+ parts: entriesOf(parsed, '## Parts', linkEntry(PART_LINK_RE)),
345
+ module: extractModule(parsed),
346
+ });
347
+
348
+ // The verdict: { kind, status, revision, structure, errors: [{ rule, message }], warnings: [{ rule,
349
+ // message }] }. Errors are collected past the first defect wherever later checks stay meaningful; a
350
+ // missing or defective frontmatter and an unknown kind each end the read (structure stays null),
351
+ // because no shape can be judged without them.
316
352
  export const readSpecDocument = (text, rel) => {
317
353
  const errors = [];
318
354
  const warnings = [];
319
- const verdict = (kind, status, revision) => ({ kind, status, revision, errors, warnings });
355
+ const verdict = (kind, status, revision, structure = null) => ({ kind, status, revision, structure, errors, warnings });
320
356
  const front = parseFrontmatter(text.replace(/\r\n/g, '\n'));
321
357
  if (front === null) {
322
358
  errors.push({ rule: 'frontmatter', message: 'missing YAML frontmatter' });
@@ -351,5 +387,5 @@ export const readSpecDocument = (text, rel) => {
351
387
  }
352
388
  const status = kind === 'spec' ? fields.status ?? null : null;
353
389
  const revision = kind === 'spec' && REVISION_RE.test(fields.revision ?? '') ? Number(fields.revision) : null;
354
- return verdict(kind, status, revision);
390
+ return verdict(kind, status, revision, extractStructure(parsed));
355
391
  };
@@ -131,6 +131,14 @@ describe('readSpecDocument — refuse, exactly one rule per defect', () => {
131
131
  refuses(specDoc({ scenarios: ['- S1 a :: /abs/login.test.mjs :: spec:login/S1'] }), 'login.md', 'scenario-path');
132
132
  });
133
133
 
134
+ it('scenarios-empty / no empty-marker escape', () => {
135
+ refuses(specDoc({ scenarios: [] }), 'login.md', 'scenarios-empty');
136
+ refuses(specDoc({ scenarios: ['*(empty)*'] }), 'login.md', 'scenario-line');
137
+ for (const status of SPEC_SCHEMA.statuses) {
138
+ expect(readSpecDocument(specDoc({ scenarios: ['- S1 x :: unbound'], fields: { status } }), 'login.md').errors).toEqual([]);
139
+ }
140
+ });
141
+
134
142
  it('out-of-scope / module-empty / module-* path refusals / module-mix / parts', () => {
135
143
  refuses(specDoc({ outOfScope: '' }), 'login.md', 'out-of-scope');
136
144
  refuses(specDoc({ outOfScope: 'nothing excluded' }), 'login.md', 'out-of-scope');
@@ -194,6 +202,73 @@ describe('readSpecDocument — refuse, exactly one rule per defect', () => {
194
202
  });
195
203
  });
196
204
 
205
+ describe('readSpecDocument — the structure verdict (additive, slice 2a)', () => {
206
+ const structureOf = (text, rel) => readSpecDocument(text, rel).structure;
207
+ const BOTH_SCENARIOS = [
208
+ { ordinal: 1, binding: { file: 'test/login.test.mjs', marker: 'spec:login/S1' } },
209
+ { ordinal: 2, binding: null },
210
+ ];
211
+
212
+ it('a flat spec extracts scenarios (bound + unbound) and its one dir/ module root', () => {
213
+ expect(structureOf(specDoc(), 'login.md')).toEqual({
214
+ scenarios: BOTH_SCENARIOS,
215
+ children: [],
216
+ parts: [],
217
+ module: { form: 'root', paths: ['src/login/'] },
218
+ });
219
+ });
220
+
221
+ it('a promoted root extracts parts and a fileSet module; ## Links stays free prose, never extracted', () => {
222
+ const text = specDoc({ module: '- src/a.mjs\n- src/b.mjs', extra: '\n## Parts\n\n- [sessions](./sessions.md)\n\n## Links\n\n- [[AD-112]]\n' });
223
+ expect(structureOf(text, 'auth/login/index.md')).toEqual({
224
+ scenarios: BOTH_SCENARIOS,
225
+ children: [],
226
+ parts: [{ name: 'sessions', target: './sessions.md' }],
227
+ module: { form: 'fileSet', paths: ['src/a.mjs', 'src/b.mjs'] },
228
+ });
229
+ });
230
+
231
+ it('an index extracts children with VERBATIM targets — ./x.md and ./x/index.md stay distinct strings', () => {
232
+ expect(structureOf(indexDoc(), 'auth/index.md')).toEqual({
233
+ scenarios: [],
234
+ children: [{ name: 'login', target: './login.md' }, { name: 'billing', target: './billing/index.md' }],
235
+ parts: [],
236
+ module: null,
237
+ });
238
+ });
239
+
240
+ it('a part extracts the empty structure; a retired *(empty)* module extracts null on a CLEAN document', () => {
241
+ expect(structureOf(partDoc(), 'auth/login/sessions.md')).toEqual({ scenarios: [], children: [], parts: [], module: null });
242
+ const retired = specDoc({ fields: { status: 'retired' }, module: '*(empty)*', scenarios: ['- S1 gone :: unbound'] });
243
+ expect(structureOf(retired, 'login.md').module).toBeNull();
244
+ });
245
+
246
+ it('EVERY early refusal reads structure null: missing frontmatter, a frontmatter defect, an unknown or absent kind', () => {
247
+ expect(structureOf('# Spec: Login\n', 'login.md')).toBeNull();
248
+ expect(structureOf(specDoc({ fields: { priority: 'high' } }), 'login.md')).toBeNull();
249
+ expect(structureOf(specDoc({ fields: { kind: 'feature' } }), 'login.md')).toBeNull();
250
+ expect(structureOf(specDoc({ drop: ['kind'] }), 'login.md')).toBeNull();
251
+ });
252
+
253
+ it('a grammar-malformed scenario/child/part line is simply ABSENT — valid lines before and after it extract', () => {
254
+ const s = structureOf(specDoc({ scenarios: ['- S1 a :: unbound', '- S2 broken', '- S3 c :: unbound'] }), 'login.md');
255
+ expect(s.scenarios).toEqual([{ ordinal: 1, binding: null }, { ordinal: 3, binding: null }]);
256
+ const c = structureOf(indexDoc({ children: ['- [a](./a.md)', '- broken', '- [b](./b/index.md)'] }), 'auth/index.md');
257
+ expect(c.children).toEqual([{ name: 'a', target: './a.md' }, { name: 'b', target: './b/index.md' }]);
258
+ const p = structureOf(specDoc({ extra: '\n## Parts\n\n- [a](./a.md)\n- broken\n- [b](./b.md)\n' }), 'auth/login/index.md');
259
+ expect(p.parts).toEqual([{ name: 'a', target: './a.md' }, { name: 'b', target: './b.md' }]);
260
+ });
261
+
262
+ it('the module is a CONJUNCTION — prose, a refused path, a dir/file mix each extract null; a rule-refused scenario line still extracts verbatim', () => {
263
+ for (const module of ['the root is\n- src/login/', '- ../src/', '- src/login/\n- src/login/a.mjs']) {
264
+ expect(structureOf(specDoc({ module }), 'login.md').module).toBeNull();
265
+ }
266
+ const v = readSpecDocument(specDoc({ scenarios: ['- S1 a :: test/a.mjs :: spec:login/S2'] }), 'login.md');
267
+ expect(v.errors.map((e) => e.rule)).toEqual(['scenario-marker']);
268
+ expect(v.structure.scenarios).toEqual([{ ordinal: 1, binding: { file: 'test/a.mjs', marker: 'spec:login/S2' } }]);
269
+ });
270
+ });
271
+
197
272
  describe('classifyPath + the frozen constants', () => {
198
273
  it('classifies the lexical path forms', () => {
199
274
  expect(['src/', 'src/a.mjs', '../x', '/x', 'C:/x', 'a\\b', 'src/*.mjs'].map(classifyPath)).toEqual([