@sabaiway/agent-workflow-memory 1.2.0 → 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 +29 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +4 -2
- package/references/contracts.md +2 -0
- package/references/templates/agent_rules.md +2 -0
- package/bin/install.test.mjs +0 -110
- package/scripts/package-content.test.mjs +0 -78
- package/scripts/stamp-takeover.test.mjs +0 -166
- package/scripts/standalone-bootstrap.test.mjs +0 -180
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ 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
|
+
|
|
26
|
+
## 1.2.1 — Hidden-mode maintenance invariant (memory)
|
|
27
|
+
|
|
28
|
+
Patch: documentation only. The deployment-lineage head stays **`1.3.0`** (no `docs/ai` structural
|
|
29
|
+
change, no migration file). The npm package version is a separate axis.
|
|
30
|
+
|
|
31
|
+
- **`agent_rules.md` template (§1.3) + the Visibility contract** now state that the task-completion
|
|
32
|
+
doc updates are **visibility-independent**: hidden mode git-ignores `docs/ai` but never makes
|
|
33
|
+
maintaining it optional — the updates simply live on disk and never enter a commit. Future
|
|
34
|
+
deployments carry the clarified protocol; the durable wording lives in `agent_rules.md` §1.3.
|
|
35
|
+
|
|
7
36
|
## 1.2.0 — Seeds the per-project orchestration config
|
|
8
37
|
|
|
9
38
|
The substrate now seeds a new **per-project, user-editable recipe config** —
|
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.
|
|
6
|
+
version: '1.2.2'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-memory
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-memory",
|
|
3
|
-
"version": "1.2.
|
|
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"
|
package/references/contracts.md
CHANGED
|
@@ -19,6 +19,8 @@ diverge:
|
|
|
19
19
|
- **visible** — artifacts are committed. Wire the project's `package.json` scripts (`docs:check` / `docs:index` / `docs:index:check` / `docs:archive` / `docs:archive:check` / `docs:archive:issues` / `docs:archive:issues:check` / `prepare: node scripts/install-git-hooks.mjs`) and add a minimal `.gitignore` (`docs/plans/`, `.claude/settings.local.json`). This is the canonical model.
|
|
20
20
|
- **hidden** (in-tree) — same files on disk, but the repo "looks normal": add memory's OWN artifact paths to **one project-local ignore list, the repo's `.git/info/exclude`** — resolve it with `git rev-parse --git-path info/exclude` (worktree-safe) and **never** the machine-global `core.excludesFile`, which would silently affect every repo on the host (visibility is a *project* setting). Write each path **once**, in the **canonical anchored form** — `/AGENTS.md`, `/CLAUDE.md`, `/docs/ai/` (this subsumes `docs/ai/.memory-version` and any kit-fallback `docs/ai/.workflow-version` — do **not** list the stamps separately), the `/scripts/*.mjs` you added, `/docs/plans/`, `/.claude/settings.local.json`, and `/.claude/settings.json` (step-4 attribution can create it; a standalone hidden memory hides it too). Append only — never duplicate a line that is already present. **Verify `git status` shows the artifacts as ignored** afterwards. **Do not edit `package.json`** — that is a tracked change and would leak; the pre-commit hook (always untracked in `.git/hooks/`) calls the scripts via `node scripts/<x>.mjs` directly. *(In a family deploy the composition root then runs its hide tool, which absorbs these project-local lines into one canonical managed block and adds the external AI/agent footprint; memory writes no machine-global excludes at any step.)*
|
|
21
21
|
|
|
22
|
+
**Visibility changes only what git sees, never how the agent works.** Both modes carry the same working memory and run the same session protocol — `docs/ai` is read at session start and maintained on task completion **identically** in hidden mode; "git-ignored / `git status` clean" is **not** "optional to update", the updates just live on disk and never enter a commit (the durable wording lives in `agent_rules.md` §1.3).
|
|
23
|
+
|
|
22
24
|
Not in this version: a fully-external hidden mode (artifacts relocated outside the repo tree).
|
|
23
25
|
Deferred to a later release + migration.
|
|
24
26
|
|
|
@@ -33,6 +33,8 @@ Read in order, then confirm before starting:
|
|
|
33
33
|
6. Run quality checks: lint, type-check, tests.
|
|
34
34
|
|
|
35
35
|
### 1.3. Task Completion
|
|
36
|
+
> **Visibility-independent.** Every step below is mandatory in **both** visibility modes. Hidden mode (the AI footprint git-ignored via `.git/info/exclude`) changes only what **git** tracks — never whether you maintain `docs/ai`. "Git-ignored / `git status` clean" is **not** "optional to update": the doc/state updates still happen, they just live on disk and never enter a commit.
|
|
37
|
+
|
|
36
38
|
Before claiming "done":
|
|
37
39
|
1. Run all quality gates (lint + type-check + tests) — all green.
|
|
38
40
|
2. Update docs: `current_state.md` (feature ready), `changelog.md` (entry), `handover.md` (**REPLACE** the last-session block — session delta, never append; older deltas live in `changelog.md` → `history/`), `pages/<page>.md` (matches implementation). Only bump "Last Updated" when content actually changed.
|
package/bin/install.test.mjs
DELETED
|
@@ -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
|
-
});
|