@ikon85/agent-workflow-kit 0.18.0 → 0.20.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 +3 -0
- package/.claude/hooks/branch-context.py +25 -0
- package/.claude/hooks/branch-watch.py +25 -0
- package/.claude/hooks/enforce-worktree-cwd.py +26 -0
- package/.claude/hooks/enforce-worktree-discipline.py +26 -0
- package/.claude/hooks/enforce-worktree.py +26 -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 +20 -0
- package/agent-workflow-kit.package.json +79 -5
- 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/README.md +37 -0
- package/scripts/worktree-lifecycle/core.py +194 -81
- package/scripts/worktree-lifecycle/profile.py +116 -0
- package/src/lib/bundle.mjs +12 -0
|
@@ -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
|
+
# Worktree Lifecycle contract
|
|
2
|
+
|
|
3
|
+
The consumer-owned `docs/agents/workflow-capabilities.json` profile activates
|
|
4
|
+
one Worktree Lifecycle. `profile.py` loads that policy. `core.py` is the only
|
|
5
|
+
place that derives repository facts and decides whether an event emits, allows,
|
|
6
|
+
or blocks. Surface adapters translate hook payloads and render the decision;
|
|
7
|
+
they do not carry a second branch regex, worktree traversal, or failure policy.
|
|
8
|
+
|
|
9
|
+
## Profile
|
|
10
|
+
|
|
11
|
+
`worktreeLifecycle` supports:
|
|
12
|
+
|
|
13
|
+
- `enabled`: explicit activation gate.
|
|
14
|
+
- `worktreeRoot`, `branchTemplate`, `pathTemplate`, and `branchRegex`: consumer
|
|
15
|
+
naming and location policy.
|
|
16
|
+
- `mainBranches` and `protectedBranches`: branches guarded in the main checkout.
|
|
17
|
+
- `setupEntry` and ordered `setupSteps`: the portable setup command and project
|
|
18
|
+
setup sequence.
|
|
19
|
+
- `riskyCommandPatterns`: commands that must target the active linked worktree.
|
|
20
|
+
|
|
21
|
+
Unknown or malformed events fail open without changing repository state.
|
|
22
|
+
Security-sensitive, profile-matched edits and commands fail closed only when
|
|
23
|
+
the core proves the target is unsafe.
|
|
24
|
+
|
|
25
|
+
## Adapters
|
|
26
|
+
|
|
27
|
+
| Adapter | Event | Outcome |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `branch-context.py` | SessionStart | emits branch, issue, status, and active-worktree facts |
|
|
30
|
+
| `branch-watch.py` | PostToolUse | emits the same facts after a branch-changing command |
|
|
31
|
+
| `enforce-worktree.py` | PreToolUse | blocks tracked main-checkout edits and cross-worktree leaks |
|
|
32
|
+
| `enforce-worktree-cwd.py` | PreToolUse | blocks verification or Git mutation in the wrong checkout |
|
|
33
|
+
| `enforce-worktree-discipline.py` | PreToolUse | routes issue-branch creation through the configured setup entry |
|
|
34
|
+
|
|
35
|
+
Claude hook wiring and any Codex adaptation consume this same profile and core.
|
|
36
|
+
An adapter may change only the surface event envelope; it must preserve the
|
|
37
|
+
core verdict and message.
|