@ikon85/agent-workflow-kit 0.17.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/hooks/_hook_utils.py +28 -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 +15 -0
- package/agent-workflow-kit.package.json +50 -4
- package/package.json +1 -1
- package/scripts/memory-lifecycle/index.mjs +247 -0
- package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +170 -0
- package/scripts/worktree-lifecycle/capabilities.json +37 -0
- package/scripts/worktree-lifecycle/core.py +106 -0
- package/scripts/worktree-lifecycle/setup.py +137 -0
- package/src/lib/bundle.mjs +9 -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.
|
|
@@ -21,12 +21,15 @@ Migration notes for hook refactors:
|
|
|
21
21
|
"""
|
|
22
22
|
import re
|
|
23
23
|
import subprocess
|
|
24
|
+
import importlib.util
|
|
25
|
+
import sys
|
|
24
26
|
from datetime import datetime
|
|
25
27
|
from pathlib import Path
|
|
26
28
|
|
|
27
29
|
# Resolved relative to CWD at call time. Hooks are invoked from repo root
|
|
28
30
|
# (or from a linked worktree root), so this resolves to <root>/.claude/logs.
|
|
29
31
|
LOG_DIR = Path(".claude/logs")
|
|
32
|
+
_WORKTREE_CORE_MODULE = "_agent_workflow_kit_worktree_lifecycle"
|
|
30
33
|
|
|
31
34
|
|
|
32
35
|
def rotate_log_if_needed(log_path: Path, max_bytes: int = 100_000, generations: int = 3) -> None:
|
|
@@ -182,3 +185,28 @@ def normalize_to_repo_relative(file_path: str, root: str) -> str | None:
|
|
|
182
185
|
return str(rel)
|
|
183
186
|
except ValueError:
|
|
184
187
|
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_worktree_lifecycle_core():
|
|
191
|
+
"""Load the shipped Worktree Lifecycle core without requiring a Python package."""
|
|
192
|
+
existing = sys.modules.get(_WORKTREE_CORE_MODULE)
|
|
193
|
+
if existing is not None:
|
|
194
|
+
return existing
|
|
195
|
+
path = Path(__file__).resolve().parents[2] / "scripts" / "worktree-lifecycle" / "core.py"
|
|
196
|
+
spec = importlib.util.spec_from_file_location(_WORKTREE_CORE_MODULE, path)
|
|
197
|
+
if spec is None or spec.loader is None:
|
|
198
|
+
raise ImportError(f"cannot load Worktree Lifecycle core from {path}")
|
|
199
|
+
module = importlib.util.module_from_spec(spec)
|
|
200
|
+
sys.modules[_WORKTREE_CORE_MODULE] = module
|
|
201
|
+
spec.loader.exec_module(module)
|
|
202
|
+
return module
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def hook_event_output(event_name: str, context: str) -> dict:
|
|
206
|
+
"""Canonical non-blocking context payload for Claude hook events."""
|
|
207
|
+
return {
|
|
208
|
+
"hookSpecificOutput": {
|
|
209
|
+
"hookEventName": event_name,
|
|
210
|
+
"additionalContext": context,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -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,21 @@ 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
|
+
|
|
343
|
+
### 0.18.0
|
|
344
|
+
|
|
345
|
+
- added: `scripts/worktree-lifecycle/capabilities.json`
|
|
346
|
+
- added: `scripts/worktree-lifecycle/core.py`
|
|
347
|
+
- added: `scripts/worktree-lifecycle/setup.py`
|
|
348
|
+
- changed: `.claude/hooks/_hook_utils.py`
|
|
349
|
+
|
|
335
350
|
### 0.17.0
|
|
336
351
|
|
|
337
352
|
- added: `src/lib/release-preview.mjs`
|
|
@@ -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
|
},
|
|
@@ -850,7 +859,7 @@
|
|
|
850
859
|
{
|
|
851
860
|
"path": ".claude/hooks/_hook_utils.py",
|
|
852
861
|
"kind": "hook",
|
|
853
|
-
"sha256": "
|
|
862
|
+
"sha256": "4a4b57633578ee8eabdf8ae70437e590e1747d1df5d76c730e2164e23cdfc2ed",
|
|
854
863
|
"mode": 420,
|
|
855
864
|
"origin": "kit"
|
|
856
865
|
},
|
|
@@ -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",
|
|
@@ -2063,6 +2088,27 @@
|
|
|
2063
2088
|
"mode": 420,
|
|
2064
2089
|
"origin": "kit"
|
|
2065
2090
|
},
|
|
2091
|
+
{
|
|
2092
|
+
"path": "scripts/worktree-lifecycle/capabilities.json",
|
|
2093
|
+
"kind": "doc",
|
|
2094
|
+
"sha256": "3e51acaf2a8ac3ace0a400e23a30732ff0c3e9b4864f763c7abf82019605f309",
|
|
2095
|
+
"mode": 420,
|
|
2096
|
+
"origin": "kit"
|
|
2097
|
+
},
|
|
2098
|
+
{
|
|
2099
|
+
"path": "scripts/worktree-lifecycle/core.py",
|
|
2100
|
+
"kind": "script",
|
|
2101
|
+
"sha256": "eae5c6076611a36a8fa90e3aef3d1c49345110a392a7d7e9d95be8de00f8d9ff",
|
|
2102
|
+
"mode": 420,
|
|
2103
|
+
"origin": "kit"
|
|
2104
|
+
},
|
|
2105
|
+
{
|
|
2106
|
+
"path": "scripts/worktree-lifecycle/setup.py",
|
|
2107
|
+
"kind": "script",
|
|
2108
|
+
"sha256": "c720cbff9123f136a53658671d880b9a73f9da4faa0611e37a2a981fa89cda92",
|
|
2109
|
+
"mode": 493,
|
|
2110
|
+
"origin": "kit"
|
|
2111
|
+
},
|
|
2066
2112
|
{
|
|
2067
2113
|
"path": "scripts/wrapup-land.py",
|
|
2068
2114
|
"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
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"capabilities": [
|
|
4
|
+
{
|
|
5
|
+
"historicalPath": "scripts/setup-worktree.sh",
|
|
6
|
+
"primitives": ["profile", "git-facts", "transactional-setup"]
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"historicalPath": ".claude/hooks/branch-context.py",
|
|
10
|
+
"primitives": ["profile", "git-facts", "hook-event-output"]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"historicalPath": ".claude/hooks/branch-watch.py",
|
|
14
|
+
"primitives": ["profile", "git-facts", "hook-event-output"]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"historicalPath": ".claude/hooks/enforce-worktree.py",
|
|
18
|
+
"primitives": ["profile", "git-facts", "worktree-decision"]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"historicalPath": ".claude/hooks/enforce-worktree-cwd.py",
|
|
22
|
+
"primitives": ["profile", "git-facts", "worktree-decision"]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"historicalPath": ".claude/hooks/enforce-worktree-discipline.py",
|
|
26
|
+
"primitives": ["profile", "git-facts", "worktree-decision"]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"historicalPath": ".claude/hooks/slice-handoff-hint.py",
|
|
30
|
+
"primitives": ["profile", "git-facts", "hook-event-output"]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"historicalPath": "scripts/cleanup-worktrees.sh",
|
|
34
|
+
"primitives": ["profile", "git-facts", "cleanup-safety"]
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Consumer-neutral worktree lifecycle profile and git facts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LifecycleError(RuntimeError):
|
|
14
|
+
"""A safe, user-visible lifecycle refusal."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class WorktreeProfile:
|
|
19
|
+
root: str
|
|
20
|
+
branch_template: str
|
|
21
|
+
path_template: str
|
|
22
|
+
main_branches: tuple[str, ...]
|
|
23
|
+
protected_branches: tuple[str, ...]
|
|
24
|
+
setup_steps: tuple[dict[str, Any], ...]
|
|
25
|
+
branch_regex: str
|
|
26
|
+
|
|
27
|
+
def branch_name(self, issue: str, slug: str, branch_type: str) -> str:
|
|
28
|
+
return _render(self.branch_template, issue, slug, branch_type)
|
|
29
|
+
|
|
30
|
+
def relative_path(self, issue: str, slug: str, branch_type: str) -> Path:
|
|
31
|
+
name = _render(self.path_template, issue, slug, branch_type)
|
|
32
|
+
return Path(self.root) / name
|
|
33
|
+
|
|
34
|
+
def issue_from_branch(self, branch: str) -> str | None:
|
|
35
|
+
match = re.match(self.branch_regex, branch)
|
|
36
|
+
return match.groupdict().get("issue") if match else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _render(template: str, issue: str, slug: str, branch_type: str) -> str:
|
|
40
|
+
values = {"issue": issue, "slug": slug, "type": branch_type}
|
|
41
|
+
try:
|
|
42
|
+
return template.format(**values)
|
|
43
|
+
except (KeyError, ValueError) as error:
|
|
44
|
+
raise LifecycleError(f"invalid worktree template: {error}") from error
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_profile(path: Path) -> WorktreeProfile:
|
|
48
|
+
try:
|
|
49
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
raw = document["worktreeLifecycle"]
|
|
51
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
52
|
+
raise LifecycleError(f"cannot load worktree lifecycle profile: {error}") from error
|
|
53
|
+
if raw.get("enabled") is not True:
|
|
54
|
+
raise LifecycleError("worktree lifecycle is not enabled")
|
|
55
|
+
main = tuple(raw.get("mainBranches") or ("main", "master"))
|
|
56
|
+
return WorktreeProfile(
|
|
57
|
+
root=raw.get("worktreeRoot", ".worktrees"),
|
|
58
|
+
branch_template=raw.get("branchTemplate", "{type}/{issue}-{slug}"),
|
|
59
|
+
path_template=raw.get("pathTemplate", "{type}-{issue}-{slug}"),
|
|
60
|
+
main_branches=main,
|
|
61
|
+
protected_branches=tuple(raw.get("protectedBranches") or main),
|
|
62
|
+
setup_steps=tuple(raw.get("setupSteps") or ()),
|
|
63
|
+
branch_regex=raw.get(
|
|
64
|
+
"branchRegex",
|
|
65
|
+
r"^(?:feat|fix|chore|docs)/(?P<issue>\d+)-",
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run(
|
|
71
|
+
command: list[str],
|
|
72
|
+
*,
|
|
73
|
+
cwd: Path,
|
|
74
|
+
check: bool = True,
|
|
75
|
+
) -> subprocess.CompletedProcess[str]:
|
|
76
|
+
result = subprocess.run(command, cwd=cwd, capture_output=True, text=True)
|
|
77
|
+
if check and result.returncode != 0:
|
|
78
|
+
detail = (result.stderr or result.stdout).strip()
|
|
79
|
+
raise LifecycleError(f"{' '.join(command)} failed: {detail}")
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main_worktree(cwd: Path) -> Path:
|
|
84
|
+
output = run(["git", "worktree", "list", "--porcelain"], cwd=cwd).stdout
|
|
85
|
+
first = next((line for line in output.splitlines() if line.startswith("worktree ")), "")
|
|
86
|
+
if not first:
|
|
87
|
+
raise LifecycleError("not inside a git worktree")
|
|
88
|
+
return Path(first.split(" ", 1)[1]).resolve()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def registered_worktrees(cwd: Path) -> set[Path]:
|
|
92
|
+
output = run(["git", "worktree", "list", "--porcelain"], cwd=cwd).stdout
|
|
93
|
+
return {
|
|
94
|
+
Path(line.split(" ", 1)[1]).resolve()
|
|
95
|
+
for line in output.splitlines()
|
|
96
|
+
if line.startswith("worktree ")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def local_branch_exists(cwd: Path, branch: str) -> bool:
|
|
101
|
+
result = run(
|
|
102
|
+
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
|
|
103
|
+
cwd=cwd,
|
|
104
|
+
check=False,
|
|
105
|
+
)
|
|
106
|
+
return result.returncode == 0
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a configured, rollback-safe consumer worktree."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import zlib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from core import (
|
|
13
|
+
LifecycleError,
|
|
14
|
+
load_profile,
|
|
15
|
+
local_branch_exists,
|
|
16
|
+
main_worktree,
|
|
17
|
+
registered_worktrees,
|
|
18
|
+
run,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_args() -> argparse.Namespace:
|
|
23
|
+
parser = argparse.ArgumentParser()
|
|
24
|
+
parser.add_argument("--profile", default="docs/agents/workflow-capabilities.json")
|
|
25
|
+
parser.add_argument("--base", default="origin/main")
|
|
26
|
+
parser.add_argument("issue")
|
|
27
|
+
parser.add_argument("slug")
|
|
28
|
+
parser.add_argument("branch_type", nargs="?", default="feat")
|
|
29
|
+
return parser.parse_args()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def port_slot(step: dict, issue: str, branch: str) -> int:
|
|
33
|
+
modulo = int(step.get("slotModulo", 900))
|
|
34
|
+
offset = int(step.get("slotOffset", 1))
|
|
35
|
+
seed = int(issue) if issue.isdigit() else zlib.crc32(branch.encode("utf-8"))
|
|
36
|
+
return seed % modulo + offset
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_ports(step: dict, *, worktree: Path, issue: str, branch: str) -> None:
|
|
40
|
+
slot = port_slot(step, issue, branch)
|
|
41
|
+
stride = int(step.get("stride", 10))
|
|
42
|
+
outputs = step.get("outputs") or {}
|
|
43
|
+
if not outputs:
|
|
44
|
+
raise LifecycleError("ports step requires outputs")
|
|
45
|
+
unsafe = {int(port) for port in step.get("unsafePorts", ())}
|
|
46
|
+
modulo = int(step.get("slotModulo", 900))
|
|
47
|
+
for _ in range(modulo):
|
|
48
|
+
ports = [int(base) + slot * stride for base in outputs.values()]
|
|
49
|
+
if not unsafe.intersection(ports):
|
|
50
|
+
break
|
|
51
|
+
slot = slot % modulo + 1
|
|
52
|
+
else:
|
|
53
|
+
raise LifecycleError("port profile has no safe allocation")
|
|
54
|
+
lines = [f"{name}={int(base) + slot * stride}" for name, base in outputs.items()]
|
|
55
|
+
(worktree / ".dev-ports").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def execute_step(
|
|
59
|
+
step: dict,
|
|
60
|
+
*,
|
|
61
|
+
main: Path,
|
|
62
|
+
worktree: Path,
|
|
63
|
+
issue: str,
|
|
64
|
+
branch: str,
|
|
65
|
+
) -> None:
|
|
66
|
+
kind = step.get("kind")
|
|
67
|
+
if kind == "copy":
|
|
68
|
+
source = main / step["source"]
|
|
69
|
+
target = worktree / step.get("target", step["source"])
|
|
70
|
+
if not source.exists() and step.get("optional"):
|
|
71
|
+
return
|
|
72
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
shutil.copy2(source, target)
|
|
74
|
+
return
|
|
75
|
+
if kind == "command":
|
|
76
|
+
command = step.get("command")
|
|
77
|
+
if not isinstance(command, list) or not all(isinstance(part, str) for part in command):
|
|
78
|
+
raise LifecycleError("setup command must be a JSON string array")
|
|
79
|
+
run(command, cwd=worktree)
|
|
80
|
+
return
|
|
81
|
+
if kind == "ports":
|
|
82
|
+
write_ports(step, worktree=worktree, issue=issue, branch=branch)
|
|
83
|
+
return
|
|
84
|
+
raise LifecycleError(f"unsupported setup step kind: {kind!r}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def remove_failed_worktree(main: Path, target: Path, branch: str, created_branch: bool) -> None:
|
|
88
|
+
run(["git", "worktree", "remove", "--force", str(target)], cwd=main, check=False)
|
|
89
|
+
if created_branch:
|
|
90
|
+
run(["git", "branch", "-d", branch], cwd=main, check=False)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def create(args: argparse.Namespace) -> Path:
|
|
94
|
+
cwd = Path.cwd()
|
|
95
|
+
main = main_worktree(cwd)
|
|
96
|
+
profile_path = Path(args.profile)
|
|
97
|
+
if not profile_path.is_absolute():
|
|
98
|
+
profile_path = main / profile_path
|
|
99
|
+
profile = load_profile(profile_path)
|
|
100
|
+
branch = profile.branch_name(args.issue, args.slug, args.branch_type)
|
|
101
|
+
target = (main / profile.relative_path(args.issue, args.slug, args.branch_type)).resolve()
|
|
102
|
+
|
|
103
|
+
if target in registered_worktrees(main):
|
|
104
|
+
print(f"Worktree already exists: {target} ({branch})")
|
|
105
|
+
return target
|
|
106
|
+
|
|
107
|
+
branch_existed = local_branch_exists(main, branch)
|
|
108
|
+
command = ["git", "worktree", "add", str(target)]
|
|
109
|
+
command += [branch] if branch_existed else ["-b", branch, args.base]
|
|
110
|
+
run(command, cwd=main)
|
|
111
|
+
try:
|
|
112
|
+
for step in profile.setup_steps:
|
|
113
|
+
execute_step(
|
|
114
|
+
step,
|
|
115
|
+
main=main,
|
|
116
|
+
worktree=target,
|
|
117
|
+
issue=args.issue,
|
|
118
|
+
branch=branch,
|
|
119
|
+
)
|
|
120
|
+
except Exception:
|
|
121
|
+
remove_failed_worktree(main, target, branch, not branch_existed)
|
|
122
|
+
raise
|
|
123
|
+
print(f"Worktree ready: {target} ({branch})")
|
|
124
|
+
return target
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main() -> int:
|
|
128
|
+
try:
|
|
129
|
+
create(parse_args())
|
|
130
|
+
return 0
|
|
131
|
+
except LifecycleError as error:
|
|
132
|
+
print(f"STOP: {error}", file=sys.stderr)
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
raise SystemExit(main())
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -62,6 +62,15 @@ 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 },
|
|
68
|
+
// Profile-driven Worktree Lifecycle foundation. The setup adapter imports
|
|
69
|
+
// core.py, while capabilities.json keeps the historical 8/8 denominator
|
|
70
|
+
// explicit until the remaining hook and cleanup adapters are activated.
|
|
71
|
+
{ path: 'scripts/worktree-lifecycle/core.py', kind: 'script', mode: 0o644 },
|
|
72
|
+
{ path: 'scripts/worktree-lifecycle/setup.py', kind: 'script', mode: 0o755 },
|
|
73
|
+
{ path: 'scripts/worktree-lifecycle/capabilities.json', kind: 'doc', mode: 0o644 },
|
|
65
74
|
// Shared hook utility imported by the shipped hooks (drift-guard,
|
|
66
75
|
// sync-board-status). Library (imported, not run) → 0o644. MUST ship or those
|
|
67
76
|
// hooks ImportError on arrival.
|