@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.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/SKILL.md +201 -0
- package/bin/install.mjs +185 -0
- package/bin/install.test.mjs +76 -0
- package/capability.json +27 -0
- package/migrations/1.1.0-communication-language.md +51 -0
- package/migrations/1.2.0-agent-attribution.md +57 -0
- package/migrations/README.md +46 -0
- package/migrations/legacy-stamp-takeover.md +72 -0
- package/package.json +53 -0
- package/references/contracts.md +62 -0
- package/references/scripts/_expect-shim.mjs +41 -0
- package/references/scripts/archive-changelog.mjs +438 -0
- package/references/scripts/archive-changelog.test.mjs +212 -0
- package/references/scripts/archive-issues.mjs +179 -0
- package/references/scripts/archive-issues.test.mjs +95 -0
- package/references/scripts/check-docs-size.mjs +353 -0
- package/references/scripts/check-docs-size.test.mjs +180 -0
- package/references/scripts/install-git-hooks.mjs +83 -0
- package/references/templates/AGENTS.md +96 -0
- package/references/templates/active_plan.md +31 -0
- package/references/templates/agent_rules.md +85 -0
- package/references/templates/architecture.md +49 -0
- package/references/templates/changelog.md +24 -0
- package/references/templates/current_state.md +36 -0
- package/references/templates/decisions.md +44 -0
- package/references/templates/env_commands.md +41 -0
- package/references/templates/handover.md +37 -0
- package/references/templates/known_issues.md +33 -0
- package/references/templates/pages/PAGE_TEMPLATE.md +53 -0
- package/references/templates/pages/index.md +23 -0
- package/references/templates/pages/shared-patterns.md +30 -0
- package/references/templates/tech_reference.md +34 -0
- package/references/templates/technical_specification.md +37 -0
- package/scripts/package-content.test.mjs +78 -0
- package/scripts/stamp-takeover.mjs +181 -0
- package/scripts/stamp-takeover.test.mjs +166 -0
- 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
|
+
});
|