@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,909 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolveRepoPath } from "../sdk/path.mjs";
3
+ import { assertPrintableSingleLine, isPrintableSingleLine } from "../sdk/text.mjs";
4
+
5
+ /** Content fingerprint used by the generated-files manifest. */
6
+ export function sha256(content) {
7
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
8
+ }
9
+
10
+ function deepFreeze(value) {
11
+ for (const child of Object.values(value)) {
12
+ if (child && typeof child === "object") deepFreeze(child);
13
+ }
14
+ return Object.freeze(value);
15
+ }
16
+
17
+ export const DEFAULT_CONFIG = deepFreeze({
18
+ // Working-artifact paths forbidden by the repository's default authority
19
+ // policy. `stdd check` fails if any tracked file matches. Deliberately
20
+ // narrow — widen or narrow per repo.
21
+ forbiddenArtifacts: ["docs/**/plans/**", "**/*.agent-plan.md", "**/*.agent-spec.md"],
22
+ // Canonical docs describe the present in the repository's chosen
23
+ // language. `stdd check` applies the configured temporal-phrase heuristic;
24
+ // fenced code blocks are skipped.
25
+ canonicalDocs: ["docs/domain/**/*.md", "docs/product/**/*.md"],
26
+ temporalPhrases: ["previously", "no longer", "used to be", "before this change"],
27
+ // Worktree-readiness contract: paths that must exist before verification
28
+ // output can be trusted, each with a repo-authored fix hint. Empty by
29
+ // default — the contract is declared by the adopting repo.
30
+ readiness: { required: [] },
31
+ // Repo-authored content lints: mechanically checkable conventions that
32
+ // would otherwise live in folklore. Empty by default — the adopting
33
+ // repo authors the rules; the kit ships only the mechanism.
34
+ contentRules: [],
35
+ // Authority policy for deferred designs. Repositories that require a
36
+ // strictly current-state-only tracked tree disable the project log; init
37
+ // then compiles that rule into the installed method and agent routing.
38
+ projectLog: { enabled: true },
39
+ // Capability profile: what the agent environment can actually do.
40
+ // Playbooks are compiled against it at init time (cap blocks,
41
+ // `requires:` frontmatter) — never branched at runtime.
42
+ capabilities: { subagents: true, crossCli: false, worktrees: true },
43
+ // The closing review's default route. `stdd review --via` overrides per
44
+ // call; either way the route must be compatible with the capability
45
+ // profile at run time.
46
+ review: { via: "subagent", maxRounds: 0 },
47
+ });
48
+
49
+ /**
50
+ * Parse the session ledger (append-only JSONL). Blank and corrupt lines are
51
+ * skipped — a torn write must never take the whole ledger down.
52
+ */
53
+ export function parseLedger(text) {
54
+ const events = [];
55
+ for (const line of text.split("\n")) {
56
+ if (!line.trim()) continue;
57
+ try {
58
+ events.push(JSON.parse(line));
59
+ } catch {
60
+ // corrupt line — skip
61
+ }
62
+ }
63
+ return events;
64
+ }
65
+
66
+ /**
67
+ * Was a red run a genuine test failure? Exit 0 is green, never red. Without
68
+ * a configured redPattern the answer is unknowable; with one, the output
69
+ * must show a test-framework failure — anything else (tool missing, config
70
+ * error) is an environment error, not a red.
71
+ */
72
+ export function redGenuine(exit, output, redPattern) {
73
+ if (exit === 0) return "no";
74
+ if (!redPattern) return "unknown";
75
+ return new RegExp(redPattern).test(output) ? "yes" : "no";
76
+ }
77
+
78
+ export const EVIDENCE_LABELS = [
79
+ "Docs updated first",
80
+ "Docs checked, no change needed",
81
+ "Docs not applicable",
82
+ ];
83
+
84
+ const EVIDENCE_MATCHERS = EVIDENCE_LABELS.map((label) => ({
85
+ label,
86
+ re: new RegExp(`^${label}:[ \\t]*(.*)$`, "i"),
87
+ }));
88
+
89
+ /**
90
+ * Find docs evidence lines in a PR body. Only lines that start at the
91
+ * beginning of a line count — quoted templates (`> Docs …`) and fenced code
92
+ * blocks do not. Returns `{ label, content, line }` per hit (1-indexed
93
+ * lines); a bare label yields empty content.
94
+ */
95
+ export function findEvidenceLines(body) {
96
+ const hits = [];
97
+ let inFence = false;
98
+ body
99
+ .replaceAll("\r\n", "\n")
100
+ .split("\n")
101
+ .forEach((line, i) => {
102
+ if (/^\s*(```|~~~)/.test(line)) {
103
+ inFence = !inFence;
104
+ return;
105
+ }
106
+ if (inFence) return;
107
+ for (const { label, re } of EVIDENCE_MATCHERS) {
108
+ const m = re.exec(line);
109
+ if (m) hits.push({ label, content: m[1].trim(), line: i + 1 });
110
+ }
111
+ });
112
+ return hits;
113
+ }
114
+
115
+ // Truncated label stems, longest-first so "Docs not applicable" wins over a
116
+ // hypothetical shorter stem. Each maps a reworded label back to its canonical
117
+ // form without a dictionary of previously observed mistakes.
118
+ const LABEL_STEMS = [
119
+ { stem: "docs not applicable", label: "Docs not applicable" },
120
+ { stem: "docs checked", label: "Docs checked, no change needed" },
121
+ { stem: "docs updated", label: "Docs updated first" },
122
+ ];
123
+
124
+ /**
125
+ * Find near-miss evidence lines in a PR body: lines that carry an evidence
126
+ * label but fail the strict column-0/exact-label match — markdown emphasis,
127
+ * list or quote markers, leading whitespace, or a reworded label. Meant for
128
+ * the zero-hits failure path of `check-pr`; strictly valid lines and fenced
129
+ * code are never near-misses. Returns `{ line, raw, suggestion }` per hit
130
+ * (1-indexed lines), where `suggestion` is the full corrected line.
131
+ */
132
+ export function nearMissEvidenceLines(body) {
133
+ const hits = [];
134
+ let inFence = false;
135
+ body
136
+ .replaceAll("\r\n", "\n")
137
+ .split("\n")
138
+ .forEach((raw, i) => {
139
+ if (/^\s*(```|~~~)/.test(raw)) {
140
+ inFence = !inFence;
141
+ return;
142
+ }
143
+ if (inFence) return;
144
+ if (EVIDENCE_MATCHERS.some(({ re }) => re.test(raw))) return;
145
+ // Normalize: strip leading whitespace, quote and list markers, then
146
+ // markdown emphasis and backticks around the label and content.
147
+ const normalized = raw
148
+ .replace(/^[\s>]*/, "")
149
+ .replace(/^(?:[-*+]|\d+[.)])\s+/, "")
150
+ .replaceAll(/[*_`]/g, "")
151
+ .trim();
152
+ let suggestion = null;
153
+ for (const { label, re } of EVIDENCE_MATCHERS) {
154
+ const m = re.exec(normalized);
155
+ if (m) {
156
+ suggestion = `${label}: ${m[1].trim()}`.trimEnd();
157
+ break;
158
+ }
159
+ }
160
+ if (!suggestion) {
161
+ const lower = normalized.toLowerCase();
162
+ const stem = LABEL_STEMS.find((s) => lower.startsWith(s.stem));
163
+ if (stem) {
164
+ const colon = normalized.indexOf(":");
165
+ const content = colon === -1 ? "" : normalized.slice(colon + 1).trim();
166
+ suggestion = `${stem.label}: ${content}`.trimEnd();
167
+ }
168
+ }
169
+ if (suggestion) hits.push({ line: i + 1, raw, suggestion });
170
+ });
171
+ return hits;
172
+ }
173
+
174
+ /**
175
+ * When a `Docs updated first:` line names no doc paths, its content is often
176
+ * a sentinel that belongs to another label. Returns the corrected line
177
+ * template, or null when the content is not a recognizable sentinel.
178
+ */
179
+ export function sentinelSuggestion(content) {
180
+ const c = content.trim().toLowerCase();
181
+ if (/^(not applicable|n\/?a)\b/.test(c)) {
182
+ return "Docs not applicable: <why implementation-only>";
183
+ }
184
+ if (/^no (docs )?change needed\b/.test(c)) {
185
+ return "Docs checked, no change needed: <docs + reason>";
186
+ }
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * True when a workflow validates the PR body from the frozen event payload
192
+ * without an `edited` trigger: `github.event.pull_request.body` piped into
193
+ * `check-pr` means a body-only fix is never re-checked and a re-run replays
194
+ * the stale text. Heuristic on the raw YAML text — no YAML parser by design.
195
+ */
196
+ export function workflowValidatesStaleBody(content) {
197
+ return (
198
+ content.includes("github.event.pull_request.body") &&
199
+ content.includes("check-pr") &&
200
+ !/\bedited\b/.test(content)
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Tiny glob dialect: `*` matches within a path segment, `**` matches across
206
+ * segments. No `?`, braces, or character classes — by design.
207
+ */
208
+ export function globToRegExp(glob) {
209
+ const segments = glob.split("/");
210
+ const parts = segments.map((segment, i) => {
211
+ const last = i === segments.length - 1;
212
+ if (segment === "**") return last ? ".*" : "(?:[^/]+/)*";
213
+ const escaped = segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
214
+ return escaped + (last ? "" : "/");
215
+ });
216
+ return new RegExp(`^${parts.join("")}$`);
217
+ }
218
+
219
+ /** Parse a `---`-fenced frontmatter block. CRLF-tolerant. */
220
+ export function parseFrontmatter(source) {
221
+ const normalized = source.replaceAll("\r\n", "\n");
222
+ const match = /^---\n([\s\S]*?)\n---\n?/.exec(normalized);
223
+ if (!match) return { meta: {}, body: normalized };
224
+ const meta = {};
225
+ for (const line of match[1].split("\n")) {
226
+ const idx = line.indexOf(":");
227
+ if (idx > 0) meta[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
228
+ }
229
+ return { meta, body: normalized.slice(match[0].length) };
230
+ }
231
+
232
+ /**
233
+ * Merge a parsed user config over the defaults and validate shape.
234
+ * Throws with an actionable message on invalid input.
235
+ */
236
+ export function mergeConfig(parsed) {
237
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
238
+ throw new Error("config must be a JSON object");
239
+ }
240
+ const config = { ...DEFAULT_CONFIG, ...parsed };
241
+ for (const key of ["forbiddenArtifacts", "canonicalDocs", "temporalPhrases"]) {
242
+ if (!Array.isArray(config[key]) || config[key].some((v) => typeof v !== "string")) {
243
+ throw new Error(`"${key}" must be an array of strings`);
244
+ }
245
+ for (const [index, value] of config[key].entries()) {
246
+ assertPrintableSingleLine(value, `${key}[${index}]`);
247
+ }
248
+ }
249
+ if ("baseRef" in config && typeof config.baseRef !== "string") {
250
+ throw new Error(`"baseRef" must be a string, e.g. "origin/main"`);
251
+ }
252
+ if ("baseRef" in config) {
253
+ assertPrintableSingleLine(config.baseRef, "baseRef");
254
+ }
255
+ if ("redPattern" in config && config.redPattern != null) {
256
+ if (typeof config.redPattern !== "string") {
257
+ throw new Error(`"redPattern" must be a string regex, e.g. "\\\\d+ failing"`);
258
+ }
259
+ try {
260
+ new RegExp(config.redPattern);
261
+ } catch (err) {
262
+ throw new Error(`"redPattern" is not a valid regex: ${err.message}`);
263
+ }
264
+ }
265
+ if ("branchPattern" in config && config.branchPattern != null) {
266
+ if (typeof config.branchPattern !== "string") {
267
+ throw new Error(`"branchPattern" must be a string regex, e.g. "^(main|dev|feat/|fix/)"`);
268
+ }
269
+ assertPrintableSingleLine(config.branchPattern, "branchPattern");
270
+ try {
271
+ new RegExp(config.branchPattern);
272
+ } catch (err) {
273
+ throw new Error(`"branchPattern" is not a valid regex: ${err.message}`);
274
+ }
275
+ }
276
+ const ruleShapeOk = (r) =>
277
+ typeof r === "object" &&
278
+ r !== null &&
279
+ typeof r.name === "string" &&
280
+ typeof r.files === "string" &&
281
+ (typeof r.forbid === "string" || typeof r.require === "string") &&
282
+ (!("forbid" in r) || typeof r.forbid === "string") &&
283
+ (!("require" in r) || typeof r.require === "string") &&
284
+ (!("message" in r) || typeof r.message === "string") &&
285
+ (!("newFilesOnly" in r) || typeof r.newFilesOnly === "boolean");
286
+ if (!Array.isArray(config.contentRules) || !config.contentRules.every(ruleShapeOk)) {
287
+ throw new Error(
288
+ `"contentRules" must be an array of { name, files, forbid and/or require, ` +
289
+ `message?, newFilesOnly? } entries (forbid or require is required)`,
290
+ );
291
+ }
292
+ for (const [index, rule] of config.contentRules.entries()) {
293
+ assertPrintableSingleLine(rule.name, `contentRules[${index}].name`);
294
+ if ("message" in rule) {
295
+ assertPrintableSingleLine(rule.message, `contentRules[${index}].message`);
296
+ }
297
+ for (const key of ["forbid", "require"]) {
298
+ if (rule[key] == null) continue;
299
+ try {
300
+ new RegExp(rule[key]);
301
+ } catch (err) {
302
+ throw new Error(`contentRules "${rule.name}": ${key} is not a valid regex: ${err.message}`);
303
+ }
304
+ }
305
+ }
306
+ const projectLog = config.projectLog;
307
+ if (
308
+ typeof projectLog !== "object" ||
309
+ projectLog === null ||
310
+ Array.isArray(projectLog) ||
311
+ Object.keys(projectLog).some((key) => key !== "enabled") ||
312
+ ("enabled" in projectLog && typeof projectLog.enabled !== "boolean")
313
+ ) {
314
+ throw new Error(`"projectLog" must be an object with an optional boolean "enabled" field`);
315
+ }
316
+ config.projectLog = { ...DEFAULT_CONFIG.projectLog, ...projectLog };
317
+ const capsKnown = Object.keys(DEFAULT_CONFIG.capabilities);
318
+ if ("capabilities" in config) {
319
+ const caps = config.capabilities;
320
+ if (typeof caps !== "object" || caps === null || Array.isArray(caps)) {
321
+ throw new Error(`"capabilities" must be an object of booleans (${capsKnown.join(", ")})`);
322
+ }
323
+ for (const [key, value] of Object.entries(caps)) {
324
+ if (!capsKnown.includes(key)) {
325
+ throw new Error(`capabilities: unknown capability "${key}" (known: ${capsKnown.join(", ")})`);
326
+ }
327
+ if (typeof value !== "boolean") {
328
+ throw new Error(`capabilities: "${key}" must be a boolean`);
329
+ }
330
+ }
331
+ }
332
+ config.capabilities = { ...DEFAULT_CONFIG.capabilities, ...config.capabilities };
333
+ if ("review" in config) {
334
+ const review = config.review;
335
+ if (typeof review !== "object" || review === null || Array.isArray(review)) {
336
+ throw new Error(`"review" must be an object, e.g. {"via": "codex"}`);
337
+ }
338
+ if ("via" in review && !["subagent", "codex", "claude"].includes(review.via)) {
339
+ throw new Error(`review.via must be "subagent", "codex", or "claude"`);
340
+ }
341
+ if ("maxRounds" in review && (!Number.isSafeInteger(review.maxRounds) || review.maxRounds < 0)) {
342
+ throw new Error(`review.maxRounds must be a non-negative integer (0 = unlimited)`);
343
+ }
344
+ }
345
+ config.review = { ...DEFAULT_CONFIG.review, ...config.review };
346
+ const readiness = config.readiness;
347
+ const entryOk = (e) =>
348
+ typeof e === "object" &&
349
+ e !== null &&
350
+ typeof e.path === "string" &&
351
+ (!("hint" in e) || typeof e.hint === "string");
352
+ if (
353
+ typeof readiness !== "object" ||
354
+ readiness === null ||
355
+ !Array.isArray(readiness.required) ||
356
+ !readiness.required.every(entryOk)
357
+ ) {
358
+ throw new Error(`"readiness.required" must be an array of { path, hint? } string entries`);
359
+ }
360
+ for (const entry of readiness.required) {
361
+ assertPrintableSingleLine(entry.path, "readiness path");
362
+ if ("hint" in entry) assertPrintableSingleLine(entry.hint, "readiness hint");
363
+ resolveRepoPath("/", entry.path, `readiness path ${JSON.stringify(entry.path)}`);
364
+ }
365
+ config.forbiddenArtifacts = [...config.forbiddenArtifacts];
366
+ config.canonicalDocs = [...config.canonicalDocs];
367
+ config.temporalPhrases = [...config.temporalPhrases];
368
+ config.contentRules = config.contentRules.map((rule) => ({ ...rule }));
369
+ config.readiness = {
370
+ required: config.readiness.required.map((entry) => ({ ...entry })),
371
+ };
372
+ return config;
373
+ }
374
+
375
+ /**
376
+ * Collapse duplicate same-named check entries (re-runs, cancelled
377
+ * concurrency twins) to the freshest run: the latest `startedAt` wins,
378
+ * array order breaks ties (later wins). A superseded cancel must never
379
+ * read as a red.
380
+ */
381
+ export function dedupeChecks(entries) {
382
+ const byName = new Map();
383
+ for (const entry of entries) {
384
+ const prev = byName.get(entry.name);
385
+ if (!prev || (entry.startedAt ?? "") >= (prev.startedAt ?? "")) {
386
+ byName.set(entry.name, entry);
387
+ }
388
+ }
389
+ return [...byName.values()];
390
+ }
391
+
392
+ /**
393
+ * Compile a playbook against the capability profile. `<!-- cap:NAME -->`
394
+ * … `<!-- /cap -->` blocks survive only when the capability is on; the
395
+ * markers themselves never survive. Blocks do not nest, and an unknown
396
+ * capability name, an unclosed block, or a stray close is an authoring
397
+ * error — thrown, never silently passed through.
398
+ */
399
+ export function compileCapabilities(body, capabilities) {
400
+ const out = [];
401
+ let open = null;
402
+ for (const line of body.split("\n")) {
403
+ const opener = /^\s*<!--\s*cap:([A-Za-z|]+)\s*-->\s*$/.exec(line);
404
+ const closer = /^\s*<!--\s*\/cap\s*-->\s*$/.test(line);
405
+ if (opener) {
406
+ if (open) throw new Error(`nested cap block "${opener[1]}" inside "${open}"`);
407
+ // cap:a|b names alternatives — the block survives when ANY is on
408
+ for (const name of opener[1].split("|")) {
409
+ if (!(name in capabilities)) {
410
+ throw new Error(`unknown capability "${name}" in cap block`);
411
+ }
412
+ }
413
+ open = opener[1];
414
+ continue;
415
+ }
416
+ if (closer) {
417
+ if (!open) throw new Error("<!-- /cap --> without an open cap block");
418
+ open = null;
419
+ continue;
420
+ }
421
+ if (open && !open.split("|").some((name) => capabilities[name])) continue;
422
+ out.push(line);
423
+ }
424
+ if (open) throw new Error(`unclosed cap block "${open}"`);
425
+ return out.join("\n").replace(/\n{3,}/g, "\n\n");
426
+ }
427
+
428
+ /**
429
+ * Parse the durable plan (`.stdd/plan.md`): checkbox items with an optional
430
+ * `[red: <substring>]` gate tag, plus entries of a `## Deferred` section
431
+ * and an optional `Mode: inline|delegated` line (first recognized match
432
+ * outside fences; any other value reads as absent). Fenced code blocks are
433
+ * skipped; checkboxes inside Deferred are cuts, not items. Returns
434
+ * `{ items: [{ line, checked, text, red, review }], deferred, mode }`
435
+ * (1-indexed lines; `mode` is `"inline"`, `"delegated"`, or null).
436
+ */
437
+ export function parsePlan(text) {
438
+ const items = [];
439
+ const deferred = [];
440
+ let mode = null;
441
+ let inFence = false;
442
+ let inDeferred = false;
443
+ text
444
+ .replaceAll("\r\n", "\n")
445
+ .split("\n")
446
+ .forEach((line, i) => {
447
+ if (/^\s*(```|~~~)/.test(line)) {
448
+ inFence = !inFence;
449
+ return;
450
+ }
451
+ if (inFence) return;
452
+ // the execution choice made at planning time; only the first
453
+ // recognized value counts, unknown values never match
454
+ const modeLine = /^\s*mode:\s*(inline|delegated)\s*$/i.exec(line);
455
+ if (modeLine && mode === null) mode = modeLine[1].toLowerCase();
456
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
457
+ if (heading) {
458
+ inDeferred = /^deferred\b/i.test(heading[1].trim());
459
+ return;
460
+ }
461
+ if (inDeferred) {
462
+ const d = /^\s*[-*+]\s+(?:\[[ xX]\]\s+)?(.*)$/.exec(line);
463
+ if (d?.[1].trim()) deferred.push(d[1].trim());
464
+ return;
465
+ }
466
+ const m = /^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/.exec(line);
467
+ if (!m) return;
468
+ // tags are read from prose only — a backticked `[red:]`/`[review:]`
469
+ // names the tag as a literal and never gates the item
470
+ const prose = m[2].replace(/(`+).*?\1/g, "");
471
+ const tag = /\[red:\s*([^\]]+)\]/.exec(prose);
472
+ items.push({
473
+ line: i + 1,
474
+ checked: m[1] !== " ",
475
+ text: m[2].trim(),
476
+ red: tag ? tag[1].trim() : null,
477
+ review: /\[review:\s*[^\]]*\]/.test(prose),
478
+ });
479
+ });
480
+ return { items, deferred, mode };
481
+ }
482
+
483
+ /**
484
+ * Grade the plan against the branch's red events. A checkbox is a claim;
485
+ * for `[red:]`-tagged items the ledger is the proof: the item is done only
486
+ * when a red event's recorded command contains the tag's substring and the
487
+ * run was not recorded `genuine: "no"`. A review item is derived directly
488
+ * from the newest verdict and may close while its checkbox stays open; a
489
+ * checked item without proof stays open. Returns
490
+ * `{ total, done, next, unproven }` where `next` is the first open item (or
491
+ * null) and `unproven` lists checked-unproven items.
492
+ */
493
+ export function planProgress(plan, redEvents, reviewEvents = []) {
494
+ // a [review:] item is proven by the branch's NEWEST review verdict —
495
+ // an approval followed by changes-requested reopens the claim
496
+ const latestReview = reviewEvents.at(-1) ?? null;
497
+ const proven = (item) => {
498
+ if (item.review) return latestReview?.verdict === "approved";
499
+ return (
500
+ item.red === null ||
501
+ redEvents.some((e) => e.genuine !== "no" && typeof e.cmd === "string" && e.cmd.includes(item.red))
502
+ );
503
+ };
504
+ const graded = plan.items.map((item) => ({
505
+ ...item,
506
+ // Review is a ledger-derived transition, not a plan projection:
507
+ // approval closes the item even when its user-authored box stays open.
508
+ done: item.review ? proven(item) : item.checked && proven(item),
509
+ }));
510
+ return {
511
+ total: graded.length,
512
+ done: graded.filter((i) => i.done).length,
513
+ next: graded.find((i) => !i.done) ?? null,
514
+ unproven: graded.filter((i) => i.checked && !i.done),
515
+ };
516
+ }
517
+
518
+ /**
519
+ * Parse and validate a reviewer's complete raw output. The boundary accepts
520
+ * exactly one JSON object with optional surrounding whitespace; prose,
521
+ * fences, wrappers, trailing values, and multiple objects all reject.
522
+ * Returns null on any syntax or schema failure, so malformed output can
523
+ * never be recovered into an approval.
524
+ */
525
+ export function parseReviewResult(text) {
526
+ if (typeof text !== "string") return null;
527
+ const candidate = text.trim();
528
+ if (candidate === "") return null;
529
+ return gradeReviewCandidate(candidate);
530
+ }
531
+
532
+ const REVIEW_RESULT_REQUIRED_KEYS = ["summary", "findings"];
533
+ const REVIEW_RESULT_KEYS = new Set(REVIEW_RESULT_REQUIRED_KEYS);
534
+ const REVIEW_FINDING_REQUIRED_KEYS = ["severity", "message"];
535
+ const REVIEW_FINDING_KEYS = new Set([...REVIEW_FINDING_REQUIRED_KEYS, "path", "line"]);
536
+
537
+ function hasOwnContractShape(value, requiredKeys, allowedKeys) {
538
+ return (
539
+ typeof value === "object" &&
540
+ value !== null &&
541
+ !Array.isArray(value) &&
542
+ Object.getPrototypeOf(value) === Object.prototype &&
543
+ requiredKeys.every((key) => Object.hasOwn(value, key)) &&
544
+ Object.keys(value).every((key) => allowedKeys.has(key))
545
+ );
546
+ }
547
+
548
+ function gradeReviewCandidate(candidate) {
549
+ let parsed;
550
+ try {
551
+ parsed = JSON.parse(candidate);
552
+ } catch {
553
+ return null;
554
+ }
555
+ if (!hasOwnContractShape(parsed, REVIEW_RESULT_REQUIRED_KEYS, REVIEW_RESULT_KEYS)) return null;
556
+ if (!isPrintableSingleLine(parsed.summary)) return null;
557
+ if (!Array.isArray(parsed.findings)) return null;
558
+ const findings = [];
559
+ for (const f of parsed.findings) {
560
+ if (!hasOwnContractShape(f, REVIEW_FINDING_REQUIRED_KEYS, REVIEW_FINDING_KEYS)) return null;
561
+ if (f.severity !== "blocking" && f.severity !== "advisory") return null;
562
+ if (!isPrintableSingleLine(f.message)) return null;
563
+ // absent path/line are legitimate ("missing behavior" findings);
564
+ // a wrongly typed field rejects the whole result — never coerce
565
+ const findingPath = Object.hasOwn(f, "path") ? f.path : null;
566
+ const findingLine = Object.hasOwn(f, "line") ? f.line : null;
567
+ if (findingPath != null && !isPrintableSingleLine(findingPath)) return null;
568
+ if (findingLine != null && (!Number.isSafeInteger(findingLine) || findingLine <= 0)) return null;
569
+ findings.push({
570
+ severity: f.severity,
571
+ path: findingPath,
572
+ line: findingLine,
573
+ message: f.message,
574
+ });
575
+ }
576
+ return { summary: parsed.summary, findings };
577
+ }
578
+
579
+ /** The verdict is derived from findings, never self-declared. */
580
+ export function deriveReviewVerdict(findings) {
581
+ return findings.some((f) => f.severity === "blocking") ? "changes-requested" : "approved";
582
+ }
583
+
584
+ // An ATX heading as Markdown defines it: up to three leading spaces, one to six
585
+ // hashes, then either whitespace and a title or nothing at all. `#hashtag` is
586
+ // not a heading. Group 1 is the level, group 2 the title when present.
587
+ const ATX_HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/;
588
+
589
+ // What a section may contain. The reader and the writer share it: if they
590
+ // disagreed, the CLI would report an entry as recorded that the reader ignores.
591
+ const SECTION_BULLET = /^-[ \t]+(.*\S)[ \t]*$/;
592
+
593
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
594
+ const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
595
+
596
+ /**
597
+ * Walk a markdown document, resolving which `##` section each line sits in.
598
+ *
599
+ * Fenced blocks and HTML comments are inert: a `## Permissions` written inside
600
+ * an example opens nothing, and a bullet inside one belongs to no section.
601
+ *
602
+ * The walk resolves headings only; whether a stray line ends a section is the
603
+ * caller's rule. It serves the policy document alone — its reader and its
604
+ * writer share it so they can never disagree about where a section is. The
605
+ * plan's `## Deferred` deliberately keeps its own heading logic: prose, fences
606
+ * and comments belong to that section, and teaching this walk to serve both
607
+ * twice moved a recorded cut to the wrong place.
608
+ */
609
+ function* documentSections(lines) {
610
+ let fence = null;
611
+ let comment = false;
612
+ let section = null;
613
+ for (const [index, raw] of lines.entries()) {
614
+ if (comment) {
615
+ if (raw.includes("-->")) comment = false;
616
+ continue;
617
+ }
618
+ if (fence) {
619
+ const close = raw.match(FENCE_CLOSE);
620
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = null;
621
+ continue;
622
+ }
623
+ const opened = raw.match(FENCE_OPEN);
624
+ if (opened) {
625
+ fence = { char: opened[1][0], length: opened[1].length };
626
+ section = null;
627
+ continue;
628
+ }
629
+ if (/^ {0,3}<!--/.test(raw)) {
630
+ comment = !raw.includes("-->");
631
+ section = null;
632
+ continue;
633
+ }
634
+ const heading = raw.match(ATX_HEADING);
635
+ if (heading) {
636
+ // Markdown lets a heading close with its own run of hashes, so
637
+ // `## Permissions ##` names the same section as `## Permissions`.
638
+ const title = (heading[2] ?? "").replace(/[ \t]+#+[ \t]*$/, "").trim();
639
+ section = heading[1].length === 2 ? title.toLowerCase() : null;
640
+ yield { index, kind: "heading", section };
641
+ continue;
642
+ }
643
+ if (raw.trim() === "") {
644
+ yield { index, kind: "blank", section };
645
+ continue;
646
+ }
647
+ const item = raw.match(SECTION_BULLET);
648
+ if (!item) {
649
+ yield { index, kind: "other", section };
650
+ continue;
651
+ }
652
+ yield { index, kind: "bullet", section, text: item[1] };
653
+ }
654
+ }
655
+
656
+ function createSection(content, heading, line) {
657
+ const base = content === "" ? "" : content.endsWith("\n") ? content : `${content}\n`;
658
+ return `${base}${base === "" ? "" : "\n"}## ${heading}\n\n- ${line}\n`;
659
+ }
660
+
661
+ /**
662
+ * The plan's `## Deferred` section as a `{start, end}` line range, or null
663
+ * when the plan has none. `end` is the next heading of any level, or the end
664
+ * of the document: the plan is free-form markdown, so prose, fences, and
665
+ * comments between the cuts all belong to the section.
666
+ *
667
+ * Exported because the review snapshot must normalize away exactly what
668
+ * `appendDeferred` writes. Two boundary rules that drift apart is how a
669
+ * recorded scope cut ends up staling the approval it was meant to preserve.
670
+ */
671
+ export function deferredSectionRange(lines) {
672
+ const start = lines.findIndex((l) => /^##\s+Deferred\s*$/i.test(l));
673
+ if (start === -1) return null;
674
+ let end = lines.length;
675
+ for (let i = start + 1; i < lines.length; i++) {
676
+ if (/^#{1,6}\s/.test(lines[i])) {
677
+ end = i;
678
+ break;
679
+ }
680
+ }
681
+ return { start, end };
682
+ }
683
+
684
+ /**
685
+ * Append a scope cut under the plan's `## Deferred` section, creating the
686
+ * section (or the whole content) as needed. Inserts after the section's last
687
+ * non-blank line, before any following heading.
688
+ *
689
+ * The plan is free-form markdown a human writes: prose, fences and comments
690
+ * between the cuts all belong to the section. That is the opposite of the
691
+ * policy document's rule, and the two deliberately share no boundary logic —
692
+ * teaching this function the policy's stricter walk twice moved a recorded cut
693
+ * to the wrong place.
694
+ */
695
+ export function appendDeferred(content, text) {
696
+ const safeText = assertPrintableSingleLine(text, "deferred cut");
697
+ const lines = content.replaceAll("\r\n", "\n").split("\n");
698
+ const section = deferredSectionRange(lines);
699
+ if (section === null) return createSection(content, "Deferred", safeText);
700
+ const { start: idx, end } = section;
701
+ let insert = end;
702
+ while (insert > idx + 1 && lines[insert - 1].trim() === "") insert--;
703
+ if (insert === idx + 1) lines.splice(insert, 0, "", `- ${safeText}`);
704
+ else lines.splice(insert, 0, `- ${safeText}`);
705
+ return lines.join("\n");
706
+ }
707
+
708
+ /**
709
+ * Append `- <line>` under a policy document's `## <heading>`, creating the
710
+ * section when absent. The section ends at the first line that is neither
711
+ * blank nor a bullet, and a fence or comment ends it too, so the entry always
712
+ * lands where `parsePolicy` will still see it.
713
+ */
714
+ function appendPolicyEntry(content, heading, line) {
715
+ const lines = content.replaceAll("\r\n", "\n").split("\n");
716
+ const target = heading.toLowerCase();
717
+ let headingIndex = -1;
718
+ let lastEntry = -1;
719
+ for (const entry of documentSections(lines)) {
720
+ if (headingIndex === -1) {
721
+ if (entry.kind === "heading" && entry.section === target) headingIndex = entry.index;
722
+ continue;
723
+ }
724
+ // A fence or a comment closes the section without emitting a line, so the
725
+ // section a line reports is what decides: a later bullet outside it is
726
+ // never an insertion point, however much it looks like one.
727
+ if (entry.kind === "heading" || entry.section !== target || entry.kind === "other") break;
728
+ if (entry.kind === "bullet") lastEntry = entry.index;
729
+ }
730
+ if (headingIndex === -1) return createSection(content, heading, line);
731
+ if (lastEntry === -1) lines.splice(headingIndex + 1, 0, "", `- ${line}`);
732
+ else lines.splice(lastEntry + 1, 0, `- ${line}`);
733
+ return lines.join("\n");
734
+ }
735
+
736
+ /**
737
+ * The outward, irreversible effects a policy permission may pre-authorize.
738
+ * Closed on purpose: an action absent from this set cannot be granted, which
739
+ * is what stops a policy file from waiving a gate the loop must prove.
740
+ */
741
+ export const POLICY_ACTIONS = deepFreeze([
742
+ "merge",
743
+ "deploy",
744
+ "publish",
745
+ "migrate",
746
+ "force-push",
747
+ "external-mutation",
748
+ ]);
749
+
750
+ export function appendPolicyNote(content, text) {
751
+ return appendPolicyEntry(content, "Notes", assertPrintableSingleLine(text, "policy note"));
752
+ }
753
+
754
+ export function assertPolicyAction(action) {
755
+ // The line rule runs first: an unknown action is quoted back in the
756
+ // diagnostic, and a bidi or zero-width one would reorder or hide the very
757
+ // text telling the operator it was refused.
758
+ assertPrintableSingleLine(action, "policy action");
759
+ if (!POLICY_ACTIONS.includes(action)) {
760
+ throw new Error(
761
+ `unknown policy action ${JSON.stringify(action)} (known: ${POLICY_ACTIONS.join(", ")})`,
762
+ );
763
+ }
764
+ return action;
765
+ }
766
+
767
+ export function appendPolicyPermission(content, action, condition) {
768
+ const safeAction = assertPolicyAction(assertPrintableSingleLine(action, "policy action"));
769
+ const safeCondition = assertPrintableSingleLine(condition, "policy condition");
770
+ return appendPolicyEntry(content, "Permissions", `${safeAction} — when: ${safeCondition}`);
771
+ }
772
+
773
+ /**
774
+ * Read the policy document. Only `## Permissions` entries are permissions — an
775
+ * item under any other heading is a note however much it reads like a grant.
776
+ *
777
+ * The document is tracked and hand-editable, so the closed action set is
778
+ * enforced here as well as on the write path: an entry naming an action the
779
+ * kit does not know is reported as `rejected` and grants nothing. Validating
780
+ * only `stdd policy allow` would rest the guarantee on the CLI being used.
781
+ */
782
+ export function parsePolicy(text) {
783
+ const notes = [];
784
+ const permissions = [];
785
+ const rejected = [];
786
+ // A policy section holds nothing but its own bullets. Enumerating what could
787
+ // close one — setext underlines, fences, rules, prose — is a losing game
788
+ // against a hand-edited file, so any other line closes it until the next
789
+ // heading.
790
+ let closed = false;
791
+ for (const line of documentSections(text.replaceAll("\r\n", "\n").split("\n"))) {
792
+ if (line.kind === "heading") {
793
+ closed = false;
794
+ continue;
795
+ }
796
+ if (line.kind === "other") {
797
+ closed = true;
798
+ continue;
799
+ }
800
+ if (closed || line.kind !== "bullet") continue;
801
+ // The reader holds the writer's line rule too: a bidi or zero-width
802
+ // entry never becomes a grant. Such a line is dropped rather than
803
+ // reported in `rejected` — echoing unprintable bytes into a diagnostic
804
+ // is what that rule exists to prevent. `rejected` is for entries naming
805
+ // an action outside the closed set: a legible grant someone meant, and
806
+ // must be told was ignored.
807
+ if (!isPrintableSingleLine(line.text)) continue;
808
+ if (line.section === "permissions") {
809
+ const entry = line.text.match(/^(\S+)\s+—\s+when:\s+(.+)$/);
810
+ if (!entry) continue;
811
+ if (POLICY_ACTIONS.includes(entry[1])) {
812
+ permissions.push({ action: entry[1], condition: entry[2] });
813
+ } else {
814
+ rejected.push(line.text);
815
+ }
816
+ } else if (line.section === "notes") {
817
+ notes.push(line.text);
818
+ }
819
+ }
820
+ return { notes, permissions, rejected };
821
+ }
822
+
823
+ function levenshtein(a, b) {
824
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
825
+ for (let i = 1; i <= a.length; i++) {
826
+ let diag = prev[0];
827
+ prev[0] = i;
828
+ for (let j = 1; j <= b.length; j++) {
829
+ const tmp = prev[j];
830
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
831
+ diag = tmp;
832
+ }
833
+ }
834
+ return prev[b.length];
835
+ }
836
+
837
+ /**
838
+ * Closest known name for a mistyped one, or null. Containment wins
839
+ * ("light-ci-status" carries "status"); otherwise a small edit distance
840
+ * (≤2) catches plain typos without matching arbitrary words.
841
+ */
842
+ export function didYouMean(input, candidates) {
843
+ const lower = input.toLowerCase();
844
+ const contained = candidates
845
+ .filter((c) => c.length >= 4 && (lower.includes(c) || c.includes(lower)))
846
+ .sort((a, b) => b.length - a.length)[0];
847
+ if (contained) return contained;
848
+ let best = null;
849
+ let bestDist = 3;
850
+ for (const c of candidates) {
851
+ const d = levenshtein(lower, c);
852
+ if (d < bestDist) {
853
+ bestDist = d;
854
+ best = c;
855
+ }
856
+ }
857
+ return best;
858
+ }
859
+
860
+ /**
861
+ * Extract repo-relative markdown paths from an evidence line's content.
862
+ * Prose (reasons, dashes, backticks) around the paths is ignored.
863
+ */
864
+ export function extractDocPaths(content) {
865
+ const quoted = [];
866
+ const withoutQuoted = content.replace(/(`+)(.*?)\1/g, (_whole, _ticks, value) => {
867
+ const candidate = value.trim();
868
+ if (candidate.endsWith(".md")) quoted.push(candidate);
869
+ return " ";
870
+ });
871
+ const bare = withoutQuoted.match(/[\p{L}\p{N}_][\p{L}\p{N}_./-]*\.md(?=$|[\s,;:)\]}])/gu) ?? [];
872
+ return [...new Set([...quoted, ...bare])];
873
+ }
874
+
875
+ /**
876
+ * Build a temporal-phrase matcher. Word-ish boundaries on both sides so
877
+ * hyphenated compounds ("no longer-lived") do not match.
878
+ */
879
+ export function temporalMatchers(phrases) {
880
+ return phrases.map((phrase) => ({
881
+ phrase,
882
+ re: new RegExp(`(?<![\\w-])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`, "i"),
883
+ }));
884
+ }
885
+
886
+ /**
887
+ * Scan markdown lines for temporal narrative, skipping fenced code blocks
888
+ * and inline code spans — a backticked phrase is a literal being named,
889
+ * not narrative. Returns `{ line, phrase }` hits (1-indexed lines).
890
+ */
891
+ export function scanTemporal(lines, matchers) {
892
+ const hits = [];
893
+ let inFence = false;
894
+ lines.forEach((line, i) => {
895
+ if (/^\s*(```|~~~)/.test(line)) {
896
+ inFence = !inFence;
897
+ return;
898
+ }
899
+ if (inFence) return;
900
+ // A span opens and closes with backtick runs of the same length
901
+ // (CommonMark), so `x`, ``x`` … all strip; a stray backtick strips
902
+ // nothing.
903
+ const prose = line.replace(/(`+).*?\1/g, "");
904
+ for (const { phrase, re } of matchers) {
905
+ if (re.test(prose)) hits.push({ line: i + 1, phrase });
906
+ }
907
+ });
908
+ return hits;
909
+ }