@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 +54 -0
- package/SKILL.md +2 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/spec-schema.mjs +42 -6
- package/references/scripts/spec-schema.test.mjs +75 -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
|
+
## 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: '
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-memory",
|
|
3
|
-
"version": "
|
|
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
|
|
314
|
-
//
|
|
315
|
-
//
|
|
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([
|