@sabaiway/agent-workflow-memory 1.2.1 → 1.2.2

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,25 @@ 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 `1.3.0`).
6
6
 
7
+ ## 1.2.2 — Strip the package's own tests from the npm tarball (memory)
8
+
9
+ Packaging only — no API/behaviour change; removed the package's own colocated tests from the
10
+ published tarball, deploy payload tests retained. The deployment-lineage head stays **`1.3.0`** (no
11
+ `docs/ai` structural change, no migration file). The npm package version is a separate axis.
12
+
13
+ - **`files[]` scoped negation.** Appended `!bin/*.test.mjs` and `!scripts/*.test.mjs` to the
14
+ package allowlist (npm ignores a root `.npmignore` when `files[]` is present, so negation entries
15
+ in `files[]` are the mechanism). Tarball **41 → 37 files**: 4 of the package's own colocated tests
16
+ no longer ship.
17
+ - **Deploy payload tests retained.** `references/scripts/*.test.mjs` are deployed into a consumer
18
+ repo's `scripts/`, so they still ship — `!scripts/*.test.mjs` does not cross `/` and never touches
19
+ `references/scripts/`. **Never broaden it to `!references/**`** — those tests are deploy payload.
20
+ - **Tarball guard.** `scripts/package-content.test.mjs` (dev-only) gains an `npm pack --dry-run
21
+ --json` invariant: no own-test leak, payload tests + runtime files present, exact file count
22
+ `=== 37`.
23
+ - Test files stay on disk; the gate + publish CI run them from the checkout, unchanged. This is a
24
+ tarball-only exclusion.
25
+
7
26
  ## 1.2.1 — Hidden-mode maintenance invariant (memory)
8
27
 
9
28
  Patch: documentation only. The deployment-lineage head stays **`1.3.0`** (no `docs/ai` structural
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: '1.2.1'
6
+ version: '1.2.2'
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": "1.2.1",
6
+ "version": "1.2.2",
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": "1.2.1",
3
+ "version": "1.2.2",
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",
@@ -42,7 +42,9 @@
42
42
  "capability.json",
43
43
  "references/",
44
44
  "scripts/",
45
- "migrations/"
45
+ "migrations/",
46
+ "!bin/*.test.mjs",
47
+ "!scripts/*.test.mjs"
46
48
  ],
47
49
  "engines": {
48
50
  "node": ">=18"
@@ -1,110 +0,0 @@
1
- import { describe, it, beforeEach, afterEach } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { spawnSync } from 'node:child_process';
4
- import { mkdtemp, rm, mkdir, symlink, readdir } from 'node:fs/promises';
5
- import { existsSync } from 'node:fs';
6
- import { tmpdir, homedir } from 'node:os';
7
- import { dirname, join, sep } from 'node:path';
8
- import { fileURLToPath, pathToFileURL } from 'node:url';
9
- import { assertContainedRealPath, tildify } from './install.mjs';
10
-
11
- const INSTALLER = join(dirname(fileURLToPath(import.meta.url)), 'install.mjs');
12
- const runInstaller = (target) => spawnSync(process.execPath, [INSTALLER, '--dir', target], { encoding: 'utf8' });
13
-
14
- describe('memory installer — payload + symlink-traversal hardening', () => {
15
- let dir;
16
- beforeEach(async () => {
17
- dir = await mkdtemp(join(tmpdir(), 'aw-memory-install-'));
18
- });
19
- afterEach(async () => {
20
- await rm(dir, { recursive: true, force: true });
21
- });
22
-
23
- it('installs the full enumerated payload (capability.json, LICENSE, stamp-takeover.mjs) and not the npm wrapper', () => {
24
- const target = join(dir, 'agent-workflow-memory');
25
- const res = runInstaller(target);
26
- assert.equal(res.status, 0, res.stderr);
27
- for (const f of [
28
- 'SKILL.md',
29
- 'capability.json',
30
- 'LICENSE',
31
- 'README.md',
32
- 'CHANGELOG.md',
33
- 'scripts/stamp-takeover.mjs',
34
- 'references/contracts.md',
35
- 'references/templates/AGENTS.md',
36
- 'references/templates/orchestration.json',
37
- 'migrations/legacy-stamp-takeover.md',
38
- ]) {
39
- assert.ok(existsSync(join(target, f)), `missing installed entry: ${f}`);
40
- }
41
- assert.equal(existsSync(join(target, 'bin/install.mjs')), false, 'the npm wrapper must not be copied into the skill dir');
42
- });
43
-
44
- it('refuses to write through a symlinked INTERMEDIATE dest component (no leak)', async () => {
45
- const target = join(dir, 'target');
46
- const evil = join(dir, 'evil');
47
- await mkdir(target, { recursive: true });
48
- await mkdir(evil, { recursive: true });
49
- await symlink(evil, join(target, 'references'));
50
- const res = runInstaller(target);
51
- assert.notEqual(res.status, 0);
52
- assert.match(res.stderr, /symlink/i);
53
- assert.deepEqual(await readdir(evil), [], 'nothing should be written through the symlink');
54
- });
55
-
56
- it('refuses a DANGLING destination symlink (existsSync would have followed it to absent)', async () => {
57
- const target = join(dir, 'target');
58
- await mkdir(target, { recursive: true });
59
- // references -> a path that does not exist (dangling). copyFile through it would create
60
- // the file at the dangling target, outside `target`.
61
- await symlink(join(dir, 'nowhere'), join(target, 'references'));
62
- const res = runInstaller(target);
63
- assert.notEqual(res.status, 0);
64
- assert.match(res.stderr, /symlink/i);
65
- assert.equal(existsSync(join(dir, 'nowhere')), false, 'dangling target must not be materialised');
66
- });
67
-
68
- it('refuses a symlinked TARGET root (no leak)', async () => {
69
- const real = join(dir, 'real');
70
- const root = join(dir, 'root');
71
- await mkdir(real, { recursive: true });
72
- await symlink(real, root);
73
- const res = runInstaller(root);
74
- assert.notEqual(res.status, 0);
75
- assert.match(res.stderr, /symlink/i);
76
- assert.deepEqual(await readdir(real), [], 'nothing should be written through the symlinked target');
77
- });
78
- });
79
-
80
- describe('memory installer — Issue-004 regressions (exported helpers, in-process)', () => {
81
- it('assertContainedRealPath accepts a contained child literally named "..foo"', () => {
82
- const root = join(tmpdir(), 'aw-mem-contain-root');
83
- assert.doesNotThrow(() => assertContainedRealPath(root, join(root, '..foo')));
84
- });
85
-
86
- it('assertContainedRealPath rejects a true `../` escape', () => {
87
- const root = join(tmpdir(), 'aw-mem-contain-root');
88
- assert.throws(() => assertContainedRealPath(root, join(root, '..', 'escape')), /outside the target dir/);
89
- });
90
-
91
- it('tildify collapses only a LEADING homedir, never a mid-path occurrence', () => {
92
- assert.equal(tildify(`${homedir()}${sep}skills${sep}x`), `~${sep}skills${sep}x`, 'leading home → ~');
93
- const midPath = `${sep}tmp${homedir()}${sep}x`;
94
- assert.equal(tildify(midPath), midPath, 'a mid-path home occurrence is left untouched');
95
- });
96
- });
97
-
98
- describe('memory installer — module hygiene', () => {
99
- it('importing install.mjs runs nothing (main() is guarded by isDirectRun)', () => {
100
- const url = JSON.stringify(pathToFileURL(INSTALLER).href);
101
- const res = spawnSync(
102
- process.execPath,
103
- ['--input-type=module', '-e', `import(${url}).then(() => console.log('IMPORT_OK'));`],
104
- { encoding: 'utf8' },
105
- );
106
- assert.equal(res.status, 0, res.stderr);
107
- assert.match(res.stdout, /IMPORT_OK/);
108
- assert.doesNotMatch(res.stdout, /installed v|updated the substrate/);
109
- });
110
- });
@@ -1,78 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { readFileSync, readdirSync, statSync } from 'node:fs';
4
- import { dirname, join, basename } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
-
7
- // The memory substrate must "know nobody": it ships ONLY its own capability.json and NO
8
- // family-wide tooling (schema/validator/injection/scanner — those are owned by the composition
9
- // root). This guards the DAG at the package-content level.
10
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
11
-
12
- const FORBIDDEN_FILES = new Set([
13
- 'validate.mjs',
14
- 'schema.md',
15
- 'inject-methodology.mjs',
16
- 'methodology-slot.md',
17
- 'release-scan.mjs',
18
- ]);
19
- const FORBIDDEN_DIRS = new Set(['tools', 'manifest']);
20
-
21
- const walk = (dir, acc = []) => {
22
- for (const entry of readdirSync(dir)) {
23
- if (entry === 'node_modules' || entry === '.git') continue;
24
- const full = join(dir, entry);
25
- if (statSync(full).isDirectory()) {
26
- acc.push({ kind: 'dir', name: entry, full });
27
- walk(full, acc);
28
- } else {
29
- acc.push({ kind: 'file', name: entry, full });
30
- }
31
- }
32
- return acc;
33
- };
34
-
35
- describe('memory package content — DAG guard (knows nobody)', () => {
36
- const entries = walk(ROOT);
37
-
38
- it('ships no family-wide tooling files', () => {
39
- const leaks = entries.filter((e) => e.kind === 'file' && FORBIDDEN_FILES.has(e.name));
40
- assert.deepEqual(leaks.map((e) => e.full), [], 'family tooling must not ship in the memory package');
41
- });
42
-
43
- it('has no tools/ or manifest/ directory', () => {
44
- const leaks = entries.filter((e) => e.kind === 'dir' && FORBIDDEN_DIRS.has(e.name));
45
- assert.deepEqual(leaks.map((e) => e.full), []);
46
- });
47
-
48
- it('package.json files[] does not enumerate any family tooling', () => {
49
- const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
50
- const offending = (pkg.files ?? []).filter((f) => /tools|manifest|validate|schema\.md|inject-methodology|release-scan/.test(f));
51
- assert.deepEqual(offending, []);
52
- });
53
-
54
- it('does ship its own capability.json (the only manifest it owns)', () => {
55
- assert.ok(entries.some((e) => e.kind === 'file' && e.name === 'capability.json' && basename(dirname(e.full)) === basename(ROOT)));
56
- });
57
-
58
- it('no source file names a specific sibling skill (knows nobody — generic "composition root" only)', () => {
59
- // Built from fragments so the literal sibling names never appear in THIS test's own source
60
- // (otherwise the walk below would flag this file).
61
- const d = '-';
62
- const SIBLINGS = [
63
- ['agent', 'workflow', 'kit'].join(d),
64
- ['codex', 'cli', 'bridge'].join(d),
65
- ['antigravity', 'cli', 'bridge'].join(d),
66
- ['agent', 'workflow', 'engine'].join(d),
67
- ];
68
- const offenders = [];
69
- for (const e of entries) {
70
- if (e.kind !== 'file' || !/\.(md|mjs|json|sh|ya?ml)$/.test(e.name)) continue;
71
- const text = readFileSync(e.full, 'utf8');
72
- for (const sibling of SIBLINGS) {
73
- if (text.includes(sibling)) offenders.push(`${e.full} → "${sibling}"`);
74
- }
75
- }
76
- assert.deepEqual(offenders, [], 'memory must reference only the generic composition root, never a named sibling');
77
- });
78
- });
@@ -1,166 +0,0 @@
1
- import { describe, it, beforeEach, afterEach } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { mkdtemp, rm, writeFile, readFile, readdir } from 'node:fs/promises';
4
- import { existsSync } from 'node:fs';
5
- import { tmpdir } from 'node:os';
6
- import { join } from 'node:path';
7
- import {
8
- LINEAGE_HEAD,
9
- parseSemver,
10
- compareSemver,
11
- decideTakeover,
12
- selectMigrations,
13
- writeStampAtomic,
14
- applyTakeover,
15
- readStamp,
16
- } from './stamp-takeover.mjs';
17
-
18
- describe('LINEAGE_HEAD', () => {
19
- it('is the shared deployment-lineage head (not this package version)', () => {
20
- assert.equal(LINEAGE_HEAD, '1.3.0');
21
- });
22
- });
23
-
24
- describe('parseSemver / compareSemver', () => {
25
- it('parses plain and v-prefixed semver, rejects junk', () => {
26
- assert.deepEqual(parseSemver('1.3.0'), [1, 3, 0]);
27
- assert.deepEqual(parseSemver(' v2.0.1 '), [2, 0, 1]);
28
- assert.equal(parseSemver('1.3'), null);
29
- assert.equal(parseSemver('next'), null);
30
- assert.equal(parseSemver(null), null);
31
- });
32
- it('orders versions and returns null on unparseable input', () => {
33
- assert.equal(compareSemver('1.2.0', '1.3.0'), -1);
34
- assert.equal(compareSemver('1.3.0', '1.3.0'), 0);
35
- assert.equal(compareSemver('1.4.0', '1.3.0'), 1);
36
- assert.equal(compareSemver('x', '1.0.0'), null);
37
- });
38
- });
39
-
40
- describe('decideTakeover — §1.5 state table (pure, per row)', () => {
41
- it('only .workflow-version=V → copy V verbatim → .memory-version; migrate from V', () => {
42
- const d = decideTakeover({ memoryVersion: null, workflowVersion: '1.2.0' });
43
- assert.equal(d.status, 'ok');
44
- assert.equal(d.writeMemoryVersion, '1.2.0'); // exact stamp value asserted
45
- assert.equal(d.migrateFrom, '1.2.0');
46
- });
47
-
48
- it('both stamps → no takeover write; migrate from .memory-version', () => {
49
- const d = decideTakeover({ memoryVersion: '1.3.0', workflowVersion: '1.1.0' });
50
- assert.equal(d.status, 'ok');
51
- assert.equal(d.writeMemoryVersion, null);
52
- assert.equal(d.migrateFrom, '1.3.0');
53
- });
54
-
55
- it('only .memory-version → migrate from it, no write', () => {
56
- const d = decideTakeover({ memoryVersion: '1.1.0', workflowVersion: null });
57
- assert.equal(d.status, 'ok');
58
- assert.equal(d.writeMemoryVersion, null);
59
- assert.equal(d.migrateFrom, '1.1.0');
60
- });
61
-
62
- it('no stamp → conservative re-bootstrap', () => {
63
- const d = decideTakeover({ memoryVersion: null, workflowVersion: null });
64
- assert.equal(d.status, 'rebootstrap');
65
- assert.equal(d.writeMemoryVersion, null);
66
- assert.equal(d.migrateFrom, null);
67
- });
68
-
69
- it('unparseable .memory-version → STOP', () => {
70
- const d = decideTakeover({ memoryVersion: 'garbage', workflowVersion: '1.2.0' });
71
- assert.equal(d.status, 'stop');
72
- assert.equal(d.writeMemoryVersion, null);
73
- });
74
-
75
- it('PRESENT but empty/whitespace stamp → STOP (not absent → rebootstrap)', () => {
76
- assert.equal(decideTakeover({ memoryVersion: null, workflowVersion: '' }).status, 'stop');
77
- assert.equal(decideTakeover({ memoryVersion: ' ', workflowVersion: null }).status, 'stop');
78
- });
79
-
80
- it('unparseable .workflow-version → STOP', () => {
81
- const d = decideTakeover({ memoryVersion: null, workflowVersion: 'not-a-version' });
82
- assert.equal(d.status, 'stop');
83
- });
84
-
85
- it('future .memory-version (> head) → STOP', () => {
86
- const d = decideTakeover({ memoryVersion: '2.0.0', workflowVersion: null });
87
- assert.equal(d.status, 'stop');
88
- assert.match(d.note, /newer than the lineage head/);
89
- });
90
-
91
- it('future .workflow-version (> head) → STOP', () => {
92
- const d = decideTakeover({ memoryVersion: null, workflowVersion: '9.9.9' });
93
- assert.equal(d.status, 'stop');
94
- });
95
-
96
- it('workflow exactly at head → takeover writes the head', () => {
97
- const d = decideTakeover({ memoryVersion: null, workflowVersion: '1.3.0' });
98
- assert.equal(d.status, 'ok');
99
- assert.equal(d.writeMemoryVersion, '1.3.0');
100
- });
101
- });
102
-
103
- describe('selectMigrations', () => {
104
- const available = ['1.1.0', '1.2.0', '1.3.0'];
105
- it('selects strictly-newer migrations up to the head, ascending', () => {
106
- assert.deepEqual(selectMigrations('1.0.0', available), ['1.1.0', '1.2.0', '1.3.0']);
107
- assert.deepEqual(selectMigrations('1.2.0', available), ['1.3.0']);
108
- assert.deepEqual(selectMigrations('1.3.0', available), []);
109
- });
110
- it('null migrateFrom selects all ≤ head; excludes future migrations', () => {
111
- assert.deepEqual(selectMigrations(null, [...available, '1.4.0']), ['1.1.0', '1.2.0', '1.3.0']);
112
- });
113
- });
114
-
115
- describe('applyTakeover — atomic, idempotent, integration (temp fs)', () => {
116
- let dir;
117
- beforeEach(async () => {
118
- dir = await mkdtemp(join(tmpdir(), 'stamp-takeover-'));
119
- });
120
- afterEach(async () => {
121
- await rm(dir, { recursive: true, force: true });
122
- });
123
-
124
- it('legacy-only: writes .memory-version verbatim and preserves .workflow-version', async () => {
125
- await writeFile(join(dir, '.workflow-version'), '1.2.0\n', 'utf8');
126
- const d = await applyTakeover(dir);
127
- assert.equal(d.writeMemoryVersion, '1.2.0');
128
- assert.equal(await readStamp(join(dir, '.memory-version')), '1.2.0');
129
- assert.equal(await readStamp(join(dir, '.workflow-version')), '1.2.0'); // never deleted
130
- // single trailing newline, no leftover temp files
131
- assert.equal(await readFile(join(dir, '.memory-version'), 'utf8'), '1.2.0\n');
132
- assert.equal((await readdir(dir)).filter((f) => f.includes('.tmp-')).length, 0);
133
- });
134
-
135
- it('is idempotent — a second run does not rewrite the stamp', async () => {
136
- await writeFile(join(dir, '.workflow-version'), '1.2.0\n', 'utf8');
137
- await applyTakeover(dir);
138
- const second = await applyTakeover(dir);
139
- assert.equal(second.writeMemoryVersion, null); // memory now present → no write
140
- assert.equal(second.migrateFrom, '1.2.0');
141
- assert.equal(await readStamp(join(dir, '.memory-version')), '1.2.0');
142
- });
143
-
144
- it('both stamps present: leaves .memory-version untouched', async () => {
145
- await writeFile(join(dir, '.memory-version'), '1.3.0\n', 'utf8');
146
- await writeFile(join(dir, '.workflow-version'), '1.1.0\n', 'utf8');
147
- const d = await applyTakeover(dir);
148
- assert.equal(d.writeMemoryVersion, null);
149
- assert.equal(await readStamp(join(dir, '.memory-version')), '1.3.0');
150
- });
151
-
152
- it('STOP state performs no write (future stamp leaves prior state intact)', async () => {
153
- await writeFile(join(dir, '.workflow-version'), '5.0.0\n', 'utf8');
154
- const d = await applyTakeover(dir);
155
- assert.equal(d.status, 'stop');
156
- assert.equal(existsSync(join(dir, '.memory-version')), false); // nothing written
157
- });
158
-
159
- it('writeStampAtomic overwrites cleanly with a single trailing newline', async () => {
160
- const target = join(dir, '.memory-version');
161
- await writeStampAtomic(target, '1.1.0');
162
- await writeStampAtomic(target, '1.3.0');
163
- assert.equal(await readFile(target, 'utf8'), '1.3.0\n');
164
- assert.equal((await readdir(dir)).filter((f) => f.includes('.tmp-')).length, 0);
165
- });
166
- });
@@ -1,180 +0,0 @@
1
- // End-to-end acceptance: a standalone substrate bootstrap into a fresh project.
2
- //
3
- // The unit suites cover each module in isolation (the stamp state machine, the atomic
4
- // writer, the hook installer). This drives the documented bootstrap WRITE steps end to end
5
- // over a real temp project + a real git checkout, then asserts the deployed artifact set:
6
- // docs/ai + entry point exist, the pre-commit hook is installed, the deployment-lineage
7
- // stamp is .memory-version (the lineage head) ONLY, and the methodology slot ships empty.
8
- //
9
- // Standalone here means substrate-only: nothing fills the slot, so it stays empty and no
10
- // second (composition-root) stamp appears. The composition path is covered separately.
11
-
12
- import { describe, it, afterEach } from 'node:test';
13
- import assert from 'node:assert/strict';
14
- import {
15
- mkdtempSync,
16
- mkdirSync,
17
- rmSync,
18
- cpSync,
19
- readdirSync,
20
- readFileSync,
21
- writeFileSync,
22
- existsSync,
23
- symlinkSync,
24
- } from 'node:fs';
25
- import { tmpdir } from 'node:os';
26
- import { join, resolve, dirname } from 'node:path';
27
- import { fileURLToPath } from 'node:url';
28
- import { execFileSync } from 'node:child_process';
29
- import { writeStampAtomic, LINEAGE_HEAD } from './stamp-takeover.mjs';
30
-
31
- const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
32
- const TEMPLATES = join(SKILL_ROOT, 'references', 'templates');
33
- const ENFORCEMENT = join(SKILL_ROOT, 'references', 'scripts');
34
-
35
- // The empty delimited slot the substrate ships in its entry point. Hard-coded (not imported
36
- // from the composition root) so this substrate test stays self-contained and dependency-free.
37
- const SLOT_START = '<!-- workflow:methodology:start -->';
38
- const SLOT_END = '<!-- workflow:methodology:end -->';
39
- const ORCH_START = '<!-- workflow:orchestration:start -->';
40
- const ORCH_END = '<!-- workflow:orchestration:end -->';
41
- // The deployed AGENTS.md line budget the composition root fills BOTH pointers inside (D-CAP). A
42
- // representative single-line fragment per slot models what the composition root injects.
43
- const AGENTS_MD_CAP = 100;
44
- const lineCount = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
45
- const extractPair = (text, start, end) => {
46
- const a = text.indexOf(start);
47
- const b = text.indexOf(end);
48
- return a !== -1 && b !== -1 && b > a ? text.slice(a + start.length, b) : null;
49
- };
50
-
51
- const tempDirs = [];
52
- const makeProject = () => {
53
- const dir = mkdtempSync(join(tmpdir(), 'substrate-bootstrap-'));
54
- tempDirs.push(dir);
55
- return dir;
56
- };
57
- afterEach(() => {
58
- while (tempDirs.length) rmSync(tempDirs.pop(), { recursive: true, force: true });
59
- });
60
-
61
- // Mirrors the bootstrap WRITE steps: entry point at the root (+ tool alias symlink), every
62
- // other template under docs/ai, enforcement scripts copied in, the hook installed against a
63
- // real checkout, and the lineage head stamped atomically. Returns the docs/ai dir.
64
- const bootstrap = (project) => {
65
- cpSync(join(TEMPLATES, 'AGENTS.md'), join(project, 'AGENTS.md'));
66
- symlinkSync('AGENTS.md', join(project, 'CLAUDE.md'));
67
-
68
- const docsAi = join(project, 'docs', 'ai');
69
- mkdirSync(docsAi, { recursive: true });
70
- for (const entry of readdirSync(TEMPLATES)) {
71
- if (entry === 'AGENTS.md') continue;
72
- cpSync(join(TEMPLATES, entry), join(docsAi, entry), { recursive: true });
73
- }
74
-
75
- const projectScripts = join(project, 'scripts');
76
- mkdirSync(projectScripts, { recursive: true });
77
- cpSync(ENFORCEMENT, projectScripts, { recursive: true });
78
-
79
- // The hook installer resolves its target from its OWN location, so run the copied-in copy
80
- // (project/scripts/...) to install into project/.git/hooks — exactly as the procedure does.
81
- execFileSync('git', ['init', '-q'], { cwd: project });
82
- execFileSync(process.execPath, [join(projectScripts, 'install-git-hooks.mjs')], {
83
- cwd: project,
84
- stdio: 'pipe',
85
- });
86
-
87
- return docsAi;
88
- };
89
-
90
- describe('standalone substrate bootstrap (end-to-end, real temp project)', () => {
91
- it('deploys docs/ai + entry point and installs the pre-commit hook', () => {
92
- const project = makeProject();
93
- const docsAi = bootstrap(project);
94
-
95
- assert.ok(existsSync(join(project, 'AGENTS.md')), 'entry point exists');
96
- assert.ok(existsSync(docsAi), 'docs/ai exists');
97
- assert.ok(existsSync(join(docsAi, 'agent_rules.md')), 'a representative docs/ai file landed');
98
-
99
- const hook = join(project, '.git', 'hooks', 'pre-commit');
100
- assert.ok(existsSync(hook), 'pre-commit hook installed');
101
- assert.match(readFileSync(hook, 'utf8'), /install-git-hooks\.mjs/, 'hook carries the installer marker');
102
- });
103
-
104
- it('stamps .memory-version (lineage head) ONLY — no second stamp', async () => {
105
- const project = makeProject();
106
- const docsAi = bootstrap(project);
107
- await writeStampAtomic(join(docsAi, '.memory-version'), LINEAGE_HEAD);
108
-
109
- assert.equal(readFileSync(join(docsAi, '.memory-version'), 'utf8').trim(), LINEAGE_HEAD);
110
- assert.ok(!existsSync(join(docsAi, '.workflow-version')), 'no composition-root stamp in a standalone bootstrap');
111
- });
112
-
113
- it('ships BOTH pointer slots present-but-empty (methodology + orchestration)', () => {
114
- const project = makeProject();
115
- bootstrap(project);
116
-
117
- const entry = readFileSync(join(project, 'AGENTS.md'), 'utf8');
118
- const meth = extractPair(entry, SLOT_START, SLOT_END);
119
- const orch = extractPair(entry, ORCH_START, ORCH_END);
120
- assert.notEqual(meth, null, 'an ordered methodology marker pair is present');
121
- assert.equal(meth.trim(), '', 'the methodology slot is empty as shipped');
122
- assert.notEqual(orch, null, 'an ordered orchestration marker pair is present');
123
- assert.equal(orch.trim(), '', 'the orchestration slot is empty as shipped');
124
- });
125
-
126
- it('seeds docs/ai/orchestration.json from the template (the bootstrap loop deploys it)', () => {
127
- const project = makeProject();
128
- const docsAi = bootstrap(project);
129
- const seeded = join(docsAi, 'orchestration.json');
130
- assert.ok(existsSync(seeded), 'the orchestration.json config is seeded into docs/ai');
131
- assert.equal(
132
- readFileSync(seeded, 'utf8'),
133
- readFileSync(join(TEMPLATES, 'orchestration.json'), 'utf8'),
134
- 'the seeded config is byte-identical to the template',
135
- );
136
- // strict JSON valid + the conservative all-solo default the maintainer chose.
137
- const config = JSON.parse(readFileSync(seeded, 'utf8'));
138
- assert.equal(typeof config._README, 'string', 'an onboarding _README is present');
139
- assert.equal(config['plan-authoring'].review, 'solo', 'default review recipe is solo');
140
- });
141
-
142
- // The stamp-independent upgrade "ensure" (SKILL.md upgrade step 2): create-if-missing /
143
- // preserve-if-edited. Modeled here the way the documented prose performs it — so an equal-head
144
- // re-run never clobbers a user's edited config, and a deleted one is re-seeded.
145
- it('the upgrade ensure preserves an edited config and re-creates a deleted one', () => {
146
- const project = makeProject();
147
- const docsAi = bootstrap(project);
148
- const dest = join(docsAi, 'orchestration.json');
149
- const ensureConfig = () => {
150
- if (!existsSync(dest)) cpSync(join(TEMPLATES, 'orchestration.json'), dest);
151
- };
152
-
153
- // A user edits the deployed config.
154
- writeFileSync(dest, '{ "plan-authoring": { "review": "council" } }\n');
155
- ensureConfig(); // an equal-head upgrade re-runs the ensure
156
- assert.match(readFileSync(dest, 'utf8'), /council/, 'an edited config is preserved (never clobbered)');
157
-
158
- // A missing config is re-seeded.
159
- rmSync(dest);
160
- ensureConfig();
161
- assert.ok(existsSync(dest), 'a missing config is re-created from the template');
162
- assert.equal(readFileSync(dest, 'utf8'), readFileSync(join(TEMPLATES, 'orchestration.json'), 'utf8'));
163
- });
164
-
165
- it('stays ≤ the cap when the composition root fills BOTH pointer slots (D-CAP headroom)', () => {
166
- const project = makeProject();
167
- bootstrap(project);
168
- const entry = readFileSync(join(project, 'AGENTS.md'), 'utf8');
169
- assert.ok(lineCount(entry) <= AGENTS_MD_CAP, `shipped (empty) AGENTS.md is ${lineCount(entry)} lines (cap ${AGENTS_MD_CAP})`);
170
- // Fill each one-line pointer the way the composition root does (replace the empty body), then re-count.
171
- const fill = (text, start, end, body) => {
172
- const a = text.indexOf(start);
173
- const b = text.indexOf(end);
174
- return `${text.slice(0, a + start.length)}\n${body}\n${text.slice(b)}`;
175
- };
176
- let filled = fill(entry, SLOT_START, SLOT_END, '> methodology pointer (one line)');
177
- filled = fill(filled, ORCH_START, ORCH_END, '> orchestration recipes pointer (one line)');
178
- assert.ok(lineCount(filled) <= AGENTS_MD_CAP, `dual-filled AGENTS.md is ${lineCount(filled)} lines (cap ${AGENTS_MD_CAP})`);
179
- });
180
- });