@ikon85/agent-workflow-kit 0.18.0 → 0.19.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.
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: memory-lifecycle
3
+ description: "Preview and apply a consumer-owned memory lifecycle without crossing configured roots or overwriting active, archived, or recovery evidence. Use when a user wants to restore archived memories, inspect memory placement, or run an approved memory recovery."
4
+ ---
5
+
6
+ # Memory Lifecycle
7
+
8
+ Coordinate the current repository's memory placement and recovery through the
9
+ deterministic helper at `scripts/memory-lifecycle/index.mjs`. The helper owns
10
+ path validation, dry-run verdicts, exclusive restore writes, hashing, and
11
+ receipts. Keep this skill thin: do not reimplement those rules with shell file
12
+ operations.
13
+
14
+ ## Boundaries
15
+
16
+ - Work only below the consumer-owned roots in
17
+ `docs/agents/workflow-capabilities.json`.
18
+ - Never scan another repository or a user-global memory root that the current
19
+ consumer profile does not explicitly own.
20
+ - Treat active memories, archives, policy templates, and restore receipts as
21
+ consumer-owned. Never overwrite them.
22
+ - `init`, `update`, and an ordinary `setup-workflow` rerun must not enable this
23
+ capability or change placement, retention, restore, or pruning grants.
24
+ - Destructive pruning is outside the helper. It requires a separate preview and
25
+ explicit user approval; do not infer that approval from restore permission.
26
+
27
+ ## Consumer profile
28
+
29
+ Adopt the `memoryLifecycle` section of
30
+ `docs/agents/workflow-capabilities.json`. Missing or disabled configuration is
31
+ a valid no-write `disabled` result.
32
+
33
+ ```json
34
+ {
35
+ "memoryLifecycle": {
36
+ "enabled": true,
37
+ "activeRoot": ".memory/active",
38
+ "archiveRoot": ".memory/archive",
39
+ "receiptRoot": ".memory/receipts",
40
+ "approvals": { "restore": false, "prune": false },
41
+ "memories": [
42
+ { "path": "decisions/example.md", "enabled": true }
43
+ ]
44
+ }
45
+ }
46
+ ```
47
+
48
+ Preserve unknown profile keys. Every configured root and candidate path must be
49
+ repository-relative. Absolute paths, parent escapes, symlink traversal,
50
+ different-content collisions, and missing restore approval are refused.
51
+
52
+ ## Workflow
53
+
54
+ 1. **Preview.** Run:
55
+
56
+ ```sh
57
+ node scripts/memory-lifecycle/index.mjs
58
+ ```
59
+
60
+ This is the default no-write mode. Count the candidate paths and confirm each
61
+ appears exactly once as `create`, `restore`, `preserve`, `skip`, or `refuse`.
62
+ 2. **Review refusals.** Explain every refusal. Do not weaken root, symlink,
63
+ collision, or approval checks to make the run green.
64
+ 3. **Confirm recovery.** Before applying, require the consumer profile's
65
+ `approvals.restore` to already record the explicit restore grant. A chat
66
+ instruction does not silently rewrite the tracked policy.
67
+ 4. **Apply.** Run:
68
+
69
+ ```sh
70
+ node scripts/memory-lifecycle/index.mjs --apply
71
+ ```
72
+
73
+ The helper restores with exclusive-create semantics and keeps the archive.
74
+ A concurrent destination collision becomes `refused`, never an overwrite.
75
+ 5. **Read back.** Verify restored files by path and hash, not by printing memory
76
+ contents. Confirm the receipt has schema/source versions plus
77
+ `restored`/`skipped`/`refused` path verdicts and contains no memory content.
78
+ 6. **Rerun.** Run the preview again. An identical restored file is preserved;
79
+ the rerun writes no duplicate receipt.
80
+
81
+ Use `--profile <repository-relative-path>` only when the repository documents a
82
+ different consumer-owned profile location.
83
+
84
+ ## Final report
85
+
86
+ Report:
87
+
88
+ - dry-run counts for all five action classes;
89
+ - applied `restored`/`skipped`/`refused` counts;
90
+ - receipt path and source versions, without memory contents;
91
+ - archive-preservation and rerun/no-write result;
92
+ - any pruning request left pending explicit approval.
@@ -18,6 +18,7 @@ Use this section as the entry-point map for agent-assisted work. The individual
18
18
  - **Multi-session build from a PRD or issue:** use `implement` to drive `tdd` end-to-end, one red-green slice at a time.
19
19
  - **Implementation slice:** use `tdd` for one behavior at a time: RED, GREEN, then refactor.
20
20
  - **Build or refresh a project-local census:** use `census-update` to scan facts, resolve ambiguous decisions one at a time, and transactionally activate a counted census.
21
+ - **Preview or restore consumer-owned memories:** use `memory-lifecycle` to classify every configured path without writing, then apply an explicitly approved collision-safe restore with a content-free receipt.
21
22
  - **Finished slice:** use `wrapup` to prepare the branch, PR, and cleanup steps your repo expects.
22
23
  - **Kit release:** use `kit-release` to derive the shipped delta, confirm Semver, regenerate the manifest, run all gates, and then hand landing to `wrapup`.
23
24
  - **Kit update:** use `kit-update` to preview and transactionally apply a parity-verified scoped release without overwriting local modifications.
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: memory-lifecycle
3
+ description: "Preview and apply a consumer-owned memory lifecycle without crossing configured roots or overwriting active, archived, or recovery evidence. Use when a user wants to restore archived memories, inspect memory placement, or run an approved memory recovery."
4
+ ---
5
+
6
+ # Memory Lifecycle
7
+
8
+ Coordinate the current repository's memory placement and recovery through the
9
+ deterministic helper at `scripts/memory-lifecycle/index.mjs`. The helper owns
10
+ path validation, dry-run verdicts, exclusive restore writes, hashing, and
11
+ receipts. Keep this skill thin: do not reimplement those rules with shell file
12
+ operations.
13
+
14
+ ## Boundaries
15
+
16
+ - Work only below the consumer-owned roots in
17
+ `docs/agents/workflow-capabilities.json`.
18
+ - Never scan another repository or a user-global memory root that the current
19
+ consumer profile does not explicitly own.
20
+ - Treat active memories, archives, policy templates, and restore receipts as
21
+ consumer-owned. Never overwrite them.
22
+ - `init`, `update`, and an ordinary `setup-workflow` rerun must not enable this
23
+ capability or change placement, retention, restore, or pruning grants.
24
+ - Destructive pruning is outside the helper. It requires a separate preview and
25
+ explicit user approval; do not infer that approval from restore permission.
26
+
27
+ ## Consumer profile
28
+
29
+ Adopt the `memoryLifecycle` section of
30
+ `docs/agents/workflow-capabilities.json`. Missing or disabled configuration is
31
+ a valid no-write `disabled` result.
32
+
33
+ ```json
34
+ {
35
+ "memoryLifecycle": {
36
+ "enabled": true,
37
+ "activeRoot": ".memory/active",
38
+ "archiveRoot": ".memory/archive",
39
+ "receiptRoot": ".memory/receipts",
40
+ "approvals": { "restore": false, "prune": false },
41
+ "memories": [
42
+ { "path": "decisions/example.md", "enabled": true }
43
+ ]
44
+ }
45
+ }
46
+ ```
47
+
48
+ Preserve unknown profile keys. Every configured root and candidate path must be
49
+ repository-relative. Absolute paths, parent escapes, symlink traversal,
50
+ different-content collisions, and missing restore approval are refused.
51
+
52
+ ## Workflow
53
+
54
+ 1. **Preview.** Run:
55
+
56
+ ```sh
57
+ node scripts/memory-lifecycle/index.mjs
58
+ ```
59
+
60
+ This is the default no-write mode. Count the candidate paths and confirm each
61
+ appears exactly once as `create`, `restore`, `preserve`, `skip`, or `refuse`.
62
+ 2. **Review refusals.** Explain every refusal. Do not weaken root, symlink,
63
+ collision, or approval checks to make the run green.
64
+ 3. **Confirm recovery.** Before applying, require the consumer profile's
65
+ `approvals.restore` to already record the explicit restore grant. A chat
66
+ instruction does not silently rewrite the tracked policy.
67
+ 4. **Apply.** Run:
68
+
69
+ ```sh
70
+ node scripts/memory-lifecycle/index.mjs --apply
71
+ ```
72
+
73
+ The helper restores with exclusive-create semantics and keeps the archive.
74
+ A concurrent destination collision becomes `refused`, never an overwrite.
75
+ 5. **Read back.** Verify restored files by path and hash, not by printing memory
76
+ contents. Confirm the receipt has schema/source versions plus
77
+ `restored`/`skipped`/`refused` path verdicts and contains no memory content.
78
+ 6. **Rerun.** Run the preview again. An identical restored file is preserved;
79
+ the rerun writes no duplicate receipt.
80
+
81
+ Use `--profile <repository-relative-path>` only when the repository documents a
82
+ different consumer-owned profile location.
83
+
84
+ ## Final report
85
+
86
+ Report:
87
+
88
+ - dry-run counts for all five action classes;
89
+ - applied `restored`/`skipped`/`refused` counts;
90
+ - receipt path and source versions, without memory contents;
91
+ - archive-preservation and rerun/no-write result;
92
+ - any pruning request left pending explicit approval.
@@ -18,6 +18,7 @@ Use this section as the entry-point map for agent-assisted work. The individual
18
18
  - **Multi-session build from a PRD or issue:** use `implement` to drive `tdd` end-to-end, one red-green slice at a time.
19
19
  - **Implementation slice:** use `tdd` for one behavior at a time: RED, GREEN, then refactor.
20
20
  - **Build or refresh a project-local census:** use `census-update` to scan facts, resolve ambiguous decisions one at a time, and transactionally activate a counted census.
21
+ - **Preview or restore consumer-owned memories:** use `memory-lifecycle` to classify every configured path without writing, then apply an explicitly approved collision-safe restore with a content-free receipt.
21
22
  - **Finished slice:** use `wrapup` to prepare the branch, PR, and cleanup steps your repo expects.
22
23
  - **Kit release:** use `kit-release` to derive the shipped delta, confirm Semver, regenerate the manifest, run all gates, and then hand landing to `wrapup`.
23
24
  - **Kit update:** use `kit-update` to preview and transactionally apply a parity-verified scoped release without overwriting local modifications.
@@ -404,6 +404,16 @@
404
404
  "codex"
405
405
  ],
406
406
  "provenance": "own"
407
+ },
408
+ "memory-lifecycle": {
409
+ "class": "generic",
410
+ "publish": true,
411
+ "entryPoint": true,
412
+ "surfaces": [
413
+ "claude",
414
+ "codex"
415
+ ],
416
+ "provenance": "own"
407
417
  }
408
418
  }
409
419
  }
package/PROVENANCE.md CHANGED
@@ -33,7 +33,7 @@ carries Chase's `THIRD-PARTY-NOTICES.md`.
33
33
 
34
34
  retro, wrapup, spec-self-critique, board-to-waves, verify-spike, decision-gate,
35
35
  codex-adapter-sync, setup-pre-commit, code-review, orchestrate-wave,
36
- census-update — covered by the root LICENSE.
36
+ census-update, memory-lifecycle — covered by the root LICENSE.
37
37
  `setup-pre-commit` was rewritten from Matt Pocock's husky-based skill to a
38
38
  zero-dep native git `core.hooksPath` scaffold; nothing of the original
39
39
  mechanic remains (courtesy attribution).
package/README.md CHANGED
@@ -332,6 +332,14 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
332
332
 
333
333
  ## Release notes
334
334
 
335
+ ### 0.19.0
336
+
337
+ - added: `.agents/skills/memory-lifecycle/SKILL.md`
338
+ - added: `.claude/skills/memory-lifecycle/SKILL.md`
339
+ - added: `scripts/memory-lifecycle/index.mjs`
340
+ - changed: `.agents/skills/setup-workflow/workflow-overview.md`
341
+ - changed: `.claude/skills/setup-workflow/workflow-overview.md`
342
+
335
343
  ### 0.18.0
336
344
 
337
345
  - added: `scripts/worktree-lifecycle/capabilities.json`
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.18.0",
2
+ "kitVersion": "0.19.0",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -352,6 +352,15 @@
352
352
  "mode": 420,
353
353
  "origin": "kit"
354
354
  },
355
+ {
356
+ "path": ".agents/skills/memory-lifecycle/SKILL.md",
357
+ "kind": "skill",
358
+ "ownerSkill": "memory-lifecycle",
359
+ "surface": "codex",
360
+ "sha256": "f4cd4a744044b59fd4ce6b7348e30f437ca4200f5f5bbae6e6aebdc30d6b3f35",
361
+ "mode": 420,
362
+ "origin": "kit"
363
+ },
355
364
  {
356
365
  "path": ".agents/skills/orchestrate-wave/references/builder-contract.md",
357
366
  "kind": "skill",
@@ -600,7 +609,7 @@
600
609
  "kind": "skill",
601
610
  "ownerSkill": "setup-workflow",
602
611
  "surface": "codex",
603
- "sha256": "b83fa87dc0226f21e01d31c91840b78d1ccad0a92e646de203fc3c003e9098f3",
612
+ "sha256": "c522a7de1b8eaa827a72880651a94aca6d066687e8c21b789d9008e3321f7362",
604
613
  "mode": 420,
605
614
  "origin": "kit"
606
615
  },
@@ -1334,6 +1343,15 @@
1334
1343
  "mode": 420,
1335
1344
  "origin": "kit"
1336
1345
  },
1346
+ {
1347
+ "path": ".claude/skills/memory-lifecycle/SKILL.md",
1348
+ "kind": "skill",
1349
+ "ownerSkill": "memory-lifecycle",
1350
+ "surface": "claude",
1351
+ "sha256": "f4cd4a744044b59fd4ce6b7348e30f437ca4200f5f5bbae6e6aebdc30d6b3f35",
1352
+ "mode": 420,
1353
+ "origin": "kit"
1354
+ },
1337
1355
  {
1338
1356
  "path": ".claude/skills/orchestrate-wave/references/builder-contract.md",
1339
1357
  "kind": "skill",
@@ -1600,7 +1618,7 @@
1600
1618
  "kind": "skill",
1601
1619
  "ownerSkill": "setup-workflow",
1602
1620
  "surface": "claude",
1603
- "sha256": "b83fa87dc0226f21e01d31c91840b78d1ccad0a92e646de203fc3c003e9098f3",
1621
+ "sha256": "c522a7de1b8eaa827a72880651a94aca6d066687e8c21b789d9008e3321f7362",
1604
1622
  "mode": 420,
1605
1623
  "origin": "kit"
1606
1624
  },
@@ -2000,6 +2018,13 @@
2000
2018
  "mode": 493,
2001
2019
  "origin": "kit"
2002
2020
  },
2021
+ {
2022
+ "path": "scripts/memory-lifecycle/index.mjs",
2023
+ "kind": "script",
2024
+ "sha256": "d83a71891e9c070179a00d6413b18d073061eef75e50919a7f28b8510f7ca20d",
2025
+ "mode": 420,
2026
+ "origin": "kit"
2027
+ },
2003
2028
  {
2004
2029
  "path": "scripts/node_kind.py",
2005
2030
  "kind": "script",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Portable AI-agent workflow skills (plan → execute → land → learn) for Claude Code & Codex — grilling, TDD, diagnosis, two-axis code review, cross-model Codex review, design & domain-modeling, plus a skill router (ask-matt). npx init/update/diff/uninstall.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,247 @@
1
+ import {
2
+ constants,
3
+ copyFile,
4
+ lstat,
5
+ mkdir,
6
+ readFile,
7
+ writeFile,
8
+ } from 'node:fs/promises';
9
+ import { createHash, randomUUID } from 'node:crypto';
10
+ import { dirname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+
13
+ async function exists(path) {
14
+ try {
15
+ return await lstat(path);
16
+ } catch (error) {
17
+ if (error.code === 'ENOENT') return null;
18
+ throw error;
19
+ }
20
+ }
21
+
22
+ function resolveCandidate(root, candidate) {
23
+ if (isAbsolute(candidate)) return null;
24
+ const normalized = normalize(candidate);
25
+ if (normalized === '..' || normalized.startsWith(`..${sep}`)) return null;
26
+ const absolute = resolve(root, normalized);
27
+ const boundary = `${resolve(root)}${sep}`;
28
+ return absolute.startsWith(boundary) ? absolute : null;
29
+ }
30
+
31
+ async function pathHasSymlink(root, candidatePath) {
32
+ const rootStat = await exists(root);
33
+ if (rootStat?.isSymbolicLink()) return true;
34
+
35
+ const relativePath = relative(resolve(root), candidatePath);
36
+ let current = resolve(root);
37
+ for (const segment of relativePath.split(sep).filter(Boolean)) {
38
+ current = resolve(current, segment);
39
+ const stat = await exists(current);
40
+ if (stat?.isSymbolicLink()) return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ async function sha256(path) {
46
+ return createHash('sha256').update(await readFile(path)).digest('hex');
47
+ }
48
+
49
+ export async function planMemoryLifecycle({
50
+ activeRoot,
51
+ archiveRoot,
52
+ candidates,
53
+ approved = false,
54
+ }) {
55
+ const actions = [];
56
+ const seen = new Set();
57
+
58
+ for (const candidateInput of candidates) {
59
+ const candidate = typeof candidateInput === 'string'
60
+ ? { path: candidateInput, enabled: true }
61
+ : { enabled: true, ...candidateInput };
62
+ if (seen.has(candidate.path)) continue;
63
+ seen.add(candidate.path);
64
+
65
+ if (!candidate.enabled) {
66
+ actions.push({
67
+ path: candidate.path,
68
+ action: 'skip',
69
+ reason: 'candidate is disabled by consumer policy',
70
+ });
71
+ continue;
72
+ }
73
+
74
+ const activePath = resolveCandidate(activeRoot, candidate.path);
75
+ const archivePath = resolveCandidate(archiveRoot, candidate.path);
76
+ let action = 'refuse';
77
+ let reason = 'outside configured roots';
78
+
79
+ if (
80
+ activePath
81
+ && archivePath
82
+ && !(await pathHasSymlink(activeRoot, activePath))
83
+ && !(await pathHasSymlink(archiveRoot, archivePath))
84
+ ) {
85
+ const active = await exists(activePath);
86
+ const archived = await exists(archivePath);
87
+ if (active && archived) {
88
+ const identical = active.isFile() && archived.isFile()
89
+ && (await sha256(activePath)) === (await sha256(archivePath));
90
+ action = identical ? 'preserve' : 'refuse';
91
+ reason = identical
92
+ ? 'active and archived memory already match'
93
+ : 'active and archived memory collide';
94
+ } else if (active) {
95
+ action = 'preserve';
96
+ reason = 'active memory already exists';
97
+ } else if (archived && approved) {
98
+ action = 'restore';
99
+ reason = 'approved archived memory is available';
100
+ } else if (archived) {
101
+ action = 'refuse';
102
+ reason = 'restore approval is required';
103
+ } else {
104
+ action = 'create';
105
+ reason = 'memory does not exist';
106
+ }
107
+ } else if (activePath && archivePath) {
108
+ reason = `symlink escape below ${dirname(activePath)}`;
109
+ }
110
+
111
+ actions.push({ path: candidate.path, action, reason });
112
+ }
113
+
114
+ return { dryRun: true, actions };
115
+ }
116
+
117
+ async function writeReceipt(receiptRoot, receipt) {
118
+ await mkdir(receiptRoot, { recursive: true });
119
+ const name = `memory-restore-${Date.now()}-${randomUUID()}.json`;
120
+ const path = resolve(receiptRoot, name);
121
+ await writeFile(path, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
122
+ return path;
123
+ }
124
+
125
+ export async function executeMemoryLifecycle(options) {
126
+ const plan = await planMemoryLifecycle(options);
127
+ const verdicts = [];
128
+
129
+ for (const item of plan.actions) {
130
+ if (item.action !== 'restore') {
131
+ verdicts.push({
132
+ path: item.path,
133
+ verdict: item.action === 'refuse' ? 'refused' : 'skipped',
134
+ reason: item.reason,
135
+ });
136
+ continue;
137
+ }
138
+
139
+ const activePath = resolveCandidate(options.activeRoot, item.path);
140
+ const archivePath = resolveCandidate(options.archiveRoot, item.path);
141
+ const hash = await sha256(archivePath);
142
+ await mkdir(dirname(activePath), { recursive: true });
143
+ try {
144
+ await copyFile(archivePath, activePath, constants.COPYFILE_EXCL);
145
+ verdicts.push({ path: item.path, verdict: 'restored', sha256: hash });
146
+ } catch (error) {
147
+ if (error.code !== 'EEXIST') throw error;
148
+ verdicts.push({
149
+ path: item.path,
150
+ verdict: 'refused',
151
+ reason: 'active memory appeared before restore',
152
+ sha256: hash,
153
+ });
154
+ }
155
+ }
156
+
157
+ const restored = verdicts.some(({ verdict }) => verdict === 'restored');
158
+ const receiptPath = restored
159
+ ? await writeReceipt(options.receiptRoot, {
160
+ schemaVersion: 1,
161
+ createdAt: new Date().toISOString(),
162
+ source: options.source,
163
+ verdicts,
164
+ })
165
+ : null;
166
+
167
+ return { verdicts, receiptPath };
168
+ }
169
+
170
+ async function readJsonIfPresent(path) {
171
+ try {
172
+ const raw = await readFile(path, 'utf8');
173
+ return { raw, value: JSON.parse(raw) };
174
+ } catch (error) {
175
+ if (error.code === 'ENOENT') return null;
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ export async function runMemoryLifecycle({
181
+ projectRoot = process.cwd(),
182
+ profilePath = 'docs/agents/workflow-capabilities.json',
183
+ apply = false,
184
+ } = {}) {
185
+ const absoluteProfile = resolveCandidate(projectRoot, profilePath);
186
+ if (!absoluteProfile || await pathHasSymlink(projectRoot, absoluteProfile)) {
187
+ return {
188
+ state: 'refused',
189
+ dryRun: !apply,
190
+ actions: [{ path: profilePath, action: 'refuse', reason: 'unsafe profile path' }],
191
+ };
192
+ }
193
+
194
+ const profileDocument = await readJsonIfPresent(absoluteProfile);
195
+ const capability = profileDocument?.value?.memoryLifecycle;
196
+ if (!capability?.enabled) {
197
+ return { state: 'disabled', dryRun: true, actions: [] };
198
+ }
199
+
200
+ const activeRoot = resolveCandidate(projectRoot, capability.activeRoot);
201
+ const archiveRoot = resolveCandidate(projectRoot, capability.archiveRoot);
202
+ const receiptRoot = resolveCandidate(projectRoot, capability.receiptRoot);
203
+ if (!activeRoot || !archiveRoot || !receiptRoot) {
204
+ return {
205
+ state: 'refused',
206
+ dryRun: !apply,
207
+ actions: [{ path: 'memoryLifecycle', action: 'refuse', reason: 'configured root escapes project' }],
208
+ };
209
+ }
210
+
211
+ const consumerManifest = await readJsonIfPresent(
212
+ resolve(projectRoot, 'agent-workflow-kit.json'),
213
+ );
214
+ const source = {
215
+ kitVersion: consumerManifest?.value?.kitVersion ?? 'unknown',
216
+ bundleVersion: consumerManifest
217
+ ? `sha256:${createHash('sha256').update(consumerManifest.raw).digest('hex')}`
218
+ : 'unknown',
219
+ };
220
+ const options = {
221
+ activeRoot,
222
+ archiveRoot,
223
+ receiptRoot,
224
+ candidates: capability.memories ?? [],
225
+ approved: capability.approvals?.restore === true,
226
+ source,
227
+ };
228
+ if (apply) {
229
+ return { state: 'applied', dryRun: false, ...await executeMemoryLifecycle(options) };
230
+ }
231
+ return { state: 'planned', ...await planMemoryLifecycle(options) };
232
+ }
233
+
234
+ async function main(argv) {
235
+ const apply = argv.includes('--apply');
236
+ const profileFlag = argv.indexOf('--profile');
237
+ const profilePath = profileFlag >= 0 ? argv[profileFlag + 1] : undefined;
238
+ const result = await runMemoryLifecycle({ apply, profilePath });
239
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
240
+ }
241
+
242
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
243
+ main(process.argv.slice(2)).catch((error) => {
244
+ process.stderr.write(`${error.message}\n`);
245
+ process.exitCode = 1;
246
+ });
247
+ }
@@ -0,0 +1,170 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, readFile, readdir, symlink, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import {
8
+ executeMemoryLifecycle,
9
+ planMemoryLifecycle,
10
+ runMemoryLifecycle,
11
+ } from './index.mjs';
12
+
13
+ test('dry-run classifies each candidate once without writing', async () => {
14
+ const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
15
+ const activeRoot = join(root, 'active');
16
+ const archiveRoot = join(root, 'archive');
17
+ await mkdir(archiveRoot, { recursive: true });
18
+ await writeFile(join(archiveRoot, 'returning.md'), 'archived memory\n');
19
+
20
+ const plan = await planMemoryLifecycle({
21
+ activeRoot,
22
+ archiveRoot,
23
+ candidates: ['new.md', 'returning.md'],
24
+ approved: true,
25
+ });
26
+
27
+ assert.deepEqual(plan.actions.map(({ path, action }) => [path, action]), [
28
+ ['new.md', 'create'],
29
+ ['returning.md', 'restore'],
30
+ ]);
31
+ await assert.rejects(readFile(join(activeRoot, 'new.md'), 'utf8'), { code: 'ENOENT' });
32
+ await assert.rejects(readFile(join(activeRoot, 'returning.md'), 'utf8'), { code: 'ENOENT' });
33
+ });
34
+
35
+ test('dry-run refuses unsafe roots, collisions, outside paths, and unapproved restores', async () => {
36
+ const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
37
+ const activeRoot = join(root, 'active');
38
+ const archiveRoot = join(root, 'archive');
39
+ await mkdir(activeRoot, { recursive: true });
40
+ await mkdir(archiveRoot, { recursive: true });
41
+ await writeFile(join(activeRoot, 'collision.md'), 'active\n');
42
+ await writeFile(join(archiveRoot, 'collision.md'), 'different archive\n');
43
+ await writeFile(join(archiveRoot, 'approval.md'), 'archived\n');
44
+
45
+ const plan = await planMemoryLifecycle({
46
+ activeRoot,
47
+ archiveRoot,
48
+ candidates: ['../foreign.md', 'collision.md', 'approval.md'],
49
+ });
50
+ assert.deepEqual(plan.actions.map(({ action }) => action), ['refuse', 'refuse', 'refuse']);
51
+
52
+ const linkedActive = join(root, 'linked-active');
53
+ await symlink(activeRoot, linkedActive, 'dir');
54
+ const symlinkPlan = await planMemoryLifecycle({
55
+ activeRoot: linkedActive,
56
+ archiveRoot,
57
+ candidates: ['safe.md'],
58
+ approved: true,
59
+ });
60
+ assert.equal(symlinkPlan.actions[0].action, 'refuse');
61
+ });
62
+
63
+ test('execute restores without removing the archive and writes a content-free receipt', async () => {
64
+ const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
65
+ const activeRoot = join(root, 'active');
66
+ const archiveRoot = join(root, 'archive');
67
+ const receiptRoot = join(root, 'receipts');
68
+ await mkdir(archiveRoot, { recursive: true });
69
+ await writeFile(join(archiveRoot, 'returning.md'), 'private memory contents\n');
70
+
71
+ const result = await executeMemoryLifecycle({
72
+ activeRoot,
73
+ archiveRoot,
74
+ receiptRoot,
75
+ candidates: ['returning.md'],
76
+ approved: true,
77
+ source: { kitVersion: '1.2.3', bundleVersion: 'bundle-abc' },
78
+ });
79
+
80
+ assert.equal(await readFile(join(activeRoot, 'returning.md'), 'utf8'), 'private memory contents\n');
81
+ assert.equal(await readFile(join(archiveRoot, 'returning.md'), 'utf8'), 'private memory contents\n');
82
+ assert.equal(result.verdicts[0].verdict, 'restored');
83
+ const [receiptName] = await readdir(receiptRoot);
84
+ const receiptText = await readFile(join(receiptRoot, receiptName), 'utf8');
85
+ const receipt = JSON.parse(receiptText);
86
+ assert.equal(receipt.schemaVersion, 1);
87
+ assert.deepEqual(receipt.source, { kitVersion: '1.2.3', bundleVersion: 'bundle-abc' });
88
+ assert.match(receipt.verdicts[0].sha256, /^[a-f0-9]{64}$/);
89
+ assert.ok(!receiptText.includes('private memory contents'));
90
+ });
91
+
92
+ test('rerun preserves restored memory and never overwrites or duplicates its receipt', async () => {
93
+ const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
94
+ const activeRoot = join(root, 'active');
95
+ const archiveRoot = join(root, 'archive');
96
+ const receiptRoot = join(root, 'receipts');
97
+ await mkdir(archiveRoot, { recursive: true });
98
+ await writeFile(join(archiveRoot, 'stable.md'), 'stable\n');
99
+ const options = {
100
+ activeRoot,
101
+ archiveRoot,
102
+ receiptRoot,
103
+ candidates: ['stable.md'],
104
+ approved: true,
105
+ source: { kitVersion: '1.2.3', bundleVersion: 'bundle-abc' },
106
+ };
107
+
108
+ const first = await executeMemoryLifecycle(options);
109
+ const receiptBefore = await readFile(first.receiptPath, 'utf8');
110
+ const second = await executeMemoryLifecycle(options);
111
+
112
+ assert.equal(second.verdicts[0].verdict, 'skipped');
113
+ assert.equal(second.receiptPath, null);
114
+ assert.deepEqual(await readdir(receiptRoot), [first.receiptPath.split('/').at(-1)]);
115
+ assert.equal(await readFile(first.receiptPath, 'utf8'), receiptBefore);
116
+ });
117
+
118
+ test('disabled and duplicate candidates produce one explicit skip verdict per path', async () => {
119
+ const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
120
+ const plan = await planMemoryLifecycle({
121
+ activeRoot: join(root, 'active'),
122
+ archiveRoot: join(root, 'archive'),
123
+ candidates: [
124
+ { path: 'later.md', enabled: false },
125
+ { path: 'later.md', enabled: false },
126
+ ],
127
+ approved: true,
128
+ });
129
+
130
+ assert.deepEqual(plan.actions, [{
131
+ path: 'later.md',
132
+ action: 'skip',
133
+ reason: 'candidate is disabled by consumer policy',
134
+ }]);
135
+ });
136
+
137
+ test('missing or disabled consumer profile is a no-write disabled result', async () => {
138
+ const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
139
+
140
+ assert.deepEqual(await runMemoryLifecycle({ projectRoot: root }), {
141
+ state: 'disabled',
142
+ dryRun: true,
143
+ actions: [],
144
+ });
145
+ await assert.rejects(readFile(join(root, '.memory'), 'utf8'), { code: 'ENOENT' });
146
+ });
147
+
148
+ test('frozen Testreporter parity profile classifies all three native memory rows', async () => {
149
+ const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
150
+ const fixture = JSON.parse(await readFile(new URL(
151
+ '../../test/fixtures/memory-lifecycle/testreporter-parity.json',
152
+ import.meta.url,
153
+ )));
154
+ const activeRoot = join(root, fixture.activeRoot);
155
+ const archiveRoot = join(root, fixture.archiveRoot);
156
+ await mkdir(archiveRoot, { recursive: true });
157
+ for (const { path } of fixture.memories) {
158
+ await writeFile(join(archiveRoot, path), `${path}\n`);
159
+ }
160
+
161
+ const plan = await planMemoryLifecycle({
162
+ activeRoot,
163
+ archiveRoot,
164
+ candidates: fixture.memories,
165
+ approved: fixture.approvals.restore,
166
+ });
167
+
168
+ assert.equal(plan.actions.length, 3);
169
+ assert.deepEqual(plan.actions.map(({ action }) => action), ['restore', 'restore', 'restore']);
170
+ });
@@ -62,6 +62,9 @@ export const HELPER_FILES = [
62
62
  { path: 'scripts/census/delta.mjs', kind: 'script', mode: 0o644 },
63
63
  { path: 'scripts/census/state.mjs', kind: 'script', mode: 0o644 },
64
64
  { path: 'scripts/census/transaction.mjs', kind: 'script', mode: 0o644 },
65
+ // Consumer-owned memory planning and recovery. The module is both the stable
66
+ // skill API and the no-write-by-default CLI.
67
+ { path: 'scripts/memory-lifecycle/index.mjs', kind: 'script', mode: 0o644 },
65
68
  // Profile-driven Worktree Lifecycle foundation. The setup adapter imports
66
69
  // core.py, while capabilities.json keeps the historical 8/8 denominator
67
70
  // explicit until the remaining hook and cleanup adapters are activated.