@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,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
|
+
}
|