@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,555 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AGENT_ADAPTERS, getAgentAdapter, MANDATORY_ROUTING_SKILLS } from "../sdk/adapters.mjs";
5
+ import { resolveRepoPath } from "../sdk/path.mjs";
6
+ import { hasLocalStddBinary } from "./claude-hooks.mjs";
7
+ import { loadConfig } from "./config.mjs";
8
+ import {
9
+ CLEANUP_JOURNAL_REL,
10
+ generatedQuarantineInventory,
11
+ readManifestDocument,
12
+ scanGeneratedDrift,
13
+ VERSION,
14
+ } from "./generated-files.mjs";
15
+ import { openNativeRepoMutation } from "./held-fs.mjs";
16
+ import {
17
+ currentBranch,
18
+ LEDGER_INTERNAL_TEMP_GIT_GLOBS,
19
+ LEDGER_INTERNAL_TEMP_RELATIVE,
20
+ LEDGER_REL,
21
+ ledgerQuarantineInventory,
22
+ PLAN_REL,
23
+ rawLedger,
24
+ } from "./ledger.mjs";
25
+ import {
26
+ findEvidenceLines,
27
+ globToRegExp,
28
+ scanTemporal,
29
+ temporalMatchers,
30
+ workflowValidatesStaleBody,
31
+ } from "./lib.mjs";
32
+ import { inspectReviewRetainedInventory } from "./review-fs.mjs";
33
+ import { fail } from "./runtime.mjs";
34
+ import { WORKER_DELETIONS_REL, workerQuarantineInventory } from "./worker-fs.mjs";
35
+ import { WORKER_METADATA_REL } from "./worker-metadata.mjs";
36
+
37
+ const PROJECT_LOG_GLOB = "docs/project/**";
38
+
39
+ function listTrackedFiles(dir) {
40
+ try {
41
+ const out = execFileSync("git", ["-C", dir, "ls-files", "-z"], {
42
+ encoding: "utf8",
43
+ stdio: ["ignore", "pipe", "ignore"],
44
+ });
45
+ const files = out.split("\0").filter(Boolean);
46
+ if (files.length > 0) return files;
47
+ } catch {
48
+ // not a git repo — fall through to a filesystem walk
49
+ }
50
+ const files = [];
51
+ const walk = (rel) => {
52
+ for (const entry of fs.readdirSync(path.join(dir, rel), {
53
+ withFileTypes: true,
54
+ })) {
55
+ if (entry.name === ".git" || entry.name === "node_modules") continue;
56
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
57
+ if (entry.isDirectory()) walk(relPath);
58
+ else files.push(relPath);
59
+ }
60
+ };
61
+ walk("");
62
+ return files;
63
+ }
64
+
65
+ /**
66
+ * Shared repository scan for `check` and `doctor`: committed working
67
+ * artifacts, canonical docs and their temporal-narrative hits, and stale
68
+ * generated files (a stale stamp means the CLI was upgraded without
69
+ * re-running `stdd init`).
70
+ */
71
+ function scanRepo(targetDir, config) {
72
+ const files = listTrackedFiles(targetDir);
73
+
74
+ const projectLogForbidden = config.projectLog.enabled ? null : globToRegExp(PROJECT_LOG_GLOB);
75
+ const projectLogArtifacts = projectLogForbidden
76
+ ? files.filter((file) => projectLogForbidden.test(file))
77
+ : [];
78
+ const projectLogArtifactSet = new Set(projectLogArtifacts);
79
+ const forbidden = config.forbiddenArtifacts.map(globToRegExp);
80
+ const artifacts = files.filter(
81
+ (file) => !projectLogArtifactSet.has(file) && forbidden.some((re) => re.test(file)),
82
+ );
83
+
84
+ const canonical = config.canonicalDocs.map(globToRegExp);
85
+ const canonicalFiles = files.filter((file) => canonical.some((re) => re.test(file)));
86
+ const matchers = temporalMatchers(config.temporalPhrases);
87
+ const temporal = [];
88
+ for (const file of canonicalFiles) {
89
+ const lines = fs.readFileSync(path.join(targetDir, file), "utf8").split("\n");
90
+ for (const hit of scanTemporal(lines, matchers)) temporal.push({ file, ...hit });
91
+ }
92
+
93
+ const contentHits = [];
94
+ let added = null;
95
+ if (config.contentRules.some((r) => r.newFilesOnly)) {
96
+ added = addedFiles(targetDir, config.baseRef);
97
+ }
98
+ for (const rule of config.contentRules) {
99
+ const fileRe = globToRegExp(rule.files);
100
+ let targets = files.filter((f) => fileRe.test(f));
101
+ if (rule.newFilesOnly && added !== null) {
102
+ targets = targets.filter((f) => added.includes(f));
103
+ }
104
+ for (const file of targets) {
105
+ const content = fs.readFileSync(path.join(targetDir, file), "utf8");
106
+ if (rule.forbid) {
107
+ const re = new RegExp(rule.forbid);
108
+ content.split("\n").forEach((line, i) => {
109
+ if (re.test(line)) contentHits.push({ file, line: i + 1, rule });
110
+ });
111
+ }
112
+ if (rule.require && !new RegExp(rule.require, "m").test(content)) {
113
+ contentHits.push({ file, line: null, rule });
114
+ }
115
+ }
116
+ }
117
+
118
+ return {
119
+ artifacts,
120
+ projectLogArtifacts,
121
+ bookkeeping: trackedBookkeeping(targetDir),
122
+ canonicalFiles,
123
+ temporal,
124
+ contentHits,
125
+ stale: scanGeneratedDrift(targetDir, config),
126
+ };
127
+ }
128
+
129
+ async function reviewQuarantineInventory(targetDir) {
130
+ try {
131
+ const branch = currentBranch(targetDir);
132
+ if (branch === null) return { entries: [], error: null };
133
+ const events = rawLedger(targetDir, branch);
134
+ const requests = events.filter((request) => {
135
+ if (request?.event !== "review-request") return false;
136
+ const terminals = events.filter(
137
+ (event) =>
138
+ (event?.event === "review" || event?.event === "review-cancelled") &&
139
+ event.request === request.id &&
140
+ event.via === request.via &&
141
+ event.taskId === request.taskId &&
142
+ event.branch === request.branch,
143
+ );
144
+ return terminals.length === 1;
145
+ });
146
+ const inventory = [];
147
+ for (const request of requests) {
148
+ const entry = await inspectReviewRetainedInventory(request, { strict: true });
149
+ if (entry) inventory.push(entry);
150
+ }
151
+ return {
152
+ entries: inventory.sort((left, right) => left.path.localeCompare(right.path)),
153
+ error: null,
154
+ };
155
+ } catch (error) {
156
+ return { entries: [], error: error?.message ?? String(error) };
157
+ }
158
+ }
159
+
160
+ async function retainedWorkerQuarantines(targetDir) {
161
+ let context;
162
+ try {
163
+ const branch = currentBranch(targetDir);
164
+ if (branch === null) return { entries: [], error: null };
165
+ const workerIds = rawLedger(targetDir, branch)
166
+ .filter(
167
+ (event) =>
168
+ event?.event === "worker-create" && /^worker-[0-9a-f]{24}$/.test(event.workerId ?? ""),
169
+ )
170
+ .map((event) => event.workerId)
171
+ .filter((workerId) => fs.existsSync(path.join(targetDir, WORKER_DELETIONS_REL, workerId)));
172
+ if (workerIds.length === 0) return { entries: [], error: null };
173
+ context = await openNativeRepoMutation(targetDir, "worker quarantine inventory helper");
174
+ return { entries: await workerQuarantineInventory(context, workerIds), error: null };
175
+ } catch (error) {
176
+ return { entries: [], error: error.message };
177
+ } finally {
178
+ if (context) await context.close().catch(() => {});
179
+ }
180
+ }
181
+
182
+ /**
183
+ * stdd's own per-checkout bookkeeping (the ledger, the plan) that git
184
+ * tracks anyway. Hardcoded, not config-driven — these files are never
185
+ * legitimate in history. Outside a git repo tracking is unknowable: [].
186
+ */
187
+ function trackedBookkeeping(targetDir) {
188
+ try {
189
+ return execFileSync(
190
+ "git",
191
+ [
192
+ "-C",
193
+ targetDir,
194
+ "ls-files",
195
+ "--",
196
+ LEDGER_REL,
197
+ PLAN_REL,
198
+ WORKER_METADATA_REL,
199
+ `:(glob)${WORKER_DELETIONS_REL}/**`,
200
+ ...LEDGER_INTERNAL_TEMP_GIT_GLOBS.map((glob) => `:(glob)${glob}`),
201
+ ],
202
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
203
+ )
204
+ .split("\n")
205
+ .filter(
206
+ (file) =>
207
+ file === LEDGER_REL ||
208
+ file === PLAN_REL ||
209
+ file === WORKER_METADATA_REL ||
210
+ file.startsWith(`${WORKER_DELETIONS_REL}/`) ||
211
+ LEDGER_INTERNAL_TEMP_RELATIVE.test(file),
212
+ );
213
+ } catch {
214
+ return [];
215
+ }
216
+ }
217
+
218
+ /** Files added against baseRef, or null when the base is unresolvable. */
219
+ function addedFiles(targetDir, baseRef) {
220
+ if (!baseRef) return null;
221
+ try {
222
+ return execFileSync(
223
+ "git",
224
+ [
225
+ "-C",
226
+ targetDir,
227
+ "diff",
228
+ "--diff-filter=A",
229
+ "--name-only",
230
+ "--end-of-options",
231
+ `${baseRef}...HEAD`,
232
+ ],
233
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
234
+ )
235
+ .split("\n")
236
+ .filter(Boolean);
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ export function check(targetDir) {
243
+ const config = loadConfig(targetDir);
244
+ const { artifacts, projectLogArtifacts, bookkeeping, temporal, contentHits, stale } = scanRepo(
245
+ targetDir,
246
+ config,
247
+ );
248
+ const violations = [
249
+ ...artifacts.map((file) => `${file}: committed working artifact (forbiddenArtifacts)`),
250
+ ...projectLogArtifacts.map(
251
+ (file) => `${file}: project log is disabled (projectLog.enabled is false)`,
252
+ ),
253
+ ...bookkeeping.map(
254
+ (file) => `${file}: committed stdd working artifact — per-checkout only; add it to .gitignore`,
255
+ ),
256
+ ...temporal.map(
257
+ (hit) => `${hit.file}:${hit.line}: temporal narrative in canonical doc ("${hit.phrase}")`,
258
+ ),
259
+ ...contentHits.map(
260
+ (h) =>
261
+ `${h.file}${h.line ? `:${h.line}` : ""}: ${h.rule.name}` +
262
+ (h.rule.message ? ` — ${h.rule.message}` : h.line ? "" : " — required pattern not found"),
263
+ ),
264
+ ...stale.map((s) => `${s.file}: ${s.message}`),
265
+ ];
266
+ // Enforced locally (the pre-push hook runs stdd check); a detached
267
+ // checkout — CI — has no branch name to validate and skips the rule.
268
+ if (config.branchPattern) {
269
+ const branch = currentBranch(targetDir);
270
+ if (branch && branch !== "HEAD" && !new RegExp(config.branchPattern).test(branch)) {
271
+ violations.push(`branch "${branch}" does not match branchPattern ${config.branchPattern}`);
272
+ }
273
+ }
274
+
275
+ if (violations.length > 0) {
276
+ console.error(`stdd check: ${violations.length} violation(s)\n`);
277
+ for (const v of violations) console.error(` ${v}`);
278
+ process.exit(1);
279
+ }
280
+ console.log("stdd check: OK");
281
+ }
282
+
283
+ /**
284
+ * Config-declared worktree readiness: report each missing required path
285
+ * with its repo-authored fix hint. Purely declarative — existence only,
286
+ * nothing is executed or installed. Returns true when everything passes.
287
+ */
288
+ function reportReadiness(targetDir, config, report) {
289
+ const required = config.readiness.required;
290
+ if (required.length === 0) return null;
291
+ const missing = [];
292
+ for (const entry of required) {
293
+ let ready;
294
+ try {
295
+ ready = fs.existsSync(resolveRepoPath(targetDir, entry.path, `readiness path ${entry.path}`));
296
+ } catch (err) {
297
+ fail(`.stdd/config.json: ${err.message}`);
298
+ }
299
+ if (!ready) missing.push(entry);
300
+ }
301
+ for (const entry of missing) {
302
+ report(false, `${entry.path} missing${entry.hint ? ` — ${entry.hint}` : ""}`);
303
+ }
304
+ if (missing.length === 0) {
305
+ report(true, `worktree ready (${required.length}/${required.length} present)`);
306
+ }
307
+ return missing.length === 0;
308
+ }
309
+
310
+ export async function doctor(targetDir, readinessOnly = false) {
311
+ const count = (n, singular, plural) => `${n} ${n === 1 ? singular : plural}`;
312
+ let failed = false;
313
+ const report = (ok, message) => {
314
+ console.log(`${ok ? "✓" : "✗"} ${message}`);
315
+ if (!ok) failed = true;
316
+ };
317
+
318
+ if (readinessOnly) {
319
+ if (reportReadiness(targetDir, loadConfig(targetDir), report) === null) {
320
+ console.log("no readiness contract declared (readiness.required in .stdd/config.json)");
321
+ }
322
+ if (failed) process.exit(1);
323
+ return;
324
+ }
325
+
326
+ const installed = fs.existsSync(path.join(targetDir, ".stdd", "method.md"));
327
+ report(installed, installed ? ".stdd/ installed" : ".stdd/ is not installed — run stdd init");
328
+
329
+ const config = loadConfig(targetDir);
330
+ reportReadiness(targetDir, config, report);
331
+ const { artifacts, projectLogArtifacts, bookkeeping, canonicalFiles, temporal, contentHits, stale } =
332
+ scanRepo(targetDir, config);
333
+
334
+ if (config.contentRules.length > 0) {
335
+ report(
336
+ contentHits.length === 0,
337
+ contentHits.length === 0
338
+ ? `content rules clean (${count(config.contentRules.length, "rule", "rules")})`
339
+ : `${count(contentHits.length, "content-rule violation", "content-rule violations")} — see stdd check`,
340
+ );
341
+ }
342
+
343
+ report(
344
+ canonicalFiles.length > 0,
345
+ canonicalFiles.length > 0
346
+ ? `canonical docs present (${count(canonicalFiles.length, "file", "files")})`
347
+ : "no canonical docs found — create docs/domain/ or docs/product/, or adjust canonicalDocs",
348
+ );
349
+
350
+ if (projectLogArtifacts.length > 0) {
351
+ report(
352
+ false,
353
+ `${count(projectLogArtifacts.length, "tracked project-log file", "tracked project-log files")} while projectLog.enabled is false — see stdd check`,
354
+ );
355
+ }
356
+ const committed = artifacts.length + bookkeeping.length;
357
+ report(
358
+ committed === 0,
359
+ committed === 0
360
+ ? projectLogArtifacts.length > 0
361
+ ? "no other committed working artifacts"
362
+ : "no committed working artifacts"
363
+ : `${count(committed, "committed working artifact", "committed working artifacts")} may mislead coding agents`,
364
+ );
365
+
366
+ const temporalFiles = new Set(temporal.map((hit) => hit.file)).size;
367
+ report(
368
+ temporalFiles === 0,
369
+ temporalFiles === 0
370
+ ? "canonical docs match no configured temporal phrases"
371
+ : `${count(temporalFiles, "canonical doc matches", "canonical docs match")} configured temporal phrases`,
372
+ );
373
+
374
+ for (const finding of stale.filter((entry) => entry.file === CLEANUP_JOURNAL_REL)) {
375
+ report(false, `${finding.file}: ${finding.message}`);
376
+ }
377
+ report(
378
+ stale.length === 0,
379
+ stale.length === 0
380
+ ? `generated files match stdd v${VERSION}`
381
+ : `${count(stale.length, "generated file is", "generated files are")} stale — re-run stdd init`,
382
+ );
383
+ const ledgerQuarantines = ledgerQuarantineInventory(targetDir);
384
+ if (ledgerQuarantines.length > 0) {
385
+ console.log(
386
+ `· ${count(
387
+ ledgerQuarantines.length,
388
+ "retained ledger quarantine",
389
+ "retained ledger quarantines",
390
+ )} (inventory proven; never removed automatically)`,
391
+ );
392
+ for (const quarantine of ledgerQuarantines) {
393
+ console.log(
394
+ ` ${quarantine.relative} — ${quarantine.provenance}; inspect and remove manually when safe`,
395
+ );
396
+ }
397
+ }
398
+ const generatedQuarantines = generatedQuarantineInventory(targetDir);
399
+ if (generatedQuarantines.length > 0) {
400
+ console.log(
401
+ `· ${count(
402
+ generatedQuarantines.length,
403
+ "retained generated quarantine",
404
+ "retained generated quarantines",
405
+ )} (manifest/journal proven; never removed automatically)`,
406
+ );
407
+ for (const quarantine of generatedQuarantines) {
408
+ console.log(
409
+ ` ${quarantine.relative} — ${quarantine.provenance}; inspect and remove manually when safe`,
410
+ );
411
+ }
412
+ }
413
+ const reviewQuarantines = await reviewQuarantineInventory(targetDir);
414
+ if (reviewQuarantines.error) {
415
+ report(false, `retained review quarantine inspection failed: ${reviewQuarantines.error}`);
416
+ }
417
+ if (reviewQuarantines.entries.length > 0) {
418
+ console.log(
419
+ `· ${count(
420
+ reviewQuarantines.entries.length,
421
+ "retained review quarantine",
422
+ "retained review quarantines",
423
+ )} (ledger and inventory proven; never removed automatically)`,
424
+ );
425
+ for (const quarantine of reviewQuarantines.entries) {
426
+ console.log(
427
+ ` ${quarantine.path} — ${quarantine.provenance}; inspect and remove manually when safe`,
428
+ );
429
+ }
430
+ }
431
+ const workerQuarantines = await retainedWorkerQuarantines(targetDir);
432
+ if (workerQuarantines.error) {
433
+ report(false, `retained worker quarantine inspection failed: ${workerQuarantines.error}`);
434
+ }
435
+ if (workerQuarantines.entries.length > 0) {
436
+ console.log(
437
+ `· ${count(
438
+ workerQuarantines.entries.length,
439
+ "retained worker quarantine",
440
+ "retained worker quarantines",
441
+ )} (ledger and inventory proven; never removed automatically)`,
442
+ );
443
+ for (const quarantine of workerQuarantines.entries) {
444
+ console.log(
445
+ ` ${quarantine.relative} — ${quarantine.provenance}; inspect and remove manually when safe`,
446
+ );
447
+ }
448
+ }
449
+
450
+ // informational only — a missing hook is a choice, never a failure
451
+ const hookInstalled = fs.existsSync(path.join(targetDir, ".stdd", "hooks", "pre-push"));
452
+ console.log(
453
+ hookInstalled
454
+ ? "· pre-push hook installed (.stdd/hooks/pre-push) — wire via git config core.hooksPath .stdd/hooks"
455
+ : "· no pre-push hook installed — optional: stdd init --hooks",
456
+ );
457
+ let settingsText = "";
458
+ for (const relative of new Set(Object.values(AGENT_ADAPTERS).map((adapter) => adapter.hooksFile))) {
459
+ try {
460
+ settingsText += fs.readFileSync(path.join(targetDir, relative), "utf8");
461
+ } catch {
462
+ // no settings for this agent
463
+ }
464
+ }
465
+ if (
466
+ hookInstalled ||
467
+ /stdd (?:status|stop-hook)|STDD managed Pi lifecycle extension/.test(settingsText)
468
+ ) {
469
+ console.log(
470
+ hasLocalStddBinary(targetDir)
471
+ ? "· project-local stdd binary is available to hooks"
472
+ : "· project-local stdd binary is missing — install exact @stdd/cli before relying on hooks",
473
+ );
474
+ }
475
+
476
+ let selectedInstructionFiles = null;
477
+ try {
478
+ const manifest = readManifestDocument(targetDir);
479
+ if (manifest && Object.hasOwn(manifest, "targets")) {
480
+ const targets = manifest.targets;
481
+ selectedInstructionFiles = new Set(
482
+ targets.tools.map((tool) => getAgentAdapter(tool).instructionsFile),
483
+ );
484
+ }
485
+ } catch (err) {
486
+ const message = err.message.startsWith("targets ")
487
+ ? `.stdd/manifest.json targets are invalid — ${err.message.slice("targets ".length)}`
488
+ : `.stdd/manifest.json is invalid — ${err.message}`;
489
+ report(false, `${message}; re-run stdd init`);
490
+ // Do not reinterpret corrupt target metadata as a legacy install.
491
+ selectedInstructionFiles = new Set();
492
+ }
493
+ const instructionAdapters = new Map(
494
+ Object.values(AGENT_ADAPTERS).map((adapter) => [adapter.instructionsFile, adapter]),
495
+ );
496
+ for (const [instructionsFile, adapter] of instructionAdapters) {
497
+ if (selectedInstructionFiles && !selectedInstructionFiles.has(instructionsFile)) continue;
498
+ const instructionsPath = path.join(targetDir, instructionsFile);
499
+ if (!fs.existsSync(instructionsPath)) {
500
+ if (selectedInstructionFiles) {
501
+ report(false, `${instructionsFile} is missing — re-run stdd init for ${adapter.id}`);
502
+ }
503
+ continue;
504
+ }
505
+ const instructions = fs.readFileSync(instructionsPath, "utf8");
506
+ const hasSection = [
507
+ ...instructions.matchAll(/<!-- stdd:begin(?:\s[^>]*)?-->\r?\n([\s\S]*?)<!-- stdd:end -->/gu),
508
+ ].some((match) => {
509
+ const section = match[1];
510
+ return (
511
+ /^## STDD$/m.test(section) &&
512
+ MANDATORY_ROUTING_SKILLS.every((name) =>
513
+ section.includes(`\`${adapter.explicitPrefix}${name}\``),
514
+ )
515
+ );
516
+ });
517
+ report(
518
+ hasSection,
519
+ hasSection
520
+ ? `${instructionsFile} carries the STDD section`
521
+ : `${instructionsFile} has no managed STDD routing contract — re-run stdd init for that agent`,
522
+ );
523
+ }
524
+
525
+ const workflowsDir = path.join(targetDir, ".github", "workflows");
526
+ if (fs.existsSync(workflowsDir)) {
527
+ for (const file of fs.readdirSync(workflowsDir).filter((f) => /\.ya?ml$/.test(f))) {
528
+ if (workflowValidatesStaleBody(fs.readFileSync(path.join(workflowsDir, file), "utf8"))) {
529
+ report(
530
+ false,
531
+ `.github/workflows/${file} validates the frozen event payload body — ` +
532
+ "body edits will not be re-checked; see stdd init --ci github",
533
+ );
534
+ }
535
+ }
536
+ }
537
+
538
+ for (const rel of [
539
+ "pull_request_template.md",
540
+ ".github/pull_request_template.md",
541
+ "docs/pull_request_template.md",
542
+ ]) {
543
+ const tplPath = path.join(targetDir, ...rel.split("/"));
544
+ if (!fs.existsSync(tplPath)) continue;
545
+ if (findEvidenceLines(fs.readFileSync(tplPath, "utf8")).length > 0) {
546
+ report(
547
+ false,
548
+ `${rel} carries an unquoted evidence label — placeholder residue would pass ` +
549
+ 'the gate on every PR; quote template instructions ("> Docs …")',
550
+ );
551
+ }
552
+ }
553
+
554
+ if (failed) process.exit(1);
555
+ }