@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.
- package/.claude-plugin/plugin.json +9 -0
- package/.codex-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/extensions/stdd.mjs +77 -0
- package/hooks/claude-hooks.json +28 -0
- package/hooks/codex-hooks.json +28 -0
- package/package.json +38 -0
- package/runtime/adapters/README.md +158 -0
- package/runtime/cli/check.mjs +555 -0
- package/runtime/cli/ci.mjs +190 -0
- package/runtime/cli/claude-hooks.mjs +689 -0
- package/runtime/cli/config.mjs +27 -0
- package/runtime/cli/evidence.mjs +249 -0
- package/runtime/cli/generated-files.mjs +1693 -0
- package/runtime/cli/held-fs.mjs +415 -0
- package/runtime/cli/init.mjs +883 -0
- package/runtime/cli/ledger.mjs +1470 -0
- package/runtime/cli/lib.mjs +909 -0
- package/runtime/cli/path-bytes.mjs +83 -0
- package/runtime/cli/policy.mjs +112 -0
- package/runtime/cli/recorders.mjs +188 -0
- package/runtime/cli/review-fs.mjs +825 -0
- package/runtime/cli/review.mjs +1065 -0
- package/runtime/cli/runtime.mjs +32 -0
- package/runtime/cli/scope.mjs +185 -0
- package/runtime/cli/snapshot.mjs +897 -0
- package/runtime/cli/state-validation.mjs +168 -0
- package/runtime/cli/status.mjs +580 -0
- package/runtime/cli/stdd.mjs +536 -0
- package/runtime/cli/worker-fs.mjs +971 -0
- package/runtime/cli/worker-metadata.mjs +139 -0
- package/runtime/cli/worker.mjs +779 -0
- package/runtime/method/README.md +634 -0
- package/runtime/method/reference-commands.md +147 -0
- package/runtime/method/reference-generated-state.md +151 -0
- package/runtime/method/reference-integration.md +233 -0
- package/runtime/package.json +65 -0
- package/runtime/playbooks/brainstorming.md +46 -0
- package/runtime/playbooks/debugging.md +36 -0
- package/runtime/playbooks/delegate-slice.md +129 -0
- package/runtime/playbooks/finish-change.md +46 -0
- package/runtime/playbooks/implement.md +26 -0
- package/runtime/playbooks/investigation.md +33 -0
- package/runtime/playbooks/managed-playbooks.json +14 -0
- package/runtime/playbooks/planning.md +177 -0
- package/runtime/playbooks/pr-green.md +50 -0
- package/runtime/playbooks/start-change.md +37 -0
- package/runtime/playbooks/worktrees.md +45 -0
- package/runtime/prebuilds/stdd-fs/darwin-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/darwin-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/manifest.json +47 -0
- package/runtime/prebuilds/stdd-fs/win32-arm64/stdd-fs.exe +0 -0
- package/runtime/prebuilds/stdd-fs/win32-x64/stdd-fs.exe +0 -0
- package/runtime/sdk/adapters.mjs +279 -0
- package/runtime/sdk/file-observation.mjs +12 -0
- package/runtime/sdk/index.d.ts +140 -0
- package/runtime/sdk/index.mjs +31 -0
- package/runtime/sdk/native-fs.mjs +1235 -0
- package/runtime/sdk/path.mjs +71 -0
- package/runtime/sdk/text.mjs +42 -0
- package/runtime/sdk/workflow.mjs +294 -0
- package/runtime/templates/deferred-design.md +47 -0
- package/runtime/templates/github-stdd.yml +42 -0
- package/runtime/templates/gitlab-stdd.yml +72 -0
- package/runtime/templates/pr-description.md +35 -0
- package/scripts/adopting-root.mjs +42 -0
- package/scripts/stdd-hook.mjs +72 -0
- package/skills/stdd-brainstorming/SKILL.md +48 -0
- package/skills/stdd-debugging/SKILL.md +38 -0
- package/skills/stdd-delegate-slice/SKILL.md +118 -0
- package/skills/stdd-finish-change/SKILL.md +40 -0
- package/skills/stdd-implement/SKILL.md +28 -0
- package/skills/stdd-investigation/SKILL.md +35 -0
- package/skills/stdd-planning/SKILL.md +165 -0
- package/skills/stdd-pr-green/SKILL.md +52 -0
- package/skills/stdd-start-change/SKILL.md +39 -0
- 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
|
+
}
|