@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,580 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveRepoPath, resolveWritableRepoPath } from "../sdk/path.mjs";
5
+ import { escapeNonPrintableSingleLine } from "../sdk/text.mjs";
6
+ import { deriveLoopState } from "../sdk/workflow.mjs";
7
+ import { statusPr } from "./ci.mjs";
8
+ import { loadConfig } from "./config.mjs";
9
+ import {
10
+ gitChangedPaths,
11
+ gitWorkingPaths,
12
+ isStateExemptPath,
13
+ LEDGER_REL,
14
+ PLAN_REL,
15
+ parseStateLedger,
16
+ rawLedger,
17
+ requireBranch,
18
+ resolveRepoDir,
19
+ scopeLedgerForCheckout,
20
+ taskPlanContent,
21
+ } from "./ledger.mjs";
22
+ import { DEFAULT_CONFIG, globToRegExp, mergeConfig, parsePlan, planProgress } from "./lib.mjs";
23
+ import { reviewRequestAnswered } from "./review.mjs";
24
+ import { statePath } from "./runtime.mjs";
25
+ import { checkoutSnapshot, reviewSnapshot } from "./snapshot.mjs";
26
+
27
+ /**
28
+ * `stdd status` — the next-step oracle. Inputs in order of trust: git (diff
29
+ * against the configured baseRef, dirty state), the ledger, then the forge.
30
+ */
31
+ export function status(cwd, asJson, localOnly = false) {
32
+ const config = loadConfig(cwd);
33
+ const branch = requireBranch(cwd);
34
+ const allBranchEvents = rawLedger(cwd, branch);
35
+ const scoped = scopeLedgerForCheckout(cwd, branch, allBranchEvents, config);
36
+ const task = scoped.state;
37
+ const events = scoped.events;
38
+
39
+ // git: changed files = committed diff vs baseRef + dirty working tree
40
+ let changed = null;
41
+ if (config.baseRef) {
42
+ try {
43
+ changed = [
44
+ ...new Set([...gitChangedPaths(cwd, `${config.baseRef}...HEAD`), ...gitWorkingPaths(cwd)]),
45
+ ].filter((file) => !isStateExemptPath(cwd, file));
46
+ } catch {
47
+ changed = null; // unresolvable baseRef — report unknown, never error
48
+ }
49
+ }
50
+ const canonical = config.canonicalDocs.map(globToRegExp);
51
+ const docsChanged = (changed ?? []).filter((f) => canonical.some((re) => re.test(f)));
52
+ const nonDocChanged = (changed ?? []).filter((f) => !canonical.some((re) => re.test(f)));
53
+ if (task.state === "invalid") {
54
+ const invalid = {
55
+ state: "invalid",
56
+ task: null,
57
+ branch,
58
+ loop: {
59
+ docs: { done: false },
60
+ red: { done: false },
61
+ impl: { done: false },
62
+ verify: { done: false, stale: false },
63
+ },
64
+ slice: { declared: false },
65
+ plan: { present: false },
66
+ review: null,
67
+ pr: { state: "unknown", reason: "invalid task ledger" },
68
+ next: `repair the malformed task boundary in .stdd/ledger.jsonl: ${task.reason}`,
69
+ };
70
+ if (asJson) console.log(JSON.stringify(invalid, null, "\t"));
71
+ else
72
+ console.log(
73
+ `task: invalid on ${branch}\nerror: ${task.reason}\nnext: repair .stdd/ledger.jsonl before recording evidence`,
74
+ );
75
+ return;
76
+ }
77
+ if (task.state === "idle") {
78
+ const idle = {
79
+ state: "idle",
80
+ task: null,
81
+ branch,
82
+ loop: {
83
+ docs: { done: false },
84
+ red: { done: false },
85
+ impl: { done: false },
86
+ verify: { done: false, stale: false },
87
+ },
88
+ slice: { declared: false },
89
+ plan: { present: false },
90
+ review: null,
91
+ pr: { state: "unknown", reason: "idle task" },
92
+ next: 'start a task with `stdd task start "<short name>"`',
93
+ };
94
+ if (asJson) console.log(JSON.stringify(idle, null, "\t"));
95
+ else
96
+ console.log(
97
+ `task: idle on ${branch}\nnext: start a task with \`stdd task start "<short name>"\``,
98
+ );
99
+ return;
100
+ }
101
+
102
+ const docsEvent = events.filter((e) => e.event === "docs").at(-1) ?? null;
103
+ const checkedPathsExist = (docsEvent?.paths ?? []).every((docPath) => {
104
+ try {
105
+ return fs.existsSync(resolveRepoPath(cwd, docPath, "recorded docs path"));
106
+ } catch {
107
+ return false;
108
+ }
109
+ });
110
+ const docsEventDone =
111
+ docsEvent &&
112
+ (changed === null
113
+ ? docsEvent.decision === "not-applicable" || (docsEvent.paths?.length > 0 && checkedPathsExist)
114
+ : docsEvent.decision === "updated-first"
115
+ ? docsEvent.paths?.length > 0 &&
116
+ docsEvent.paths.every((docPath) => docsChanged.includes(docPath))
117
+ : docsEvent.decision === "checked"
118
+ ? docsChanged.length === 0 && docsEvent.paths?.length > 0 && checkedPathsExist
119
+ : docsEvent.decision === "not-applicable" && docsChanged.length === 0);
120
+ const currentSnapshot = checkoutSnapshot(cwd);
121
+ const {
122
+ redEvent,
123
+ redLegacy,
124
+ verifyEvent,
125
+ recordedVerify,
126
+ verifyStale,
127
+ loop: recordedLoop,
128
+ } = deriveLoopState(events, currentSnapshot, nonDocChanged.length > 0);
129
+
130
+ const loop = {
131
+ docs: docsEvent
132
+ ? docsEventDone
133
+ ? {
134
+ done: true,
135
+ source: "ledger",
136
+ decision: docsEvent.decision,
137
+ paths: docsEvent.paths,
138
+ }
139
+ : {
140
+ done: false,
141
+ source: "ledger",
142
+ decision: docsEvent.decision,
143
+ paths: docsEvent.paths,
144
+ stale: true,
145
+ }
146
+ : docsChanged.length > 0
147
+ ? { done: true, source: "diff", paths: docsChanged }
148
+ : { done: false },
149
+ ...recordedLoop,
150
+ };
151
+ const scopeEvent = events.filter((e) => e.event === "scope").at(-1) ?? null;
152
+ const slice = scopeEvent
153
+ ? {
154
+ declared: true,
155
+ frozenPaths: scopeEvent.frozenPaths,
156
+ allowedPaths: scopeEvent.allowedPaths,
157
+ }
158
+ : { declared: false };
159
+ // the durable plan: a checkbox is a claim; [red:]-tagged items need the
160
+ // ledger's proof (see planProgress)
161
+ const reviewEvents = events.filter((e) => e.event === "review");
162
+ const latestReview = reviewEvents.at(-1) ?? null;
163
+ // a stale approval reopens the review everywhere: for grading purposes
164
+ // it stops being the newest approval
165
+ const reviewStale =
166
+ latestReview?.verdict === "approved" &&
167
+ latestReview.snapshot !== reviewSnapshot(cwd, config.baseRef);
168
+ const gradableReviews = reviewStale
169
+ ? [...reviewEvents, { event: "review", verdict: "stale" }]
170
+ : reviewEvents;
171
+ const planPath = statePath(cwd, PLAN_REL, "plan path");
172
+ const planContent = taskPlanContent(cwd, task, planPath);
173
+ const plan =
174
+ planContent !== null
175
+ ? (() => {
176
+ const parsed = parsePlan(planContent);
177
+ const p = planProgress(
178
+ parsed,
179
+ events.filter((e) => e.event === "red"),
180
+ gradableReviews,
181
+ );
182
+ const pick = (i) => ({
183
+ text: i.text,
184
+ line: i.line,
185
+ red: i.red,
186
+ review: i.review,
187
+ });
188
+ // a [review:]-tagged item is the closing review and closes only
189
+ // through the ledger; untagged plans fall back to the LAST item
190
+ // mentioning "review" — a mid-plan "review X" step never counts
191
+ const tagged = parsed.items.find((i) => i.review) ?? null;
192
+ const lastItem = parsed.items.at(-1) ?? null;
193
+ const heuristic = lastItem && /\breview\b/i.test(lastItem.text) ? lastItem : null;
194
+ const reviewItem = tagged ?? heuristic;
195
+ const reviewDone = tagged
196
+ ? latestReview?.verdict === "approved" && !reviewStale
197
+ : (reviewItem?.checked ?? false);
198
+ return {
199
+ present: true,
200
+ total: p.total,
201
+ done: p.done,
202
+ mode: parsed.mode,
203
+ deferred: parsed.deferred.length,
204
+ next: p.next ? pick(p.next) : null,
205
+ unproven: p.unproven.map(pick),
206
+ review: reviewItem ? { present: true, done: reviewDone } : { present: false },
207
+ };
208
+ })()
209
+ : { present: false };
210
+ const pr = localOnly ? { state: "unknown", reason: "local mode" } : statusPr(cwd);
211
+ const trunc = (s) => (s.length > 72 ? `${s.slice(0, 69)}…` : s);
212
+ const canReview = Boolean(config.capabilities?.subagents || config.capabilities?.crossCli);
213
+ const reviewBudget = config.review.maxRounds ?? 0;
214
+ const reviewRoundsSpent = events.filter(
215
+ (event) => event.event === "review" && event.verdict === "changes-requested",
216
+ ).length;
217
+ const reviewBudgetSpent = reviewBudget > 0 && reviewRoundsSpent >= reviewBudget;
218
+ const reviewInvocation = reviewBudgetSpent
219
+ ? '`stdd review --force --reason "<why>"`'
220
+ : "`stdd review`";
221
+ const rerunReview = canReview
222
+ ? reviewBudgetSpent
223
+ ? `run ${reviewInvocation} deliberately`
224
+ : `run ${reviewInvocation} again`
225
+ : `enable a compatible review capability/route, then run ${reviewInvocation}${
226
+ reviewBudgetSpent ? " deliberately" : " again"
227
+ }`;
228
+ const planReviewSatisfied = Boolean(plan.present && plan.review?.present && plan.review.done);
229
+ const recordedReviewSatisfied = latestReview?.verdict === "approved" && !reviewStale;
230
+ // Once a ledger verdict exists it is authoritative; a checked legacy
231
+ // heuristic item must never hide a newer failed or stale review.
232
+ const reviewSatisfied = latestReview ? recordedReviewSatisfied : planReviewSatisfied;
233
+ const reviewNeedsAction = !reviewSatisfied;
234
+ const reviewFailureGuidance =
235
+ latestReview?.verdict === "changes-requested"
236
+ ? `fix the ${latestReview.findings?.length ?? 0} review finding(s) and ${rerunReview}`
237
+ : latestReview?.verdict === "error" || reviewStale
238
+ ? `repair the stale or errored closing review and ${rerunReview}`
239
+ : null;
240
+ const reviewFailureOutranksPlan =
241
+ reviewFailureGuidance !== null && (!plan.present || !plan.next || plan.next.review);
242
+
243
+ let next;
244
+ if (pr.state === "open" && pr.checks.failure > 0) {
245
+ // a red required check on an open PR outranks everything — pr-green
246
+ next = `drive PR #${pr.number}'s required checks terminal-green (pr-green playbook)`;
247
+ } else if (!loop.docs.done) {
248
+ next = "make the docs decision: edit the canonical docs, or record `stdd docs <decision>`";
249
+ } else if (!loop.red.done) {
250
+ next = "write the failing test and record it via `stdd red -- <cmd>`";
251
+ } else if (!loop.impl.done) {
252
+ next = "implement until the red test passes";
253
+ } else if (!loop.verify.done) {
254
+ next = "run the narrowest verify lane via `stdd verify -- <cmd>`";
255
+ } else if (reviewFailureOutranksPlan) {
256
+ const scopeFirst = scopeEvent ? "run `stdd scope` (slice postflight), then " : "";
257
+ next = `${scopeFirst}${reviewFailureGuidance}`;
258
+ } else if (plan.present && plan.next) {
259
+ if (plan.unproven.some((u) => u.line === plan.next.line)) {
260
+ next = plan.next.review
261
+ ? `plan item "${trunc(plan.next.text)}" is checked but the review is unproven — ${rerunReview}`
262
+ : `plan item "${trunc(plan.next.text)}" is checked but unproven — ` +
263
+ `record \`stdd red -- <cmd containing "${plan.next.red}">\` or uncheck it`;
264
+ } else if (plan.next.review) {
265
+ next = `${rerunReview} — the closing review closes "${trunc(plan.next.text)}"`;
266
+ } else {
267
+ next = `continue the plan (${plan.done}/${plan.total} done) — next item: "${trunc(plan.next.text)}"`;
268
+ }
269
+ } else if (reviewNeedsAction && (canReview || latestReview)) {
270
+ const scopeFirst = scopeEvent ? "run `stdd scope` (slice postflight), then " : "";
271
+ next = `${scopeFirst}${
272
+ reviewFailureGuidance ??
273
+ "dispatch a fresh reviewer with `stdd review`; after approval, draft the evidence line via `stdd evidence`"
274
+ }`;
275
+ } else if (pr.state === "none") {
276
+ // the closing review rides on a dispatch capability — with both routes
277
+ // off the suggestion is omitted, never degraded to self-review; a
278
+ // plan whose own review item is checked is not asked twice
279
+ const review =
280
+ !reviewSatisfied && canReview
281
+ ? "dispatch a fresh reviewer over the diff (delegate-slice playbook), then "
282
+ : "";
283
+ next = scopeEvent
284
+ ? `run \`stdd scope\` (slice postflight), then ${review}draft the evidence line via \`stdd evidence\` and open the PR`
285
+ : `${review}draft the evidence line via \`stdd evidence\`, then open the PR`;
286
+ } else if (pr.state === "open" && (pr.checks.failure > 0 || pr.checks.pending > 0)) {
287
+ next = `drive PR #${pr.number}'s required checks terminal-green (pr-green playbook)`;
288
+ } else if (pr.state === "open") {
289
+ next = `PR #${pr.number} checks are green — done pending review and merge`;
290
+ } else {
291
+ next = `PR state unknown (${pr.reason}) — draft the evidence line via \`stdd evidence\` if no PR exists yet`;
292
+ }
293
+
294
+ const reviewState = latestReview
295
+ ? {
296
+ verdict: latestReview.verdict,
297
+ via: latestReview.via,
298
+ blocking: (latestReview.findings ?? []).filter((f) => f.severity === "blocking").length,
299
+ stale: reviewStale,
300
+ }
301
+ : null;
302
+ if (asJson) {
303
+ console.log(
304
+ JSON.stringify(
305
+ {
306
+ state: task.state,
307
+ task: task.state === "active" ? task.task : null,
308
+ branch,
309
+ loop,
310
+ slice,
311
+ plan,
312
+ review: reviewState,
313
+ pr,
314
+ next,
315
+ },
316
+ null,
317
+ "\t",
318
+ ),
319
+ );
320
+ return;
321
+ }
322
+ const mark = (step) => (step.done ? "✓" : "✗");
323
+ const docsDetail = docsEventDone
324
+ ? ` (${docsEvent.decision}${docsEvent.paths?.length ? `: ${docsEvent.paths.join(", ")}` : ""})`
325
+ : docsEvent
326
+ ? ` — stale or contradicted ${docsEvent.decision} decision`
327
+ : docsChanged.length > 0
328
+ ? ` (diff: ${docsChanged.join(", ")})`
329
+ : changed === null
330
+ ? " — unknown (no resolvable baseRef)"
331
+ : " — no docs decision recorded, no canonical docs in the diff";
332
+ const redDetail = redEvent
333
+ ? ` (genuine: ${redEvent.genuine}, exit ${redEvent.exit}: ${escapeNonPrintableSingleLine(redEvent.cmd)}${redLegacy ? "; legacy evidence" : redEvent.workerId ? "; imported worker evidence" : ""})`
334
+ : " — no red recorded";
335
+ const implDetail = loop.impl.done
336
+ ? " (checkout changed after the recorded red)"
337
+ : redEvent
338
+ ? " — no checkout change after the recorded red"
339
+ : " — waiting for red";
340
+ const verifyDetail = verifyEvent
341
+ ? ` (exit 0: ${escapeNonPrintableSingleLine(verifyEvent.cmd)}${verifyEvent.snapshot ? "" : "; legacy evidence"})`
342
+ : verifyStale
343
+ ? recordedVerify?.workerId
344
+ ? " — imported worker verify is stale by design; run fresh source verification"
345
+ : " — stale: checkout changed after the passing verify"
346
+ : " — no passing verify recorded since the last red";
347
+ const prLine =
348
+ pr.state === "open"
349
+ ? `#${pr.number} — ${pr.checks.failure} failing, ${pr.checks.pending} pending, ${pr.checks.success} green`
350
+ : pr.state === "none"
351
+ ? `none for ${branch}`
352
+ : `unknown (${pr.reason})`;
353
+ console.log(
354
+ [
355
+ ...(task.state === "active"
356
+ ? [`task: ${task.task.id} (${task.task.name})`]
357
+ : ["task: legacy branch-scoped state"]),
358
+ `loop: docs ${mark(loop.docs)}${docsDetail}`,
359
+ ` red ${mark(loop.red)}${redDetail}`,
360
+ ` impl ${mark(loop.impl)}${implDetail}`,
361
+ ` verify ${mark(loop.verify)}${verifyDetail}`,
362
+ ...(scopeEvent
363
+ ? [
364
+ `slice: declared (frozen: ${scopeEvent.frozenPaths.join(", ") || "—"}; ` +
365
+ `allowed: ${scopeEvent.allowedPaths.join(", ") || "—"}) — postflight: stdd scope`,
366
+ ]
367
+ : []),
368
+ ...(plan.present
369
+ ? [
370
+ `plan: ${plan.total === 0 ? "no checklist items" : `${plan.done}/${plan.total} done`}` +
371
+ (plan.mode ? ` [mode: ${plan.mode}]` : "") +
372
+ (plan.deferred > 0 ? ` (${plan.deferred} deferred)` : "") +
373
+ (plan.next
374
+ ? ` — next: "${trunc(plan.next.text)}"`
375
+ : plan.total > 0
376
+ ? " — all items closed"
377
+ : ""),
378
+ ...plan.unproven.map((u) =>
379
+ u.review
380
+ ? ` unproven: "${trunc(u.text)}" — checked, but the newest recorded review is not an approval`
381
+ : ` unproven: "${trunc(u.text)}" — checked, but no recorded red matches "${u.red}"`,
382
+ ),
383
+ ]
384
+ : []),
385
+ ...(reviewState
386
+ ? [
387
+ `review: ${
388
+ reviewState.verdict === "approved"
389
+ ? `approved via ${reviewState.via}${reviewState.stale ? " — STALE, the checkout changed since" : ""}`
390
+ : reviewState.verdict === "changes-requested"
391
+ ? `changes requested via ${reviewState.via} — ${reviewState.blocking} blocking`
392
+ : `error via ${reviewState.via} — rerun \`stdd review\``
393
+ }`,
394
+ ]
395
+ : []),
396
+ `pr: ${prLine}`,
397
+ `next: ${next}`,
398
+ ].join("\n"),
399
+ );
400
+ }
401
+
402
+ /**
403
+ * The gate's inputs loaded without fail(): null when the repo has no
404
+ * usable branch or config — the stop hook treats that as nothing to
405
+ * judge, because fail() exits and would bypass its fail-open contract.
406
+ */
407
+ function softGateInputs(cwd) {
408
+ try {
409
+ const configPath = resolveWritableRepoPath(cwd, ".stdd/config.json", "config path");
410
+ const ledgerPath = resolveWritableRepoPath(cwd, LEDGER_REL, "ledger path");
411
+ const planPath = resolveWritableRepoPath(cwd, PLAN_REL, "plan path");
412
+ const config = fs.existsSync(configPath)
413
+ ? mergeConfig(JSON.parse(fs.readFileSync(configPath, "utf8")))
414
+ : DEFAULT_CONFIG;
415
+ const branch = execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
416
+ encoding: "utf8",
417
+ stdio: ["ignore", "pipe", "pipe"],
418
+ }).trim();
419
+ if (!branch || branch === "HEAD") return null;
420
+ return { config, branch, ledgerPath, planPath };
421
+ } catch {
422
+ return null;
423
+ }
424
+ }
425
+
426
+ function unavailableReviewRouteReason(via, capabilities, subject) {
427
+ if ((via === "codex" || via === "claude") && !capabilities.crossCli) {
428
+ return `${subject} uses "${via}" but the crossCli capability is off`;
429
+ }
430
+ if (via === "subagent" && !capabilities.subagents) {
431
+ return `${subject} uses "subagent" but the subagents capability is off`;
432
+ }
433
+ return null;
434
+ }
435
+
436
+ function gateReasons(cwd, inputs = null) {
437
+ const config = inputs?.config ?? loadConfig(cwd);
438
+ const branch = inputs?.branch ?? requireBranch(cwd);
439
+ const branchEvents = inputs?.ledgerPath
440
+ ? fs.existsSync(inputs.ledgerPath)
441
+ ? parseStateLedger(fs.readFileSync(inputs.ledgerPath, "utf8"), branch)
442
+ : []
443
+ : rawLedger(cwd, branch);
444
+ const scoped = scopeLedgerForCheckout(cwd, branch, branchEvents, config);
445
+ if (scoped.state.state === "idle") return [];
446
+ if (scoped.state.state === "invalid") {
447
+ return [
448
+ `malformed task boundary in .stdd/ledger.jsonl: ${scoped.state.reason} — repair .stdd/ledger.jsonl before recording or claiming evidence`,
449
+ ];
450
+ }
451
+ const events = scoped.events;
452
+ const reasons = [];
453
+ const latest = events.filter((e) => e.event === "review").at(-1) ?? null;
454
+ const approvalStale =
455
+ latest?.verdict === "approved" && latest.snapshot !== reviewSnapshot(cwd, config.baseRef);
456
+ if (latest?.verdict === "changes-requested") {
457
+ const blocking = (latest.findings ?? []).filter((f) => f.severity === "blocking").length;
458
+ reasons.push(
459
+ `the newest review requested changes (${blocking} blocking) — fix and rerun \`stdd review\``,
460
+ );
461
+ }
462
+ if (latest?.verdict === "error") {
463
+ reasons.push(`the newest review errored (${latest.reason ?? "unknown"}) — rerun \`stdd review\``);
464
+ }
465
+ if (approvalStale) {
466
+ reasons.push("the approved review is stale — the checkout changed since; rerun `stdd review`");
467
+ }
468
+ const planPath = inputs?.planPath ?? statePath(cwd, PLAN_REL, "plan path");
469
+ const planContent = taskPlanContent(cwd, scoped.state, planPath);
470
+ let unprovenClaim = false;
471
+ if (planContent !== null) {
472
+ const parsed = parsePlan(planContent);
473
+ const claimed = parsed.items.some((i) => i.review && i.checked);
474
+ if (claimed && latest?.verdict !== "approved") {
475
+ unprovenClaim = true;
476
+ reasons.push("a [review:] item is checked but no approved review is recorded — run `stdd review`");
477
+ }
478
+ }
479
+ const openRequests = events.filter(
480
+ (event) => event.event === "review-request" && !reviewRequestAnswered(events, event.id),
481
+ );
482
+ for (const request of openRequests) {
483
+ const unavailable = unavailableReviewRouteReason(
484
+ request.via,
485
+ config.capabilities,
486
+ `open review request ${request.id ?? "(unknown)"}`,
487
+ );
488
+ if (unavailable) reasons.push(unavailable);
489
+ }
490
+ const needsFreshDispatch =
491
+ unprovenClaim ||
492
+ latest?.verdict === "changes-requested" ||
493
+ latest?.verdict === "error" ||
494
+ approvalStale;
495
+ if (needsFreshDispatch && openRequests.length === 0) {
496
+ const unavailable = unavailableReviewRouteReason(
497
+ config.review.via,
498
+ config.capabilities,
499
+ "review.via",
500
+ );
501
+ if (unavailable) reasons.push(unavailable);
502
+ }
503
+ return reasons;
504
+ }
505
+
506
+ /**
507
+ * `stdd status --gate` — the review state as an exit code for hooks.
508
+ * Fails on broken claims (checked-but-unproven review, changes-requested,
509
+ * error, stale approval, impossible route), never on unfinished work.
510
+ */
511
+ export function statusGate(cwd) {
512
+ const reasons = gateReasons(cwd);
513
+ if (reasons.length === 0) {
514
+ console.log("stdd status --gate: ok");
515
+ process.exit(0);
516
+ }
517
+ for (const r of reasons) console.log(`✗ ${r}`);
518
+ process.exit(1);
519
+ }
520
+
521
+ /**
522
+ * `stdd stop-hook` — the gate as a Claude Code Stop hook. Blocks the stop
523
+ * (exit 2, reasons on stderr) only on broken claims; respects a host-provided
524
+ * stop_hook_active guard so a blocked stop is never re-blocked into a loop,
525
+ * and fails open on internal errors — a broken hook must not trap the session.
526
+ */
527
+ export function stopHookCmd(rawCwd, agent = "claude") {
528
+ const allow = () => {
529
+ if (agent === "codex") console.log("{}");
530
+ process.exit(0);
531
+ };
532
+ let payload = {};
533
+ try {
534
+ payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
535
+ } catch {
536
+ // an unreadable payload cannot prove stop_hook_active is false —
537
+ // blocking here could re-block indefinitely; fail open
538
+ allow();
539
+ }
540
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) allow();
541
+ if (payload.stop_hook_active) allow();
542
+ // soft repo resolution — resolveRepoDir()'s fail() would exit 1 and
543
+ // bypass the fail-open contract
544
+ let cwd = null;
545
+ try {
546
+ const resolved = resolveRepoDir(rawCwd);
547
+ cwd = fs.existsSync(path.join(resolved, ".stdd")) ? resolved : null;
548
+ } catch {
549
+ cwd = null;
550
+ }
551
+ if (!cwd) allow();
552
+ const inputs = softGateInputs(cwd);
553
+ if (!inputs) allow();
554
+ let reasons;
555
+ try {
556
+ reasons = gateReasons(cwd, inputs);
557
+ } catch {
558
+ allow();
559
+ }
560
+ if (agent === "codex") {
561
+ // On Stop, "block" means keep the agent going with `reason` as the
562
+ // continuation prompt. An empty object allows the turn to end.
563
+ console.log(
564
+ JSON.stringify(
565
+ reasons.length === 0
566
+ ? {}
567
+ : {
568
+ decision: "block",
569
+ reason: `STDD review claims are not proven: ${reasons.join("; ")}`,
570
+ },
571
+ ),
572
+ );
573
+ process.exit(0);
574
+ }
575
+ if (reasons.length === 0) process.exit(0);
576
+ console.error("stdd stop-hook: broken review claims —");
577
+ for (const r of reasons) console.error(` ✗ ${r}`);
578
+ console.error("fix, defer, or run `stdd review` — then end the session");
579
+ process.exit(2);
580
+ }