phronesis 1.0.0 → 1.1.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/package.json +1 -1
- package/src/act.js +62 -1
- package/src/adapter.js +366 -61
- package/src/cli.js +527 -32
- package/src/compile.js +141 -10
- package/src/doctor.js +63 -2
- package/src/hook.js +312 -0
- package/src/hooks-refresh.js +1363 -0
- package/src/hooks.js +48 -33
- package/src/init.js +37 -4
- package/src/launcher.js +2105 -0
- package/src/layout.js +51 -7
- package/src/profile.js +169 -0
- package/src/prompts.js +88 -0
- package/src/skills-bump.js +88 -20
- package/src/skills.js +103 -10
- package/templates/codex/INDEX.md +4 -2
- package/templates/codex/seed/ai-practice/the-workspace-is-part-of-the-thinking.md +96 -0
- package/templates/codex-surface/README.md +15 -16
- package/templates/codex-surface/hooks/_resolve.sh +25 -24
- package/templates/codex-surface/hooks/recall-guard.sh +5 -30
- package/templates/codex-surface/hooks/session-start.sh +5 -18
- package/templates/codex-surface/hooks/session-sweep-precompact.sh +5 -20
- package/templates/codex-surface/hooks/session-sweep-subagent.sh +5 -23
- package/templates/codex-surface/hooks/session-sweep.sh +5 -19
- package/templates/launcher/phronesis +95 -0
- package/templates/phronesis-hooks/compile-active-context.sh +12 -38
- package/templates/phronesis-hooks/recall-guard.sh +12 -103
- package/templates/phronesis-hooks/session-start.sh +10 -88
- package/templates/phronesis-hooks/session-sweep.sh +16 -139
- package/templates/phronesis-hooks/skill-lifecycle.sh +12 -37
- package/templates/skills/lint/SKILL.md +7 -2
- package/templates/skills/onboard/SKILL.md +47 -9
- package/templates/skills/onboard/evals/rubric.md +5 -3
- package/templates/skills/prd-draft/SKILL.md +34 -26
- package/templates/skills/prd-draft/evals/rubric.md +1 -1
- package/vendor/core/src/action-registry.js +6 -3
- package/vendor/core/src/actions.js +120 -16
- package/vendor/core/src/index.js +3 -0
- package/vendor/core/src/lint.js +22 -5
- package/vendor/core/src/skill-lifecycle.js +67 -7
|
@@ -0,0 +1,1363 @@
|
|
|
1
|
+
// `phronesis hooks refresh` — the narrow, approval-gated D9 migration slice.
|
|
2
|
+
//
|
|
3
|
+
// The manifest comes from layout.js's init copies. A missing global 0047 baseline
|
|
4
|
+
// does not change this command's posture: without a hook-scoped entry, drift is
|
|
5
|
+
// unattributable and requires explicit approval. This module owns only
|
|
6
|
+
// .phronesis/hook-stamp.json and never treats any broader install baseline as authority.
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { lstat, mkdir, readdir, realpath, unlink } from 'node:fs/promises';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { RE_SLUG, resolveInstall } from '@phronesis/core';
|
|
15
|
+
import { hookManifestCopies } from './layout.js';
|
|
16
|
+
import {
|
|
17
|
+
inspectLauncher,
|
|
18
|
+
readRegularFileNoFollow,
|
|
19
|
+
runLauncherInstall,
|
|
20
|
+
writeAtomicFileNoFollow,
|
|
21
|
+
} from './launcher.js';
|
|
22
|
+
import {
|
|
23
|
+
confirmHookRestamp,
|
|
24
|
+
confirmPathAppend,
|
|
25
|
+
confirmPersistence,
|
|
26
|
+
PERSISTENCE_PROMPT,
|
|
27
|
+
} from './prompts.js';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const { version: CLI_VERSION } = require('../package.json');
|
|
31
|
+
const TEMPLATES_DIR = fileURLToPath(new URL('../templates/', import.meta.url));
|
|
32
|
+
const HOOK_STAMP_REL = '.phronesis/hook-stamp.json';
|
|
33
|
+
const SHA256_RE = /^[a-f0-9]{64}$/;
|
|
34
|
+
const STALE_HOOK_TEMP_AGE_MS = 60 * 1000;
|
|
35
|
+
|
|
36
|
+
function sha256(bytes) {
|
|
37
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pathError(message) {
|
|
41
|
+
return new Error(`hooks refresh: ${message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function changedSinceReviewError(path, detail) {
|
|
45
|
+
const error = pathError(`changed-since-review ${path}: ${detail}`);
|
|
46
|
+
error.code = 'PHR_HOOK_CHANGED_SINCE_REVIEW';
|
|
47
|
+
error.path = path;
|
|
48
|
+
error.detail = detail;
|
|
49
|
+
return error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateRelativePath(path, label = 'path') {
|
|
53
|
+
if (typeof path !== 'string' || !path || isAbsolute(path)) {
|
|
54
|
+
throw pathError(`${label} must be a non-empty install-relative path`);
|
|
55
|
+
}
|
|
56
|
+
const parts = path.split('/');
|
|
57
|
+
if (parts.some((part) => !part || part === '.' || part === '..' || part.includes('\\'))) {
|
|
58
|
+
throw pathError(`${label} is not a normalized install-relative path: ${path}`);
|
|
59
|
+
}
|
|
60
|
+
return path;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function expectedUid() {
|
|
64
|
+
return typeof process.getuid === 'function' ? process.getuid() : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assertOwned(path, state, label = 'managed path') {
|
|
68
|
+
const uid = expectedUid();
|
|
69
|
+
if (state.present && uid !== null && state.uid !== uid) {
|
|
70
|
+
throw pathError(
|
|
71
|
+
`refusing ${label} owned by uid ${state.uid}; current uid is ${uid}: ${path}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function stateAt(path) {
|
|
77
|
+
try {
|
|
78
|
+
const value = await lstat(path);
|
|
79
|
+
return {
|
|
80
|
+
present: true,
|
|
81
|
+
symlink: value.isSymbolicLink(),
|
|
82
|
+
file: value.isFile(),
|
|
83
|
+
directory: value.isDirectory(),
|
|
84
|
+
mode: value.mode,
|
|
85
|
+
uid: value.uid,
|
|
86
|
+
mtimeMs: value.mtimeMs,
|
|
87
|
+
};
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error?.code === 'ENOENT') {
|
|
90
|
+
return {
|
|
91
|
+
present: false,
|
|
92
|
+
symlink: false,
|
|
93
|
+
file: false,
|
|
94
|
+
directory: false,
|
|
95
|
+
mode: 0,
|
|
96
|
+
uid: null,
|
|
97
|
+
mtimeMs: null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function inspectInstallPath(root, relPath) {
|
|
105
|
+
validateRelativePath(relPath);
|
|
106
|
+
const parts = relPath.split('/');
|
|
107
|
+
let current = root;
|
|
108
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
109
|
+
current = join(current, parts[index]);
|
|
110
|
+
const state = await stateAt(current);
|
|
111
|
+
if (!state.present) return { present: false, path: current, missingAt: parts.slice(0, index + 1).join('/') };
|
|
112
|
+
if (state.symlink) {
|
|
113
|
+
return { present: true, unsafe: true, path: current, detail: `symlinked path component ${parts.slice(0, index + 1).join('/')}` };
|
|
114
|
+
}
|
|
115
|
+
if (index < parts.length - 1 && !state.directory) {
|
|
116
|
+
return { present: true, unsafe: true, path: current, detail: `non-directory path component ${parts.slice(0, index + 1).join('/')}` };
|
|
117
|
+
}
|
|
118
|
+
if (index === parts.length - 1) return { ...state, path: current };
|
|
119
|
+
}
|
|
120
|
+
throw pathError(`could not inspect ${relPath}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function ensureParentDirectoriesNoFollow(root, relPath) {
|
|
124
|
+
const parentRel = dirname(relPath);
|
|
125
|
+
if (parentRel === '.') return;
|
|
126
|
+
let current = root;
|
|
127
|
+
for (const part of parentRel.split('/')) {
|
|
128
|
+
current = join(current, part);
|
|
129
|
+
let state = await stateAt(current);
|
|
130
|
+
if (!state.present) {
|
|
131
|
+
try {
|
|
132
|
+
await mkdir(current, { mode: 0o755 });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
135
|
+
}
|
|
136
|
+
state = await stateAt(current);
|
|
137
|
+
}
|
|
138
|
+
assertOwned(current, state, 'managed parent directory');
|
|
139
|
+
if (state.symlink || !state.directory) {
|
|
140
|
+
throw pathError(`refusing non-directory or symlinked managed parent: ${current}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function shellCommandWords(command) {
|
|
146
|
+
if (typeof command !== 'string') return null;
|
|
147
|
+
const words = [];
|
|
148
|
+
let word = '';
|
|
149
|
+
let started = false;
|
|
150
|
+
let quote = null;
|
|
151
|
+
|
|
152
|
+
function finishWord() {
|
|
153
|
+
if (!started) return;
|
|
154
|
+
words.push(word);
|
|
155
|
+
word = '';
|
|
156
|
+
started = false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
160
|
+
const character = command[index];
|
|
161
|
+
if (quote === "'") {
|
|
162
|
+
if (character === "'") quote = null;
|
|
163
|
+
else word += character;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (quote === '"') {
|
|
167
|
+
if (character === '"') {
|
|
168
|
+
quote = null;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (character === '\\') {
|
|
172
|
+
index += 1;
|
|
173
|
+
if (index >= command.length) return null;
|
|
174
|
+
word += command[index];
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (character === '`' || (character === '$' && command[index + 1] === '(')) return null;
|
|
178
|
+
word += character;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (character === "'" || character === '"') {
|
|
182
|
+
quote = character;
|
|
183
|
+
started = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (character === '\\') {
|
|
187
|
+
index += 1;
|
|
188
|
+
if (index >= command.length) return null;
|
|
189
|
+
word += command[index];
|
|
190
|
+
started = true;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (character === '`' || (character === '$' && command[index + 1] === '(')) return null;
|
|
194
|
+
if (/\s/.test(character)) {
|
|
195
|
+
finishWord();
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (';|&<>'.includes(character)) {
|
|
199
|
+
finishWord();
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
word += character;
|
|
203
|
+
started = true;
|
|
204
|
+
}
|
|
205
|
+
if (quote) return null;
|
|
206
|
+
finishWord();
|
|
207
|
+
return words;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function primaryInvokedWords(words, depth) {
|
|
211
|
+
if (depth > 4 || !words?.length) return null;
|
|
212
|
+
let index = 0;
|
|
213
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index] || '')) index += 1;
|
|
214
|
+
if (index >= words.length) return null;
|
|
215
|
+
|
|
216
|
+
const invokedToken = words[index];
|
|
217
|
+
// Wrapper recognition is exact, never basename-derived. In particular, an
|
|
218
|
+
// operator executable such as scripts/sh or ./env must remain the primary
|
|
219
|
+
// target instead of borrowing semantics from the system command with the same
|
|
220
|
+
// basename. /usr/bin/env is the one explicit canonical system path supported
|
|
221
|
+
// by Claude hook emissions and migration tests.
|
|
222
|
+
const executable = invokedToken === '/usr/bin/env'
|
|
223
|
+
? 'env'
|
|
224
|
+
: invokedToken.includes('/') ? null : invokedToken;
|
|
225
|
+
if (executable === 'env') {
|
|
226
|
+
index += 1;
|
|
227
|
+
while (index < words.length) {
|
|
228
|
+
if (words[index] === '--') {
|
|
229
|
+
index += 1;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
if (words[index] === '-S' || words[index] === '--split-string') {
|
|
233
|
+
return primaryInvokedScript(words[index + 1], depth + 1);
|
|
234
|
+
}
|
|
235
|
+
if (words[index].startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index])) {
|
|
236
|
+
index += 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
return primaryInvokedWords(words.slice(index), depth + 1);
|
|
242
|
+
}
|
|
243
|
+
if (['command', 'exec', 'nohup'].includes(executable)) {
|
|
244
|
+
index += 1;
|
|
245
|
+
while (words[index]?.startsWith('-')) index += 1;
|
|
246
|
+
return primaryInvokedWords(words.slice(index), depth + 1);
|
|
247
|
+
}
|
|
248
|
+
if (['sh', 'bash', 'dash', 'zsh'].includes(executable)) {
|
|
249
|
+
index += 1;
|
|
250
|
+
while (index < words.length) {
|
|
251
|
+
if (words[index] === '-c') return primaryInvokedScript(words[index + 1], depth + 1);
|
|
252
|
+
if (words[index] === '--') {
|
|
253
|
+
index += 1;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
if (words[index].startsWith('-')) {
|
|
257
|
+
index += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
return words[index] || null;
|
|
263
|
+
}
|
|
264
|
+
return invokedToken;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function primaryInvokedScript(command, depth = 0) {
|
|
268
|
+
return primaryInvokedWords(shellCommandWords(command), depth);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function managedTargetForCommand(command, managedTargets, root) {
|
|
272
|
+
const invoked = primaryInvokedScript(command);
|
|
273
|
+
// Conservative attribution is intentional: an unresolvable/normalized command
|
|
274
|
+
// is preserved as operator-owned. The residual failure direction is a duplicate,
|
|
275
|
+
// never deletion of an operator hook.
|
|
276
|
+
if (!invoked) return null;
|
|
277
|
+
for (const target of managedTargets) {
|
|
278
|
+
const variants = new Set([
|
|
279
|
+
target,
|
|
280
|
+
`./${target}`,
|
|
281
|
+
`$CLAUDE_PROJECT_DIR/${target}`,
|
|
282
|
+
`\${CLAUDE_PROJECT_DIR}/${target}`,
|
|
283
|
+
join(root, target),
|
|
284
|
+
]);
|
|
285
|
+
if (variants.has(invoked)) return target;
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function managedClaudeHook(hook, managedTargets, root) {
|
|
291
|
+
return managedTargetForCommand(hook?.command, managedTargets, root);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function managedClaudeEntries(settings, managedTargets, root) {
|
|
295
|
+
const entries = [];
|
|
296
|
+
const hooks = settings?.hooks;
|
|
297
|
+
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return entries;
|
|
298
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
299
|
+
if (!Array.isArray(groups)) continue;
|
|
300
|
+
for (const group of groups) {
|
|
301
|
+
if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) continue;
|
|
302
|
+
for (const hook of group.hooks) {
|
|
303
|
+
const target = managedClaudeHook(hook, managedTargets, root);
|
|
304
|
+
if (target) entries.push({ event, target, command: hook.command });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return entries;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function managedClaudeBlock(settings, managedTargets, root) {
|
|
312
|
+
const hooks = settings?.hooks;
|
|
313
|
+
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return {};
|
|
314
|
+
const block = {};
|
|
315
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
316
|
+
if (!Array.isArray(groups)) continue;
|
|
317
|
+
const managedGroups = [];
|
|
318
|
+
for (const group of groups) {
|
|
319
|
+
if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) continue;
|
|
320
|
+
const managedHooks = group.hooks.filter((hook) =>
|
|
321
|
+
managedClaudeHook(hook, managedTargets, root));
|
|
322
|
+
if (managedHooks.length) managedGroups.push({ ...group, hooks: managedHooks });
|
|
323
|
+
}
|
|
324
|
+
if (managedGroups.length) block[event] = managedGroups;
|
|
325
|
+
}
|
|
326
|
+
return block;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function managedBlockBytes(block) {
|
|
330
|
+
return Buffer.from(JSON.stringify(block), 'utf8');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function mergeClaudeHookEntries(liveSettings, expectedBlock, managedTargets, root) {
|
|
334
|
+
if (!liveSettings || typeof liveSettings !== 'object' || Array.isArray(liveSettings)) {
|
|
335
|
+
throw pathError('.claude/settings.json must contain a JSON object; refusing whole-file replacement');
|
|
336
|
+
}
|
|
337
|
+
if (
|
|
338
|
+
Object.hasOwn(liveSettings, 'hooks')
|
|
339
|
+
&& (liveSettings.hooks === null || typeof liveSettings.hooks !== 'object' || Array.isArray(liveSettings.hooks))
|
|
340
|
+
) {
|
|
341
|
+
throw pathError('.claude/settings.json hooks must be an object; refusing to discard operator settings');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const preservedHooks = {};
|
|
345
|
+
for (const [event, groups] of Object.entries(liveSettings.hooks || {})) {
|
|
346
|
+
if (!Array.isArray(groups)) {
|
|
347
|
+
if (Object.hasOwn(expectedBlock, event)) {
|
|
348
|
+
throw pathError(
|
|
349
|
+
`.claude/settings.json hooks.${event} is not an array; refusing to overwrite an unmergeable operator value`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
preservedHooks[event] = groups;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const preservedGroups = [];
|
|
356
|
+
for (const group of groups) {
|
|
357
|
+
if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) {
|
|
358
|
+
preservedGroups.push(group);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const operatorHooks = group.hooks.filter((hook) =>
|
|
362
|
+
!managedClaudeHook(hook, managedTargets, root));
|
|
363
|
+
if (operatorHooks.length === group.hooks.length) preservedGroups.push(group);
|
|
364
|
+
else if (operatorHooks.length) preservedGroups.push({ ...group, hooks: operatorHooks });
|
|
365
|
+
}
|
|
366
|
+
if (preservedGroups.length) preservedHooks[event] = preservedGroups;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Re-emit managed events in the shipped block's order. Assigning over keys in
|
|
370
|
+
// live order would preserve a historical/operator key order and make an approved
|
|
371
|
+
// refresh report `differs` forever even though every managed entry was replaced.
|
|
372
|
+
const nextHooks = {};
|
|
373
|
+
for (const [event, groups] of Object.entries(expectedBlock)) {
|
|
374
|
+
nextHooks[event] = [...groups, ...(preservedHooks[event] || [])];
|
|
375
|
+
}
|
|
376
|
+
for (const [event, groups] of Object.entries(preservedHooks)) {
|
|
377
|
+
if (!Object.hasOwn(expectedBlock, event)) nextHooks[event] = groups;
|
|
378
|
+
}
|
|
379
|
+
return { ...liveSettings, hooks: nextHooks };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function parseJson(bytes, label) {
|
|
383
|
+
try {
|
|
384
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
385
|
+
} catch (error) {
|
|
386
|
+
throw pathError(`${label} is not valid JSON: ${error.message}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// JSON.parse silently keeps the last duplicate object key. Class-C surgery cannot
|
|
391
|
+
// safely round-trip that input because doing so would erase operator bytes without
|
|
392
|
+
// any indication that two values existed. This small grammar pass rejects duplicates
|
|
393
|
+
// at every object depth before the normal JSON parser is allowed to construct values.
|
|
394
|
+
function assertNoDuplicateJsonKeys(bytes, label) {
|
|
395
|
+
const source = bytes.toString('utf8');
|
|
396
|
+
let index = 0;
|
|
397
|
+
|
|
398
|
+
function fail(message) {
|
|
399
|
+
throw pathError(`${label} ${message}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function skipWhitespace() {
|
|
403
|
+
while (/[\t\n\r ]/.test(source[index] || '')) index += 1;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function parseStringToken() {
|
|
407
|
+
if (source[index] !== '"') fail(`has invalid JSON at byte ${index}`);
|
|
408
|
+
const start = index;
|
|
409
|
+
index += 1;
|
|
410
|
+
let escaped = false;
|
|
411
|
+
while (index < source.length) {
|
|
412
|
+
const character = source[index];
|
|
413
|
+
index += 1;
|
|
414
|
+
if (escaped) {
|
|
415
|
+
escaped = false;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (character === '\\') {
|
|
419
|
+
escaped = true;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (character === '"') {
|
|
423
|
+
const token = source.slice(start, index);
|
|
424
|
+
try {
|
|
425
|
+
return JSON.parse(token);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
fail(`is not valid JSON: ${error.message}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
fail('is not valid JSON: unterminated string');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function parseValue() {
|
|
435
|
+
skipWhitespace();
|
|
436
|
+
const character = source[index];
|
|
437
|
+
if (character === '{') return parseObject();
|
|
438
|
+
if (character === '[') return parseArray();
|
|
439
|
+
if (character === '"') {
|
|
440
|
+
parseStringToken();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const literal = /^(?:true|false|null)/.exec(source.slice(index));
|
|
444
|
+
if (literal) {
|
|
445
|
+
index += literal[0].length;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const number = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(source.slice(index));
|
|
449
|
+
if (number) {
|
|
450
|
+
index += number[0].length;
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
fail(`has invalid JSON at byte ${index}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function parseObject() {
|
|
457
|
+
index += 1;
|
|
458
|
+
skipWhitespace();
|
|
459
|
+
const keys = new Set();
|
|
460
|
+
if (source[index] === '}') {
|
|
461
|
+
index += 1;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
for (;;) {
|
|
465
|
+
skipWhitespace();
|
|
466
|
+
const key = parseStringToken();
|
|
467
|
+
if (keys.has(key)) {
|
|
468
|
+
fail(`contains duplicate object key ${JSON.stringify(key)}; refusing settings surgery`);
|
|
469
|
+
}
|
|
470
|
+
keys.add(key);
|
|
471
|
+
skipWhitespace();
|
|
472
|
+
if (source[index] !== ':') fail(`has invalid JSON at byte ${index}`);
|
|
473
|
+
index += 1;
|
|
474
|
+
parseValue();
|
|
475
|
+
skipWhitespace();
|
|
476
|
+
if (source[index] === '}') {
|
|
477
|
+
index += 1;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (source[index] !== ',') fail(`has invalid JSON at byte ${index}`);
|
|
481
|
+
index += 1;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function parseArray() {
|
|
486
|
+
index += 1;
|
|
487
|
+
skipWhitespace();
|
|
488
|
+
if (source[index] === ']') {
|
|
489
|
+
index += 1;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
for (;;) {
|
|
493
|
+
parseValue();
|
|
494
|
+
skipWhitespace();
|
|
495
|
+
if (source[index] === ']') {
|
|
496
|
+
index += 1;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (source[index] !== ',') fail(`has invalid JSON at byte ${index}`);
|
|
500
|
+
index += 1;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
parseValue();
|
|
505
|
+
skipWhitespace();
|
|
506
|
+
if (index !== source.length) fail(`has invalid JSON at byte ${index}`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function parseClaudeSettings(bytes, label = '.claude/settings.json') {
|
|
510
|
+
assertNoDuplicateJsonKeys(bytes, label);
|
|
511
|
+
return parseJson(bytes, label);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function installDomains(registry) {
|
|
515
|
+
if (!Array.isArray(registry?.domains) || registry.domains.length === 0) {
|
|
516
|
+
throw pathError('.phronesis/registry.json must declare at least one domain');
|
|
517
|
+
}
|
|
518
|
+
const domains = [];
|
|
519
|
+
for (const domain of registry.domains) {
|
|
520
|
+
if (typeof domain !== 'string' || !RE_SLUG.test(domain)) {
|
|
521
|
+
throw pathError(`.phronesis/registry.json contains an invalid domain: ${JSON.stringify(domain)}`);
|
|
522
|
+
}
|
|
523
|
+
if (!domains.includes(domain)) domains.push(domain);
|
|
524
|
+
}
|
|
525
|
+
return domains;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function buildHookManifest(root, { templatesDir = TEMPLATES_DIR } = {}) {
|
|
529
|
+
const registryPath = join(root, '.phronesis', 'registry.json');
|
|
530
|
+
const registryInspection = await inspectInstallPath(root, '.phronesis/registry.json');
|
|
531
|
+
if (
|
|
532
|
+
!registryInspection.present
|
|
533
|
+
|| registryInspection.unsafe
|
|
534
|
+
|| registryInspection.symlink
|
|
535
|
+
|| !registryInspection.file
|
|
536
|
+
) {
|
|
537
|
+
throw pathError(
|
|
538
|
+
`.phronesis/registry.json must be a regular non-symlink file (${registryInspection.detail || 'missing or not a file'})`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const registry = parseJson(
|
|
542
|
+
await readRegularFileNoFollow(registryPath, 'installation registry'),
|
|
543
|
+
'.phronesis/registry.json',
|
|
544
|
+
);
|
|
545
|
+
const domains = installDomains(registry);
|
|
546
|
+
const copies = [
|
|
547
|
+
...hookManifestCopies(domains[0]).filter(({ to }) => !to.startsWith(`domains/${domains[0]}/`)),
|
|
548
|
+
...domains.flatMap((domain) =>
|
|
549
|
+
hookManifestCopies(domain).filter(({ to }) => to.startsWith(`domains/${domain}/`))),
|
|
550
|
+
];
|
|
551
|
+
const managedClaudeTargets = copies
|
|
552
|
+
.filter(({ to }) => to.startsWith('.phronesis/hooks/') && to.endsWith('.sh'))
|
|
553
|
+
.map(({ to }) => to)
|
|
554
|
+
.sort();
|
|
555
|
+
const entries = [];
|
|
556
|
+
for (const copy of copies) {
|
|
557
|
+
validateRelativePath(copy.to, 'hook-manifest path');
|
|
558
|
+
const sourcePath = join(templatesDir, copy.from);
|
|
559
|
+
const sourceBytes = await readRegularFileNoFollow(sourcePath, `shipped template ${copy.from}`);
|
|
560
|
+
if (copy.kind === 'claude-hook-entries') {
|
|
561
|
+
const expectedSettings = parseJson(sourceBytes, copy.shippedTemplate);
|
|
562
|
+
const expectedBlock = expectedSettings?.hooks;
|
|
563
|
+
if (!expectedBlock || typeof expectedBlock !== 'object' || Array.isArray(expectedBlock)) {
|
|
564
|
+
throw pathError(`${copy.shippedTemplate} must contain a hooks object`);
|
|
565
|
+
}
|
|
566
|
+
entries.push({
|
|
567
|
+
...copy,
|
|
568
|
+
path: copy.to,
|
|
569
|
+
sourcePath,
|
|
570
|
+
expectedSettings,
|
|
571
|
+
expectedBlock,
|
|
572
|
+
managedClaudeTargets,
|
|
573
|
+
expectedBytes: managedBlockBytes(expectedBlock),
|
|
574
|
+
});
|
|
575
|
+
} else {
|
|
576
|
+
entries.push({
|
|
577
|
+
...copy,
|
|
578
|
+
path: copy.to,
|
|
579
|
+
sourcePath,
|
|
580
|
+
expectedBytes: sourceBytes,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return { domains, entries };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function stampSchemaError(message) {
|
|
588
|
+
return pathError(`invalid ${HOOK_STAMP_REL}: ${message}`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function validateHookStamp(value) {
|
|
592
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw stampSchemaError('expected an object');
|
|
593
|
+
const outerKeys = Object.keys(value).sort();
|
|
594
|
+
if (JSON.stringify(outerKeys) !== JSON.stringify(['files', 'schema_version', 'scope'])) {
|
|
595
|
+
throw stampSchemaError('expected exactly schema_version, scope, and files');
|
|
596
|
+
}
|
|
597
|
+
if (value.schema_version !== 1) throw stampSchemaError('schema_version must be 1');
|
|
598
|
+
if (value.scope !== 'hooks') throw stampSchemaError('scope must be "hooks"');
|
|
599
|
+
if (!Array.isArray(value.files)) throw stampSchemaError('files must be an array');
|
|
600
|
+
|
|
601
|
+
const seen = new Set();
|
|
602
|
+
for (const [index, entry] of value.files.entries()) {
|
|
603
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
604
|
+
throw stampSchemaError(`files[${index}] must be an object`);
|
|
605
|
+
}
|
|
606
|
+
const keys = Object.keys(entry).sort();
|
|
607
|
+
const expectedKeys = ['cli_version', 'path', 'sha256', 'shipped_template', 'stamped_at'];
|
|
608
|
+
if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) {
|
|
609
|
+
throw stampSchemaError(`files[${index}] has unknown or missing fields`);
|
|
610
|
+
}
|
|
611
|
+
validateRelativePath(entry.path, `files[${index}].path`);
|
|
612
|
+
if (seen.has(entry.path)) throw stampSchemaError(`duplicate path ${entry.path}`);
|
|
613
|
+
seen.add(entry.path);
|
|
614
|
+
if (typeof entry.shipped_template !== 'string' || !entry.shipped_template) {
|
|
615
|
+
throw stampSchemaError(`files[${index}].shipped_template must be a non-empty string`);
|
|
616
|
+
}
|
|
617
|
+
if (typeof entry.sha256 !== 'string' || !SHA256_RE.test(entry.sha256)) {
|
|
618
|
+
throw stampSchemaError(`files[${index}].sha256 must be lowercase sha256 hex`);
|
|
619
|
+
}
|
|
620
|
+
if (typeof entry.cli_version !== 'string' || !entry.cli_version) {
|
|
621
|
+
throw stampSchemaError(`files[${index}].cli_version must be a non-empty string`);
|
|
622
|
+
}
|
|
623
|
+
if (typeof entry.stamped_at !== 'string' || Number.isNaN(Date.parse(entry.stamped_at))) {
|
|
624
|
+
throw stampSchemaError(`files[${index}].stamped_at must be an ISO timestamp`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return value;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function loadHookStamp(root) {
|
|
631
|
+
const inspected = await inspectInstallPath(root, HOOK_STAMP_REL);
|
|
632
|
+
if (!inspected.present) return null;
|
|
633
|
+
if (inspected.unsafe || inspected.symlink || !inspected.file) {
|
|
634
|
+
throw stampSchemaError(inspected.detail || 'must be a regular non-symlink file');
|
|
635
|
+
}
|
|
636
|
+
const bytes = await readRegularFileNoFollow(join(root, HOOK_STAMP_REL), HOOK_STAMP_REL);
|
|
637
|
+
return validateHookStamp(parseJson(bytes, HOOK_STAMP_REL));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function stampDisposition(report, stamped) {
|
|
641
|
+
if (!stamped) return 'unstamped';
|
|
642
|
+
if (stamped.shipped_template !== report.shippedTemplate) return 'unstamped';
|
|
643
|
+
if (report.status === 'current') return 'current';
|
|
644
|
+
if (
|
|
645
|
+
report.liveHash
|
|
646
|
+
&& report.liveHash === stamped.sha256
|
|
647
|
+
&& stamped.sha256 !== report.expectedHash
|
|
648
|
+
) return 'safe';
|
|
649
|
+
return 'operator-modified';
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function classifyManifestEntry(root, entry, stampEntry) {
|
|
653
|
+
const inspected = await inspectInstallPath(root, entry.path);
|
|
654
|
+
const expectedHash = sha256(entry.expectedBytes);
|
|
655
|
+
const base = { ...entry, expectedHash, inspected };
|
|
656
|
+
if (!inspected.present) {
|
|
657
|
+
const report = {
|
|
658
|
+
...base,
|
|
659
|
+
status: 'missing',
|
|
660
|
+
detail: 'managed path is absent',
|
|
661
|
+
liveHash: null,
|
|
662
|
+
reviewedDigest: null,
|
|
663
|
+
reviewedPresent: false,
|
|
664
|
+
settingsPlan: entry.kind === 'claude-hook-entries'
|
|
665
|
+
? {
|
|
666
|
+
remove: [],
|
|
667
|
+
add: managedClaudeEntries(entry.expectedSettings, entry.managedClaudeTargets, root),
|
|
668
|
+
}
|
|
669
|
+
: undefined,
|
|
670
|
+
};
|
|
671
|
+
report.disposition = stampDisposition(report, stampEntry);
|
|
672
|
+
return report;
|
|
673
|
+
}
|
|
674
|
+
if (inspected.unsafe || inspected.symlink || !inspected.file) {
|
|
675
|
+
const report = {
|
|
676
|
+
...base,
|
|
677
|
+
status: 'differs',
|
|
678
|
+
detail: inspected.detail || 'managed path is not a regular non-symlink file',
|
|
679
|
+
liveHash: null,
|
|
680
|
+
unsafe: true,
|
|
681
|
+
reviewedDigest: null,
|
|
682
|
+
reviewedPresent: true,
|
|
683
|
+
};
|
|
684
|
+
report.disposition = stampDisposition(report, stampEntry);
|
|
685
|
+
return report;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const liveBytes = await readRegularFileNoFollow(join(root, entry.path), entry.path);
|
|
689
|
+
const reviewedDigest = sha256(liveBytes);
|
|
690
|
+
if (entry.kind === 'claude-hook-entries') {
|
|
691
|
+
let liveSettings;
|
|
692
|
+
try {
|
|
693
|
+
liveSettings = parseClaudeSettings(liveBytes, entry.path);
|
|
694
|
+
} catch (error) {
|
|
695
|
+
const report = {
|
|
696
|
+
...base,
|
|
697
|
+
status: 'differs',
|
|
698
|
+
detail: error.message,
|
|
699
|
+
liveHash: null,
|
|
700
|
+
reviewedDigest,
|
|
701
|
+
reviewedPresent: true,
|
|
702
|
+
unmergeable: true,
|
|
703
|
+
};
|
|
704
|
+
report.disposition = stampDisposition(report, stampEntry);
|
|
705
|
+
return report;
|
|
706
|
+
}
|
|
707
|
+
try {
|
|
708
|
+
// Preflight the bounded Class-C surgery while the command is still entirely
|
|
709
|
+
// read-only. A malformed operator value must fail before any selected sibling
|
|
710
|
+
// is re-stamped, never halfway through a multi-path apply.
|
|
711
|
+
mergeClaudeHookEntries(
|
|
712
|
+
liveSettings,
|
|
713
|
+
entry.expectedBlock,
|
|
714
|
+
entry.managedClaudeTargets,
|
|
715
|
+
root,
|
|
716
|
+
);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
const report = {
|
|
719
|
+
...base,
|
|
720
|
+
status: 'differs',
|
|
721
|
+
detail: error.message,
|
|
722
|
+
liveHash: null,
|
|
723
|
+
liveBytes,
|
|
724
|
+
liveSettings,
|
|
725
|
+
reviewedDigest,
|
|
726
|
+
reviewedPresent: true,
|
|
727
|
+
unmergeable: true,
|
|
728
|
+
};
|
|
729
|
+
report.disposition = stampDisposition(report, stampEntry);
|
|
730
|
+
return report;
|
|
731
|
+
}
|
|
732
|
+
const liveBlock = managedClaudeBlock(liveSettings, entry.managedClaudeTargets, root);
|
|
733
|
+
const settingsPlan = {
|
|
734
|
+
remove: managedClaudeEntries(liveSettings, entry.managedClaudeTargets, root),
|
|
735
|
+
add: managedClaudeEntries(entry.expectedSettings, entry.managedClaudeTargets, root),
|
|
736
|
+
};
|
|
737
|
+
const blockBytes = managedBlockBytes(liveBlock);
|
|
738
|
+
const managedCount = Object.values(liveBlock).reduce((count, groups) =>
|
|
739
|
+
count + groups.reduce((sum, group) => sum + group.hooks.length, 0), 0);
|
|
740
|
+
const report = {
|
|
741
|
+
...base,
|
|
742
|
+
status: managedCount === 0 ? 'missing' : blockBytes.equals(entry.expectedBytes) ? 'current' : 'differs',
|
|
743
|
+
detail: managedCount === 0 ? 'Phronesis hook entries are absent from operator settings' : 'Phronesis hook entries differ',
|
|
744
|
+
liveHash: managedCount === 0 ? null : sha256(blockBytes),
|
|
745
|
+
liveBytes,
|
|
746
|
+
liveSettings,
|
|
747
|
+
liveBlock,
|
|
748
|
+
settingsPlan,
|
|
749
|
+
reviewedDigest,
|
|
750
|
+
reviewedPresent: true,
|
|
751
|
+
};
|
|
752
|
+
report.disposition = stampDisposition(report, stampEntry);
|
|
753
|
+
return report;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const bytesMatch = liveBytes.equals(entry.expectedBytes);
|
|
757
|
+
const modeMatch = !entry.executable || Boolean(inspected.mode & 0o111);
|
|
758
|
+
const report = {
|
|
759
|
+
...base,
|
|
760
|
+
status: bytesMatch && modeMatch ? 'current' : 'differs',
|
|
761
|
+
detail: !bytesMatch ? 'live bytes differ from the shipped template' : 'script is not executable',
|
|
762
|
+
liveHash: sha256(liveBytes),
|
|
763
|
+
liveBytes,
|
|
764
|
+
reviewedDigest,
|
|
765
|
+
reviewedPresent: true,
|
|
766
|
+
};
|
|
767
|
+
report.disposition = stampDisposition(report, stampEntry);
|
|
768
|
+
return report;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function findUnknownSiblings(root, entries) {
|
|
772
|
+
const directories = new Map();
|
|
773
|
+
for (const entry of entries.filter(({ scanUnknownSiblings }) => scanUnknownSiblings)) {
|
|
774
|
+
const dir = dirname(entry.path);
|
|
775
|
+
if (!directories.has(dir)) directories.set(dir, new Set());
|
|
776
|
+
directories.get(dir).add(basename(entry.path));
|
|
777
|
+
}
|
|
778
|
+
const extras = [];
|
|
779
|
+
for (const [dir, expectedNames] of directories) {
|
|
780
|
+
const inspected = await inspectInstallPath(root, dir);
|
|
781
|
+
if (!inspected.present || inspected.unsafe || !inspected.directory) continue;
|
|
782
|
+
const children = await readdir(join(root, dir), { withFileTypes: true });
|
|
783
|
+
for (const child of children) {
|
|
784
|
+
if (!expectedNames.has(child.name)) {
|
|
785
|
+
extras.push({
|
|
786
|
+
path: `${dir}/${child.name}`,
|
|
787
|
+
status: 'extra-unknown',
|
|
788
|
+
detail: 'present outside the derived hook manifest; never touched',
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return extras.sort((a, b) => a.path.localeCompare(b.path));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function reportSuffix(report) {
|
|
797
|
+
if (report.status === 'extra-unknown') return report.detail;
|
|
798
|
+
if (report.status === 'current') return '';
|
|
799
|
+
if (report.disposition === 'safe') {
|
|
800
|
+
return 'stamped bytes are unchanged by the operator and the shipped template changed; safe to re-stamp with approval';
|
|
801
|
+
}
|
|
802
|
+
if (report.disposition === 'operator-modified') {
|
|
803
|
+
return 'stamped path was modified or removed after stamping; skipped unless explicitly approved';
|
|
804
|
+
}
|
|
805
|
+
return 'no hook-scoped baseline; explicit approval required';
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function printReport(root, reports, extras) {
|
|
809
|
+
console.log(`Hook refresh report: ${root}`);
|
|
810
|
+
for (const report of [...reports, ...extras]) {
|
|
811
|
+
const suffix = reportSuffix(report);
|
|
812
|
+
console.log(` ${report.status.padEnd(13)} ${report.path}${suffix ? ` — ${suffix}` : ''}`);
|
|
813
|
+
if (report.kind === 'claude-hook-entries' && report.status !== 'current' && report.settingsPlan) {
|
|
814
|
+
if (!report.settingsPlan.remove.length) console.log(' settings remove: (no target-attributed managed entries)');
|
|
815
|
+
for (const entry of report.settingsPlan.remove) {
|
|
816
|
+
console.log(` settings remove ${entry.event}: ${JSON.stringify(entry.command)} -> ${entry.target}`);
|
|
817
|
+
}
|
|
818
|
+
for (const entry of report.settingsPlan.add) {
|
|
819
|
+
console.log(` settings add ${entry.event}: ${JSON.stringify(entry.command)} -> ${entry.target}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function preflightWrite(root, report) {
|
|
826
|
+
const inspected = await inspectInstallPath(root, report.path);
|
|
827
|
+
if (inspected.unsafe || inspected.symlink) {
|
|
828
|
+
throw pathError(`refusing to follow ${inspected.detail || `symlinked ${report.path}`}`);
|
|
829
|
+
}
|
|
830
|
+
if (inspected.present && !inspected.file) {
|
|
831
|
+
throw pathError(`refusing non-file refresh target: ${report.path}`);
|
|
832
|
+
}
|
|
833
|
+
if (inspected.present) assertOwned(join(root, report.path), inspected, 'refresh target');
|
|
834
|
+
if (report.unmergeable) throw pathError(`${report.path} cannot be bounded-merged: ${report.detail}`);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async function reviewedTargetAtWriteBoundary(root, report) {
|
|
838
|
+
const inspected = await inspectInstallPath(root, report.path);
|
|
839
|
+
if (!report.reviewedPresent) {
|
|
840
|
+
if (!inspected.present) return { matches: true, inspected, liveBytes: null, liveSettings: null };
|
|
841
|
+
return {
|
|
842
|
+
matches: false,
|
|
843
|
+
detail: 'the path was missing when reviewed but now exists',
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
if (inspected.unsafe || inspected.symlink || !inspected.file) {
|
|
847
|
+
return {
|
|
848
|
+
matches: false,
|
|
849
|
+
detail: inspected.detail || 'the reviewed regular file is now missing or unsafe',
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
let liveBytes;
|
|
853
|
+
try {
|
|
854
|
+
liveBytes = await readRegularFileNoFollow(join(root, report.path), report.path);
|
|
855
|
+
} catch (error) {
|
|
856
|
+
return { matches: false, detail: error.message };
|
|
857
|
+
}
|
|
858
|
+
if (sha256(liveBytes) !== report.reviewedDigest) {
|
|
859
|
+
return { matches: false, detail: 'live bytes no longer match the reviewed digest' };
|
|
860
|
+
}
|
|
861
|
+
let liveSettings = null;
|
|
862
|
+
if (report.kind === 'claude-hook-entries') {
|
|
863
|
+
try {
|
|
864
|
+
liveSettings = parseClaudeSettings(liveBytes, report.path);
|
|
865
|
+
mergeClaudeHookEntries(
|
|
866
|
+
liveSettings,
|
|
867
|
+
report.expectedBlock,
|
|
868
|
+
report.managedClaudeTargets,
|
|
869
|
+
root,
|
|
870
|
+
);
|
|
871
|
+
} catch (error) {
|
|
872
|
+
return { matches: false, detail: error.message };
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return { matches: true, inspected, liveBytes, liveSettings };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function atomicTestPhase(phase) {
|
|
879
|
+
if (
|
|
880
|
+
process.env.NODE_ENV === 'test'
|
|
881
|
+
&& process.env.PHRONESIS_TEST_HOOK_REFRESH_SIGKILL_PHASE === phase
|
|
882
|
+
) process.kill(process.pid, 'SIGKILL');
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function escapedRegExp(value) {
|
|
886
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function cleanupAgedManagedTemps(root, entries, nowMs) {
|
|
890
|
+
const directories = new Map([
|
|
891
|
+
['.phronesis', new Set(['hook-stamp.json'])],
|
|
892
|
+
]);
|
|
893
|
+
for (const entry of entries) {
|
|
894
|
+
const dir = dirname(entry.path);
|
|
895
|
+
if (!directories.has(dir)) directories.set(dir, new Set());
|
|
896
|
+
directories.get(dir).add(basename(entry.path));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const removed = [];
|
|
900
|
+
for (const [dir, managedNames] of directories) {
|
|
901
|
+
const inspected = await inspectInstallPath(root, dir);
|
|
902
|
+
if (!inspected.present) continue;
|
|
903
|
+
if (inspected.unsafe || !inspected.directory) {
|
|
904
|
+
throw pathError(`refusing temporary cleanup through unsafe managed directory: ${dir}`);
|
|
905
|
+
}
|
|
906
|
+
for (const child of await readdir(join(root, dir), { withFileTypes: true })) {
|
|
907
|
+
const managedName = [...managedNames].find((name) => (
|
|
908
|
+
new RegExp(`^${escapedRegExp(name)}\\.tmp-[0-9]+-[a-f0-9]+$`).test(child.name)
|
|
909
|
+
));
|
|
910
|
+
if (!managedName) continue;
|
|
911
|
+
const path = join(root, dir, child.name);
|
|
912
|
+
const state = await stateAt(path);
|
|
913
|
+
assertOwned(path, state, 'stale hook temporary');
|
|
914
|
+
if (state.symlink || !state.file) {
|
|
915
|
+
throw pathError(`refusing stale hook temporary cleanup for non-file or symlink: ${path}`);
|
|
916
|
+
}
|
|
917
|
+
if (nowMs - state.mtimeMs < STALE_HOOK_TEMP_AGE_MS) continue;
|
|
918
|
+
await unlink(path);
|
|
919
|
+
removed.push(path);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return removed;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function writeManifestEntry(
|
|
926
|
+
root,
|
|
927
|
+
report,
|
|
928
|
+
reviewed,
|
|
929
|
+
{ onBeforeTargetRename, onBeforeTargetCreate } = {},
|
|
930
|
+
) {
|
|
931
|
+
await ensureParentDirectoriesNoFollow(root, report.path);
|
|
932
|
+
const targetPath = join(root, report.path);
|
|
933
|
+
let bytes;
|
|
934
|
+
let mode;
|
|
935
|
+
if (report.kind === 'claude-hook-entries') {
|
|
936
|
+
if (reviewed.inspected.present) {
|
|
937
|
+
const merged = mergeClaudeHookEntries(
|
|
938
|
+
reviewed.liveSettings,
|
|
939
|
+
report.expectedBlock,
|
|
940
|
+
report.managedClaudeTargets,
|
|
941
|
+
root,
|
|
942
|
+
);
|
|
943
|
+
bytes = Buffer.from(`${JSON.stringify(merged, null, 2)}\n`, 'utf8');
|
|
944
|
+
mode = reviewed.inspected.mode & 0o777;
|
|
945
|
+
} else {
|
|
946
|
+
bytes = await readRegularFileNoFollow(report.sourcePath, report.shippedTemplate);
|
|
947
|
+
mode = 0o644;
|
|
948
|
+
}
|
|
949
|
+
} else {
|
|
950
|
+
bytes = report.expectedBytes;
|
|
951
|
+
mode = report.executable ? 0o755 : reviewed.inspected.present ? reviewed.inspected.mode & 0o777 : 0o644;
|
|
952
|
+
}
|
|
953
|
+
try {
|
|
954
|
+
await writeAtomicFileNoFollow({
|
|
955
|
+
path: targetPath,
|
|
956
|
+
bytes,
|
|
957
|
+
mode: mode || 0o600,
|
|
958
|
+
exclusiveTarget: !report.reviewedPresent,
|
|
959
|
+
onBeforeRename: async () => {
|
|
960
|
+
await onBeforeTargetRename?.(report);
|
|
961
|
+
const finalReview = await reviewedTargetAtWriteBoundary(root, report);
|
|
962
|
+
if (!finalReview.matches) {
|
|
963
|
+
throw changedSinceReviewError(report.path, finalReview.detail);
|
|
964
|
+
}
|
|
965
|
+
atomicTestPhase('before-hook-rename');
|
|
966
|
+
},
|
|
967
|
+
onBeforeExclusiveCreate: async () => onBeforeTargetCreate?.(report),
|
|
968
|
+
onAfterRename: () => atomicTestPhase('after-hook-rename'),
|
|
969
|
+
});
|
|
970
|
+
} catch (error) {
|
|
971
|
+
if (error?.code !== 'PHR_ATOMIC_TARGET_EXISTS') throw error;
|
|
972
|
+
throw changedSinceReviewError(
|
|
973
|
+
report.path,
|
|
974
|
+
'the path was missing when reviewed but appeared before exclusive creation',
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
return { bytes, mode, reserializedSettings: report.kind === 'claude-hook-entries' };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function currentEmissionAtStampBoundary(root, report) {
|
|
981
|
+
const inspected = await inspectInstallPath(root, report.path);
|
|
982
|
+
if (inspected.unsafe || inspected.symlink || !inspected.file) {
|
|
983
|
+
return { current: false, detail: inspected.detail || 'stamped target is missing or unsafe' };
|
|
984
|
+
}
|
|
985
|
+
const liveBytes = await readRegularFileNoFollow(join(root, report.path), report.path);
|
|
986
|
+
if (report.kind === 'claude-hook-entries') {
|
|
987
|
+
let settings;
|
|
988
|
+
try {
|
|
989
|
+
settings = parseClaudeSettings(liveBytes, report.path);
|
|
990
|
+
} catch (error) {
|
|
991
|
+
return { current: false, detail: error.message };
|
|
992
|
+
}
|
|
993
|
+
const liveBlock = managedClaudeBlock(settings, report.managedClaudeTargets, root);
|
|
994
|
+
if (!managedBlockBytes(liveBlock).equals(report.expectedBytes)) {
|
|
995
|
+
return { current: false, detail: 'managed Claude hook entries changed before provenance commit' };
|
|
996
|
+
}
|
|
997
|
+
return { current: true };
|
|
998
|
+
}
|
|
999
|
+
if (!liveBytes.equals(report.expectedBytes)) {
|
|
1000
|
+
return { current: false, detail: 'hook bytes changed before provenance commit' };
|
|
1001
|
+
}
|
|
1002
|
+
if (report.executable && !(inspected.mode & 0o111)) {
|
|
1003
|
+
return { current: false, detail: 'hook executable mode changed before provenance commit' };
|
|
1004
|
+
}
|
|
1005
|
+
return { current: true };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function stampEntryFor(report, stampedAt) {
|
|
1009
|
+
return {
|
|
1010
|
+
path: report.path,
|
|
1011
|
+
shipped_template: report.shippedTemplate,
|
|
1012
|
+
sha256: report.expectedHash,
|
|
1013
|
+
cli_version: CLI_VERSION,
|
|
1014
|
+
stamped_at: stampedAt,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async function writeHookStamp(root, priorStamp, reports, stampedAt) {
|
|
1019
|
+
const manifestEntries = new Map(reports.all.map((report) => [report.path, report]));
|
|
1020
|
+
const next = new Map(
|
|
1021
|
+
(priorStamp?.files || [])
|
|
1022
|
+
.filter((entry) => (
|
|
1023
|
+
manifestEntries.get(entry.path)?.shippedTemplate === entry.shipped_template
|
|
1024
|
+
))
|
|
1025
|
+
.map((entry) => [entry.path, entry]),
|
|
1026
|
+
);
|
|
1027
|
+
for (const report of reports.stamped) next.set(report.path, stampEntryFor(report, stampedAt));
|
|
1028
|
+
const files = reports.all.flatMap(({ path }) => next.has(path) ? [next.get(path)] : []);
|
|
1029
|
+
const value = { schema_version: 1, scope: 'hooks', files };
|
|
1030
|
+
validateHookStamp(value);
|
|
1031
|
+
|
|
1032
|
+
const stampPath = join(root, HOOK_STAMP_REL);
|
|
1033
|
+
const inspected = await inspectInstallPath(root, HOOK_STAMP_REL);
|
|
1034
|
+
const mode = inspected.present && inspected.file ? inspected.mode & 0o777 : 0o644;
|
|
1035
|
+
await writeAtomicFileNoFollow({
|
|
1036
|
+
path: stampPath,
|
|
1037
|
+
bytes: Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8'),
|
|
1038
|
+
mode: mode || 0o600,
|
|
1039
|
+
onBeforeRename: async () => {
|
|
1040
|
+
for (const report of reports.stamped) {
|
|
1041
|
+
const verification = await currentEmissionAtStampBoundary(root, report);
|
|
1042
|
+
if (!verification.current) {
|
|
1043
|
+
throw changedSinceReviewError(report.path, verification.detail);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
atomicTestPhase('before-hook-stamp-rename');
|
|
1047
|
+
},
|
|
1048
|
+
onAfterRename: () => atomicTestPhase('after-hook-stamp-rename'),
|
|
1049
|
+
});
|
|
1050
|
+
console.log(`Wrote ${HOOK_STAMP_REL} for ${reports.stamped.length} re-stamped path(s).`);
|
|
1051
|
+
return value;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function printCodexTrustGuidance(stamped) {
|
|
1055
|
+
const codexScripts = stamped.filter(({ path }) => path.includes('/.codex/hooks/'));
|
|
1056
|
+
const hooksJsonChanged = stamped.some(({ path }) => path.endsWith('/.codex/hooks.json'));
|
|
1057
|
+
if (!codexScripts.length && !hooksJsonChanged) return;
|
|
1058
|
+
|
|
1059
|
+
console.log('');
|
|
1060
|
+
console.log(
|
|
1061
|
+
'Codex trust (empirically verified 2026-07-18 on v0.145.0-alpha.18): per-hook trust keys on the hooks.json config registration, not hook-script bytes.',
|
|
1062
|
+
);
|
|
1063
|
+
console.log(
|
|
1064
|
+
'Script-only re-stamps do not invalidate trust on that v0.145-class build, so no script-only re-trust prompt is promised; later Codex versions may change this behavior.',
|
|
1065
|
+
);
|
|
1066
|
+
console.log(
|
|
1067
|
+
'On that version, a trusted script byte change is not re-verified by Codex; this refresh does not imply byte-level re-verification.',
|
|
1068
|
+
);
|
|
1069
|
+
if (hooksJsonChanged) {
|
|
1070
|
+
console.log(
|
|
1071
|
+
'A hooks.json registration changed. Expect Codex\'s "Hooks need review" banner and review the registrations with `/hooks` or Settings → Hooks.',
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async function offerLauncher({
|
|
1077
|
+
home,
|
|
1078
|
+
persist,
|
|
1079
|
+
isTTY,
|
|
1080
|
+
inspectManagedLauncher,
|
|
1081
|
+
installManagedLauncher,
|
|
1082
|
+
promptLauncher,
|
|
1083
|
+
promptPath,
|
|
1084
|
+
hookOutcome,
|
|
1085
|
+
}) {
|
|
1086
|
+
const launcher = inspectManagedLauncher({ home });
|
|
1087
|
+
if (launcher.compliant) {
|
|
1088
|
+
return { offered: false, launcher };
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const issues = launcher.issues || [];
|
|
1092
|
+
const absent = issues.some(({ code }) => code === 'not-installed');
|
|
1093
|
+
const backend = launcher.manifest?.PHR_BACKEND || null;
|
|
1094
|
+
const selfCopyOwned = backend === 'self-copy';
|
|
1095
|
+
console.log('');
|
|
1096
|
+
for (const issue of issues) console.error(`Launcher issue ${issue.code}: ${issue.detail}`);
|
|
1097
|
+
if (backend === 'app') {
|
|
1098
|
+
console.log('The unhealthy launcher is app-owned; hooks refresh will not replace it with a self-copy launcher.');
|
|
1099
|
+
console.log('Use the Phronesis desktop app’s Install command-line tools action to repair it, or use `phronesis launcher remove` before intentionally switching ownership.');
|
|
1100
|
+
console.log(hookOutcome);
|
|
1101
|
+
console.log('PATH-isolated surfaces may stay inactive until the app-owned launcher is repaired.');
|
|
1102
|
+
return { offered: false, installed: false, status: 'app-owned-not-clobbered', launcher };
|
|
1103
|
+
}
|
|
1104
|
+
if (!absent && !selfCopyOwned) {
|
|
1105
|
+
console.log('Launcher ownership is invalid or unreadable; hooks refresh will not force a self-copy replacement.');
|
|
1106
|
+
console.log('Resolve the reported launcher.env/dispatcher issue, then run `phronesis launcher install` again.');
|
|
1107
|
+
console.log(hookOutcome);
|
|
1108
|
+
console.log('PATH-isolated surfaces may stay inactive until launcher ownership is resolved.');
|
|
1109
|
+
return { offered: false, installed: false, status: 'ownership-unresolved', launcher };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
console.log(
|
|
1113
|
+
absent
|
|
1114
|
+
? 'No managed launcher is installed. Refreshed hooks are still inactive in PATH-isolated surfaces until the launcher is installed.'
|
|
1115
|
+
: 'The self-copy launcher is unhealthy. Refreshed hooks may remain inactive until launcher install repairs it.',
|
|
1116
|
+
);
|
|
1117
|
+
let accepted = persist === true;
|
|
1118
|
+
if (persist === undefined && isTTY) {
|
|
1119
|
+
accepted = await promptLauncher({ message: PERSISTENCE_PROMPT });
|
|
1120
|
+
}
|
|
1121
|
+
if (!accepted) {
|
|
1122
|
+
console.log('Install or repair it when ready: `phronesis launcher install`.');
|
|
1123
|
+
return { offered: true, installed: false, launcher };
|
|
1124
|
+
}
|
|
1125
|
+
const installed = await installManagedLauncher({ home, isTTY, promptPath });
|
|
1126
|
+
return { offered: true, installed: true, status: 'installed', result: installed, launcher };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function describeHookOutcome(stamped, refused) {
|
|
1130
|
+
const summary = `Hook refresh outcome: ${stamped.length} re-stamped, ${refused.length} refused`;
|
|
1131
|
+
if (!refused.length) return `${summary}.`;
|
|
1132
|
+
const reasons = refused.map(({ path, status, detail }) => (
|
|
1133
|
+
`${path} (${status}: ${detail})`
|
|
1134
|
+
));
|
|
1135
|
+
return `${summary}: ${reasons.join('; ')}.`;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
export async function runHooksRefresh({
|
|
1139
|
+
dir,
|
|
1140
|
+
apply = [],
|
|
1141
|
+
applyAll = false,
|
|
1142
|
+
persist,
|
|
1143
|
+
isTTY = Boolean(process.stdin.isTTY),
|
|
1144
|
+
home = homedir(),
|
|
1145
|
+
templatesDir = TEMPLATES_DIR,
|
|
1146
|
+
now = () => new Date(),
|
|
1147
|
+
promptRestamp = confirmHookRestamp,
|
|
1148
|
+
promptLauncher = confirmPersistence,
|
|
1149
|
+
promptPath = confirmPathAppend,
|
|
1150
|
+
inspectManagedLauncher = inspectLauncher,
|
|
1151
|
+
installManagedLauncher = runLauncherInstall,
|
|
1152
|
+
onBeforeTargetRename,
|
|
1153
|
+
onBeforeTargetCreate,
|
|
1154
|
+
} = {}) {
|
|
1155
|
+
const installed = resolveInstall({ dir });
|
|
1156
|
+
if (installed.error) {
|
|
1157
|
+
console.error(`hooks refresh: ${installed.error}`);
|
|
1158
|
+
process.exitCode = 1;
|
|
1159
|
+
return { error: installed.error };
|
|
1160
|
+
}
|
|
1161
|
+
const root = await realpath(installed.root);
|
|
1162
|
+
const manifest = await buildHookManifest(root, { templatesDir });
|
|
1163
|
+
|
|
1164
|
+
let stamp = null;
|
|
1165
|
+
let stampError = null;
|
|
1166
|
+
try {
|
|
1167
|
+
stamp = await loadHookStamp(root);
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
stampError = error;
|
|
1170
|
+
}
|
|
1171
|
+
const stampByPath = new Map((stamp?.files || []).map((entry) => [entry.path, entry]));
|
|
1172
|
+
const reports = [];
|
|
1173
|
+
for (const entry of manifest.entries) {
|
|
1174
|
+
reports.push(await classifyManifestEntry(
|
|
1175
|
+
root,
|
|
1176
|
+
entry,
|
|
1177
|
+
stampByPath.get(entry.path),
|
|
1178
|
+
));
|
|
1179
|
+
}
|
|
1180
|
+
const extras = await findUnknownSiblings(root, manifest.entries);
|
|
1181
|
+
printReport(root, reports, extras);
|
|
1182
|
+
|
|
1183
|
+
if (stampError) {
|
|
1184
|
+
console.error(stampError.message);
|
|
1185
|
+
console.error('No writes or launcher changes were attempted because hook provenance is unreadable.');
|
|
1186
|
+
process.exitCode = 1;
|
|
1187
|
+
return { root, reports, extras, error: stampError.message };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const byPath = new Map(reports.map((report) => [report.path, report]));
|
|
1191
|
+
const requested = Array.isArray(apply) ? apply : [apply];
|
|
1192
|
+
for (const path of requested) {
|
|
1193
|
+
if (!byPath.has(path)) {
|
|
1194
|
+
console.error(`hooks refresh: --apply target is not in the derived hook manifest: ${path}`);
|
|
1195
|
+
process.exitCode = 1;
|
|
1196
|
+
return { root, reports, extras, error: `unknown apply target ${path}` };
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const actionable = reports.filter(({ status }) => status !== 'current');
|
|
1201
|
+
// A TTY prompt that already has drift to resolve may also complete provenance
|
|
1202
|
+
// for current unstamped paths. A wholly current fresh install remains quiet;
|
|
1203
|
+
// crash recovery with no other drift is available through the same explicit
|
|
1204
|
+
// `--apply <path>` authority that initiated the interrupted write.
|
|
1205
|
+
const promptCandidates = actionable.length
|
|
1206
|
+
? reports.filter((report) => (
|
|
1207
|
+
report.status !== 'current' || report.disposition === 'unstamped'
|
|
1208
|
+
))
|
|
1209
|
+
: [];
|
|
1210
|
+
let selectedPaths;
|
|
1211
|
+
if (applyAll) selectedPaths = actionable
|
|
1212
|
+
.filter(({ disposition }) => disposition === 'safe')
|
|
1213
|
+
.map(({ path }) => path);
|
|
1214
|
+
else if (requested.length) selectedPaths = [...new Set(requested)];
|
|
1215
|
+
else if (isTTY && promptCandidates.length) {
|
|
1216
|
+
const consent = await promptRestamp({
|
|
1217
|
+
entries: promptCandidates.map(({ path, shippedTemplate, disposition, status }) => ({
|
|
1218
|
+
path,
|
|
1219
|
+
shippedTemplate,
|
|
1220
|
+
disposition,
|
|
1221
|
+
status,
|
|
1222
|
+
})),
|
|
1223
|
+
});
|
|
1224
|
+
if (Array.isArray(consent)) selectedPaths = consent;
|
|
1225
|
+
else {
|
|
1226
|
+
const blanket = (consent?.blanket || []).filter((path) => (
|
|
1227
|
+
byPath.get(path)?.disposition === 'safe'
|
|
1228
|
+
));
|
|
1229
|
+
selectedPaths = [...blanket, ...(consent?.targeted || [])];
|
|
1230
|
+
}
|
|
1231
|
+
} else selectedPaths = [];
|
|
1232
|
+
|
|
1233
|
+
const selected = [...new Set(selectedPaths || [])]
|
|
1234
|
+
.map((path) => byPath.get(path))
|
|
1235
|
+
.filter((report) => report && (
|
|
1236
|
+
report.status !== 'current' || report.disposition === 'unstamped'
|
|
1237
|
+
));
|
|
1238
|
+
|
|
1239
|
+
let stamped = [];
|
|
1240
|
+
let written = [];
|
|
1241
|
+
const refused = [];
|
|
1242
|
+
if (selected.length) {
|
|
1243
|
+
const stampInspection = await inspectInstallPath(root, HOOK_STAMP_REL);
|
|
1244
|
+
if (stampInspection.unsafe || stampInspection.symlink || (stampInspection.present && !stampInspection.file)) {
|
|
1245
|
+
throw pathError(
|
|
1246
|
+
`refusing non-file or symlinked ${HOOK_STAMP_REL}; no hook path was changed`,
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
if (stampInspection.present) assertOwned(join(root, HOOK_STAMP_REL), stampInspection, HOOK_STAMP_REL);
|
|
1250
|
+
for (const report of selected) await preflightWrite(root, report);
|
|
1251
|
+
// Crash residue is removed only inside an explicitly approved apply. Inspecting
|
|
1252
|
+
// and cleaning it before any target write also makes a planted symlink fail the
|
|
1253
|
+
// whole apply before hook bytes move.
|
|
1254
|
+
const operationTime = now();
|
|
1255
|
+
const stampedAt = operationTime.toISOString();
|
|
1256
|
+
await cleanupAgedManagedTemps(root, manifest.entries, operationTime.getTime());
|
|
1257
|
+
|
|
1258
|
+
for (const report of selected) {
|
|
1259
|
+
const reviewed = await reviewedTargetAtWriteBoundary(root, report);
|
|
1260
|
+
if (!reviewed.matches) {
|
|
1261
|
+
const refusal = { path: report.path, status: 'changed-since-review', detail: reviewed.detail };
|
|
1262
|
+
refused.push(refusal);
|
|
1263
|
+
console.error(` changed-since-review ${report.path} — ${reviewed.detail}; no bytes or provenance were written for this path`);
|
|
1264
|
+
process.exitCode = 1;
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
if (report.status === 'current') {
|
|
1269
|
+
stamped.push(report);
|
|
1270
|
+
console.log(`Recorded provenance for already-current ${report.path}; hook bytes were not rewritten.`);
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
let result;
|
|
1275
|
+
try {
|
|
1276
|
+
result = await writeManifestEntry(root, report, reviewed, {
|
|
1277
|
+
onBeforeTargetRename,
|
|
1278
|
+
onBeforeTargetCreate,
|
|
1279
|
+
});
|
|
1280
|
+
} catch (error) {
|
|
1281
|
+
if (error?.code !== 'PHR_HOOK_CHANGED_SINCE_REVIEW') throw error;
|
|
1282
|
+
const refusal = { path: report.path, status: 'changed-since-review', detail: error.detail };
|
|
1283
|
+
refused.push(refusal);
|
|
1284
|
+
console.error(` changed-since-review ${report.path} — ${error.detail}; no bytes or provenance were written for this path`);
|
|
1285
|
+
process.exitCode = 1;
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
stamped.push(report);
|
|
1289
|
+
written.push(report);
|
|
1290
|
+
console.log(`Re-stamped ${report.path} from ${report.shippedTemplate}.`);
|
|
1291
|
+
if (result.reserializedSettings) {
|
|
1292
|
+
console.log(' .claude/settings.json was re-serialized; every non-managed key, hook, and value was preserved semantically.');
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
if (stamped.length) {
|
|
1296
|
+
try {
|
|
1297
|
+
await writeHookStamp(root, stamp, { all: reports, stamped }, stampedAt);
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
if (error?.code !== 'PHR_HOOK_CHANGED_SINCE_REVIEW') throw error;
|
|
1300
|
+
if (!refused.some(({ path }) => path === error.path)) {
|
|
1301
|
+
refused.push({ path: error.path, status: 'changed-since-review', detail: error.detail });
|
|
1302
|
+
}
|
|
1303
|
+
console.error(` changed-since-review ${error.path} — ${error.detail}; hook provenance was not committed`);
|
|
1304
|
+
process.exitCode = 1;
|
|
1305
|
+
stamped = [];
|
|
1306
|
+
}
|
|
1307
|
+
printCodexTrustGuidance(written);
|
|
1308
|
+
}
|
|
1309
|
+
} else if (actionable.length) {
|
|
1310
|
+
console.log(
|
|
1311
|
+
applyAll
|
|
1312
|
+
? 'Report only for non-safe paths. `--apply-all` applies only safely attributable stamped updates; name unstamped or operator-modified paths with `--apply <path>`.'
|
|
1313
|
+
: 'Report only. Re-run with `--apply <path>` or `--apply-all` to approve writes off-TTY.',
|
|
1314
|
+
);
|
|
1315
|
+
} else {
|
|
1316
|
+
console.log('All derived hook-manifest paths are current; no files were rewritten.');
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
let launcher;
|
|
1320
|
+
const hookOutcome = describeHookOutcome(stamped, refused);
|
|
1321
|
+
try {
|
|
1322
|
+
launcher = await offerLauncher({
|
|
1323
|
+
home,
|
|
1324
|
+
persist,
|
|
1325
|
+
isTTY,
|
|
1326
|
+
inspectManagedLauncher,
|
|
1327
|
+
installManagedLauncher,
|
|
1328
|
+
promptLauncher,
|
|
1329
|
+
promptPath,
|
|
1330
|
+
hookOutcome,
|
|
1331
|
+
});
|
|
1332
|
+
} catch (error) {
|
|
1333
|
+
console.error(`Launcher install failed: ${error.message}`);
|
|
1334
|
+
console.error(hookOutcome);
|
|
1335
|
+
console.error('The separate launcher installation step failed.');
|
|
1336
|
+
process.exitCode = 1;
|
|
1337
|
+
const launcherFailure = {
|
|
1338
|
+
offered: true,
|
|
1339
|
+
installed: false,
|
|
1340
|
+
status: 'install-failed',
|
|
1341
|
+
error: error.message,
|
|
1342
|
+
};
|
|
1343
|
+
return {
|
|
1344
|
+
root,
|
|
1345
|
+
reports,
|
|
1346
|
+
extras,
|
|
1347
|
+
stamped,
|
|
1348
|
+
written,
|
|
1349
|
+
refused,
|
|
1350
|
+
launcher: launcherFailure,
|
|
1351
|
+
...(stamped.length ? {} : { error: error.message }),
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
return { root, reports, extras, stamped, written, refused, launcher };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
export {
|
|
1358
|
+
HOOK_STAMP_REL,
|
|
1359
|
+
buildHookManifest,
|
|
1360
|
+
managedClaudeBlock,
|
|
1361
|
+
mergeClaudeHookEntries,
|
|
1362
|
+
validateHookStamp,
|
|
1363
|
+
};
|