@sabaiway/agent-workflow-memory 1.12.0 → 2.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 +52 -1
- package/README.md +2 -2
- package/SKILL.md +35 -21
- package/bin/install.mjs +32 -0
- package/capability.json +1 -1
- package/migrations/README.md +1 -1
- package/migrations/legacy-stamp-takeover.md +3 -3
- package/package.json +1 -1
- package/references/scripts/archive-decisions.mjs +705 -320
- package/references/scripts/archive-decisions.test.mjs +652 -312
- package/references/scripts/check-docs-size.mjs +35 -5
- package/references/scripts/check-docs-size.test.mjs +55 -0
- package/references/templates/adr/log.md +25 -0
- package/references/templates/adr-record.md +72 -0
- package/references/templates/decisions.md +7 -18
- package/scripts/stamp-takeover.mjs +2 -2
|
@@ -270,6 +270,31 @@ const formatIndexRow = (row) => {
|
|
|
270
270
|
return `| ${link} | ${fm.type ?? '—'} | ${row.lineCount}/${fm.maxLines ?? '—'} | ${fm.lastUpdated ?? '—'} | ${fm.staleAfter ?? '—'} |`;
|
|
271
271
|
};
|
|
272
272
|
|
|
273
|
+
// The one-file-per-ADR store (docs/ai/adr/) grows O(n) forever, so its rows would blow the index's
|
|
274
|
+
// own 80-line cap. It COLLAPSES to a single aggregate row (link → the navigator adr/log.md, record
|
|
275
|
+
// count + numeric id range) — while walkMarkdownFiles still finds + cap-checks every individual body
|
|
276
|
+
// (a body over its own cap still fails in the main flow; only the index RENDERING is collapsed).
|
|
277
|
+
const ADR_DIR_PREFIX = 'docs/ai/adr/';
|
|
278
|
+
const ADR_RECORD_RE = /\/AD-(\d{3,})-[^/]*\.md$/;
|
|
279
|
+
const ADR_NAV_PATH = 'docs/ai/adr/log.md';
|
|
280
|
+
|
|
281
|
+
// Only genuine records + the navigator collapse into the aggregate row; an UNEXPECTED file under
|
|
282
|
+
// adr/ (a stray README.md, AD-foo.md) renders as its OWN visible index row — never silently hidden
|
|
283
|
+
// by the collapse (it also fails archive-decisions' own store-integrity check).
|
|
284
|
+
const isCollapsibleAdr = (path) => path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(path) || path === ADR_NAV_PATH);
|
|
285
|
+
|
|
286
|
+
const formatAdrCollapseRow = (adrRows) => {
|
|
287
|
+
const recs = adrRows
|
|
288
|
+
.map((r) => {
|
|
289
|
+
const m = r.path.match(ADR_RECORD_RE);
|
|
290
|
+
return m ? { idStr: m[1], idNum: Number(m[1]) } : null;
|
|
291
|
+
})
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
.sort((a, b) => a.idNum - b.idNum); // NUMERIC id ordering (AD-200 before AD-1000), never lexical
|
|
294
|
+
const range = recs.length > 0 ? `AD-${recs[0].idStr} … AD-${recs[recs.length - 1].idStr}` : '—';
|
|
295
|
+
return `| [\`adr/\`](./adr/log.md) | adr | ${recs.length} records | ${range} | — |`;
|
|
296
|
+
};
|
|
297
|
+
|
|
273
298
|
// Pure index renderer — given inspected rows + the date to stamp in the header,
|
|
274
299
|
// returns the exact bytes `docs/ai/index.md` should contain. Shared by
|
|
275
300
|
// `--write-index` (writes it) and `--check-index` (diffs against on-disk).
|
|
@@ -277,13 +302,18 @@ export const buildIndex = (rows, todayStr, meta = {}) => {
|
|
|
277
302
|
const projectName = meta.projectName ?? DEFAULT_PROJECT_NAME;
|
|
278
303
|
const onDemandLinks = meta.onDemandLinks ?? [];
|
|
279
304
|
const hierarchicalLinks = meta.hierarchicalLinks ?? [];
|
|
280
|
-
const sorted = [...rows].sort((a, b) => a.path.localeCompare(b.path));
|
|
281
305
|
const header = INDEX_HEADER.replace('__TODAY__', todayStr).replace('__PROJECT__', projectName);
|
|
282
306
|
const tableHeader = `| File | Type | Lines/Max | Updated | Stale after |\n|------|------|-----------|---------|-------------|`;
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
.
|
|
307
|
+
const nonAdr = [];
|
|
308
|
+
const adrRows = [];
|
|
309
|
+
for (const r of rows) {
|
|
310
|
+
if (r.path === 'docs/ai/index.md') continue;
|
|
311
|
+
(isCollapsibleAdr(r.path) ? adrRows : nonAdr).push(r);
|
|
312
|
+
}
|
|
313
|
+
const tableEntries = nonAdr.map((r) => ({ sortPath: r.path, md: formatIndexRow(r) }));
|
|
314
|
+
if (adrRows.length > 0) tableEntries.push({ sortPath: ADR_DIR_PREFIX, md: formatAdrCollapseRow(adrRows) });
|
|
315
|
+
tableEntries.sort((a, b) => a.sortPath.localeCompare(b.sortPath));
|
|
316
|
+
const tableRows = tableEntries.map((e) => e.md).join('\n');
|
|
287
317
|
const onDemandSection =
|
|
288
318
|
onDemandLinks.length > 0
|
|
289
319
|
? `\n\n## Skills (on-demand)\n\n${onDemandLinks.map((link) => `- ${link}`).join('\n')}`
|
|
@@ -164,6 +164,61 @@ const makeRow = (path, overrides = {}) => ({
|
|
|
164
164
|
...overrides,
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
// The one-file-per-ADR store collapses to a SINGLE aggregate index row so the index stays under its
|
|
168
|
+
// own 80-line cap no matter how many records accumulate — while every body is still cap-checked.
|
|
169
|
+
describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
|
|
170
|
+
it('200 synthetic AD-*.md records collapse to ONE aggregate row; the index stays ≤ 80 lines', () => {
|
|
171
|
+
const adr = Array.from({ length: 200 }, (_, i) => makeRow(`docs/ai/adr/AD-${String(i + 1).padStart(3, '0')}-record-${i}.md`, { frontmatter: { type: 'adr', maxLines: '400', lastUpdated: '2026-07-09', staleAfter: 'never' } }));
|
|
172
|
+
const rows = [makeRow('docs/ai/handover.md'), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }), ...adr];
|
|
173
|
+
const out = buildIndex(rows, '2026-07-09');
|
|
174
|
+
const adrRowLines = out.split('\n').filter((l) => l.includes('](./adr/log.md)'));
|
|
175
|
+
expect(adrRowLines.length).toBe(1); // exactly one row references the whole adr/ tree
|
|
176
|
+
expect(out).not.toMatch(/AD-001-record-0\.md/); // individual records are NOT listed
|
|
177
|
+
expect(out.split('\n').length <= 80).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('a stray adr/ markdown file renders as its OWN visible row (never collapsed/hidden)', () => {
|
|
181
|
+
const rows = [
|
|
182
|
+
makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
183
|
+
makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
|
|
184
|
+
makeRow('docs/ai/adr/notes.md', { frontmatter: { type: 'reference', maxLines: '100' } }),
|
|
185
|
+
];
|
|
186
|
+
const out = buildIndex(rows, '2026-07-09');
|
|
187
|
+
expect(out).toMatch(/adr\/notes\.md/); // the stray is a visible row, not swallowed by the collapse
|
|
188
|
+
const aggregateRows = out.split('\n').filter((l) => l.includes('](./adr/log.md)'));
|
|
189
|
+
expect(aggregateRows.length).toBe(1); // exactly one aggregate row for the real record(s)
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('the aggregate row shows the record count and a NUMERIC id range (AD-200 … AD-1000)', () => {
|
|
193
|
+
const rows = [
|
|
194
|
+
makeRow('docs/ai/adr/AD-200-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
195
|
+
makeRow('docs/ai/adr/AD-1000-b.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
|
|
196
|
+
makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
|
|
197
|
+
];
|
|
198
|
+
const out = buildIndex(rows, '2026-07-09');
|
|
199
|
+
expect(out).toMatch(/\[`adr\/`\]\(\.\/adr\/log\.md\) \| adr \| 2 records \| AD-200 … AD-1000/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('adding a record drifts the collapse row → checkIndexFreshness flags it stale', () => {
|
|
203
|
+
const base = [makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } })];
|
|
204
|
+
const onDisk = buildIndex(base, '2026-07-09');
|
|
205
|
+
const grown = [...base, makeRow('docs/ai/adr/AD-002-b.md', { frontmatter: { type: 'adr', maxLines: '400' } })];
|
|
206
|
+
expect(checkIndexFreshness(grown, onDisk).fresh).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('a single adr record OVER its own cap still fails inspectFile (the collapse never hides a fat body)', async () => {
|
|
210
|
+
const dir = await mkdtemp(join(tmpdir(), 'adr-cap-'));
|
|
211
|
+
try {
|
|
212
|
+
const path = join(dir, 'AD-001-huge.md');
|
|
213
|
+
await writeFile(path, `---\ntype: adr\nlastUpdated: 2026-07-09\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: 5\n---\n\n## AD-001 — Huge\n${'x\n'.repeat(20)}`);
|
|
214
|
+
const result = await inspectFile(path, computeToday('2026-07-09'));
|
|
215
|
+
expect(result.errors.some((e) => /lines > maxLines/.test(e))).toBe(true);
|
|
216
|
+
} finally {
|
|
217
|
+
await rm(dir, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
167
222
|
describe('buildIndex', () => {
|
|
168
223
|
it('is deterministic, sorts rows by path, and excludes index.md itself', () => {
|
|
169
224
|
const rows = [
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: reference
|
|
3
|
+
lastUpdated: {{DATE}}
|
|
4
|
+
scope: permanent
|
|
5
|
+
staleAfter: never
|
|
6
|
+
owner: none
|
|
7
|
+
maxLines: 200
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# ADR Navigator — active set (AD-001 … AD-001)
|
|
11
|
+
|
|
12
|
+
> **Auto-generated** (`archive-decisions.mjs --write-navigator`). The governing heads only —
|
|
13
|
+
> an ADR superseded/amended by another drops OUT (still reachable by filename, grep, or the
|
|
14
|
+
> `[[AD-NNN]]` chain). The HOT window lives in [`../decisions.md`](../decisions.md); every
|
|
15
|
+
> archived record is one file in this directory. This is a navigator, never a full ledger.
|
|
16
|
+
|
|
17
|
+
## Governing (1) — accepted & not superseded
|
|
18
|
+
|
|
19
|
+
| ADR | Title | Record |
|
|
20
|
+
|-----|-------|--------|
|
|
21
|
+
| AD-001 | Adopt AI-agent memory system (`docs/ai/`) | [`../decisions.md`](../decisions.md) |
|
|
22
|
+
|
|
23
|
+
## Recent (1)
|
|
24
|
+
|
|
25
|
+
- **AD-001** — governing — `decisions.md (HOT)`
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# ADR record format — authoring & lifecycle reference
|
|
2
|
+
|
|
3
|
+
> How an Architecture Decision Record is authored, archived, and retrieved in this project's
|
|
4
|
+
> **one-file-per-ADR** store. Read this when you author a new ADR or change an existing one's
|
|
5
|
+
> lifecycle. This file is a reference — it is **not** deployed into `docs/ai/` (it never becomes a
|
|
6
|
+
> record itself).
|
|
7
|
+
|
|
8
|
+
## Where ADRs live
|
|
9
|
+
|
|
10
|
+
- **HOT window — `docs/ai/decisions.md`.** The active ADR window. You **author here**, newest at the
|
|
11
|
+
bottom. It is self-bounding under its frontmatter `maxLines`.
|
|
12
|
+
- **Store — `docs/ai/adr/AD-NNN-slug.md`.** One immutable record per archived ADR (full frontmatter +
|
|
13
|
+
the verbatim `## AD-NNN — title` block). You **never hand-write** these — `archive-decisions.mjs`
|
|
14
|
+
produces them by exploding the oldest HOT entries beyond the cap.
|
|
15
|
+
- **Navigator — `docs/ai/adr/log.md`.** The one generated map: the currently-governing heads
|
|
16
|
+
(accepted ∧ not superseded) + a recent window. Superseded ADRs drop OUT (still reachable by
|
|
17
|
+
filename, grep, or the `[[AD-NNN]]` chain). It is a navigator, never a full ledger.
|
|
18
|
+
|
|
19
|
+
## Authoring a new ADR (in the HOT window)
|
|
20
|
+
|
|
21
|
+
Append a block at the **bottom** of `docs/ai/decisions.md`:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
## AD-NNN — <concise title>
|
|
25
|
+
|
|
26
|
+
**Date:** YYYY-MM-DD
|
|
27
|
+
**Status:** Accepted
|
|
28
|
+
|
|
29
|
+
**Context.** Why this decision is needed; the forces at play.
|
|
30
|
+
**Decision.** What was chosen (imperative, unambiguous).
|
|
31
|
+
**Consequences.** ➕ benefits · ➖ costs. Note any ADR this supersedes.
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **ID grammar: `AD-\d{3,}`.** Three digits minimum; `AD-1000+` is valid. Allocate the next integer
|
|
35
|
+
after the highest id across HOT ∪ `adr/`. Ordering is always **numeric**, never lexical.
|
|
36
|
+
- **The `## AD-NNN — <title>` heading is strict.** Use the em dash ` — `. A non-canonical `## ` heading
|
|
37
|
+
is a loud parse failure — the store never silently glues an entry to the previous body.
|
|
38
|
+
- **`slug` is cosmetic and frozen at creation** (`slugify(title)`); the `AD-NNN` prefix is the key. A
|
|
39
|
+
retitle never renames the file.
|
|
40
|
+
- The block is preserved **verbatim** when it is archived, so write it as you want it to persist.
|
|
41
|
+
|
|
42
|
+
## Lifecycle (the only mutable part after acceptance)
|
|
43
|
+
|
|
44
|
+
The **body is immutable** once accepted — only lifecycle changes. Express supersession in the body of
|
|
45
|
+
the **new** ADR; governance is then computed automatically (no predecessor-file edit is required):
|
|
46
|
+
|
|
47
|
+
| Body form (in the newer ADR) | Effect |
|
|
48
|
+
|----------------------------------|------------------------------------------------------------------|
|
|
49
|
+
| `Supersedes [[AD-NNN]]` | retires AD-NNN (when the citing ADR is `accepted`) |
|
|
50
|
+
| `Superseded by [[AD-NNN]]` | marks this ADR retired by AD-NNN |
|
|
51
|
+
| `Amended by [[AD-NNN]]` | marks this ADR amended (also drops it from the governing heads) |
|
|
52
|
+
|
|
53
|
+
- **`status`** is the leading word of `**Status:**`, lowercased (`accepted` / `superseded` /
|
|
54
|
+
`amended` / `deprecated`). A **missing** `**Status:**` line defaults to `accepted` (the in-force
|
|
55
|
+
default). Only `accepted` ADRs govern.
|
|
56
|
+
- When an ADR is archived, its record frontmatter backfills `status`, `date`, `supersedes`, and
|
|
57
|
+
`supersededBy` from these body forms — you do not maintain the frontmatter by hand.
|
|
58
|
+
|
|
59
|
+
## Retrieval (never through an O(n) artifact)
|
|
60
|
+
|
|
61
|
+
- **by id** → the deterministic filename `docs/ai/adr/AD-NNN-*.md` (glob).
|
|
62
|
+
- **by topic** → `grep` the flat `docs/ai/adr/` tree.
|
|
63
|
+
- **by lifecycle** → the two-way `supersedes` / `supersededBy` frontmatter + the `[[AD-NNN]]` chain;
|
|
64
|
+
`ls docs/ai/adr/` IS the log.
|
|
65
|
+
|
|
66
|
+
## Commands
|
|
67
|
+
|
|
68
|
+
- `node scripts/archive-decisions.mjs` — rotate: explode the oldest HOT entries beyond the cap into
|
|
69
|
+
`adr/` records, then regenerate the navigator + `docs/ai/index.md`.
|
|
70
|
+
- `node scripts/archive-decisions.mjs --write-navigator` — regenerate `docs/ai/adr/log.md` (and the
|
|
71
|
+
index) after authoring an ADR or editing a supersession, so `--check` stays green.
|
|
72
|
+
- `node scripts/archive-decisions.mjs --check` — verify HOT cap + store integrity + navigator freshness.
|
|
@@ -9,7 +9,11 @@ maxLines: 500
|
|
|
9
9
|
|
|
10
10
|
# Architecture Decision Records (ADRs)
|
|
11
11
|
|
|
12
|
-
>
|
|
12
|
+
> The **HOT window** of Architecture Decision Records — every significant choice with long-term
|
|
13
|
+
> consequences, newest at the bottom. Link related ADRs with `[[AD-XXX]]`; retrieval is by the
|
|
14
|
+
> `AD-NNN` id (filename), grep over the flat store, or the `[[AD-NNN]]` supersession chain.
|
|
15
|
+
|
|
16
|
+
> **Archive:** older ADRs are stored one immutable file per record under [`adr/`](./adr/) — see the active-set navigator [`adr/log.md`](./adr/log.md). `archive-decisions.mjs` explodes the oldest entries beyond this window automatically; no cap is ever raised and there is no monolithic ledger.
|
|
13
17
|
|
|
14
18
|
## AD-001 — Adopt AI-agent memory system (`docs/ai/`)
|
|
15
19
|
|
|
@@ -18,27 +22,12 @@ maxLines: 500
|
|
|
18
22
|
|
|
19
23
|
**Context.** Multi-session AI work loses context between runs. Without a structured handover, each new session re-reads code, re-discovers decisions, and repeats past mistakes.
|
|
20
24
|
|
|
21
|
-
**Decision.** Adopt a Memory Map in `AGENTS.md` (entry point — the cross-agent standard; tool aliases like `CLAUDE.md` symlink to it) + structured files under `docs/ai/`. Define three protocols (Start / During / Complete). Enforce frontmatter caps + index freshness +
|
|
25
|
+
**Decision.** Adopt a Memory Map in `AGENTS.md` (entry point — the cross-agent standard; tool aliases like `CLAUDE.md` symlink to it) + structured files under `docs/ai/`. Define three protocols (Start / During / Complete). Enforce frontmatter caps + index freshness + a one-file-per-ADR archive via a pre-commit hook. Deployed via the `agent-workflow-memory` substrate (standalone, or as part of the agent-workflow family).
|
|
22
26
|
|
|
23
|
-
**Rationale.** Single entry + structured spec files = constant boot-up cost regardless of project size. ADRs prevent litigating the same decision twice. `pages/<page>.md` keeps behaviour canonical (docs > assumptions). Caps +
|
|
27
|
+
**Rationale.** Single entry + structured spec files = constant boot-up cost regardless of project size. ADRs prevent litigating the same decision twice. `pages/<page>.md` keeps behaviour canonical (docs > assumptions). Caps + one-file-per-ADR archival keep every record scannable as the decision history grows without bound.
|
|
24
28
|
|
|
25
29
|
**Consequences.**
|
|
26
30
|
- ➕ Faster session start, less drift between agents.
|
|
27
31
|
- ➕ ADRs as institutional memory; honest `known_issues.md`.
|
|
28
32
|
- ➖ Discipline cost: docs updated alongside code.
|
|
29
33
|
- ➖ A set of markdown files + scripts to maintain.
|
|
30
|
-
|
|
31
|
-
---
|
|
32
|
-
|
|
33
|
-
## AD-002 — {{Next decision}}
|
|
34
|
-
|
|
35
|
-
**Date:** {{DATE}}
|
|
36
|
-
**Status:** Proposed / Accepted / Superseded
|
|
37
|
-
|
|
38
|
-
**Context.** {{...}}
|
|
39
|
-
**Decision.** {{...}}
|
|
40
|
-
**Consequences.** {{...}}
|
|
41
|
-
|
|
42
|
-
---
|
|
43
|
-
|
|
44
|
-
> When this file nears ~90% of its cap, move the oldest Accepted/Superseded ADRs to `decisions-archive.md` (keep the IDs + a one-line pointer here) and record the split. Bumping the cap instead of splitting is a conscious exception, justified in an ADR.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// The deployment lineage is a SINGLE shared sequence; its current head is LINEAGE_HEAD.
|
|
7
7
|
// Both `.memory-version` and the kit-fallback `.workflow-version` track THAT sequence —
|
|
8
8
|
// never their package versions. So this substrate's package may be 1.0.0 while the stamp
|
|
9
|
-
// it writes is the lineage head (
|
|
9
|
+
// it writes is the lineage head (2.0.0 today).
|
|
10
10
|
//
|
|
11
11
|
// `decideTakeover` is a PURE function (stamp state in → action out) so the state machine is
|
|
12
12
|
// unit-testable per row. `applyTakeover` is the thin fs wrapper; stamp writes are ATOMIC
|
|
@@ -22,7 +22,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
22
22
|
|
|
23
23
|
// The shared agent-workflow deployment-lineage head. Bumped only when a project-migration
|
|
24
24
|
// changes the deployed docs/ai structure — NOT on a packaging-only release.
|
|
25
|
-
export const LINEAGE_HEAD = '
|
|
25
|
+
export const LINEAGE_HEAD = '2.0.0';
|
|
26
26
|
|
|
27
27
|
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
|
28
28
|
|