@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.
@@ -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 tableRows = sorted
284
- .filter((r) => r.path !== 'docs/ai/index.md')
285
- .map(formatIndexRow)
286
- .join('\n');
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
- > Every significant choice that has long-term consequences. Newest at the bottom. Link related ADRs with `[[AD-XXX]]`.
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 + 3-tier archive via a pre-commit hook. Deployed via the `agent-workflow-memory` substrate (standalone, or as part of the agent-workflow family).
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 + archive keep files scannable as history grows.
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 (1.3.0 today).
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 = '1.3.0';
25
+ export const LINEAGE_HEAD = '2.0.0';
26
26
 
27
27
  const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
28
28