@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.
- package/.agents/skills/memory-lifecycle/SKILL.md +92 -0
- package/.agents/skills/setup-workflow/workflow-overview.md +1 -0
- package/.claude/skills/memory-lifecycle/SKILL.md +92 -0
- package/.claude/skills/setup-workflow/workflow-overview.md +1 -0
- package/.claude/skills/skill-manifest.json +10 -0
- package/PROVENANCE.md +1 -1
- package/README.md +8 -0
- package/agent-workflow-kit.package.json +28 -3
- package/package.json +1 -1
- package/scripts/memory-lifecycle/index.mjs +247 -0
- package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +170 -0
- package/src/lib/bundle.mjs +3 -0
|
@@ -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.
|
|
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": "
|
|
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": "
|
|
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.
|
|
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
|
+
});
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -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.
|