@gobing-ai/spur 0.3.62 → 0.3.64

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 (94) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/corpus-baseline.json +3428 -3796
  3. package/config/workflows/history-anatomy.yaml +40 -11
  4. package/package.json +9 -9
  5. package/plugins/sp/README.md +8 -5
  6. package/plugins/sp/agents/expert-spur.md +20 -4
  7. package/plugins/sp/commands/dev-find-issue.md +4 -4
  8. package/plugins/sp/commands/dev-gitmsg.md +12 -6
  9. package/plugins/sp/commands/dev-gtd.md +8 -19
  10. package/plugins/sp/commands/dev-idea.md +3 -3
  11. package/plugins/sp/commands/dev-plan.md +1 -1
  12. package/plugins/sp/commands/dev-run.md +2 -2
  13. package/plugins/sp/commands/dev-runall.md +2 -2
  14. package/plugins/sp/commands/dev-wrap.md +5 -6
  15. package/plugins/sp/commands/dev-wrapall.md +5 -7
  16. package/plugins/sp/plugin.json +1 -1
  17. package/plugins/sp/references/environment-lens.md +66 -0
  18. package/plugins/sp/scripts/history-anatomy-cache.mjs +165 -15
  19. package/plugins/sp/scripts/history-anatomy-cache.ts +216 -15
  20. package/plugins/sp/skills/dogfood-testing/SKILL.md +14 -1
  21. package/plugins/sp/skills/dogfood-testing/references/report-template.md +30 -0
  22. package/plugins/sp/skills/history-anatomy/SKILL.md +3 -3
  23. package/plugins/sp/skills/history-anatomy/references/operations.md +21 -8
  24. package/plugins/sp/skills/history-anatomy/references/report-contract.md +63 -5
  25. package/plugins/sp/skills/next-router/SKILL.md +4 -4
  26. package/plugins/sp/skills/pr-reviewing/SKILL.md +2 -3
  27. package/plugins/sp/skills/redesign-web-ui/SKILL.md +184 -0
  28. package/plugins/sp/skills/redesign-web-ui/references/audit-checklist.md +121 -0
  29. package/plugins/sp/skills/redesign-web-ui/references/upgrade-techniques.md +66 -0
  30. package/plugins/sp/skills/spur-cli/references/agent.md +16 -7
  31. package/plugins/sp/skills/spur-cli/references/message.md +4 -2
  32. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +5 -0
  33. package/plugins/sp/skills/spur-cli/references/workflows/operations.md +20 -5
  34. package/plugins/sp/skills/spur-cli/references/workflows/workflow-fit-and-tuning.md +230 -0
  35. package/plugins/sp/skills/spur-cli/references/workflows.md +26 -4
  36. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +28 -34
  37. package/plugins/sp/skills/spur-dev/references/dev-operations.md +62 -20
  38. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +1 -1
  39. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +18 -8
  40. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +7 -6
  41. package/spur.js +1012 -372
  42. package/web/_astro/{BoardApp.rkWGVwwK.js → BoardApp.CKolAjUz.js} +56 -56
  43. package/web/_astro/BoardApp.DXD--ybM.js +1 -0
  44. package/web/_astro/{TaskDetail.B7ODt_bA.js → TaskDetail.Bre7G4gC.js} +1 -1
  45. package/web/_astro/{arc.C5QRz6AQ.js → arc.7luwOGiC.js} +1 -1
  46. package/web/_astro/{architectureDiagram-3BPJPVTR.CHptn8gZ.js → architectureDiagram-3BPJPVTR.F6KaHXp-.js} +1 -1
  47. package/web/_astro/{blockDiagram-GPEHLZMM.b1nEDkDw.js → blockDiagram-GPEHLZMM.CCGHeRVi.js} +1 -1
  48. package/web/_astro/{c4Diagram-AAUBKEIU.BWj8S_Qq.js → c4Diagram-AAUBKEIU.CpqewGmd.js} +1 -1
  49. package/web/_astro/channel.DxfOFf1l.js +1 -0
  50. package/web/_astro/{chunk-2J33WTMH.q0BBuE1n.js → chunk-2J33WTMH.CB9vKa5F.js} +1 -1
  51. package/web/_astro/{chunk-4BX2VUAB.DP2KDkAU.js → chunk-4BX2VUAB.ifGXoUA3.js} +1 -1
  52. package/web/_astro/{chunk-55IACEB6.CkCId72p.js → chunk-55IACEB6.VIaRo7l8.js} +1 -1
  53. package/web/_astro/{chunk-727SXJPM.oHWzxMot.js → chunk-727SXJPM.DzPE41OS.js} +1 -1
  54. package/web/_astro/{chunk-AQP2D5EJ.DkkgxfMP.js → chunk-AQP2D5EJ.UF2QRXYF.js} +1 -1
  55. package/web/_astro/{chunk-FMBD7UC4.lmFJDvRK.js → chunk-FMBD7UC4.D2zXKa1R.js} +1 -1
  56. package/web/_astro/{chunk-ND2GUHAM.52jt0eHK.js → chunk-ND2GUHAM.Cb9bDyvx.js} +1 -1
  57. package/web/_astro/{chunk-QZHKN3VN.Cg8KEFoD.js → chunk-QZHKN3VN.nUKFBiLD.js} +1 -1
  58. package/web/_astro/{classDiagram-4FO5ZUOK.BuOUhxcD.js → classDiagram-4FO5ZUOK.DZg9K9mO.js} +1 -1
  59. package/web/_astro/{classDiagram-v2-Q7XG4LA2.BuOUhxcD.js → classDiagram-v2-Q7XG4LA2.DZg9K9mO.js} +1 -1
  60. package/web/_astro/{cose-bilkent-S5V4N54A.BRUU8E_0.js → cose-bilkent-S5V4N54A.D9STo90d.js} +1 -1
  61. package/web/_astro/{dagre-BM42HDAG.GDbfFYVV.js → dagre-BM42HDAG.D3IbwhHz.js} +1 -1
  62. package/web/_astro/{diagram-2AECGRRQ.CvIDBeJF.js → diagram-2AECGRRQ.BWTDxBe9.js} +1 -1
  63. package/web/_astro/{diagram-5GNKFQAL.DMgJjWOX.js → diagram-5GNKFQAL.XnXlonHG.js} +1 -1
  64. package/web/_astro/{diagram-KO2AKTUF.njjl-0AP.js → diagram-KO2AKTUF.CywOngCO.js} +1 -1
  65. package/web/_astro/{diagram-LMA3HP47.CcCqgP8M.js → diagram-LMA3HP47.DY1D21Iu.js} +1 -1
  66. package/web/_astro/{diagram-OG6HWLK6.Btqd-YVE.js → diagram-OG6HWLK6.DQxb59KA.js} +1 -1
  67. package/web/_astro/{erDiagram-TEJ5UH35.BbhML_Xo.js → erDiagram-TEJ5UH35.CF2U-pQZ.js} +1 -1
  68. package/web/_astro/{flowDiagram-I6XJVG4X.CkvsIgY8.js → flowDiagram-I6XJVG4X.BJK4M3in.js} +1 -1
  69. package/web/_astro/{ganttDiagram-6RSMTGT7.m8IWD_wW.js → ganttDiagram-6RSMTGT7.BSniMzdB.js} +1 -1
  70. package/web/_astro/{gitGraphDiagram-PVQCEYII.5z87HXO-.js → gitGraphDiagram-PVQCEYII.Dxg-yRov.js} +1 -1
  71. package/web/_astro/index.BVXdIsZV.css +1 -0
  72. package/web/_astro/{infoDiagram-5YYISTIA.Bvyvsd7Q.js → infoDiagram-5YYISTIA.BY4CgO_n.js} +1 -1
  73. package/web/_astro/{ishikawaDiagram-YF4QCWOH.DdWVYO87.js → ishikawaDiagram-YF4QCWOH.BmWDZtwF.js} +1 -1
  74. package/web/_astro/{journeyDiagram-JHISSGLW.C3kOlYH1.js → journeyDiagram-JHISSGLW.CpB1YWDP.js} +1 -1
  75. package/web/_astro/{kanban-definition-UN3LZRKU.CfKTUHVC.js → kanban-definition-UN3LZRKU.k-fukQX9.js} +1 -1
  76. package/web/_astro/{linear.CMDHnzgX.js → linear.BNNCobvI.js} +1 -1
  77. package/web/_astro/{mermaid.core.DN7-WrsP.js → mermaid.core.DnpzzuPU.js} +4 -4
  78. package/web/_astro/{mindmap-definition-RKZ34NQL.DcQxLvqG.js → mindmap-definition-RKZ34NQL.D9NnlLBu.js} +1 -1
  79. package/web/_astro/{pieDiagram-4H26LBE5.Nu-Inbw6.js → pieDiagram-4H26LBE5.CKhoMiyC.js} +1 -1
  80. package/web/_astro/{quadrantDiagram-W4KKPZXB.Dck2EChs.js → quadrantDiagram-W4KKPZXB.BkAygRlm.js} +1 -1
  81. package/web/_astro/{requirementDiagram-4Y6WPE33.C--E5XuW.js → requirementDiagram-4Y6WPE33.CX8ibmwc.js} +1 -1
  82. package/web/_astro/{sankeyDiagram-5OEKKPKP.B_0iRNna.js → sankeyDiagram-5OEKKPKP.Dvurpa0Y.js} +1 -1
  83. package/web/_astro/{sequenceDiagram-3UESZ5HK.PKuKr9mk.js → sequenceDiagram-3UESZ5HK.veO8c2tk.js} +1 -1
  84. package/web/_astro/{stateDiagram-AJRCARHV.ClhGHb-O.js → stateDiagram-AJRCARHV.DpMr4CO3.js} +1 -1
  85. package/web/_astro/{stateDiagram-v2-BHNVJYJU.Bv6BBnvg.js → stateDiagram-v2-BHNVJYJU.CKjso86_.js} +1 -1
  86. package/web/_astro/{timeline-definition-PNZ67QCA.BZm2ZvAH.js → timeline-definition-PNZ67QCA.DayoPp_2.js} +1 -1
  87. package/web/_astro/{vennDiagram-CIIHVFJN.Dt6yZXry.js → vennDiagram-CIIHVFJN.XNHf04O9.js} +1 -1
  88. package/web/_astro/{wardley-L42UT6IY.rUs-E00M.js → wardley-L42UT6IY.CpM_031g.js} +1 -1
  89. package/web/_astro/{wardleyDiagram-YWT4CUSO.BcWgv4cA.js → wardleyDiagram-YWT4CUSO.lIAjSkZJ.js} +1 -1
  90. package/web/_astro/{xychartDiagram-2RQKCTM6.DH-M-cX9.js → xychartDiagram-2RQKCTM6.D8_2K6U1.js} +1 -1
  91. package/web/index.html +2 -2
  92. package/web/_astro/BoardApp.Z9jMwI9G.js +0 -1
  93. package/web/_astro/channel.BJtn6CGV.js +0 -1
  94. package/web/_astro/index.nWve6EHS.css +0 -1
@@ -0,0 +1,66 @@
1
+ # Environment-improvement lens
2
+
3
+ One mapping turns retrospective "environment" observations into concrete surface improvements
4
+ across the two live report owners: `sp:dogfood-testing` report §6 and `sp:history-anatomy`
5
+ sections 4/9. (The inspiration is an out-of-tree retrospective practice; it is deliberately not
6
+ shipped or invoked by anything in this plugin.)
7
+
8
+ **This file is the single source of truth for the seven categories and the placement rule.**
9
+ Neither report projection restates the table below; each links here as its category table
10
+ (task 0686 / feature I9; accepted design `docs/design/environment-improvement-lens.md`,
11
+ ADR-084/085). There is deliberately no `/sp:dev-retro` command, no CLI change, and no protocol
12
+ bump behind this mapping.
13
+
14
+ ## Canonical categories and projections
15
+
16
+ Exactly one file (this one) enumerates these seven names. Encode them **only** in the projected
17
+ field named by each column — never as a history-anatomy `category` (that vocabulary is frozen at
18
+ `reliability | repetition | workflow | performance | coverage | telemetry | positive`).
19
+
20
+ | Retro category | History-anatomy `category` (closed) | History-anatomy `<signal>` | Dogfood class |
21
+ | --- | --- | --- | --- |
22
+ | navigation | `workflow` | `navigation` | `environment` (P3–P2) |
23
+ | automated checks | `reliability` | `automated-checks` | `environment`; action is a gate, not prose |
24
+ | coding standards | `workflow` | `coding-standards` | `environment`; owner surface is review, never implementer |
25
+ | AGENTS.md placement | `workflow` | `agents-md-placement` | `environment`; action = move to skill/reference/check |
26
+ | tool economy | `performance` | `tool-economy` | existing cache-health P3; `environment` when the tool itself is the waste |
27
+ | no-ops | `workflow` | `no-ops` | `environment`; `file:line` of the dead instruction |
28
+ | information access | `telemetry` | `information-access` | existing chained-step `~unknown` P3; `environment` when access is missing |
29
+
30
+ Dogfood class tags are optional and closed: `environment` | `testee` | `waste`. Grammar,
31
+ classification table, and fix-mode boundaries live in the owning projection:
32
+ [`report-template.md` §6](../skills/dogfood-testing/references/report-template.md).
33
+ History-anatomy key grammar and the structure gate live in
34
+ [`report-contract.md`](../skills/history-anatomy/references/report-contract.md).
35
+
36
+ ## Placement rule
37
+
38
+ When an observation lands, choose a home in this order — never a new sentence in an
39
+ always-loaded steering file when a lower step fits:
40
+
41
+ 1. **Automatable → propose a check.** Anything a linter, typechecker, test, script-contract
42
+ check, or filesystem linter could catch becomes a new-or-tighter automated gate
43
+ (`spur-check`, biome rules, bun tests), not a reminder sentence.
44
+ 2. **Coding standard → the review path.** The owner surface is `sp:code-verification`,
45
+ `sp:code-review`, or pipeline review — never the implementer skill.
46
+ 3. **Always-loaded steering (`AGENTS.md` / `CLAUDE.md`) → navigation pointer only.** Detail
47
+ moves to a skill, reference, or check; the steering file keeps at most a pointer.
48
+
49
+ ## Present-don't-apply
50
+
51
+ Findings projected through this lens are **proposals**, in either owner's report. No projection
52
+ applies a change, produces a diff, or claims to have executed anything. Dogfood bounded fix-mode
53
+ may repair a failed `testee`-class step, but never mutates a finding tagged `environment`;
54
+ class — not the cited file path — decides whether bounded fix may mutate. History-anatomy stays
55
+ report-only. Accepted proposals reach the tree through operator-approved surfaces (`spur task
56
+ create` handoff, manual edit), which remain explicit human gates.
57
+
58
+ ## Keep / drop boundary
59
+
60
+ Kept here because both live reports consume it: the seven names above, their projections, and
61
+ this placement rule. Dropped (each needs its own operator decision): installing or invoking the
62
+ out-of-tree retro practice, a standalone retro command, `CODING_STANDARDS.md` as a file, runtime
63
+ parsing of this markdown by validators, automatic remediation of environment findings, and
64
+ folding the lens into wrap-up learnings or `.spur/context/` memory.
65
+ `plugins/sp/skills/issue-finding/` remains the legacy coexistence non-target — it gains nothing
66
+ from this mapping.
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
5
  // plugins/sp/scripts/history-anatomy-cache.ts
6
+ import { spawnSync } from "node:child_process";
6
7
  import { createHash as createHash2 } from "node:crypto";
7
8
  import {
8
9
  closeSync,
@@ -63,7 +64,7 @@ function semanticArtifactDigest(artifactJson) {
63
64
  }
64
65
 
65
66
  // plugins/sp/scripts/history-anatomy-cache.ts
66
- var ELEVEN_SECTIONS = [
67
+ var REPORT_SECTIONS = [
67
68
  "Scope and provenance",
68
69
  "Executive summary",
69
70
  "Baseline comparison",
@@ -73,6 +74,7 @@ var ELEVEN_SECTIONS = [
73
74
  "Remediation options",
74
75
  "Performance analysis",
75
76
  "Workflow and process improvements",
77
+ "Report-only advisories",
76
78
  "Positive patterns",
77
79
  "Evidence ledger"
78
80
  ];
@@ -85,7 +87,10 @@ var FINDING_FIELDS = [
85
87
  "inference",
86
88
  "confidence",
87
89
  "contradictions",
88
- "evidenceAnchor"
90
+ "evidenceAnchor",
91
+ "severity",
92
+ "reproCommand",
93
+ "ownerSurface"
89
94
  ];
90
95
  function parseScalar(raw) {
91
96
  const t = raw.trim();
@@ -249,12 +254,21 @@ function decideCache(cached, current, opts) {
249
254
  function escapeRe(s) {
250
255
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
251
256
  }
257
+ var FINDING_CATEGORIES = [
258
+ "reliability",
259
+ "repetition",
260
+ "workflow",
261
+ "performance",
262
+ "coverage",
263
+ "telemetry",
264
+ "positive"
265
+ ];
252
266
  function checkReportStructure(reportMarkdown) {
253
267
  const problems = [];
254
268
  if (/TODO|PLACEHOLDER|FIXME|^\|\s*\|/im.test(reportMarkdown))
255
269
  problems.push("placeholder-or-todo-present");
256
270
  let lastIdx = -1;
257
- for (const section of ELEVEN_SECTIONS) {
271
+ for (const section of REPORT_SECTIONS) {
258
272
  const re = new RegExp(`^#{2,3}\\s+${escapeRe(section)}\\s*$`, "m");
259
273
  const m = reportMarkdown.match(re);
260
274
  if (m === null || (m.index ?? -1) <= lastIdx) {
@@ -263,11 +277,45 @@ function checkReportStructure(reportMarkdown) {
263
277
  lastIdx = m.index;
264
278
  }
265
279
  }
266
- const findingRows = reportMarkdown.match(/^\|\s*(reliability|repetition|workflow|performance|coverage|telemetry|positive):[^|]+/gm);
267
- for (const row of findingRows ?? []) {
268
- for (const field of FINDING_FIELDS) {
269
- if (!row.toLowerCase().includes(field))
270
- problems.push(`finding-missing-field:${field}`);
280
+ const findingsIdx = reportMarkdown.search(/^##\s+Findings\s*$/im);
281
+ if (findingsIdx !== -1) {
282
+ const tail = reportMarkdown.slice(findingsIdx);
283
+ const nextSection = tail.slice(1).search(/^##\s+/im);
284
+ const findingsBody = nextSection === -1 ? tail : tail.slice(0, nextSection + 1);
285
+ const catAlt = FINDING_CATEGORIES.join("|");
286
+ const knownRows = findingsBody.match(new RegExp(`^\\|\\s*(?:${catAlt}):[^|]+`, "gm")) ?? [];
287
+ const invalidRows = findingsBody.match(/^\|\s*([^|:\s][^|:]*):[^|]+/gm) ?? [];
288
+ for (const row of [...new Set([...knownRows, ...invalidRows])]) {
289
+ const segMatch = row.match(/^\|\s*([^:|]+):/);
290
+ if (segMatch && !FINDING_CATEGORIES.includes(segMatch[1].trim())) {
291
+ problems.push(`finding-invalid-key-category:${segMatch[1].trim()}`);
292
+ }
293
+ const rowLower = row.toLowerCase();
294
+ for (const field of FINDING_FIELDS) {
295
+ if (!rowLower.includes(field.toLowerCase()))
296
+ problems.push(`finding-missing-field:${field}`);
297
+ }
298
+ }
299
+ const blocks = findingsBody.split(/^###\s+/m).slice(1);
300
+ for (const block of blocks) {
301
+ if (!block.includes("`key`") && !/\|\s*key\s*:/.test(block))
302
+ continue;
303
+ for (const field of FINDING_FIELDS) {
304
+ if (!block.includes(field))
305
+ problems.push(`finding-missing-field:${field}`);
306
+ }
307
+ if (!/(^|[\s`])P[123]([\s`.]|$)/.test(block) && !block.includes("symbolic-severity")) {
308
+ problems.push("finding-invalid-severity");
309
+ }
310
+ const catValue = block.match(/`category`\s*[:=]\s*`?([^`\n]+?)`?\s*(?:\n|$)/)?.[1];
311
+ if (catValue && !FINDING_CATEGORIES.includes(catValue.trim())) {
312
+ problems.push(`finding-invalid-category:${catValue.trim()}`);
313
+ }
314
+ const keyValue = block.match(/`key`\s*[:=]\s*`?([^`\n]+?)`?\s*(?:\n|$)/)?.[1] ?? "";
315
+ const firstSegment = keyValue.split(":")[0]?.trim() ?? "";
316
+ if (keyValue !== "" && !FINDING_CATEGORIES.includes(firstSegment)) {
317
+ problems.push(`finding-invalid-key-category:${firstSegment}`);
318
+ }
271
319
  }
272
320
  }
273
321
  const ledgerIdx = reportMarkdown.search(/^#{2,3}\s+Evidence\s+ledger/im);
@@ -319,17 +367,69 @@ function localDay(tz, at = new Date) {
319
367
  return at.toISOString().slice(0, 10);
320
368
  }
321
369
  }
370
+ function tzOffsetMs(tz, at) {
371
+ const parts = Object.fromEntries(new Intl.DateTimeFormat("en-US", {
372
+ timeZone: tz,
373
+ hour12: false,
374
+ year: "numeric",
375
+ month: "2-digit",
376
+ day: "2-digit",
377
+ hour: "2-digit",
378
+ minute: "2-digit",
379
+ second: "2-digit"
380
+ }).formatToParts(at).filter((p) => p.type !== "literal").map((p) => [p.type, p.value]));
381
+ const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), parts.hour === "24" ? 0 : Number(parts.hour), Number(parts.minute), Number(parts.second));
382
+ const atSec = Math.floor(at.getTime() / 1000) * 1000;
383
+ return asUtc - atSec;
384
+ }
385
+ function zonedDayStart(tz, ymd) {
386
+ const [y, m, d] = ymd.split("-").map(Number);
387
+ const utcMidnight = Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1);
388
+ const guess = new Date(utcMidnight - tzOffsetMs(tz, new Date(utcMidnight)));
389
+ return new Date(utcMidnight - tzOffsetMs(tz, guess));
390
+ }
391
+ function formatZonedIso(tz, at) {
392
+ const off = tzOffsetMs(tz, at);
393
+ const abs = Math.abs(off);
394
+ const pad = (n) => String(n).padStart(2, "0");
395
+ const offStr = `${off < 0 ? "-" : "+"}${pad(Math.floor(abs / 3600000))}:${pad(Math.floor(abs % 3600000 / 60000))}`;
396
+ return `${new Date(at.getTime() + off).toISOString().slice(0, 23)}${offStr}`;
397
+ }
398
+ function dayBounds(tz, ymd) {
399
+ const start = zonedDayStart(tz, ymd);
400
+ const nextYmd = localDay(tz, new Date(start.getTime() + 30 * 3600000));
401
+ return {
402
+ since: formatZonedIso(tz, start),
403
+ until: formatZonedIso(tz, new Date(zonedDayStart(tz, nextYmd).getTime() - 1))
404
+ };
405
+ }
322
406
  function resolvePaths(opts) {
323
- const pluginRoot = opts.helper.replace(/\/scripts\/[^/]+$/, "");
324
- const skill = `${pluginRoot}/skills/history-anatomy`;
325
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
407
+ const m = opts.helper.match(/\/scripts\/(?:([^/]+)\/)?[^/]+$/);
408
+ const pluginRoot = m ? opts.helper.slice(0, m.index) : opts.helper;
409
+ const skill = `${pluginRoot}/skills/${m?.[1] ? `${m[1]}-history-anatomy` : "history-anatomy"}`;
410
+ const tz = opts.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
326
411
  const date = opts.date !== undefined && opts.date !== "" ? opts.date : localDay(tz, opts.now ?? new Date);
327
412
  const target = opts.output !== undefined && opts.output !== "" ? opts.output : `${opts.reportDir}/${date}-history-anatomy.md`;
328
- return `HA_HELPER=${opts.helper}
413
+ const adHoc = opts.mode === "ad-hoc" && !!opts.since && !!opts.until;
414
+ let env = `HA_HELPER=${opts.helper}
329
415
  HA_SKILL=${skill}
330
416
  HA_TARGET=${target}
331
417
  HA_DATE=${date}
332
418
  `;
419
+ if (adHoc) {
420
+ return `${env}HA_SINCE=${opts.since}
421
+ HA_UNTIL=${opts.until}
422
+ `;
423
+ }
424
+ const current = dayBounds(tz, date);
425
+ const baseline = dayBounds(tz, localDay(tz, new Date(zonedDayStart(tz, date).getTime() - 12 * 3600000)));
426
+ env += `HA_SINCE=${current.since}
427
+ HA_UNTIL=${current.until}
428
+ `;
429
+ env += `HA_BASELINE_SINCE=${baseline.since}
430
+ HA_BASELINE_UNTIL=${baseline.until}
431
+ `;
432
+ return env;
333
433
  }
334
434
  function buildProvenance(opts) {
335
435
  let raw;
@@ -486,7 +586,15 @@ function publishAtomically(candidatePath, targetPath) {
486
586
  throw err;
487
587
  }
488
588
  }
489
- var VALID_COMMANDS = "digest, check, paths, probe, stamp, refresh, publish";
589
+ function porcelainPaths(text) {
590
+ return new Set(text.split(`
591
+ `).map((line) => line.replace(/^\S+\s+/, "").trim()).filter((line) => line.length > 0));
592
+ }
593
+ function diffPorcelain(before, now, expects) {
594
+ const beforePaths = porcelainPaths(before);
595
+ return [...porcelainPaths(now)].filter((p) => !beforePaths.has(p) && !expects.has(p)).sort();
596
+ }
597
+ var VALID_COMMANDS = "digest, check, paths, assert-clean, probe, stamp, refresh, publish";
490
598
  var PROBE_USAGE = "<script> probe --artifact <a.json> --target <report.md> [--baseline <b.json>] [--mode daily|ad-hoc] " + "[--date <YYYY-MM-DD>] [--recompute true] [--out <prov.json>] [--skill-dir <d>] [--contract <f>] [--workflow <f>]";
491
599
  function parseFlags(args) {
492
600
  const out = {};
@@ -543,13 +651,50 @@ ${result.problems.map((p) => `- ${p}
543
651
  publishAtomically(a, b);
544
652
  return { exitCode: 0, stdout: "", stderr: "" };
545
653
  }
654
+ case "assert-clean": {
655
+ const f = parseFlags(argv.slice(1));
656
+ if (f.baseline === undefined) {
657
+ return {
658
+ exitCode: 1,
659
+ stdout: "",
660
+ stderr: `usage: <script> assert-clean --baseline <porcelain.txt> [--expect <path>]...
661
+ `
662
+ };
663
+ }
664
+ const expects = new Set;
665
+ for (const arg of argv.slice(1)) {
666
+ if (arg.startsWith("--expect="))
667
+ expects.add(arg.slice("--expect=".length));
668
+ }
669
+ let now;
670
+ try {
671
+ now = spawnSync("git", ["status", "--porcelain"], {
672
+ encoding: "utf8",
673
+ ...f.cwd !== undefined ? { cwd: f.cwd } : {}
674
+ }).stdout ?? "";
675
+ } catch {
676
+ return { exitCode: 0, stdout: "", stderr: `assert-clean: git unavailable; skipped
677
+ ` };
678
+ }
679
+ const undeclared = diffPorcelain(readFileSync(f.baseline, "utf8"), now, expects);
680
+ if (undeclared.length > 0) {
681
+ return {
682
+ exitCode: 1,
683
+ stdout: "",
684
+ stderr: undeclared.map((p) => `undeclared write: ${p}
685
+ `).join("")
686
+ };
687
+ }
688
+ return { exitCode: 0, stdout: `clean
689
+ `, stderr: "" };
690
+ }
546
691
  case "paths": {
547
692
  const f = parseFlags(argv.slice(1));
548
693
  if (f.helper === undefined || f.out === undefined) {
549
694
  return {
550
695
  exitCode: 1,
551
696
  stdout: "",
552
- stderr: `usage: <script> paths --helper <p> --out <env> [--report-dir <d>] [--date <d>] [--output <p>]
697
+ stderr: `usage: <script> paths --helper <p> --out <env> [--report-dir <d>] [--date <d>] [--output <p>] [--mode <m>] [--since <s>] [--until <u>]
553
698
  `
554
699
  };
555
700
  }
@@ -557,7 +702,10 @@ ${result.problems.map((p) => `- ${p}
557
702
  helper: f.helper,
558
703
  reportDir: f["report-dir"] ?? "docs/report",
559
704
  date: f.date,
560
- output: f.output
705
+ output: f.output,
706
+ mode: f.mode,
707
+ since: f.since,
708
+ until: f.until
561
709
  }));
562
710
  return { exitCode: 0, stdout: "", stderr: "" };
563
711
  }
@@ -659,9 +807,11 @@ export {
659
807
  refreshReport,
660
808
  publishAtomically,
661
809
  probe,
810
+ porcelainPaths,
662
811
  parseProvenance,
663
812
  logicDigest,
664
813
  importedSnapshotAsOf,
814
+ diffPorcelain,
665
815
  decideCache,
666
816
  checkReportStructure,
667
817
  buildProvenance,
@@ -27,6 +27,7 @@
27
27
  * `packages/` imports and no `Bun.*` globals, so the committed `.mjs` twin runs under bare `node`.
28
28
  */
29
29
 
30
+ import { spawnSync } from 'node:child_process';
30
31
  import { createHash } from 'node:crypto';
31
32
  import {
32
33
  closeSync,
@@ -89,7 +90,7 @@ export interface CacheDecision {
89
90
 
90
91
  // Frozen vocabulary from 0658's references/report-contract.md, kept local (not imported from
91
92
  // `packages/`, not re-read from the skill) so this deterministic script is self-contained.
92
- const ELEVEN_SECTIONS = [
93
+ const REPORT_SECTIONS = [
93
94
  'Scope and provenance',
94
95
  'Executive summary',
95
96
  'Baseline comparison',
@@ -99,6 +100,9 @@ const ELEVEN_SECTIONS = [
99
100
  'Remediation options',
100
101
  'Performance analysis',
101
102
  'Workflow and process improvements',
103
+ // 0680 R5: standing report-only advisory slot (repeated tool-and-argument
104
+ // signatures propose no automatic interruption).
105
+ 'Report-only advisories',
102
106
  'Positive patterns',
103
107
  'Evidence ledger',
104
108
  ];
@@ -113,6 +117,10 @@ const FINDING_FIELDS = [
113
117
  'confidence',
114
118
  'contradictions',
115
119
  'evidenceAnchor',
120
+ // 0680 R1-R3: triage fields — the gate fails a finding missing any of them.
121
+ 'severity',
122
+ 'reproCommand',
123
+ 'ownerSurface',
116
124
  ];
117
125
 
118
126
  // ── 1. Semantic artifact digest ────────────────────────────────────────────────────────────
@@ -311,8 +319,19 @@ function escapeRe(s: string): string {
311
319
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
312
320
  }
313
321
 
322
+ /** Closed finding categories (0686/I9). Kept next to FINDING_FIELDS — the gate parses them. */
323
+ const FINDING_CATEGORIES = [
324
+ 'reliability',
325
+ 'repetition',
326
+ 'workflow',
327
+ 'performance',
328
+ 'coverage',
329
+ 'telemetry',
330
+ 'positive',
331
+ ] as const;
332
+
314
333
  /**
315
- * Check a candidate report against the frozen report contract — the eleven sections in order,
334
+ * Check a candidate report against the frozen report contract — the twelve sections in order,
316
335
  * the nine per-finding fields, no placeholders/TODOs/empty bodies, and evidence-ledger anchors.
317
336
  */
318
337
  export function checkReportStructure(reportMarkdown: string): { ok: boolean; problems: string[] } {
@@ -321,7 +340,7 @@ export function checkReportStructure(reportMarkdown: string): { ok: boolean; pro
321
340
  if (/TODO|PLACEHOLDER|FIXME|^\|\s*\|/im.test(reportMarkdown)) problems.push('placeholder-or-todo-present');
322
341
 
323
342
  let lastIdx = -1;
324
- for (const section of ELEVEN_SECTIONS) {
343
+ for (const section of REPORT_SECTIONS) {
325
344
  const re = new RegExp(`^#{2,3}\\s+${escapeRe(section)}\\s*$`, 'm');
326
345
  const m = reportMarkdown.match(re);
327
346
  if (m === null || (m.index ?? -1) <= lastIdx) {
@@ -331,12 +350,57 @@ export function checkReportStructure(reportMarkdown: string): { ok: boolean; pro
331
350
  }
332
351
  }
333
352
 
334
- const findingRows = reportMarkdown.match(
335
- /^\|\s*(reliability|repetition|workflow|performance|coverage|telemetry|positive):[^|]+/gm,
336
- );
337
- for (const row of findingRows ?? []) {
338
- for (const field of FINDING_FIELDS) {
339
- if (!row.toLowerCase().includes(field)) problems.push(`finding-missing-field:${field}`);
353
+ // 0680 R1-R3: findings are authored as bullet blocks under `## Findings` (one `### <title>`
354
+ // block per finding, key-value bullets). Scan each block that carries a stable key and fail
355
+ // it missing any triage field — including the three new ones. Without this scan a bullet
356
+ // finding never matches the legacy pipe-row regex and the triage gate was vacuous.
357
+ // 0686/I9: category parsing is scoped to that same body — an explicit category outside the
358
+ // closed vocabulary (FINDING_CATEGORIES) or a stable key whose first segment does so fails
359
+ // rather than passing vacuously.
360
+ const findingsIdx = reportMarkdown.search(/^##\s+Findings\s*$/im);
361
+ if (findingsIdx !== -1) {
362
+ const tail = reportMarkdown.slice(findingsIdx);
363
+ const nextSection = tail.slice(1).search(/^##\s+/im);
364
+ const findingsBody = nextSection === -1 ? tail : tail.slice(0, nextSection + 1);
365
+
366
+ const catAlt = FINDING_CATEGORIES.join('|');
367
+ const knownRows = findingsBody.match(new RegExp(`^\\|\\s*(?:${catAlt}):[^|]+`, 'gm')) ?? [];
368
+ const invalidRows = findingsBody.match(/^\|\s*([^|:\s][^|:]*):[^|]+/gm) ?? [];
369
+ for (const row of [...new Set([...knownRows, ...invalidRows])]) {
370
+ // 0686/I9: a pipe row outside the closed first-segment set fails by name instead of
371
+ // being skipped silently by the legacy regex.
372
+ const segMatch = row.match(/^\|\s*([^:|]+):/);
373
+ if (segMatch && !(FINDING_CATEGORIES as readonly string[]).includes(segMatch[1].trim())) {
374
+ problems.push(`finding-invalid-key-category:${segMatch[1].trim()}`);
375
+ }
376
+ // Case-insensitive on BOTH sides — camelCase triage fields otherwise never match a
377
+ // lowercased row.
378
+ const rowLower = row.toLowerCase();
379
+ for (const field of FINDING_FIELDS) {
380
+ if (!rowLower.includes(field.toLowerCase())) problems.push(`finding-missing-field:${field}`);
381
+ }
382
+ }
383
+
384
+ const blocks = findingsBody.split(/^###\s+/m).slice(1);
385
+ for (const block of blocks) {
386
+ if (!block.includes('`key`') && !/\|\s*key\s*:/.test(block)) continue;
387
+ for (const field of FINDING_FIELDS) {
388
+ if (!block.includes(field)) problems.push(`finding-missing-field:${field}`);
389
+ }
390
+ // Severity vocabulary is closed (0680 R1): P1/P2/P3 only (symbolic placeholders allowed).
391
+ if (!/(^|[\s`])P[123]([\s`.]|$)/.test(block) && !block.includes('symbolic-severity')) {
392
+ problems.push('finding-invalid-severity');
393
+ }
394
+ // 0686/I9 closed-category enforcement on bullet findings.
395
+ const catValue = block.match(/`category`\s*[:=]\s*`?([^`\n]+?)`?\s*(?:\n|$)/)?.[1];
396
+ if (catValue && !(FINDING_CATEGORIES as readonly string[]).includes(catValue.trim())) {
397
+ problems.push(`finding-invalid-category:${catValue.trim()}`);
398
+ }
399
+ const keyValue = block.match(/`key`\s*[:=]\s*`?([^`\n]+?)`?\s*(?:\n|$)/)?.[1] ?? '';
400
+ const firstSegment = keyValue.split(':')[0]?.trim() ?? '';
401
+ if (keyValue !== '' && !(FINDING_CATEGORIES as readonly string[]).includes(firstSegment)) {
402
+ problems.push(`finding-invalid-key-category:${firstSegment}`);
403
+ }
340
404
  }
341
405
  }
342
406
 
@@ -422,6 +486,66 @@ function localDay(tz: string, at: Date = new Date()): string {
422
486
  }
423
487
  }
424
488
 
489
+ /** Wall-clock offset of `tz` at instant `at`, ms east of UTC (0674 R2). */
490
+ function tzOffsetMs(tz: string, at: Date): number {
491
+ const parts = Object.fromEntries(
492
+ new Intl.DateTimeFormat('en-US', {
493
+ timeZone: tz,
494
+ hour12: false,
495
+ year: 'numeric',
496
+ month: '2-digit',
497
+ day: '2-digit',
498
+ hour: '2-digit',
499
+ minute: '2-digit',
500
+ second: '2-digit',
501
+ })
502
+ .formatToParts(at)
503
+ .filter((p) => p.type !== 'literal')
504
+ .map((p) => [p.type, p.value]),
505
+ );
506
+ const asUtc = Date.UTC(
507
+ Number(parts.year),
508
+ Number(parts.month) - 1,
509
+ Number(parts.day),
510
+ parts.hour === '24' ? 0 : Number(parts.hour),
511
+ Number(parts.minute),
512
+ Number(parts.second),
513
+ );
514
+ // Millisecond-truncate the comparison instant — Intl parts carry second precision, and an
515
+ // untruncated .999 epoch would leak into the offset (0674).
516
+ const atSec = Math.floor(at.getTime() / 1000) * 1000;
517
+ return asUtc - atSec;
518
+ }
519
+
520
+ /** First instant of local calendar day `ymd` (two-pass so the offset guess survives DST edges). */
521
+ function zonedDayStart(tz: string, ymd: string): Date {
522
+ const [y, m, d] = ymd.split('-').map(Number);
523
+ const utcMidnight = Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1);
524
+ const guess = new Date(utcMidnight - tzOffsetMs(tz, new Date(utcMidnight)));
525
+ return new Date(utcMidnight - tzOffsetMs(tz, guess));
526
+ }
527
+
528
+ /** Instant rendered in `tz` wall clock with explicit offset: YYYY-MM-DDTHH:mm:ss.sss±HH:MM. */
529
+ function formatZonedIso(tz: string, at: Date): string {
530
+ const off = tzOffsetMs(tz, at);
531
+ const abs = Math.abs(off);
532
+ const pad = (n: number): string => String(n).padStart(2, '0');
533
+ const offStr = `${off < 0 ? '-' : '+'}${pad(Math.floor(abs / 3_600_000))}:${pad(Math.floor((abs % 3_600_000) / 60_000))}`;
534
+ // Shifting by the offset then formatting as UTC yields the zone's own wall clock.
535
+ return `${new Date(at.getTime() + off).toISOString().slice(0, 23)}${offStr}`;
536
+ }
537
+
538
+ /** Inclusive bounds of one local calendar day; a DST day is 23/24/25h and both ends carry the real offset. */
539
+ function dayBounds(tz: string, ymd: string): { since: string; until: string } {
540
+ const start = zonedDayStart(tz, ymd);
541
+ // start + 30h always lands inside the NEXT calendar day regardless of 23/24/25h lengths.
542
+ const nextYmd = localDay(tz, new Date(start.getTime() + 30 * 3_600_000));
543
+ return {
544
+ since: formatZonedIso(tz, start),
545
+ until: formatZonedIso(tz, new Date(zonedDayStart(tz, nextYmd).getTime() - 1)),
546
+ };
547
+ }
548
+
425
549
  /**
426
550
  * Resolve the run's fixed paths once (0660 R4/R15): the skill directory beside the helper, the
427
551
  * effective local date, and the publication target. Emitted as an env file so every downstream
@@ -433,14 +557,36 @@ export function resolvePaths(opts: {
433
557
  date?: string;
434
558
  output?: string;
435
559
  now?: Date;
560
+ /** IANA zone override (test hook); defaults to the process zone. */
561
+ tz?: string;
562
+ mode?: string;
563
+ since?: string;
564
+ until?: string;
436
565
  }): string {
437
- const pluginRoot = opts.helper.replace(/\/scripts\/[^/]+$/, '');
438
- const skill = `${pluginRoot}/skills/history-anatomy`;
439
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
566
+ // Layouts: monorepo `<root>/scripts/<file>` → skill `<root>/skills/history-anatomy`;
567
+ // superskill-installed `<root>/scripts/<plugin>/<file>` → skill `<root>/skills/<plugin>-history-anatomy`.
568
+ // (0660 dogfood 2026-08-26: the single-segment strip left HA_SKILL bogus on installed layouts,
569
+ // silently degrading probe contract/skill digests to "not available".)
570
+ const m = opts.helper.match(/\/scripts\/(?:([^/]+)\/)?[^/]+$/);
571
+ const pluginRoot = m ? opts.helper.slice(0, m.index) : opts.helper;
572
+ const skill = `${pluginRoot}/skills/${m?.[1] ? `${m[1]}-history-anatomy` : 'history-anatomy'}`;
573
+ const tz = opts.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
440
574
  const date = opts.date !== undefined && opts.date !== '' ? opts.date : localDay(tz, opts.now ?? new Date());
441
575
  const target =
442
576
  opts.output !== undefined && opts.output !== '' ? opts.output : `${opts.reportDir}/${date}-history-anatomy.md`;
443
- return `HA_HELPER=${opts.helper}\nHA_SKILL=${skill}\nHA_TARGET=${target}\nHA_DATE=${date}\n`;
577
+ // 0674 R1/R2: daily bounds are derived here — date arithmetic in a named zone is exactly
578
+ // the "exceeds the shell composition threshold" case (ADR-069 R1), and DST makes a local
579
+ // day 23/24/25h. Ad-hoc (0674 R3): operator bounds pass through untouched, no baseline pair.
580
+ const adHoc = opts.mode === 'ad-hoc' && !!opts.since && !!opts.until;
581
+ let env = `HA_HELPER=${opts.helper}\nHA_SKILL=${skill}\nHA_TARGET=${target}\nHA_DATE=${date}\n`;
582
+ if (adHoc) {
583
+ return `${env}HA_SINCE=${opts.since}\nHA_UNTIL=${opts.until}\n`;
584
+ }
585
+ const current = dayBounds(tz, date);
586
+ const baseline = dayBounds(tz, localDay(tz, new Date(zonedDayStart(tz, date).getTime() - 12 * 3_600_000)));
587
+ env += `HA_SINCE=${current.since}\nHA_UNTIL=${current.until}\n`;
588
+ env += `HA_BASELINE_SINCE=${baseline.since}\nHA_BASELINE_UNTIL=${baseline.until}\n`;
589
+ return env;
444
590
  }
445
591
 
446
592
  export interface ProbeOptions {
@@ -658,7 +804,23 @@ export interface CacheCliResult {
658
804
  stderr: string;
659
805
  }
660
806
 
661
- const VALID_COMMANDS = 'digest, check, paths, probe, stamp, refresh, publish';
807
+ /** Parse a `git status --porcelain` text into its path set. */
808
+ export function porcelainPaths(text: string): Set<string> {
809
+ return new Set(
810
+ text
811
+ .split('\n')
812
+ .map((line) => line.replace(/^\S+\s+/, '').trim())
813
+ .filter((line) => line.length > 0),
814
+ );
815
+ }
816
+
817
+ /** Paths present now but absent from the baseline and not declared outputs (0676 R3). */
818
+ export function diffPorcelain(before: string, now: string, expects: Set<string>): string[] {
819
+ const beforePaths = porcelainPaths(before);
820
+ return [...porcelainPaths(now)].filter((p) => !beforePaths.has(p) && !expects.has(p)).sort();
821
+ }
822
+
823
+ const VALID_COMMANDS = 'digest, check, paths, assert-clean, probe, stamp, refresh, publish';
662
824
  const PROBE_USAGE =
663
825
  '<script> probe --artifact <a.json> --target <report.md> [--baseline <b.json>] [--mode daily|ad-hoc] ' +
664
826
  '[--date <YYYY-MM-DD>] [--recompute true] [--out <prov.json>] [--skill-dir <d>] [--contract <f>] [--workflow <f>]';
@@ -716,13 +878,49 @@ export function runCacheCli(argv: string[]): CacheCliResult {
716
878
  publishAtomically(a, b);
717
879
  return { exitCode: 0, stdout: '', stderr: '' };
718
880
  }
881
+ case 'assert-clean': {
882
+ // 0676 R3: fingerprint diff around a model stage. `--baseline` holds the
883
+ // pre-stage `git status --porcelain`; every path present now but absent there
884
+ // must be one of the stage's declared outputs, else exit 1 naming each.
885
+ const f = parseFlags(argv.slice(1));
886
+ if (f.baseline === undefined) {
887
+ return {
888
+ exitCode: 1,
889
+ stdout: '',
890
+ stderr: 'usage: <script> assert-clean --baseline <porcelain.txt> [--expect <path>]...\n',
891
+ };
892
+ }
893
+ const expects = new Set<string>();
894
+ for (const arg of argv.slice(1)) {
895
+ if (arg.startsWith('--expect=')) expects.add(arg.slice('--expect='.length));
896
+ }
897
+ let now: string;
898
+ try {
899
+ now =
900
+ spawnSync('git', ['status', '--porcelain'], {
901
+ encoding: 'utf8',
902
+ ...(f.cwd !== undefined ? { cwd: f.cwd } : {}),
903
+ }).stdout ?? '';
904
+ } catch {
905
+ return { exitCode: 0, stdout: '', stderr: 'assert-clean: git unavailable; skipped\n' };
906
+ }
907
+ const undeclared = diffPorcelain(readFileSync(f.baseline, 'utf8'), now, expects);
908
+ if (undeclared.length > 0) {
909
+ return {
910
+ exitCode: 1,
911
+ stdout: '',
912
+ stderr: undeclared.map((p) => `undeclared write: ${p}\n`).join(''),
913
+ };
914
+ }
915
+ return { exitCode: 0, stdout: 'clean\n', stderr: '' };
916
+ }
719
917
  case 'paths': {
720
918
  const f = parseFlags(argv.slice(1));
721
919
  if (f.helper === undefined || f.out === undefined) {
722
920
  return {
723
921
  exitCode: 1,
724
922
  stdout: '',
725
- stderr: 'usage: <script> paths --helper <p> --out <env> [--report-dir <d>] [--date <d>] [--output <p>]\n',
923
+ stderr: 'usage: <script> paths --helper <p> --out <env> [--report-dir <d>] [--date <d>] [--output <p>] [--mode <m>] [--since <s>] [--until <u>]\n',
726
924
  };
727
925
  }
728
926
  writeFileSync(
@@ -732,6 +930,9 @@ export function runCacheCli(argv: string[]): CacheCliResult {
732
930
  reportDir: f['report-dir'] ?? 'docs/report',
733
931
  date: f.date,
734
932
  output: f.output,
933
+ mode: f.mode,
934
+ since: f.since,
935
+ until: f.until,
735
936
  }),
736
937
  );
737
938
  return { exitCode: 0, stdout: '', stderr: '' };