@sabaiway/agent-workflow-memory 1.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +61 -0
  4. package/SKILL.md +201 -0
  5. package/bin/install.mjs +185 -0
  6. package/bin/install.test.mjs +76 -0
  7. package/capability.json +27 -0
  8. package/migrations/1.1.0-communication-language.md +51 -0
  9. package/migrations/1.2.0-agent-attribution.md +57 -0
  10. package/migrations/README.md +46 -0
  11. package/migrations/legacy-stamp-takeover.md +72 -0
  12. package/package.json +53 -0
  13. package/references/contracts.md +62 -0
  14. package/references/scripts/_expect-shim.mjs +41 -0
  15. package/references/scripts/archive-changelog.mjs +438 -0
  16. package/references/scripts/archive-changelog.test.mjs +212 -0
  17. package/references/scripts/archive-issues.mjs +179 -0
  18. package/references/scripts/archive-issues.test.mjs +95 -0
  19. package/references/scripts/check-docs-size.mjs +353 -0
  20. package/references/scripts/check-docs-size.test.mjs +180 -0
  21. package/references/scripts/install-git-hooks.mjs +83 -0
  22. package/references/templates/AGENTS.md +96 -0
  23. package/references/templates/active_plan.md +31 -0
  24. package/references/templates/agent_rules.md +85 -0
  25. package/references/templates/architecture.md +49 -0
  26. package/references/templates/changelog.md +24 -0
  27. package/references/templates/current_state.md +36 -0
  28. package/references/templates/decisions.md +44 -0
  29. package/references/templates/env_commands.md +41 -0
  30. package/references/templates/handover.md +37 -0
  31. package/references/templates/known_issues.md +33 -0
  32. package/references/templates/pages/PAGE_TEMPLATE.md +53 -0
  33. package/references/templates/pages/index.md +23 -0
  34. package/references/templates/pages/shared-patterns.md +30 -0
  35. package/references/templates/tech_reference.md +34 -0
  36. package/references/templates/technical_specification.md +37 -0
  37. package/scripts/package-content.test.mjs +78 -0
  38. package/scripts/stamp-takeover.mjs +181 -0
  39. package/scripts/stamp-takeover.test.mjs +166 -0
  40. package/scripts/standalone-bootstrap.test.mjs +111 -0
@@ -0,0 +1,37 @@
1
+ ---
2
+ type: state
3
+ lastUpdated: {{DATE}}
4
+ scope: session
5
+ staleAfter: 7d
6
+ owner: handover
7
+ maxLines: 80
8
+ ---
9
+
10
+ # Handover โ€” Session Context
11
+
12
+ > **Read this FIRST every session.** What we did last, what's open, what's risky.
13
+ > This is a **session delta**: REPLACE the last-session block each time โ€” do not append. Older deltas live in `changelog.md` โ†’ `history/`.
14
+
15
+ **Last session:** {{DATE}}
16
+ **Branch:** {{BRANCH}}
17
+
18
+ ## What was done last session
19
+
20
+ - Bootstrap โ€” AI-agent memory system initialised.
21
+
22
+ ## What's open right now
23
+
24
+ - TODO: fill domain-specific sections after the first real task.
25
+
26
+ ## Risks / gotchas for next session
27
+
28
+ - None recorded yet.
29
+
30
+ ## Quality snapshot
31
+
32
+ | Check | Status |
33
+ |-------|--------|
34
+ | lint | n/a |
35
+ | type-check | n/a |
36
+ | unit tests | n/a |
37
+ | E2E tests | n/a |
@@ -0,0 +1,33 @@
1
+ ---
2
+ type: reference
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: 30d
6
+ owner: none
7
+ maxLines: 300
8
+ ---
9
+
10
+ # Known Issues
11
+
12
+ > Every bug we hit. Status, workaround, impact, plan. Avoids re-discovering pain.
13
+
14
+ ## ๐Ÿ”ด Open
15
+
16
+ ### Issue-001 โ€” {{Title}}
17
+ - **Discovered:** {{DATE}}
18
+ - **Status:** Open
19
+ - **Impact:** {{user-facing? dev-only? blocking?}}
20
+ - **Workaround:** {{if any}}
21
+ - **Plan:** {{next action}}
22
+ - **Related files:** `{{src/...}}`
23
+
24
+ ## ๐ŸŸข Resolved
25
+
26
+ ### Issue-XXX โ€” {{Title}}
27
+ - **Resolved:** {{DATE}}
28
+ - **Resolution:** {{what fixed it}}
29
+ - **Commit:** {{SHA}}
30
+
31
+ ---
32
+
33
+ > Resolved issues older than the window are rotated to `history/issues-resolved.md` by the issue-archive script.
@@ -0,0 +1,53 @@
1
+ ---
2
+ type: reference
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: 90d
6
+ owner: none
7
+ maxLines: 200
8
+ ---
9
+
10
+ # Page: {{PAGE_NAME}}
11
+
12
+ **Route:** `{{/path}}`
13
+ **Component:** `{{src/routes/<file>}}`
14
+
15
+ > Template โ€” copy to `docs/ai/pages/<page>.md` when a route appears. NOT deployed on bootstrap.
16
+
17
+ ## Purpose
18
+
19
+ {{1โ€“2 sentences.}}
20
+
21
+ ## Layout
22
+
23
+ ```
24
+ {{ASCII or description of regions.}}
25
+ ```
26
+
27
+ ## States
28
+
29
+ | State | Trigger | UI |
30
+ |-------|---------|----|
31
+ | Empty | No data | {{description}} |
32
+ | Loading | Fetch in flight | {{description}} |
33
+ | Ready | Data loaded | {{description}} |
34
+ | Error | Fetch failed | {{description}} |
35
+
36
+ ## User Actions
37
+
38
+ | Action | Trigger | Result | Validation |
39
+ |--------|---------|--------|------------|
40
+ | โ€” | โ€” | โ€” | โ€” |
41
+
42
+ ## Data Dependencies
43
+
44
+ - Reads: {{data sources}}
45
+ - Writes: {{mutations}}
46
+
47
+ ## Linked Components
48
+
49
+ - `{{src/components/...}}`
50
+
51
+ ## Open Questions / TODO
52
+
53
+ - [ ] โ€”
@@ -0,0 +1,23 @@
1
+ ---
2
+ type: reference
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: 90d
6
+ owner: none
7
+ maxLines: 80
8
+ ---
9
+
10
+ # Pages Index
11
+
12
+ > Registry of every user-facing page/route. Source of truth for routing.
13
+
14
+ | Route | Name | Spec | Status |
15
+ |-------|------|------|--------|
16
+ | `/` | Home | [home.md](./home.md) | TODO |
17
+ | `{{/path}}` | {{Name}} | [{{name}}.md](./{{name}}.md) | TODO |
18
+
19
+ ## Shared
20
+
21
+ - [shared-patterns.md](./shared-patterns.md) โ€” loading, empty, error, modal, form, toast patterns.
22
+
23
+ > Create one `pages/<page>.md` per route as it appears (use `PAGE_TEMPLATE.md` from the skill). Every page must ship an HTML-validity / a11y E2E test.
@@ -0,0 +1,30 @@
1
+ ---
2
+ type: reference
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: 90d
6
+ owner: none
7
+ maxLines: 400
8
+ ---
9
+
10
+ # Shared Page Patterns
11
+
12
+ > Cross-cutting UI patterns. Copy these, don't reinvent them.
13
+
14
+ ## Loading State
15
+ {{Pattern + example.}}
16
+
17
+ ## Empty State
18
+ {{Pattern + example.}}
19
+
20
+ ## Error State
21
+ {{Pattern + example.}}
22
+
23
+ ## Modal Lifecycle
24
+ {{Pattern + example.}}
25
+
26
+ ## Form Validation Flow
27
+ {{Pattern + example.}}
28
+
29
+ ## Toast / Notification
30
+ {{Pattern + example.}}
@@ -0,0 +1,34 @@
1
+ ---
2
+ type: reference
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: 90d
6
+ owner: none
7
+ maxLines: 300
8
+ ---
9
+
10
+ # Technical Reference โ€” Patterns & Examples
11
+
12
+ > Copy-paste-ready snippets for libraries and patterns used in this project. (Mature projects may fold this into a project patterns skill.)
13
+
14
+ ## No Silent Failures
15
+
16
+ Every internal validation/guard that rejects an action MUST log via the structured logger with context (component, action, ids, inputs). User-facing failures also surface in the UI.
17
+
18
+ ```{{lang}}
19
+ {{Show the project's logger usage. Replace with a real example after first use.}}
20
+ ```
21
+
22
+ ## {{Library Name}} โ€” {{Pattern}}
23
+
24
+ ```{{lang}}
25
+ {{Example after first use.}}
26
+ ```
27
+
28
+ ---
29
+
30
+ ## Add a section when
31
+
32
+ - You discover a non-trivial library configuration.
33
+ - You establish a reusable pattern across 2+ files.
34
+ - You hit a gotcha future agents should not re-discover.
@@ -0,0 +1,37 @@
1
+ ---
2
+ type: reference
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: 90d
6
+ owner: none
7
+ maxLines: 280
8
+ ---
9
+
10
+ # Technical Specification
11
+
12
+ > Application-level overview: what it does, who uses it, key data models.
13
+
14
+ ## Purpose
15
+
16
+ {{One-paragraph mission.}}
17
+
18
+ ## Users / Personas
19
+
20
+ - {{Persona}} โ€” {{what they do}}
21
+
22
+ ## Core Domain Model
23
+
24
+ ```
25
+ {{Entities and relationships, e.g.}}
26
+ User โ”€โ”€ownsโ”€โ”€> Project โ”€โ”€containsโ”€โ”€> Item
27
+ ```
28
+
29
+ ## Data Flow
30
+
31
+ {{Numbered flow: client โ†’ API โ†’ storage โ†’ render.}}
32
+
33
+ ## External Integrations
34
+
35
+ | Service | Purpose | Auth |
36
+ |---------|---------|------|
37
+ | โ€” | โ€” | โ€” |
@@ -0,0 +1,78 @@
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
+ });
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ // Legacy-stamp takeover โ€” the lineage-coherent state machine for the
3
+ // `.workflow-version` (kit-fallback) โ†’ `.memory-version` (memory) transition.
4
+ //
5
+ // Two independent axes: npm *package* versions vs the *deployment-lineage* stamp.
6
+ // The deployment lineage is a SINGLE shared sequence; its current head is LINEAGE_HEAD.
7
+ // Both `.memory-version` and the kit-fallback `.workflow-version` track THAT sequence โ€”
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).
10
+ //
11
+ // `decideTakeover` is a PURE function (stamp state in โ†’ action out) so the state machine is
12
+ // unit-testable per row. `applyTakeover` is the thin fs wrapper; stamp writes are ATOMIC
13
+ // (write temp + rename) so an interrupted write can never corrupt the prior stamp.
14
+ //
15
+ // The Markdown twin `migrations/legacy-stamp-takeover.md` documents the same table as the
16
+ // no-Node manual fallback. Dependency-free, Node >= 18.
17
+
18
+ import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
19
+ import { dirname, basename, join, resolve } from 'node:path';
20
+ import { randomBytes } from 'node:crypto';
21
+ import { pathToFileURL } from 'node:url';
22
+
23
+ // The shared agent-workflow deployment-lineage head. Bumped only when a project-migration
24
+ // changes the deployed docs/ai structure โ€” NOT on a packaging-only release.
25
+ export const LINEAGE_HEAD = '1.3.0';
26
+
27
+ const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
28
+
29
+ export const parseSemver = (value) => {
30
+ if (value == null) return null;
31
+ const match = String(value).trim().match(SEMVER_RE);
32
+ if (!match) return null;
33
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
34
+ };
35
+
36
+ // Returns -1 | 0 | 1 for a<b | a==b | a>b, or null if either side is unparseable.
37
+ export const compareSemver = (a, b) => {
38
+ const pa = parseSemver(a);
39
+ const pb = parseSemver(b);
40
+ if (!pa || !pb) return null;
41
+ for (let i = 0; i < 3; i += 1) {
42
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
43
+ }
44
+ return 0;
45
+ };
46
+
47
+ // null ONLY for a truly absent value (ENOENT). A PRESENT but empty/whitespace stamp normalizes to
48
+ // '' (not null), so decideTakeover treats it as unparseable โ†’ STOP rather than absent โ†’ rebootstrap.
49
+ const normalize = (value) => (value == null ? null : String(value).trim());
50
+
51
+ // PURE: given the two stamp values (string contents or null) and the lineage head, decide the
52
+ // takeover action. Mirrors the ยง1.5 state table. Output:
53
+ // status 'ok' | 'stop' | 'rebootstrap'
54
+ // writeMemoryVersion the value to atomically write to .memory-version now, or null
55
+ // migrateFrom the version memory migrations run from (exclusive), or null
56
+ // note human-readable reason (for the report)
57
+ export const decideTakeover = (state, head = LINEAGE_HEAD) => {
58
+ const memory = normalize(state?.memoryVersion);
59
+ const workflow = normalize(state?.workflowVersion);
60
+
61
+ // Validate every PRESENT stamp before any state branch: unparseable or future โ†’ STOP.
62
+ for (const [name, value] of [['.memory-version', memory], ['.workflow-version', workflow]]) {
63
+ if (value == null) continue;
64
+ if (!parseSemver(value)) {
65
+ return {
66
+ status: 'stop',
67
+ writeMemoryVersion: null,
68
+ migrateFrom: null,
69
+ note: `unparseable ${name} stamp "${value}" โ€” STOP and report (needs manual review).`,
70
+ };
71
+ }
72
+ if (compareSemver(value, head) > 0) {
73
+ return {
74
+ status: 'stop',
75
+ writeMemoryVersion: null,
76
+ migrateFrom: null,
77
+ note: `${name} stamp "${value}" is newer than the lineage head ${head} โ€” STOP and report (do not downgrade).`,
78
+ };
79
+ }
80
+ }
81
+
82
+ if (memory != null) {
83
+ // .memory-version present (valid, not future): takeover is a no-op; migrate from it.
84
+ return {
85
+ status: 'ok',
86
+ writeMemoryVersion: null,
87
+ migrateFrom: memory,
88
+ note:
89
+ workflow != null
90
+ ? 'both stamps present โ€” no takeover; each tool migrates from its own stamp (.memory-version here).'
91
+ : 'migrate from the existing .memory-version.',
92
+ };
93
+ }
94
+
95
+ if (workflow != null) {
96
+ // Legacy only: copy verbatim โ†’ .memory-version; leave .workflow-version; migrate from it.
97
+ return {
98
+ status: 'ok',
99
+ writeMemoryVersion: workflow,
100
+ migrateFrom: workflow,
101
+ note: `legacy takeover โ€” copy .workflow-version "${workflow}" verbatim into .memory-version; the legacy stamp is left in place.`,
102
+ };
103
+ }
104
+
105
+ // No stamp at all โ†’ conservative re-bootstrap offer (existing behaviour).
106
+ return {
107
+ status: 'rebootstrap',
108
+ writeMemoryVersion: null,
109
+ migrateFrom: null,
110
+ note: 'no stamp found โ€” offer a conservative re-bootstrap.',
111
+ };
112
+ };
113
+
114
+ // Select migration versions strictly newer than `migrateFrom` and โ‰ค head, ascending.
115
+ // `available` is the list of migration `<version>` strings present in migrations/.
116
+ export const selectMigrations = (migrateFrom, available, head = LINEAGE_HEAD) =>
117
+ [...available]
118
+ .filter((v) => parseSemver(v))
119
+ .filter((v) => (migrateFrom == null || compareSemver(v, migrateFrom) > 0) && compareSemver(v, head) <= 0)
120
+ .sort((a, b) => compareSemver(a, b));
121
+
122
+ // Absent ONLY for ENOENT. A permission/EISDIR/IO error must NOT masquerade as "no stamp" โ€” that
123
+ // would silently route a real failure into re-bootstrap or a wrong takeover; surface it so the
124
+ // caller STOPs.
125
+ export const readStamp = async (filePath) => {
126
+ try {
127
+ return normalize(await readFile(filePath, 'utf8'));
128
+ } catch (err) {
129
+ if (err && err.code === 'ENOENT') return null;
130
+ throw err;
131
+ }
132
+ };
133
+
134
+ // Atomic single-line stamp write: an EXCLUSIVE (`wx` = O_EXCL|O_CREAT) randomized temp file in the
135
+ // same dir, then rename over the target. `wx` never follows or overwrites a pre-existing path
136
+ // (so a planted temp-name symlink can't redirect the write); a randomized name avoids collisions;
137
+ // a crash before the rename leaves the prior stamp byte-for-byte intact; the temp is cleaned on
138
+ // any failure so no residue is left behind.
139
+ export const writeStampAtomic = async (filePath, value) => {
140
+ const tmp = join(dirname(filePath), `.${basename(filePath)}.tmp-${randomBytes(8).toString('hex')}`);
141
+ try {
142
+ await writeFile(tmp, `${String(value).trim()}\n`, { encoding: 'utf8', flag: 'wx' });
143
+ await rename(tmp, filePath);
144
+ } catch (err) {
145
+ await unlink(tmp).catch(() => {});
146
+ throw err;
147
+ }
148
+ };
149
+
150
+ // Thin fs wrapper: read both stamps from a docs/ai dir, decide, and (only when the decision
151
+ // says so) atomically write .memory-version. Idempotent: a second run sees .memory-version
152
+ // and writes nothing. Returns the decision for the caller to report.
153
+ export const applyTakeover = async (docsAiDir, { head = LINEAGE_HEAD } = {}) => {
154
+ const memoryPath = join(docsAiDir, '.memory-version');
155
+ const workflowPath = join(docsAiDir, '.workflow-version');
156
+ const decision = decideTakeover(
157
+ { memoryVersion: await readStamp(memoryPath), workflowVersion: await readStamp(workflowPath) },
158
+ head,
159
+ );
160
+ if (decision.status === 'ok' && decision.writeMemoryVersion != null) {
161
+ await writeStampAtomic(memoryPath, decision.writeMemoryVersion);
162
+ }
163
+ return decision;
164
+ };
165
+
166
+ // Direct-run CLI: `node stamp-takeover.mjs <docs/ai-dir>` reads the two stamps, applies the
167
+ // lineage takeover (atomic, idempotent), and prints the decision. Exits non-zero on a STOP state.
168
+ const main = async (argv) => {
169
+ const dir = argv[0];
170
+ if (!dir) {
171
+ console.error('usage: stamp-takeover.mjs <docs/ai-dir> (applies the .workflow-version โ†’ .memory-version lineage takeover)');
172
+ process.exit(2);
173
+ }
174
+ const decision = await applyTakeover(resolve(dir));
175
+ console.log(`[stamp-takeover] ${decision.status}: ${decision.note}`);
176
+ if (decision.migrateFrom) console.log(`[stamp-takeover] migrate memory migrations from: ${decision.migrateFrom} (head ${LINEAGE_HEAD})`);
177
+ if (decision.status === 'stop') process.exit(1);
178
+ };
179
+
180
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
181
+ if (isDirectRun) await main(process.argv.slice(2));
@@ -0,0 +1,166 @@
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
+ });