@sabaiway/agent-workflow-memory 7.2.1 → 8.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,26 @@ 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
+ ## 8.0.0 — the reader refuses an open-list clause: a guarantee is an invariant, never a list of cases (AD-133)
8
+
9
+ **BREAKING — the 35th rule id, `open-list`.** `references/scripts/spec-schema.mjs` refuses a Contract clause that
10
+ carries a check verb (`refuse` / `refusal`, case-sensitive substrings) AND an enumeration of two or more items
11
+ (`a, b or c` / `a · b`) AND no closure token inside that same clause. The unit is the CLAUSE: a top-level bullet joined
12
+ with its continuation lines (a blank line, the next top-level bullet — an empty `-` included — or any ATX heading,
13
+ indented up to three spaces, ends it), split at `. ` and `; ` outside a backtick span — the span is opaque to the split
14
+ and to nothing else. Every `## Contract` section of a `kind: spec`, every top-level bullet of a `kind: part`; never an
15
+ index or prose outside bullets. Twelve closure tokens (`only if`, `only when`, `the only`, `everything else`,
16
+ `anything else`, `nothing else`, `every other`, `any other`, `otherwise`, `exactly`, `one of`, `closed`) match
17
+ case-insensitively at word boundaries; `ONLY` is the one case-sensitive emphatic word (a lowercase `only` alone closes
18
+ nothing; `READ-ONLY` and `closed-key` glue). A single-case check is its own closure; a closure that is FALSE is out of
19
+ scope — a form rule. The remedy for a spec valid under the old canon that now refuses: state the invariant in the
20
+ clause ("accepted only if …; everything else is the named refusal …"), never a fourth case; the refusal names the
21
+ clause's first six words. `SPEC_SCHEMA.openList` carries the frozen fragment (every list frozen); `rules` gains
22
+ `open-list` after `fence`. The comment blocks became one-line pointers naming no sibling package; the module is 415
23
+ lines, recorded. The test twin gains the "open-list clauses" describe (clause and bullet boundaries, the span mask, the
24
+ matching rules, every Contract section, part scope, frozenness). Mirrored byte-identically into the kit and the root
25
+ `scripts/`.
26
+
7
27
  ## 7.2.1 — the orchestration seed's note names four activities (AD-132)
8
28
 
9
29
  `references/templates/orchestration.json`: the `_README` is the kit's new canonical byte for byte —
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: '7.2.1'
6
+ version: '8.0.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": "7.2.1",
6
+ "version": "8.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": "7.2.1",
3
+ "version": "8.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",
@@ -1,13 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // spec-schema.mjs — the ONE reader that DEFINES a well-formed spec document under docs/ai/specs/.
3
- //
4
- // Pure text in, verdict out: readSpecDocument(text, rel) never touches the filesystem and imports
5
- // nothing, so it seeds layout-free into any deployment. The navigator's collapse (check-docs-size.mjs)
6
- // and the future spec-check read through THIS module — "malformed" has one definition, never two.
7
- // `rel` is the path INSIDE docs/ai/specs/ (the store root navigator is `index.md`).
8
- //
9
- // SPEC_SCHEMA carries the frozen values; the engine canon (references/specs.md) is pinned against it.
10
- // A refusal names exactly one rule id per defect, so a fixture corpus can be read rule by rule.
2
+ // Canon: the methodology engine's references/specs.md (frozen schema, refusals table).
11
3
 
12
4
  export const SPEC_SCHEMA = Object.freeze({
13
5
  storePrefix: 'docs/ai/specs/',
@@ -34,17 +26,25 @@ export const SPEC_SCHEMA = Object.freeze({
34
26
  rootOwnedKeys: Object.freeze(['status', 'revision']),
35
27
  rootOwnedSections: Object.freeze(['## Scenarios', '## Out of scope', '## Module', '## Parts']),
36
28
  scenarioGrammar: '- S<N> <name> :: <repo-relative test path> :: spec:<slug>/S<N> | - S<N> <name> :: unbound',
29
+ openList: Object.freeze({
30
+ checkVerbs: Object.freeze(['refuse', 'refusal', 'REFUSE', 'REFUSAL']),
31
+ enumerationForms: Object.freeze([Object.freeze([',', ' or ']), Object.freeze([' · '])]),
32
+ closureTokens: Object.freeze([
33
+ 'only if', 'only when', 'the only', 'everything else', 'anything else', 'nothing else',
34
+ 'every other', 'any other', 'otherwise', 'exactly', 'one of', 'closed',
35
+ ]),
36
+ emphaticToken: 'ONLY',
37
+ }),
37
38
  rules: Object.freeze([
38
39
  'frontmatter', 'frontmatter-key', 'substrate-key', 'type', 'kind', 'maxlines', 'status', 'revision',
39
40
  'root-owns', 'slug', 'kind-path', 'root-uplink', 'title', 'section-missing', 'section-order',
40
- 'section-forbidden', 'fence', 'children-link', 'children-duplicate', 'fan-out', 'scenario-line',
41
+ 'section-forbidden', 'fence', 'open-list', 'children-link', 'children-duplicate', 'fan-out', 'scenario-line',
41
42
  'scenario-number', 'scenario-marker', 'scenario-path', 'scenarios-empty', 'out-of-scope', 'module-line', 'module-empty',
42
43
  'module-traversal', 'module-absolute', 'module-backslash', 'module-glob', 'module-mix', 'parts',
43
44
  ]),
44
45
  });
45
46
 
46
- // The descriptor check-docs-size.mjs joins to its ADR group: rows under `prefix` whose reader verdict
47
- // is clean collapse into ONE navigator row linking `navPath`; a row with reader errors stays visible.
47
+ // Consumer contract: scripts/check-docs-size.mjs.
48
48
  export const SPECS_COLLAPSE = Object.freeze({
49
49
  prefix: SPEC_SCHEMA.storePrefix,
50
50
  navPath: `${SPEC_SCHEMA.storePrefix}${SPEC_SCHEMA.navigatorFile}`,
@@ -72,8 +72,6 @@ const FORBIDDEN_SECTIONS = Object.freeze({
72
72
 
73
73
  const KNOWN_KEYS = Object.freeze([...SPEC_SCHEMA.substrateKeys, 'kind', ...SPEC_SCHEMA.rootOwnedKeys]);
74
74
 
75
- // The frontmatter is a closed key set: an unknown key, a repeated key or a line that is not
76
- // `key: value` is a defect (`frontmatter-key`), never silently dropped or last-one-wins.
77
75
  const parseFrontmatter = (text) => {
78
76
  const match = text.match(FRONTMATTER_RE);
79
77
  if (!match) return null;
@@ -91,10 +89,6 @@ const parseFrontmatter = (text) => {
91
89
 
92
90
  const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
93
91
 
94
- // The body as { title, sections: [{ heading, lines }], preamble, fenced } — the title is the FIRST
95
- // `# ` line, every `## ` line opens a section, the lines before the first section are the preamble.
96
- // The reader parses NO markdown code: a fence line is recorded (the `fence` refusal) rather than
97
- // modelled, so a spec carries no code sample and no line is ever ambiguous between code and structure.
98
92
  const parseBody = (body) => {
99
93
  const sections = [];
100
94
  const preamble = [];
@@ -116,8 +110,6 @@ const parseBody = (body) => {
116
110
  return { title: state.title, sections, preamble, fenced, sectionBeforeTitle: state.sectionBeforeTitle };
117
111
  };
118
112
 
119
- // Lexical classification of a repo-relative path field — the same vocabulary for module roots and
120
- // scenario bindings. Realpath/symlink containment needs the filesystem and is the checker's duty.
121
113
  export const classifyPath = (path) => {
122
114
  if (path.trim() === '') return 'empty';
123
115
  if (path.includes('\\')) return 'backslash';
@@ -139,8 +131,47 @@ const MODULE_PATH_RULES = Object.freeze({
139
131
  const isBullet = (line) => line === '-' || line.startsWith('- ');
140
132
  const bulletsOf = (lines) => lines.filter(isBullet).map((line) => line.slice(2));
141
133
  const contentOf = (lines) => lines.filter((line) => line.trim() !== '');
134
+ const isAtxHeading = (line) => /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line);
135
+ const topLevelBulletsOf = (lines) => lines.reduce((state, raw) => {
136
+ const line = raw.replace(/\s+$/, '');
137
+ if (isBullet(line)) {
138
+ state.current = [line === '-' ? '' : line.slice(2)];
139
+ state.bullets.push(state.current);
140
+ } else if (line.trim() === '' || isAtxHeading(line)) state.current = null;
141
+ else if (state.current !== null) state.current.push(line.trim());
142
+ return state;
143
+ }, { bullets: [], current: null }).bullets.map((parts) => parts.join(' ').replace(/\s+/g, ' ').trim());
144
+
145
+ const CLAUSE_BREAK = String.fromCharCode(0);
146
+ const MASKED_DOT = String.fromCharCode(1);
147
+ const MASKED_SEMICOLON = String.fromCharCode(2);
148
+ const maskClauseSpans = (text) => [...text].reduce((state, character) => {
149
+ if (character === '`') state.inSpan = !state.inSpan;
150
+ state.output.push(state.inSpan && character === '.' ? MASKED_DOT : state.inSpan && character === ';' ? MASKED_SEMICOLON : character);
151
+ return state;
152
+ }, { inSpan: false, output: [] }).output.join('');
153
+ const clausesOf = (bullet) => maskClauseSpans(bullet).replace(/([.;][*`\)\]]*) /g, `$1${CLAUSE_BREAK}`).split(CLAUSE_BREAK)
154
+ .map((clause) => clause.split(MASKED_DOT).join('.').split(MASKED_SEMICOLON).join(';').trim()).filter(Boolean);
155
+ const containsInOrder = (text, tokens) => tokens.reduce((position, token) => {
156
+ const found = position === -1 ? -1 : text.indexOf(token, position);
157
+ return found === -1 ? -1 : found + token.length;
158
+ }, 0) !== -1;
159
+ const hasBoundedToken = (clause, token, flags) => new RegExp(`(^|[^A-Za-z0-9_-])${token}([^A-Za-z0-9_-]|$)`, flags).test(clause);
160
+ const checkOpenLists = (parsed, kind, errors) => {
161
+ const groups = kind === 'spec' ? parsed.sections.filter((section) => section.heading === '## Contract').map((section) => section.lines)
162
+ : kind === 'part' ? [parsed.preamble, ...parsed.sections.map((section) => section.lines)] : [];
163
+ for (const clause of groups.flatMap(topLevelBulletsOf).flatMap(clausesOf)) {
164
+ const hasVerb = SPEC_SCHEMA.openList.checkVerbs.some((verb) => clause.includes(verb));
165
+ const hasEnumeration = SPEC_SCHEMA.openList.enumerationForms.some((form) => containsInOrder(clause, form));
166
+ const hasClosure = SPEC_SCHEMA.openList.closureTokens.some((token) => hasBoundedToken(clause, token, 'i'))
167
+ || hasBoundedToken(clause, SPEC_SCHEMA.openList.emphaticToken, '');
168
+ if (hasVerb && hasEnumeration && !hasClosure) {
169
+ const prefix = clause.split(/\s+/).slice(0, 6).join(' ');
170
+ errors.push({ rule: 'open-list', message: `the clause "${prefix}…" is an open list — add a closing form to it such as "only if …; everything else …"` });
171
+ }
172
+ }
173
+ };
142
174
 
143
- // The slug a document owns: the file stem for a flat file, the folder name for an index.md.
144
175
  const describeRel = (rel) => {
145
176
  const segments = rel.split('/');
146
177
  const file = segments[segments.length - 1];
@@ -314,11 +345,7 @@ const checkParts = (parsed, at, errors) => {
314
345
  if (duplicate !== undefined) errors.push({ rule: 'parts', message: `part "${duplicate}" is listed twice` });
315
346
  };
316
347
 
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.
348
+ // Structure verdict: the same canon, "The frozen schema".
322
349
  const scenarioEntry = (line) => {
323
350
  const scenario = parseScenario(line);
324
351
  if (scenario === null) return null;
@@ -345,10 +372,6 @@ const extractStructure = (parsed) => ({
345
372
  module: extractModule(parsed),
346
373
  });
347
374
 
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.
352
375
  export const readSpecDocument = (text, rel) => {
353
376
  const errors = [];
354
377
  const warnings = [];
@@ -372,6 +395,7 @@ export const readSpecDocument = (text, rel) => {
372
395
  const at = checkPath(rel, kind, errors);
373
396
  const parsed = parseBody(body);
374
397
  if (parsed.fenced.length > 0) errors.push({ rule: 'fence', message: `"${parsed.fenced[0]}" — a spec document carries no code fence` });
398
+ checkOpenLists(parsed, kind, errors);
375
399
  checkSections(parsed, kind, errors);
376
400
  if (kind === 'index') {
377
401
  checkChildren(parsed, errors);
@@ -32,6 +32,8 @@ const refuses = (text, rel, rule) => {
32
32
  const verdict = readSpecDocument(text, rel);
33
33
  expect(rulesOf(verdict)).toEqual([rule]);
34
34
  };
35
+ const withContract = (contract) => specDoc().replace('Accepts a credential pair.', contract);
36
+ const openListErrors = (contract, rel = 'login.md') => readSpecDocument(withContract(contract), rel).errors;
35
37
 
36
38
  describe('readSpecDocument — accept', () => {
37
39
  it('a flat draft spec reads clean with kind/status/revision', () => {
@@ -269,6 +271,69 @@ describe('readSpecDocument — the structure verdict (additive, slice 2a)', () =
269
271
  });
270
272
  });
271
273
 
274
+ describe('readSpecDocument — open-list clauses', () => {
275
+ const refusal = 'the clause "an anchor path reported modified, untracked…" is an open list — add a closing form to it such as "only if …; everything else …"';
276
+ const open = '- an anchor path reported modified, untracked or ignored is the named refusal `anchor-dirty`';
277
+ const closed = '- an anchor path is accepted only if `ls-files -v` lists it with tag `H` and `status` is silent — everything else, a path reported modified, untracked or ignored, is the named refusal `anchor-dirty`';
278
+
279
+ it('refuses the measured clause with the exact message; verb alone, form alone and an in-clause closure pass', () => {
280
+ expect(openListErrors(open)).toEqual([{ rule: 'open-list', message: refusal }]);
281
+ for (const text of [closed, '- refuse one case', '- alpha, beta or gamma']) expect(openListErrors(text)).toEqual([]);
282
+ });
283
+
284
+ it('joins continuation lines, collapses whitespace and keeps an indented bullet in its top-level bullet', () => {
285
+ for (const text of ['- refuse alpha,\n beta or ignored', '- refuse alpha,\n - beta or ignored']) expect(rulesOf(readSpecDocument(withContract(text), 'login.md'))).toEqual(['open-list']);
286
+ });
287
+
288
+ it('ends a bullet at a blank, the next top-level bullet or every ATX heading; Contract prose is ignored', () => {
289
+ for (const tail of ['\n\nEverything else closes', '\n- Everything else closes', ...Array.from({ length: 6 }, (_, i) => `\n${'#'.repeat(i + 1)} Everything else closes`)]) {
290
+ expect(rulesOf(readSpecDocument(withContract('- refuse alpha, beta or gamma' + tail), 'login.md'))).toEqual(['open-list']);
291
+ }
292
+ expect(openListErrors('refuse alpha, beta or gamma')).toEqual([]);
293
+ });
294
+
295
+ it('ends a bullet at indented and tab-separated ATX headings', () => {
296
+ for (const tail of ['\n ## Everything else closes', '\n#\tEverything else closes']) expect(rulesOf(readSpecDocument(withContract('- refuse alpha, beta or gamma' + tail), 'login.md'))).toEqual(['open-list']);
297
+ });
298
+
299
+ it('starts a new empty top-level bullet before following prose', () => {
300
+ for (const tail of ['\n- \nEverything else closes', '\n-\nonly if closes']) expect(rulesOf(readSpecDocument(withContract('- refuse alpha, beta or gamma' + tail), 'login.md'))).toEqual(['open-list']);
301
+ });
302
+
303
+ it('judges an open list in every Contract section', () => {
304
+ const duplicate = specDoc().replace('## Scenarios', '## Contract\n\n- refuse alpha, beta or gamma\n\n## Scenarios');
305
+ expect(rulesOf(readSpecDocument(duplicate, 'login.md')).includes('open-list')).toBe(true);
306
+ });
307
+
308
+ it('splits clauses at punctuation plus markdown tails, but not inside backtick spans', () => {
309
+ for (const tail of ['.** ', '.` ', '.) ', '.] ', '; ']) expect(rulesOf(readSpecDocument(withContract(`- refuse alpha, beta or gamma${tail}Everything else closes`), 'login.md'))).toEqual(['open-list']);
310
+ for (const text of ['- refuse `alpha. beta`, gamma or delta', '- refuse `alpha; beta`, gamma or delta', '- refuse `alpha. beta, gamma or delta']) expect(rulesOf(readSpecDocument(withContract(text), 'login.md'))).toEqual(['open-list']);
311
+ expect(openListErrors('- refuse alpha``. beta, gamma or delta')).toEqual([]);
312
+ });
313
+
314
+ it('keeps span content in judgement, and a closure in a neighbouring clause cannot lift a refusal', () => {
315
+ expect(rulesOf(readSpecDocument(withContract('- `refuse` alpha, beta or gamma'), 'login.md'))).toEqual(['open-list']);
316
+ expect(openListErrors('- refuse alpha, beta or gamma with `everything else`')).toEqual([]);
317
+ expect(rulesOf(readSpecDocument(withContract('- refuse alpha, beta or gamma. Everything else closes'), 'login.md'))).toEqual(['open-list']);
318
+ });
319
+
320
+ it('matches verbs, forms and closure tokens with the frozen case and boundary rules', () => {
321
+ for (const verb of ['refuse', 'refuses', 'refused', 'refusal', 'refusals', 'REFUSE', 'REFUSAL']) expect(rulesOf(readSpecDocument(withContract(`- ${verb} alpha, beta or gamma`), 'login.md'))).toEqual(['open-list']);
322
+ for (const text of ['- Refuse alpha, beta or gamma', '- refuse alpha, beta and gamma']) expect(openListErrors(text)).toEqual([]);
323
+ expect(rulesOf(readSpecDocument(withContract('- refuse alpha · beta'), 'login.md'))).toEqual(['open-list']);
324
+ for (const token of SPEC_SCHEMA.openList.closureTokens) expect(openListErrors(`- ${token.toUpperCase()}: refuse alpha, beta or gamma`)).toEqual([]);
325
+ for (const text of ['- refuse alpha, beta or gamma — none of these', '- refuse alpha, beta or gamma — closed-key', '- refuse alpha, beta or gamma — READ-ONLY', '- refuse alpha, beta or gamma — if only this', '- refuse alpha, beta or gamma — not only this']) expect(rulesOf(readSpecDocument(withContract(text), 'login.md'))).toEqual(['open-list']);
326
+ expect(openListErrors('- refuse alpha, beta or gamma — ONLY this')).toEqual([]);
327
+ });
328
+
329
+ it('judges every part bullet, never an index, and freezes every schema list and inner pair', () => {
330
+ expect(rulesOf(readSpecDocument(partDoc({ extra: `\n${open}\n` }), 'auth/login/sessions.md'))).toEqual(['open-list']);
331
+ expect(readSpecDocument(indexDoc({ children: [], preamble: `\n${open}\n` }), 'auth/index.md').errors).toEqual([]);
332
+ expect(SPEC_SCHEMA.openList).toEqual({ checkVerbs: ['refuse', 'refusal', 'REFUSE', 'REFUSAL'], enumerationForms: [[',', ' or '], [' · ']], closureTokens: ['only if', 'only when', 'the only', 'everything else', 'anything else', 'nothing else', 'every other', 'any other', 'otherwise', 'exactly', 'one of', 'closed'], emphaticToken: 'ONLY' });
333
+ expect([SPEC_SCHEMA.openList, SPEC_SCHEMA.openList.checkVerbs, SPEC_SCHEMA.openList.enumerationForms, ...SPEC_SCHEMA.openList.enumerationForms, SPEC_SCHEMA.openList.closureTokens].every(Object.isFrozen)).toBe(true);
334
+ });
335
+ });
336
+
272
337
  describe('classifyPath + the frozen constants', () => {
273
338
  it('classifies the lexical path forms', () => {
274
339
  expect(['src/', 'src/a.mjs', '../x', '/x', 'C:/x', 'a\\b', 'src/*.mjs'].map(classifyPath)).toEqual([