@stdd/plugin 0.9.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.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/.codex-plugin/plugin.json +21 -0
  3. package/LICENSE +21 -0
  4. package/README.md +47 -0
  5. package/extensions/stdd.mjs +77 -0
  6. package/hooks/claude-hooks.json +28 -0
  7. package/hooks/codex-hooks.json +28 -0
  8. package/package.json +38 -0
  9. package/runtime/adapters/README.md +158 -0
  10. package/runtime/cli/check.mjs +555 -0
  11. package/runtime/cli/ci.mjs +190 -0
  12. package/runtime/cli/claude-hooks.mjs +689 -0
  13. package/runtime/cli/config.mjs +27 -0
  14. package/runtime/cli/evidence.mjs +249 -0
  15. package/runtime/cli/generated-files.mjs +1693 -0
  16. package/runtime/cli/held-fs.mjs +415 -0
  17. package/runtime/cli/init.mjs +883 -0
  18. package/runtime/cli/ledger.mjs +1470 -0
  19. package/runtime/cli/lib.mjs +909 -0
  20. package/runtime/cli/path-bytes.mjs +83 -0
  21. package/runtime/cli/policy.mjs +112 -0
  22. package/runtime/cli/recorders.mjs +188 -0
  23. package/runtime/cli/review-fs.mjs +825 -0
  24. package/runtime/cli/review.mjs +1065 -0
  25. package/runtime/cli/runtime.mjs +32 -0
  26. package/runtime/cli/scope.mjs +185 -0
  27. package/runtime/cli/snapshot.mjs +897 -0
  28. package/runtime/cli/state-validation.mjs +168 -0
  29. package/runtime/cli/status.mjs +580 -0
  30. package/runtime/cli/stdd.mjs +536 -0
  31. package/runtime/cli/worker-fs.mjs +971 -0
  32. package/runtime/cli/worker-metadata.mjs +139 -0
  33. package/runtime/cli/worker.mjs +779 -0
  34. package/runtime/method/README.md +634 -0
  35. package/runtime/method/reference-commands.md +147 -0
  36. package/runtime/method/reference-generated-state.md +151 -0
  37. package/runtime/method/reference-integration.md +233 -0
  38. package/runtime/package.json +65 -0
  39. package/runtime/playbooks/brainstorming.md +46 -0
  40. package/runtime/playbooks/debugging.md +36 -0
  41. package/runtime/playbooks/delegate-slice.md +129 -0
  42. package/runtime/playbooks/finish-change.md +46 -0
  43. package/runtime/playbooks/implement.md +26 -0
  44. package/runtime/playbooks/investigation.md +33 -0
  45. package/runtime/playbooks/managed-playbooks.json +14 -0
  46. package/runtime/playbooks/planning.md +177 -0
  47. package/runtime/playbooks/pr-green.md +50 -0
  48. package/runtime/playbooks/start-change.md +37 -0
  49. package/runtime/playbooks/worktrees.md +45 -0
  50. package/runtime/prebuilds/stdd-fs/darwin-arm64/stdd-fs +0 -0
  51. package/runtime/prebuilds/stdd-fs/darwin-x64/stdd-fs +0 -0
  52. package/runtime/prebuilds/stdd-fs/linux-arm64/stdd-fs +0 -0
  53. package/runtime/prebuilds/stdd-fs/linux-x64/stdd-fs +0 -0
  54. package/runtime/prebuilds/stdd-fs/manifest.json +47 -0
  55. package/runtime/prebuilds/stdd-fs/win32-arm64/stdd-fs.exe +0 -0
  56. package/runtime/prebuilds/stdd-fs/win32-x64/stdd-fs.exe +0 -0
  57. package/runtime/sdk/adapters.mjs +279 -0
  58. package/runtime/sdk/file-observation.mjs +12 -0
  59. package/runtime/sdk/index.d.ts +140 -0
  60. package/runtime/sdk/index.mjs +31 -0
  61. package/runtime/sdk/native-fs.mjs +1235 -0
  62. package/runtime/sdk/path.mjs +71 -0
  63. package/runtime/sdk/text.mjs +42 -0
  64. package/runtime/sdk/workflow.mjs +294 -0
  65. package/runtime/templates/deferred-design.md +47 -0
  66. package/runtime/templates/github-stdd.yml +42 -0
  67. package/runtime/templates/gitlab-stdd.yml +72 -0
  68. package/runtime/templates/pr-description.md +35 -0
  69. package/scripts/adopting-root.mjs +42 -0
  70. package/scripts/stdd-hook.mjs +72 -0
  71. package/skills/stdd-brainstorming/SKILL.md +48 -0
  72. package/skills/stdd-debugging/SKILL.md +38 -0
  73. package/skills/stdd-delegate-slice/SKILL.md +118 -0
  74. package/skills/stdd-finish-change/SKILL.md +40 -0
  75. package/skills/stdd-implement/SKILL.md +28 -0
  76. package/skills/stdd-investigation/SKILL.md +35 -0
  77. package/skills/stdd-planning/SKILL.md +165 -0
  78. package/skills/stdd-pr-green/SKILL.md +52 -0
  79. package/skills/stdd-start-change/SKILL.md +39 -0
  80. package/skills/stdd-worktrees/SKILL.md +46 -0
@@ -0,0 +1,689 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { getAgentAdapter } from "../sdk/adapters.mjs";
5
+ import { resolveWritableRepoPath } from "../sdk/path.mjs";
6
+ import { publishNativeRepoFile, readOptionalNativeRepoFile } from "./held-fs.mjs";
7
+
8
+ const CLAUDE_HOOKS_FILE = getAgentAdapter("claude").hooksFile;
9
+ const CODEX_HOOKS_FILE = getAgentAdapter("codex").hooksFile;
10
+ const PI_HOOKS_FILE = getAgentAdapter("pi").hooksFile;
11
+ const PI_LIFECYCLE_MARKER = "STDD managed Pi lifecycle extension v1";
12
+ const LEGACY_STATUS_COMMANDS = [
13
+ "npx --no stdd status || true",
14
+ "npm exec --offline -- stdd status || true",
15
+ "npm exec --offline -- stdd status --local || true",
16
+ ];
17
+ const GENERATED_STATUS_COMMAND_PATTERN =
18
+ /^npm exec --offline --package=@stdd\/cli@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)? -- stdd status(?: --local)? \|\| true$/;
19
+ const SOURCE_CHECKOUT_RUNNER = 'node "$(git rev-parse --show-toplevel)/cli/stdd.mjs"';
20
+ const PINNED_NPM_RUNNER_IN_COMMAND_PATTERN =
21
+ /npm exec --offline --package=@stdd\/cli@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)? -- stdd/;
22
+ const LEGACY_CODEX_STOP_NORMALIZER =
23
+ 'const fs=require("node:fs");let value;try{value=JSON.parse(fs.readFileSync(0,"utf8"));}catch{}const valid=typeof value==="object"&&value!==null&&!Array.isArray(value)&&(Object.keys(value).length===0||(value.decision==="block"&&typeof value.reason==="string"&&value.reason.length>0));process.stdout.write(valid?JSON.stringify(value)+"\\n":"{}\\n");';
24
+ const CODEX_STOP_NORMALIZER =
25
+ 'const fs=require("node:fs");let value;try{value=JSON.parse(fs.readFileSync(0,"utf8"));}catch{}const object=typeof value==="object"&&value!==null&&!Array.isArray(value);const keys=object?Object.keys(value):[];const valid=object&&(keys.length===0||(keys.length===2&&keys.includes("decision")&&keys.includes("reason")&&value.decision==="block"&&typeof value.reason==="string"&&value.reason.trim().length>0));process.stdout.write(valid?JSON.stringify(value)+"\\n":"{}\\n");';
26
+
27
+ export function isStddSourceCheckout(targetDir) {
28
+ if (
29
+ fs.existsSync(path.join(targetDir, "cli", "stdd.mjs")) &&
30
+ fs.existsSync(path.join(targetDir, "package.json"))
31
+ ) {
32
+ try {
33
+ if (JSON.parse(fs.readFileSync(path.join(targetDir, "package.json"), "utf8")).name === "@stdd/cli")
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+
42
+ export function hasLocalStddBinary(targetDir) {
43
+ if (isStddSourceCheckout(targetDir)) return true;
44
+ return ["stdd", "stdd.cmd", "stdd.ps1"].some((name) =>
45
+ fs.existsSync(path.join(targetDir, "node_modules", ".bin", name)),
46
+ );
47
+ }
48
+
49
+ function hookSettingsShapeError(settings, events) {
50
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return "settings";
51
+ if (
52
+ settings.hooks !== undefined &&
53
+ (typeof settings.hooks !== "object" || settings.hooks === null || Array.isArray(settings.hooks))
54
+ ) {
55
+ return "hooks";
56
+ }
57
+ const allEvents = new Set([...events, ...Object.keys(settings.hooks ?? {})]);
58
+ for (const event of allEvents) {
59
+ const groups = settings.hooks?.[event];
60
+ if (groups === undefined) continue;
61
+ if (!Array.isArray(groups)) return `hooks.${event}`;
62
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
63
+ const group = groups[groupIndex];
64
+ if (typeof group !== "object" || group === null || Array.isArray(group)) {
65
+ return `hooks.${event}[${groupIndex}]`;
66
+ }
67
+ if (group.matcher !== undefined && typeof group.matcher !== "string") {
68
+ return `hooks.${event}[${groupIndex}].matcher`;
69
+ }
70
+ if (!Array.isArray(group.hooks)) return `hooks.${event}[${groupIndex}].hooks`;
71
+ for (let hookIndex = 0; hookIndex < group.hooks.length; hookIndex++) {
72
+ const hook = group.hooks[hookIndex];
73
+ if (typeof hook !== "object" || hook === null || Array.isArray(hook)) {
74
+ return `hooks.${event}[${groupIndex}].hooks[${hookIndex}]`;
75
+ }
76
+ if (typeof hook.type !== "string" || hook.type === "") {
77
+ return `hooks.${event}[${groupIndex}].hooks[${hookIndex}].type`;
78
+ }
79
+ if (hook.type === "command" && (typeof hook.command !== "string" || hook.command === "")) {
80
+ return `hooks.${event}[${groupIndex}].hooks[${hookIndex}].command`;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function validateJsonHookEvents({ targetDir, settingsRelative, settingsLabel, events, parseMessage }) {
89
+ const settingsPath = resolveWritableRepoPath(
90
+ targetDir,
91
+ settingsRelative,
92
+ `${settingsLabel} settings path`,
93
+ );
94
+ if (!fs.existsSync(settingsPath)) return true;
95
+ let settings;
96
+ try {
97
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
98
+ } catch {
99
+ console.error(`${settingsRelative} does not parse — left untouched; ${parseMessage}`);
100
+ return false;
101
+ }
102
+ const invalid = hookSettingsShapeError(settings, events);
103
+ if (invalid) {
104
+ console.error(
105
+ `${settingsRelative} has an invalid ${invalid} shape — left untouched; ${parseMessage}`,
106
+ );
107
+ return false;
108
+ }
109
+ return true;
110
+ }
111
+
112
+ function installJsonHook({
113
+ targetDir,
114
+ settingsRelative,
115
+ settingsLabel,
116
+ event,
117
+ entry,
118
+ legacyCommands,
119
+ generatedCommandPattern = null,
120
+ isManagedCommand = () => false,
121
+ parseMessage,
122
+ existingMessage,
123
+ migratedMessage,
124
+ installedMessage,
125
+ }) {
126
+ const settingsPath = resolveWritableRepoPath(
127
+ targetDir,
128
+ settingsRelative,
129
+ `${settingsLabel} settings path`,
130
+ );
131
+ let settings = {};
132
+ if (fs.existsSync(settingsPath)) {
133
+ try {
134
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
135
+ } catch {
136
+ console.error(
137
+ `${settingsRelative} does not parse — left untouched; ${parseMessage}:\n ` +
138
+ JSON.stringify({ hooks: { [event]: [entry] } }),
139
+ );
140
+ return false;
141
+ }
142
+ }
143
+ const invalid = hookSettingsShapeError(settings, [event]);
144
+ if (invalid) {
145
+ console.error(
146
+ `${settingsRelative} has an invalid ${invalid} shape — left untouched; ${parseMessage}`,
147
+ );
148
+ return false;
149
+ }
150
+ settings.hooks ??= {};
151
+ settings.hooks[event] ??= [];
152
+ let migrated = false;
153
+ const currentCommand = entry.hooks[0].command;
154
+ const splitGroups = [];
155
+ for (const group of [...settings.hooks[event]]) {
156
+ if (!Array.isArray(group?.hooks)) continue;
157
+ for (let index = group.hooks.length - 1; index >= 0; index--) {
158
+ const hook = group.hooks[index];
159
+ const managed =
160
+ hook?.command === currentCommand ||
161
+ legacyCommands.includes(hook?.command) ||
162
+ (typeof hook?.command === "string" &&
163
+ (generatedCommandPattern?.test(hook.command) || isManagedCommand(hook.command)));
164
+ if (!managed) continue;
165
+ if (hook.command !== currentCommand) {
166
+ hook.command = currentCommand;
167
+ migrated = true;
168
+ }
169
+ if (entry.matcher === undefined || group.matcher === entry.matcher) continue;
170
+ if (group.hooks.length === 1) {
171
+ group.matcher = entry.matcher;
172
+ } else {
173
+ // A matcher belongs to the whole group. Moving only the managed
174
+ // hook keeps unrelated sibling hooks on the user's matcher.
175
+ group.hooks.splice(index, 1);
176
+ splitGroups.push({ matcher: entry.matcher, hooks: [hook] });
177
+ }
178
+ migrated = true;
179
+ }
180
+ }
181
+ settings.hooks[event].push(...splitGroups);
182
+ let currentSeen = false;
183
+ for (const group of settings.hooks[event]) {
184
+ if (!Array.isArray(group?.hooks)) continue;
185
+ group.hooks = group.hooks.filter((hook) => {
186
+ if (hook?.command !== currentCommand) return true;
187
+ if (!currentSeen) {
188
+ currentSeen = true;
189
+ return true;
190
+ }
191
+ migrated = true;
192
+ return false;
193
+ });
194
+ }
195
+ settings.hooks[event] = settings.hooks[event].filter(
196
+ (group) => !Array.isArray(group?.hooks) || group.hooks.length > 0,
197
+ );
198
+ if (migrated) {
199
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, "\t")}\n`);
200
+ console.log(migratedMessage);
201
+ return true;
202
+ }
203
+ if (
204
+ currentSeen ||
205
+ settings.hooks[event].some((group) =>
206
+ (Array.isArray(group?.hooks) ? group.hooks : []).some((hook) => hook?.command === currentCommand),
207
+ )
208
+ ) {
209
+ console.log(existingMessage);
210
+ return true;
211
+ }
212
+ settings.hooks[event].push(entry);
213
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
214
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, "\t")}\n`);
215
+ console.log(installedMessage);
216
+ return true;
217
+ }
218
+
219
+ function removeManagedJsonHook({
220
+ targetDir,
221
+ settingsRelative,
222
+ settingsLabel,
223
+ event,
224
+ currentCommand,
225
+ legacyCommands,
226
+ generatedCommandPattern,
227
+ removedMessage,
228
+ }) {
229
+ const settingsPath = resolveWritableRepoPath(
230
+ targetDir,
231
+ settingsRelative,
232
+ `${settingsLabel} settings path`,
233
+ );
234
+ if (!fs.existsSync(settingsPath)) return;
235
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
236
+ const groups = settings.hooks?.[event];
237
+ if (!Array.isArray(groups)) return;
238
+ let removed = false;
239
+ for (const group of groups) {
240
+ if (!Array.isArray(group?.hooks)) continue;
241
+ group.hooks = group.hooks.filter((hook) => {
242
+ const managed =
243
+ hook?.command === currentCommand ||
244
+ legacyCommands.includes(hook?.command) ||
245
+ (typeof hook?.command === "string" && generatedCommandPattern.test(hook.command));
246
+ if (managed) removed = true;
247
+ return !managed;
248
+ });
249
+ }
250
+ settings.hooks[event] = groups.filter(
251
+ (group) => !Array.isArray(group?.hooks) || group.hooks.length > 0,
252
+ );
253
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
254
+ if (!removed) return;
255
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, "\t")}\n`);
256
+ console.log(removedMessage);
257
+ }
258
+
259
+ function installClaudeSessionHooks(targetDir, npmRunner) {
260
+ if (
261
+ !validateJsonHookEvents({
262
+ targetDir,
263
+ settingsRelative: CLAUDE_HOOKS_FILE,
264
+ settingsLabel: "Claude",
265
+ events: ["SessionStart", "PostCompact"],
266
+ parseMessage: "merge the session hooks manually",
267
+ })
268
+ ) {
269
+ return;
270
+ }
271
+ const sessionInstalled = installJsonHook({
272
+ targetDir,
273
+ settingsRelative: CLAUDE_HOOKS_FILE,
274
+ settingsLabel: "Claude",
275
+ event: "SessionStart",
276
+ entry: {
277
+ matcher: "startup|resume|clear|compact",
278
+ hooks: [{ type: "command", command: `${npmRunner} status --local || true` }],
279
+ },
280
+ legacyCommands: LEGACY_STATUS_COMMANDS,
281
+ generatedCommandPattern: GENERATED_STATUS_COMMAND_PATTERN,
282
+ parseMessage: "merge the hook manually",
283
+ existingMessage: ".claude/settings.json already carries the session-start hook — left untouched",
284
+ migratedMessage:
285
+ "Migrated the Claude Code session-start hook to the current STDD lifecycle contract",
286
+ installedMessage:
287
+ "Wired the Claude Code SessionStart hook (startup|resume|clear|compact → stdd status --local)",
288
+ });
289
+ if (!sessionInstalled) return;
290
+ removeManagedJsonHook({
291
+ targetDir,
292
+ settingsRelative: CLAUDE_HOOKS_FILE,
293
+ settingsLabel: "Claude",
294
+ event: "PostCompact",
295
+ currentCommand: `${npmRunner} status --local || true`,
296
+ legacyCommands: LEGACY_STATUS_COMMANDS,
297
+ generatedCommandPattern: GENERATED_STATUS_COMMAND_PATTERN,
298
+ removedMessage:
299
+ "Removed the redundant managed Claude Code PostCompact hook (SessionStart compact owns restore)",
300
+ });
301
+ }
302
+
303
+ export function claudeStopCommand(npmRunner) {
304
+ return `{ output="$(${npmRunner} stop-hook 2>&1)"; status=$?; if [ "$status" -eq 2 ]; then printf '%s\\n' "$output" >&2; exit 2; fi; exit 0; }`;
305
+ }
306
+
307
+ function normalizedCodexStopCommand(npmRunner, normalizer) {
308
+ return `{ : "STDD managed Codex Stop protocol v1"; output="$(${npmRunner} stop-hook --agent codex 2>/dev/null)" && printf '%s' "$output" | node -e '${normalizer}' 2>/dev/null || printf '{}\\n'; exit 0; }`;
309
+ }
310
+
311
+ function legacyMarkedCodexStopCommand(npmRunner, normalizer) {
312
+ return `{ stdd_codex_stop_protocol=1; output="$(${npmRunner} stop-hook --agent codex 2>/dev/null)" && printf '%s' "$output" | node -e '${normalizer}' 2>/dev/null || printf '{}\\n'; exit 0; }`;
313
+ }
314
+
315
+ export function codexStopCommand(npmRunner) {
316
+ return normalizedCodexStopCommand(npmRunner, CODEX_STOP_NORMALIZER);
317
+ }
318
+
319
+ function legacyCodexStopCommand(npmRunner) {
320
+ return `{ output="$(${npmRunner} stop-hook --agent codex 2>/dev/null)" && printf '%s\\n' "$output" || printf '{}\\n'; exit 0; }`;
321
+ }
322
+
323
+ function knownCodexStopRunners(command) {
324
+ const runners = [SOURCE_CHECKOUT_RUNNER, "npm exec --offline -- stdd"].filter((runner) =>
325
+ command.includes(runner),
326
+ );
327
+ const pinnedNpmRunner = command.match(PINNED_NPM_RUNNER_IN_COMMAND_PATTERN)?.[0];
328
+ if (pinnedNpmRunner) runners.push(pinnedNpmRunner);
329
+ return runners;
330
+ }
331
+
332
+ function isKnownGeneratedCodexStopCommand(command) {
333
+ return knownCodexStopRunners(command).some(
334
+ (runner) =>
335
+ command === `${runner} stop-hook --agent codex` ||
336
+ command === legacyCodexStopCommand(runner) ||
337
+ command === legacyMarkedCodexStopCommand(runner, LEGACY_CODEX_STOP_NORMALIZER) ||
338
+ command === legacyMarkedCodexStopCommand(runner, CODEX_STOP_NORMALIZER) ||
339
+ command === normalizedCodexStopCommand(runner, LEGACY_CODEX_STOP_NORMALIZER) ||
340
+ command === normalizedCodexStopCommand(runner, CODEX_STOP_NORMALIZER),
341
+ );
342
+ }
343
+
344
+ function installClaudeStopHook(targetDir, npmRunner) {
345
+ const entry = { hooks: [{ type: "command", command: claudeStopCommand(npmRunner) }] };
346
+ installJsonHook({
347
+ targetDir,
348
+ settingsRelative: CLAUDE_HOOKS_FILE,
349
+ settingsLabel: "Claude",
350
+ event: "Stop",
351
+ entry,
352
+ legacyCommands: [
353
+ "npx --no stdd stop-hook",
354
+ "npm exec --offline -- stdd stop-hook",
355
+ `${npmRunner} stop-hook`,
356
+ ],
357
+ generatedCommandPattern:
358
+ /^(?:npm exec --offline --package=@stdd\/cli@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)? -- stdd stop-hook|\{ output="\$\(npm exec --offline --package=@stdd\/cli@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)? -- stdd stop-hook 2>&1\)"; status=\$\?; if \[ "\$status" -eq 2 \]; then printf '%s\\n' "\$output" >&2; exit 2; fi; exit 0; \})$/,
359
+ parseMessage: "merge the hook manually",
360
+ existingMessage: ".claude/settings.json already carries the stop hook — left untouched",
361
+ migratedMessage: "Migrated the Claude Code stop hook to the local offline stdd binary",
362
+ installedMessage:
363
+ "Wired the Claude Code Stop hook (stdd stop-hook — gate on session end) in .claude/settings.json",
364
+ });
365
+ }
366
+
367
+ function installCodexSessionHook(targetDir, npmRunner) {
368
+ installJsonHook({
369
+ targetDir,
370
+ settingsRelative: CODEX_HOOKS_FILE,
371
+ settingsLabel: "Codex",
372
+ event: "SessionStart",
373
+ entry: {
374
+ matcher: "startup|resume|clear|compact",
375
+ hooks: [{ type: "command", command: `${npmRunner} status --local || true` }],
376
+ },
377
+ legacyCommands: LEGACY_STATUS_COMMANDS,
378
+ generatedCommandPattern: GENERATED_STATUS_COMMAND_PATTERN,
379
+ parseMessage: "merge the hook manually",
380
+ existingMessage: ".codex/hooks.json already carries the session-start hook — left untouched",
381
+ migratedMessage: "Re-pinned the Codex session-start hook",
382
+ installedMessage:
383
+ "Wired the Codex SessionStart hook (startup|resume|clear|compact → stdd status --local)",
384
+ });
385
+ }
386
+
387
+ function installCodexStopHook(targetDir, npmRunner) {
388
+ installJsonHook({
389
+ targetDir,
390
+ settingsRelative: CODEX_HOOKS_FILE,
391
+ settingsLabel: "Codex",
392
+ event: "Stop",
393
+ entry: {
394
+ hooks: [{ type: "command", command: codexStopCommand(npmRunner) }],
395
+ },
396
+ legacyCommands: [
397
+ "npm exec --offline -- stdd stop-hook --agent codex",
398
+ `${npmRunner} stop-hook --agent codex`,
399
+ legacyCodexStopCommand(npmRunner),
400
+ ],
401
+ isManagedCommand: isKnownGeneratedCodexStopCommand,
402
+ parseMessage: "merge the hook manually",
403
+ existingMessage: ".codex/hooks.json already carries the stop hook — left untouched",
404
+ migratedMessage: "Re-pinned the Codex stop hook",
405
+ installedMessage: "Wired the Codex Stop hook (stdd stop-hook --agent codex)",
406
+ });
407
+ }
408
+
409
+ export function renderPiLifecycleExtension(npmRunner, { sessionHook = false, stopHook = false } = {}) {
410
+ const metadata = JSON.stringify({
411
+ sessionHook: Boolean(sessionHook),
412
+ stopHook: Boolean(stopHook),
413
+ runner: npmRunner,
414
+ });
415
+ const lines = [
416
+ `// ${PI_LIFECYCLE_MARKER} ${metadata}`,
417
+ 'import { execFile } from "node:child_process";',
418
+ "",
419
+ `const runner = ${JSON.stringify(npmRunner)};`,
420
+ "",
421
+ "function runStdd(ctx, args) {",
422
+ "\treturn new Promise((resolve) => {",
423
+ '\t\tconst child = execFile("/bin/sh", ["-c", runner + " " + args], {',
424
+ "\t\t\tcwd: ctx.cwd,",
425
+ '\t\t\tencoding: "utf8",',
426
+ "\t\t\ttimeout: 10_000,",
427
+ "\t\t\tmaxBuffer: 1024 * 1024,",
428
+ "\t\t}, (error, stdout, stderr) => {",
429
+ "\t\t\tconst exitCode = error",
430
+ '\t\t\t\t? (typeof error.code === "number" ? error.code : null)',
431
+ "\t\t\t\t: 0;",
432
+ "\t\t\tresolve({ exitCode, stdout, stderr });",
433
+ "\t\t});",
434
+ "\t\tchild.stdin?.end();",
435
+ "\t});",
436
+ "}",
437
+ "",
438
+ "export default function stddLifecycle(pi) {",
439
+ ];
440
+ if (sessionHook) {
441
+ lines.push(
442
+ "\tconst restore = async (_event, ctx) => {",
443
+ '\t\tconst result = await runStdd(ctx, "status --local");',
444
+ '\t\tif (result.exitCode !== 0 || result.stdout.trim() === "") return;',
445
+ "\t\tpi.sendMessage({",
446
+ '\t\t\tcustomType: "stdd-status",',
447
+ "\t\t\tcontent: result.stdout,",
448
+ "\t\t\tdisplay: false,",
449
+ '\t\t}, { deliverAs: "nextTurn" });',
450
+ "\t};",
451
+ '\tpi.on("session_start", restore);',
452
+ '\tpi.on("session_compact", restore);',
453
+ );
454
+ }
455
+ if (stopHook) {
456
+ if (sessionHook) lines.push("");
457
+ lines.push(
458
+ "\tlet skipNextGate = false;",
459
+ '\tpi.on("agent_settled", async (_event, ctx) => {',
460
+ "\t\tif (skipNextGate) {",
461
+ "\t\t\tskipNextGate = false;",
462
+ "\t\t\treturn;",
463
+ "\t\t}",
464
+ '\t\tconst result = await runStdd(ctx, "stop-hook");',
465
+ '\t\tif (result.exitCode !== 2 || result.stderr.trim() === "") return;',
466
+ "\t\tskipNextGate = true;",
467
+ "\t\tpi.sendMessage({",
468
+ '\t\t\tcustomType: "stdd-stop-gate",',
469
+ "\t\t\tcontent: result.stderr,",
470
+ "\t\t\tdisplay: true,",
471
+ '\t\t}, { deliverAs: "followUp", triggerTurn: true });',
472
+ "\t});",
473
+ );
474
+ }
475
+ lines.push("}", "");
476
+ return lines.join("\n");
477
+ }
478
+
479
+ function parseManagedPiLifecycleExtension(content) {
480
+ const firstLine = content.split(/\r?\n/u, 1)[0];
481
+ const prefix = `// ${PI_LIFECYCLE_MARKER} `;
482
+ if (!firstLine.startsWith(prefix)) return null;
483
+ let metadata;
484
+ try {
485
+ metadata = JSON.parse(firstLine.slice(prefix.length));
486
+ } catch {
487
+ return null;
488
+ }
489
+ if (
490
+ typeof metadata !== "object" ||
491
+ metadata === null ||
492
+ Array.isArray(metadata) ||
493
+ typeof metadata.runner !== "string" ||
494
+ typeof metadata.sessionHook !== "boolean" ||
495
+ typeof metadata.stopHook !== "boolean"
496
+ ) {
497
+ return null;
498
+ }
499
+ const expected = renderPiLifecycleExtension(metadata.runner, metadata);
500
+ return expected === content ? metadata : null;
501
+ }
502
+
503
+ function validatePiLifecycleExtension(targetDir) {
504
+ const extensionPath = resolveWritableRepoPath(targetDir, PI_HOOKS_FILE, "Pi lifecycle extension path");
505
+ if (!fs.existsSync(extensionPath)) return true;
506
+ const current = fs.readFileSync(extensionPath, "utf8");
507
+ if (parseManagedPiLifecycleExtension(current)) return true;
508
+ console.error(
509
+ `${PI_HOOKS_FILE} conflicts with the managed Pi lifecycle extension — left untouched; ` +
510
+ "move or merge it manually",
511
+ );
512
+ return false;
513
+ }
514
+
515
+ function installPiLifecycleExtension(
516
+ targetDir,
517
+ npmRunner,
518
+ { sessionHook = false, stopHook = false } = {},
519
+ ) {
520
+ const extensionPath = resolveWritableRepoPath(targetDir, PI_HOOKS_FILE, "Pi lifecycle extension path");
521
+ let previous = null;
522
+ if (fs.existsSync(extensionPath)) {
523
+ const current = fs.readFileSync(extensionPath, "utf8");
524
+ previous = parseManagedPiLifecycleExtension(current);
525
+ if (!previous) return false;
526
+ }
527
+ const desired = {
528
+ sessionHook: Boolean(sessionHook || previous?.sessionHook),
529
+ stopHook: Boolean(stopHook || previous?.stopHook),
530
+ };
531
+ const rendered = renderPiLifecycleExtension(npmRunner, desired);
532
+ if (fs.existsSync(extensionPath) && fs.readFileSync(extensionPath, "utf8") === rendered) {
533
+ console.log(`${PI_HOOKS_FILE} already carries the requested STDD lifecycle extension`);
534
+ return true;
535
+ }
536
+ fs.mkdirSync(path.dirname(extensionPath), { recursive: true });
537
+ fs.writeFileSync(extensionPath, rendered);
538
+ console.log(
539
+ `Wired the Pi lifecycle extension (${[
540
+ ...(desired.sessionHook ? ["session restore"] : []),
541
+ ...(desired.stopHook ? ["bounded stop continuation"] : []),
542
+ ].join(", ")})`,
543
+ );
544
+ return true;
545
+ }
546
+
547
+ export function installSessionHook(targetDir, npmRunner, tools = ["claude"]) {
548
+ if (tools.includes("claude")) installClaudeSessionHooks(targetDir, npmRunner);
549
+ if (tools.includes("codex")) installCodexSessionHook(targetDir, npmRunner);
550
+ if (tools.includes("pi")) installPiLifecycleExtension(targetDir, npmRunner, { sessionHook: true });
551
+ }
552
+
553
+ export function installStopHook(targetDir, npmRunner, tools = ["claude"]) {
554
+ if (tools.includes("claude")) installClaudeStopHook(targetDir, npmRunner);
555
+ if (tools.includes("codex")) installCodexStopHook(targetDir, npmRunner);
556
+ if (tools.includes("pi")) installPiLifecycleExtension(targetDir, npmRunner, { stopHook: true });
557
+ }
558
+
559
+ /**
560
+ * Install every requested lifecycle hook as one configuration operation.
561
+ * All touched settings files are validated before the first write, so an
562
+ * invalid sibling event cannot leave a partial install behind.
563
+ */
564
+ function installAgentHooksViaPathnames(
565
+ targetDir,
566
+ npmRunner,
567
+ tools = ["claude"],
568
+ { sessionHook = false, stopHook = false } = {},
569
+ ) {
570
+ const validations = [];
571
+ if (tools.includes("claude")) {
572
+ const events = [
573
+ ...(sessionHook ? ["SessionStart", "PostCompact"] : []),
574
+ ...(stopHook ? ["Stop"] : []),
575
+ ];
576
+ if (events.length > 0) {
577
+ validations.push({
578
+ settingsRelative: CLAUDE_HOOKS_FILE,
579
+ settingsLabel: "Claude",
580
+ events,
581
+ });
582
+ }
583
+ }
584
+ if (tools.includes("codex")) {
585
+ const events = [...(sessionHook ? ["SessionStart"] : []), ...(stopHook ? ["Stop"] : [])];
586
+ if (events.length > 0) {
587
+ validations.push({
588
+ settingsRelative: CODEX_HOOKS_FILE,
589
+ settingsLabel: "Codex",
590
+ events,
591
+ });
592
+ }
593
+ }
594
+ if (tools.includes("pi") && (sessionHook || stopHook) && !validatePiLifecycleExtension(targetDir)) {
595
+ return false;
596
+ }
597
+ for (const validation of validations) {
598
+ if (
599
+ !validateJsonHookEvents({
600
+ targetDir,
601
+ ...validation,
602
+ parseMessage: "merge the requested lifecycle hooks manually",
603
+ })
604
+ ) {
605
+ return false;
606
+ }
607
+ }
608
+ if (sessionHook) installSessionHook(targetDir, npmRunner, tools);
609
+ if (stopHook) installStopHook(targetDir, npmRunner, tools);
610
+ return true;
611
+ }
612
+
613
+ /**
614
+ * Render lifecycle hook updates in an isolated staging directory, then
615
+ * publish the exact resulting bytes through the init/configure helper
616
+ * session. Target bytes and identities are read through capabilities before
617
+ * rendering, and publication rejects any target that changed in between.
618
+ */
619
+ export async function prepareAgentHooks(
620
+ context,
621
+ npmRunner,
622
+ tools = ["claude"],
623
+ { sessionHook = false, stopHook = false } = {},
624
+ ) {
625
+ const relatives = [
626
+ ...(tools.includes("claude") && (sessionHook || stopHook) ? [CLAUDE_HOOKS_FILE] : []),
627
+ ...(tools.includes("codex") && (sessionHook || stopHook) ? [CODEX_HOOKS_FILE] : []),
628
+ ...(tools.includes("pi") && (sessionHook || stopHook) ? [PI_HOOKS_FILE] : []),
629
+ ];
630
+ const inspected = new Map();
631
+ for (const relative of relatives) {
632
+ inspected.set(
633
+ relative,
634
+ await readOptionalNativeRepoFile(context, relative, {
635
+ label: `${relative} lifecycle configuration`,
636
+ }),
637
+ );
638
+ }
639
+
640
+ const publications = [];
641
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), "stdd-hook-publication-"));
642
+ try {
643
+ for (const [relative, state] of inspected) {
644
+ if (!state) continue;
645
+ const stagedPath = path.join(staging, ...relative.split("/"));
646
+ fs.mkdirSync(path.dirname(stagedPath), { recursive: true });
647
+ fs.writeFileSync(stagedPath, state.bytes);
648
+ }
649
+ const installed = installAgentHooksViaPathnames(staging, npmRunner, tools, {
650
+ sessionHook,
651
+ stopHook,
652
+ });
653
+ if (!installed) return async () => false;
654
+ for (const [relative, state] of inspected) {
655
+ const stagedPath = path.join(staging, ...relative.split("/"));
656
+ if (!fs.existsSync(stagedPath)) continue;
657
+ const desired = fs.readFileSync(stagedPath);
658
+ if (state?.bytes.equals(desired)) continue;
659
+ const mode =
660
+ state?.file.observation.identity.platform === "win32"
661
+ ? 0o644
662
+ : Number(state?.file.observation.permissions ?? 0o644) & 0o777;
663
+ if (![0o600, 0o644, 0o755].includes(mode)) {
664
+ throw new Error(
665
+ `${relative} has unsupported mode ${mode.toString(8)}; preserve it manually before retrying`,
666
+ );
667
+ }
668
+ publications.push({ relative, desired, mode, state });
669
+ }
670
+ } finally {
671
+ fs.rmSync(staging, { recursive: true, force: true });
672
+ }
673
+ return async () => {
674
+ for (const { relative, desired, mode, state } of publications) {
675
+ await publishNativeRepoFile(context, relative, desired, {
676
+ mode,
677
+ tempPrefix: ".stdd-hook-",
678
+ expectedTarget: state?.file.observation.identity ?? null,
679
+ expectedBytes: state?.bytes ?? null,
680
+ });
681
+ }
682
+ return true;
683
+ };
684
+ }
685
+
686
+ export async function installAgentHooks(context, npmRunner, tools = ["claude"], options = {}) {
687
+ const publish = await prepareAgentHooks(context, npmRunner, tools, options);
688
+ return publish();
689
+ }