@gobing-ai/spur 0.3.95 → 0.3.97

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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/plugin-scripts.json +0 -54
  3. package/config/rules/boundary/sp-script-placement.yaml +19 -0
  4. package/config/rules/strict/runtime-boundaries.yaml +6 -0
  5. package/config/rules/structure/test-location.yaml +2 -0
  6. package/config/rules/typescript/no-syscall-emulation-in-boundary-mock.yaml +1 -1
  7. package/config/rules/typescript/output-boundaries.yaml +15 -2
  8. package/config/script-placement-baseline.json +51 -0
  9. package/config/templates/AGENTS.md +3 -3
  10. package/config/workflows/feature-verification.yaml +1 -1
  11. package/config/workflows/history-anatomy.yaml +23 -11
  12. package/config/workflows/idea-pipeline.yaml +80 -85
  13. package/config/workflows/pr-review.yaml +18 -9
  14. package/config/workflows/task-pipeline.yaml +42 -95
  15. package/config/workflows/wrapup-pipeline.yaml +91 -38
  16. package/package.json +2 -2
  17. package/plugins/sp/README.md +14 -10
  18. package/plugins/sp/agents/super-reviewer.md +43 -18
  19. package/plugins/sp/commands/dev-fixgha.md +83 -0
  20. package/plugins/sp/commands/dev-gitmsg.md +8 -6
  21. package/plugins/sp/commands/dev-gtd.md +2 -2
  22. package/plugins/sp/commands/dev-idea.md +22 -12
  23. package/plugins/sp/commands/dev-job-dump.md +29 -0
  24. package/plugins/sp/commands/dev-job-resume.md +29 -0
  25. package/plugins/sp/commands/dev-plan.md +8 -9
  26. package/plugins/sp/commands/dev-review.md +22 -13
  27. package/plugins/sp/commands/dev-verifyall.md +1 -1
  28. package/plugins/sp/commands/spur-init.md +2 -2
  29. package/plugins/sp/lib/history-anatomy.generated.d.mts +112 -0
  30. package/plugins/sp/lib/history-anatomy.generated.mjs +686 -0
  31. package/plugins/sp/lib/idea-handoff.generated.mjs +5 -4
  32. package/plugins/sp/lib/inline-run.generated.d.mts +11 -0
  33. package/plugins/sp/lib/inline-run.generated.mjs +39 -13
  34. package/plugins/sp/lib/quality-gate.generated.d.mts +104 -0
  35. package/plugins/sp/lib/quality-gate.generated.mjs +445 -0
  36. package/plugins/sp/lib/residual-scan.generated.d.mts +63 -0
  37. package/plugins/sp/lib/residual-scan.generated.mjs +226 -0
  38. package/plugins/sp/lib/spur-bin.ts +36 -0
  39. package/plugins/sp/lib/step-profile.generated.d.mts +71 -0
  40. package/plugins/sp/lib/step-profile.generated.mjs +174 -0
  41. package/plugins/sp/plugin.json +1 -1
  42. package/plugins/sp/references/roles.md +3 -3
  43. package/plugins/sp/scripts/feature-verification-steps.mjs +14 -1
  44. package/plugins/sp/scripts/feature-verification-steps.ts +14 -1
  45. package/plugins/sp/scripts/history-anatomy-cache.mjs +20 -19
  46. package/plugins/sp/scripts/history-anatomy-cache.ts +23 -928
  47. package/plugins/sp/scripts/inline-run-setup.mjs +85 -320
  48. package/plugins/sp/scripts/inline-run-setup.ts +104 -667
  49. package/plugins/sp/scripts/quality-gate.mjs +46 -24
  50. package/plugins/sp/scripts/quality-gate.ts +13 -659
  51. package/plugins/sp/scripts/residual-scan.mjs +179 -174
  52. package/plugins/sp/scripts/residual-scan.ts +125 -517
  53. package/plugins/sp/scripts/script-root.mjs +5 -1
  54. package/plugins/sp/scripts/script-root.ts +5 -1
  55. package/plugins/sp/scripts/workflow-step-profile.mjs +25 -17
  56. package/plugins/sp/scripts/workflow-step-profile.ts +21 -315
  57. package/plugins/sp/scripts/wrapup-drift-probe.mjs +8 -2
  58. package/plugins/sp/scripts/wrapup-drift-probe.ts +3 -2
  59. package/plugins/sp/scripts/wrapup-steps.mjs +72 -33
  60. package/plugins/sp/scripts/wrapup-steps.ts +128 -37
  61. package/plugins/sp/skills/code-improvement/SKILL.md +5 -4
  62. package/plugins/sp/skills/code-verification/SKILL.md +34 -9
  63. package/plugins/sp/skills/code-verification/references/verdict-schema.md +3 -3
  64. package/plugins/sp/skills/functional-review/SKILL.md +7 -4
  65. package/plugins/sp/skills/functional-review/references/verdict-schema.md +1 -1
  66. package/plugins/sp/skills/history-anatomy/references/modes.md +2 -1
  67. package/plugins/sp/skills/next-feature/references/handoff-routing.md +1 -1
  68. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  69. package/plugins/sp/skills/source-driven-development/SKILL.md +11 -0
  70. package/plugins/sp/skills/spur-cli/SKILL.md +3 -3
  71. package/plugins/sp/skills/spur-cli/references/agent.md +10 -10
  72. package/plugins/sp/skills/spur-cli/references/features.md +17 -6
  73. package/plugins/sp/skills/spur-cli/references/init.md +17 -16
  74. package/plugins/sp/skills/spur-cli/references/self.md +3 -2
  75. package/plugins/sp/skills/spur-cli/references/serve.md +10 -10
  76. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +9 -5
  77. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +21 -6
  78. package/plugins/sp/skills/spur-cli/references/tasks.md +14 -8
  79. package/plugins/sp/skills/spur-cli/references/workflows.md +17 -14
  80. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  81. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +4 -3
  82. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +14 -10
  83. package/plugins/sp/skills/spur-dev/references/decision-brief.md +1 -1
  84. package/plugins/sp/skills/spur-dev/references/dev-operations.md +165 -75
  85. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +7 -5
  86. package/plugins/sp/skills/spur-dev/references/execution-batch.md +84 -39
  87. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +8 -12
  88. package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +3 -3
  89. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +40 -21
  90. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +19 -19
  91. package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +4 -3
  92. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +72 -15
  93. package/plugins/sp/skills/spur-doctor/SKILL.md +1 -1
  94. package/plugins/sp/skills/sys-architecture/SKILL.md +3 -2
  95. package/spur.js +4461 -2085
  96. package/web/_astro/{BoardApp.Cpxntzad.js → BoardApp.Bmv5WkJ9.js} +1 -1
  97. package/web/_astro/BoardApp.vveLOdDq.js +322 -0
  98. package/web/_astro/{TaskDetail.C5bW4WGV.js → TaskDetail.IKc3uDYV.js} +1 -1
  99. package/web/_astro/{arc.CyjRvNMY.js → arc.C63Ufg35.js} +1 -1
  100. package/web/_astro/{architectureDiagram-3BPJPVTR.B4lWRzJA.js → architectureDiagram-3BPJPVTR.CP8Hyldk.js} +1 -1
  101. package/web/_astro/{blockDiagram-GPEHLZMM.Be38USQ2.js → blockDiagram-GPEHLZMM.Bo_48MRA.js} +1 -1
  102. package/web/_astro/{c4Diagram-AAUBKEIU.DT9Fj5Qx.js → c4Diagram-AAUBKEIU.Ch61sw-O.js} +1 -1
  103. package/web/_astro/channel.BKCmJpWM.js +1 -0
  104. package/web/_astro/{chunk-2J33WTMH.SeSBWLg5.js → chunk-2J33WTMH.CHpzS7xf.js} +1 -1
  105. package/web/_astro/{chunk-4BX2VUAB.DoO4VE14.js → chunk-4BX2VUAB.DzMNNWIw.js} +1 -1
  106. package/web/_astro/{chunk-55IACEB6.DrmTFA53.js → chunk-55IACEB6.BqPrDw_Q.js} +1 -1
  107. package/web/_astro/{chunk-727SXJPM.Bi-TUb_V.js → chunk-727SXJPM.ZzBHhNw-.js} +1 -1
  108. package/web/_astro/{chunk-AQP2D5EJ.B-xp8Uhi.js → chunk-AQP2D5EJ.1_O_LLkx.js} +1 -1
  109. package/web/_astro/{chunk-FMBD7UC4.DGWrDnUU.js → chunk-FMBD7UC4.GS4UHg7_.js} +1 -1
  110. package/web/_astro/{chunk-ND2GUHAM._BagvPDy.js → chunk-ND2GUHAM.BnqotN4g.js} +1 -1
  111. package/web/_astro/{chunk-QZHKN3VN.VOboYWQ0.js → chunk-QZHKN3VN.Ghuh8zoJ.js} +1 -1
  112. package/web/_astro/{classDiagram-4FO5ZUOK.D_apfHS7.js → classDiagram-4FO5ZUOK.BW9vC3zg.js} +1 -1
  113. package/web/_astro/{classDiagram-v2-Q7XG4LA2.D_apfHS7.js → classDiagram-v2-Q7XG4LA2.BW9vC3zg.js} +1 -1
  114. package/web/_astro/{cose-bilkent-S5V4N54A.DNLU_L-x.js → cose-bilkent-S5V4N54A.D6jNC0ti.js} +1 -1
  115. package/web/_astro/{cynefin-OW5HDTMX.D8borhV-.js → cynefin-OW5HDTMX.BOGoCs4L.js} +1 -1
  116. package/web/_astro/{dagre-BM42HDAG.Uc0lvjcV.js → dagre-BM42HDAG.8iU5VcWi.js} +1 -1
  117. package/web/_astro/{diagram-2AECGRRQ.DDIxtDBo.js → diagram-2AECGRRQ.CVt88HwH.js} +1 -1
  118. package/web/_astro/{diagram-5GNKFQAL.DVoDnD3H.js → diagram-5GNKFQAL.C9HxUUMl.js} +1 -1
  119. package/web/_astro/{diagram-KO2AKTUF.CRubTJ-0.js → diagram-KO2AKTUF.4ywkQN3t.js} +1 -1
  120. package/web/_astro/{diagram-LMA3HP47.m9SeYcXQ.js → diagram-LMA3HP47.C48A6gpz.js} +1 -1
  121. package/web/_astro/{diagram-OG6HWLK6.C6s2ZjV5.js → diagram-OG6HWLK6.DxFieQW4.js} +1 -1
  122. package/web/_astro/{erDiagram-TEJ5UH35.nSzr5KTj.js → erDiagram-TEJ5UH35.CtjtQqYs.js} +1 -1
  123. package/web/_astro/{flowDiagram-I6XJVG4X.DrZcys16.js → flowDiagram-I6XJVG4X.C5zAiahU.js} +1 -1
  124. package/web/_astro/{ganttDiagram-6RSMTGT7.Do4M1b-K.js → ganttDiagram-6RSMTGT7.D_L6zsny.js} +1 -1
  125. package/web/_astro/{gitGraphDiagram-PVQCEYII.BWc9oJ4l.js → gitGraphDiagram-PVQCEYII.Ignnrk3V.js} +1 -1
  126. package/web/_astro/index.CbNz17Sx.css +1 -0
  127. package/web/_astro/{infoDiagram-5YYISTIA.BIdOctFk.js → infoDiagram-5YYISTIA.DLv8-P5u.js} +1 -1
  128. package/web/_astro/{ishikawaDiagram-YF4QCWOH.CV5SuG2t.js → ishikawaDiagram-YF4QCWOH.p0OTpwFO.js} +1 -1
  129. package/web/_astro/{journeyDiagram-JHISSGLW.CxE-rEJ-.js → journeyDiagram-JHISSGLW.CIaBt-DD.js} +1 -1
  130. package/web/_astro/{kanban-definition-UN3LZRKU.BPRdKWs_.js → kanban-definition-UN3LZRKU.DGySIntq.js} +1 -1
  131. package/web/_astro/{linear.zRsuuDTE.js → linear.BATV4RXu.js} +1 -1
  132. package/web/_astro/{mermaid.core.jAJTcMKc.js → mermaid.core.DzkwM3VX.js} +4 -4
  133. package/web/_astro/{mindmap-definition-RKZ34NQL.BffJxfCr.js → mindmap-definition-RKZ34NQL.3piBRRzA.js} +1 -1
  134. package/web/_astro/{pieDiagram-4H26LBE5.CacwWaE3.js → pieDiagram-4H26LBE5.DUNueLub.js} +1 -1
  135. package/web/_astro/{quadrantDiagram-W4KKPZXB.hXELD06-.js → quadrantDiagram-W4KKPZXB.9bihVbrt.js} +1 -1
  136. package/web/_astro/{requirementDiagram-4Y6WPE33.DdEbPcqT.js → requirementDiagram-4Y6WPE33.CkjsC-YT.js} +1 -1
  137. package/web/_astro/{sankeyDiagram-5OEKKPKP.DgaDocKv.js → sankeyDiagram-5OEKKPKP.Dbq4vaoQ.js} +1 -1
  138. package/web/_astro/{sequenceDiagram-3UESZ5HK.B0KScnAu.js → sequenceDiagram-3UESZ5HK.BivrH99P.js} +1 -1
  139. package/web/_astro/{stateDiagram-AJRCARHV.CV9M_WNc.js → stateDiagram-AJRCARHV.CqLQ1Vzz.js} +1 -1
  140. package/web/_astro/{stateDiagram-v2-BHNVJYJU.BOAo74Et.js → stateDiagram-v2-BHNVJYJU.DvvoLD3b.js} +1 -1
  141. package/web/_astro/{timeline-definition-PNZ67QCA.CqqepUMe.js → timeline-definition-PNZ67QCA.B-KR78gv.js} +1 -1
  142. package/web/_astro/{vennDiagram-CIIHVFJN.DDqe4dOG.js → vennDiagram-CIIHVFJN.Ck0Eieb5.js} +1 -1
  143. package/web/_astro/{wardleyDiagram-YWT4CUSO.B3R9OPdO.js → wardleyDiagram-YWT4CUSO.CRfdd_do.js} +1 -1
  144. package/web/_astro/{xychartDiagram-2RQKCTM6.B3SvgXpQ.js → xychartDiagram-2RQKCTM6.Ck_Jxk9C.js} +1 -1
  145. package/web/index.html +2 -2
  146. package/plugins/sp/lib/artifact-digest.generated.d.mts +0 -7
  147. package/plugins/sp/lib/artifact-digest.generated.mjs +0 -48
  148. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -301
  149. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -481
  150. package/plugins/sp/scripts/idea-coverage-check.ts +0 -168
  151. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -298
  152. package/plugins/sp/scripts/record-feature-sync.mjs +0 -63
  153. package/plugins/sp/scripts/record-feature-sync.ts +0 -84
  154. package/plugins/sp/scripts/script-contract-check.ts +0 -506
  155. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  156. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -989
  157. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -189
  158. package/plugins/sp/scripts/task-size-precheck.ts +0 -212
  159. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  160. package/plugins/sp/scripts/validate-commands.ts +0 -689
  161. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -890
  162. package/plugins/sp/scripts/verify-answer-lint.ts +0 -549
  163. package/web/_astro/BoardApp.BxJuwD7I.js +0 -191
  164. package/web/_astro/channel.CI6N_tCg.js +0 -1
  165. package/web/_astro/index.CENnIEqT.css +0 -1
@@ -1,921 +1,16 @@
1
+ #!/usr/bin/env bun
1
2
  /**
2
- * history-anatomy-cache — deterministic cache helper for the daily/ad-hoc history-anatomy
3
- * report (feature I8, HA-S1 0659 / ADR-079).
4
- *
5
- * ADR-079 makes cache validity a *derived* fact, not a stored claim: a cached report is reusable
6
- * only for its model-authored half, and only when a freshly derived semantic digest of the analyze
7
- * artifact plus the contract/skill/workflow/helper logic digests all match what the cache
8
- * recorded (helper digest: task 0771 — the deterministic half is part of cache identity).
9
- *
10
- * This script performs deterministic file, hash, and schema work only — no finding, remediation,
11
- * severity, or ranking logic (that is judgment, owned by the sp:history-anatomy skill). Jobs:
12
- *
13
- * 1. semanticArtifactDigest — normalized SHA-256 over the analyze artifact
14
- * 2. parseProvenance — read cache frontmatter; null on absent/malformed, never throws
15
- * 3. decideCache — the full invalidation matrix
16
- * 4. checkReportStructure — the structure gate over a candidate report
17
- * 5. publishAtomically — same-directory tmp + rename; target untouched on failure
18
- * 6. resolvePaths — helper/skill/target path resolution, once, into an env file
19
- * 7. buildProvenance/probe — derive this run's provenance and decide reuse against the cache
20
- * 8. stampReport/refreshReport — attach or refresh the R7 frontmatter block and the banner
21
- *
22
- * CLI verbs: paths, probe, stamp, refresh, digest, check, publish. `probe` is the seam the
23
- * workflow's cache branch turns on — it must run AFTER analyze, because ADR-079 derives validity
24
- * from the fresh artifact rather than trusting what the cached report claims about itself.
25
- *
26
- * Mirrors the feature-sync-bounded.ts pattern (ADR-065 / 0659): pure exported functions for every
27
- * decision, a thin CLI entry, `node:` imports only, `process.argv.slice(2)`, local types — no
28
- * `packages/` imports and no `Bun.*` globals, so the committed `.mjs` twin runs under bare `node`.
3
+ * history-anatomy-cache — CLI glue (task 1005 R4, ADR-130 lib rule) for the history-anatomy
4
+ * report cache (feature I8, HA-S1 0659 / ADR-079): eight verbs, byte-identical argv/stdout/exit.
5
+ * Logic lives in packages/app/src/services/history-anatomy.ts, reached through the generated
6
+ * standalone bundle `plugins/sp/lib/history-anatomy.generated.mjs`; twin runs under bare `node`
7
+ * (`bun run build:scripts`).
29
8
  */
30
-
31
9
  import { spawnSync } from 'node:child_process';
32
- import { createHash } from 'node:crypto';
33
- import {
34
- closeSync,
35
- existsSync,
36
- fsyncSync,
37
- mkdirSync,
38
- openSync,
39
- readdirSync,
40
- readFileSync,
41
- renameSync,
42
- rmSync,
43
- statSync,
44
- writeFileSync,
45
- } from 'node:fs';
46
- import { join } from 'node:path';
47
- // Task 0669: the digest authority (classification + canonicalization + hash) lives beside
48
- // `HistoryArtifact` in packages/domain; this script consumes the GENERATED plugin-side copy so
49
- // the ADR-065 twin keeps running under bare node with no monorepo dependency.
50
- import { semanticArtifactDigest } from '../lib/artifact-digest.generated.mjs';
51
-
52
- // ── Local types (match the 0658/0660 frozen vocabulary; no package import) ───────────────
53
-
54
- export interface CacheIdentity {
55
- contractVersion: string;
56
- mode: 'daily' | 'ad-hoc';
57
- date: string; // YYYY-MM-DD, local calendar day
58
- timezone: string; // IANA zone id
59
- bounds: { since: string; until: string }; // normalized, inclusive, RFC3339
60
- sources: string[];
61
- }
62
-
63
- export interface CacheProvenance {
64
- identity: CacheIdentity;
65
- windowState: 'provisional' | 'closed';
66
- generatedAt: string;
67
- validatedAt: string;
68
- artifactDigest: string;
69
- baselineArtifactDigest: string | null;
70
- contractDigest: string;
71
- skillDigest: string;
72
- workflowDigest: string;
73
- /** Digest of the executing helper twin itself (0771): a changed deterministic half invalidates. */
74
- helperDigest: string;
75
- coverage: Array<{ source: string; status: string; lastImportedAt: string | null }>;
76
- // 0660 R7 audit fields. Recorded in the published frontmatter for provenance; deliberately
77
- // NOT part of the invalidation matrix — a changed run id or executor is not stale evidence.
78
- runId?: string;
79
- currentArtifactPath?: string;
80
- baselineArtifactPath?: string | null;
81
- spurVersion?: string;
82
- schemaVersion?: number;
83
- executor?: string;
84
- model?: string;
85
- cacheDisposition?: CacheDisposition;
86
- }
87
-
88
- export type CacheDisposition = 'hit' | 'miss' | 'forced-recompute';
89
-
90
- export interface CacheDecision {
91
- disposition: CacheDisposition;
92
- reasons: string[];
93
- }
94
-
95
- // Frozen vocabulary from 0658's references/report-contract.md, kept local (not imported from
96
- // `packages/`, not re-read from the skill) so this deterministic script is self-contained.
97
- const REPORT_SECTIONS = [
98
- 'Scope and provenance',
99
- 'Executive summary',
100
- 'Baseline comparison',
101
- 'Findings',
102
- 'Recurrence ledger',
103
- 'Telemetry gaps',
104
- 'Remediation options',
105
- 'Performance analysis',
106
- 'Workflow and process improvements',
107
- // 0680 R5: standing report-only advisory slot (repeated tool-and-argument
108
- // signatures propose no automatic interruption).
109
- 'Report-only advisories',
110
- 'Positive patterns',
111
- 'Evidence ledger',
112
- ];
113
-
114
- const FINDING_FIELDS = [
115
- 'key',
116
- 'category',
117
- 'impact',
118
- 'trend',
119
- 'observation',
120
- 'inference',
121
- 'confidence',
122
- 'contradictions',
123
- 'evidenceAnchor',
124
- // 0680 R1-R3: triage fields — the gate fails a finding missing any of them.
125
- 'severity',
126
- 'reproCommand',
127
- 'ownerSurface',
128
- ];
129
-
130
- // ── 1. Semantic artifact digest ────────────────────────────────────────────────────────────
131
-
132
- /** JSON-compatible value — the domain type for canonicalized artifact material and YAML scalars. */
133
- type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue };
134
-
135
- export { semanticArtifactDigest };
136
-
137
- // ── 2. Provenance parsing ───────────────────────────────────────────────────────────────────
138
-
139
- function parseScalar(raw: string): JsonValue {
140
- const t = raw.trim();
141
- if (t.startsWith('"') && t.endsWith('"')) return t.slice(1, -1).replaceAll('\\"', '"');
142
- if (t === 'null') return null;
143
- return t;
144
- }
145
-
146
- /** Minimal YAML block parser for the deterministic cache frontmatter (nested blocks + list items). */
147
- function parseBlock(text: string): Record<string, unknown> {
148
- const obj: Record<string, unknown> = {};
149
- const lines = text.split('\n');
150
- for (let i = 0; i < lines.length; i++) {
151
- const line = lines[i] ?? '';
152
- if (/^\s*$/.test(line) || /^\s*#/.test(line) || /^-\s+/.test(line.trim())) continue;
153
- const indent = line.search(/\S/);
154
- const eq = line.indexOf(':');
155
- if (eq === -1) continue;
156
- const key = line.slice(0, eq).trim();
157
- const val = parseScalar(line.slice(eq + 1).trim());
158
- let consumed = 0;
159
- // Peek ahead: list item(s) under this key → capture as an array.
160
- const next = lines[i + 1];
161
- if ((val === '' || val === undefined) && /^\s*-\s+/.test(next ?? '')) {
162
- const items: Record<string, unknown>[] = [];
163
- let j = i + 1;
164
- while (j < lines.length && /^\s*-\s+/.test(lines[j] ?? '')) {
165
- const entry: Record<string, unknown> = {};
166
- for (const part of (lines[j] ?? '').trim().replace(/^-\s+/, '').split(',')) {
167
- const e = part.indexOf(':');
168
- if (e === -1) continue;
169
- entry[part.slice(0, e).trim()] = parseScalar(part.slice(e + 1).trim());
170
- }
171
- items.push(entry);
172
- j++;
173
- }
174
- obj[key] = items;
175
- consumed = j - i - 1;
176
- } else if (val === '' || val === undefined) {
177
- // Nested block: capture indented lines into a recursive parse.
178
- const block: string[] = [];
179
- let j = i + 1;
180
- while (j < lines.length) {
181
- const nl = lines[j] ?? '';
182
- if (nl.trim() === '') {
183
- block.push('');
184
- j++;
185
- continue;
186
- }
187
- const nind = nl.search(/\S/);
188
- if (nind <= indent) break;
189
- block.push(nl);
190
- j++;
191
- }
192
- obj[key] = parseBlock(block.join('\n'));
193
- consumed = j - i - 1;
194
- } else {
195
- obj[key] = val;
196
- }
197
- i += consumed;
198
- }
199
- return obj;
200
- }
201
-
202
- /** A source list entry is either a bare scalar or a `- source: <name>` row. */
203
- function readSourceName(s: unknown): string {
204
- if (s !== null && typeof s === 'object') return String((s as { source?: unknown }).source ?? '');
205
- return String(s);
206
- }
207
-
208
- const DISPOSITIONS: CacheDisposition[] = ['hit', 'miss', 'forced-recompute'];
209
-
210
- /** Absent stays absent — an empty string would render as a real value in the republished block. */
211
- function optionalString(v: unknown): string | undefined {
212
- return v == null || v === '' ? undefined : String(v);
213
- }
214
-
215
- /**
216
- * Parse the YAML frontmatter of a published report into CacheProvenance. Returns `null` on
217
- * absent, truncated, or unparsable frontmatter — never throws.
218
- */
219
- export function parseProvenance(reportMarkdown: string): CacheProvenance | null {
220
- const match = reportMarkdown.match(/^---\n([\s\S]*?)\n---/);
221
- if (match === null) return null;
222
- try {
223
- const obj = parseBlock(match[1] ?? '');
224
- const identity = obj.identity as Partial<CacheIdentity> | undefined;
225
- const coverage = obj.coverage as Array<Record<string, unknown>> | undefined;
226
- if (identity === undefined || !Array.isArray(coverage)) return null;
227
- const bounds = identity.bounds as { since?: string; until?: string } | undefined;
228
- if (bounds === undefined || typeof bounds.since !== 'string' || typeof bounds.until !== 'string') {
229
- return null;
230
- }
231
- return {
232
- identity: {
233
- contractVersion: String(identity.contractVersion ?? ''),
234
- mode: identity.mode === 'ad-hoc' ? 'ad-hoc' : 'daily',
235
- date: String(identity.date ?? ''),
236
- timezone: String(identity.timezone ?? ''),
237
- bounds: { since: bounds.since, until: bounds.until },
238
- // Rendered as `- source: <name>` list items (the coverage-row style parseBlock
239
- // understands); tolerate a bare scalar list too.
240
- sources: Array.isArray(identity.sources) ? (identity.sources as unknown[]).map(readSourceName) : [],
241
- },
242
- windowState: obj.windowState === 'closed' ? 'closed' : 'provisional',
243
- generatedAt: String(obj.generatedAt ?? ''),
244
- validatedAt: String(obj.validatedAt ?? ''),
245
- artifactDigest: String(obj.artifactDigest ?? ''),
246
- baselineArtifactDigest: obj.baselineArtifactDigest == null ? null : String(obj.baselineArtifactDigest),
247
- contractDigest: String(obj.contractDigest ?? ''),
248
- skillDigest: String(obj.skillDigest ?? ''),
249
- workflowDigest: String(obj.workflowDigest ?? ''),
250
- helperDigest: String(obj.helperDigest ?? ''),
251
- coverage: coverage.map((c) => ({
252
- source: String(c.source ?? ''),
253
- status: String(c.status ?? ''),
254
- lastImportedAt: c.lastImportedAt == null ? null : String(c.lastImportedAt),
255
- })),
256
- // 0660 R7 audit fields must round-trip: the cache-hit path rebuilds the frontmatter
257
- // from this object, so anything not read back here would be silently dropped on
258
- // republish and the published report would stop carrying the full block.
259
- runId: optionalString(obj.runId),
260
- currentArtifactPath: optionalString(obj.currentArtifactPath),
261
- baselineArtifactPath: obj.baselineArtifactPath == null ? null : String(obj.baselineArtifactPath),
262
- spurVersion: optionalString(obj.spurVersion),
263
- schemaVersion: obj.schemaVersion == null ? undefined : Number(obj.schemaVersion),
264
- executor: optionalString(obj.executor),
265
- model: optionalString(obj.model),
266
- cacheDisposition: DISPOSITIONS.includes(obj.cacheDisposition as CacheDisposition)
267
- ? (obj.cacheDisposition as CacheDisposition)
268
- : undefined,
269
- };
270
- } catch {
271
- return null;
272
- }
273
- }
274
-
275
- // ── 3. Invalidation matrix ──────────────────────────────────────────────────────────────────
276
-
277
- /**
278
- * Decide cache reuse. Returns `hit` only when every invalidation-matrix row passes; each failing
279
- * row appends a human-readable reason. `forced-recompute` (from `--recompute`) is a disposition.
280
- */
281
- export function decideCache(
282
- cached: CacheProvenance | null,
283
- current: CacheProvenance,
284
- opts: { recompute: boolean; dayClosed: boolean },
285
- ): CacheDecision {
286
- if (opts.recompute) return { disposition: 'forced-recompute', reasons: ['recompute'] };
287
- if (cached === null) return { disposition: 'miss', reasons: ['no-cache'] };
288
-
289
- const reasons: string[] = [];
290
- const id = cached.identity;
291
- const cur = current.identity;
292
-
293
- // Identity tuple equality.
294
- if (id.contractVersion !== cur.contractVersion) reasons.push('identity:contractVersion');
295
- if (id.mode !== cur.mode) reasons.push('identity:mode');
296
- if (id.date !== cur.date) reasons.push('identity:date');
297
- if (id.timezone !== cur.timezone) reasons.push('identity:timezone');
298
- if (id.bounds.since !== cur.bounds.since || id.bounds.until !== cur.bounds.until) reasons.push('identity:bounds');
299
- if ([...id.sources].sort().join('\0') !== [...cur.sources].sort().join('\0')) reasons.push('identity:sources');
300
-
301
- // Semantic artifact digest.
302
- if (cached.artifactDigest !== current.artifactDigest) reasons.push('data-changed');
303
-
304
- // Logic digests.
305
- if (cached.contractDigest !== current.contractDigest) reasons.push('logic-changed:contract');
306
- if (cached.skillDigest !== current.skillDigest) reasons.push('logic-changed:skill');
307
- if (cached.workflowDigest !== current.workflowDigest) reasons.push('logic-changed:workflow');
308
- if (cached.helperDigest !== current.helperDigest) reasons.push('logic-changed:helper');
309
-
310
- // Coverage cannot degrade: the cache must not claim broader coverage than the current
311
- // analyze covers. If the cached report covered a source the current analyze no longer does,
312
- // the cache is stale.
313
- const currentSources = new Set(current.coverage.map((c) => c.source));
314
- if (cached.coverage.some((c) => !currentSources.has(c.source))) reasons.push('coverage-degraded');
315
-
316
- // Window-state transition: a provisional cache read once the day has closed is invalid.
317
- if (cached.windowState === 'provisional' && opts.dayClosed) reasons.push('window-closed');
318
-
319
- return { disposition: reasons.length === 0 ? 'hit' : 'miss', reasons };
320
- }
321
-
322
- // ── 4. Structure gate ─────────────────────────────────────────────────────────────────────────
323
-
324
- function escapeRe(s: string): string {
325
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
326
- }
327
-
328
- /** Closed finding categories (0686/I9). Kept next to FINDING_FIELDS — the gate parses them. */
329
- const FINDING_CATEGORIES = [
330
- 'reliability',
331
- 'repetition',
332
- 'workflow',
333
- 'performance',
334
- 'coverage',
335
- 'telemetry',
336
- 'positive',
337
- ] as const;
338
-
339
- /**
340
- * Check a candidate report against the frozen report contract — the twelve sections in order,
341
- * the nine per-finding fields, no placeholders/TODOs/empty bodies, and evidence-ledger anchors.
342
- */
343
- export function checkReportStructure(reportMarkdown: string): { ok: boolean; problems: string[] } {
344
- const problems: string[] = [];
345
-
346
- if (/TODO|PLACEHOLDER|FIXME|^\|\s*\|/im.test(reportMarkdown)) problems.push('placeholder-or-todo-present');
347
-
348
- let lastIdx = -1;
349
- for (const section of REPORT_SECTIONS) {
350
- const re = new RegExp(`^#{2,3}\\s+${escapeRe(section)}\\s*$`, 'm');
351
- const m = reportMarkdown.match(re);
352
- if (m === null || (m.index ?? -1) <= lastIdx) {
353
- problems.push(`section-missing-or-out-of-order:${section}`);
354
- } else if (m.index !== undefined) {
355
- lastIdx = m.index;
356
- }
357
- }
358
-
359
- // 0680 R1-R3: findings are authored as bullet blocks under `## Findings` (one `### <title>`
360
- // block per finding, key-value bullets). Scan each block that carries a stable key and fail
361
- // it missing any triage field — including the three new ones. Without this scan a bullet
362
- // finding never matches the legacy pipe-row regex and the triage gate was vacuous.
363
- // 0686/I9: category parsing is scoped to that same body — an explicit category outside the
364
- // closed vocabulary (FINDING_CATEGORIES) or a stable key whose first segment does so fails
365
- // rather than passing vacuously.
366
- const findingsIdx = reportMarkdown.search(/^##\s+Findings\s*$/im);
367
- if (findingsIdx !== -1) {
368
- const tail = reportMarkdown.slice(findingsIdx);
369
- const nextSection = tail.slice(1).search(/^##\s+/im);
370
- const findingsBody = nextSection === -1 ? tail : tail.slice(0, nextSection + 1);
371
-
372
- const catAlt = FINDING_CATEGORIES.join('|');
373
- const knownRows = findingsBody.match(new RegExp(`^\\|\\s*(?:${catAlt}):[^|]+`, 'gm')) ?? [];
374
- const invalidRows = findingsBody.match(/^\|\s*([^|:\s][^|:]*):[^|]+/gm) ?? [];
375
- for (const row of [...new Set([...knownRows, ...invalidRows])]) {
376
- // 0686/I9: a pipe row outside the closed first-segment set fails by name instead of
377
- // being skipped silently by the legacy regex.
378
- const seg = row.match(/^\|\s*([^:|]+):/)?.[1];
379
- if (seg !== undefined && !(FINDING_CATEGORIES as readonly string[]).includes(seg.trim())) {
380
- problems.push(`finding-invalid-key-category:${seg.trim()}`);
381
- }
382
- // Case-insensitive on BOTH sides — camelCase triage fields otherwise never match a
383
- // lowercased row.
384
- const rowLower = row.toLowerCase();
385
- for (const field of FINDING_FIELDS) {
386
- if (!rowLower.includes(field.toLowerCase())) problems.push(`finding-missing-field:${field}`);
387
- }
388
- }
389
-
390
- const blocks = findingsBody.split(/^###\s+/m).slice(1);
391
- for (const block of blocks) {
392
- if (!block.includes('`key`') && !/\|\s*key\s*:/.test(block)) continue;
393
- for (const field of FINDING_FIELDS) {
394
- if (!block.includes(field)) problems.push(`finding-missing-field:${field}`);
395
- }
396
- // Severity vocabulary is closed (0680 R1): P1/P2/P3 only (symbolic placeholders allowed).
397
- if (!/(^|[\s`])P[123]([\s`.]|$)/.test(block) && !block.includes('symbolic-severity')) {
398
- problems.push('finding-invalid-severity');
399
- }
400
- // 0686/I9 closed-category enforcement on bullet findings.
401
- const catValue = block.match(/`category`\s*[:=]\s*`?([^`\n]+?)`?\s*(?:\n|$)/)?.[1];
402
- if (catValue && !(FINDING_CATEGORIES as readonly string[]).includes(catValue.trim())) {
403
- problems.push(`finding-invalid-category:${catValue.trim()}`);
404
- }
405
- const keyValue = block.match(/`key`\s*[:=]\s*`?([^`\n]+?)`?\s*(?:\n|$)/)?.[1] ?? '';
406
- const firstSegment = keyValue.split(':')[0]?.trim() ?? '';
407
- if (keyValue !== '' && !(FINDING_CATEGORIES as readonly string[]).includes(firstSegment)) {
408
- problems.push(`finding-invalid-key-category:${firstSegment}`);
409
- }
410
- }
411
- }
412
-
413
- const ledgerIdx = reportMarkdown.search(/^#{2,3}\s+Evidence\s+ledger/im);
414
- if (ledgerIdx !== -1) {
415
- const ledgerSection = reportMarkdown.slice(ledgerIdx);
416
- // A table's header + separator are structure, not claims — scanning from the first row
417
- // would fail every well-formed ledger on its own header. Start after the separator when
418
- // there is one; a blockquote ledger has no separator and every `>` line is a claim.
419
- const sep = ledgerSection.match(/^\|[\s:|-]+\|[ \t]*$/m);
420
- const body = sep?.index === undefined ? ledgerSection : ledgerSection.slice(sep.index + sep[0].length);
421
- const claimRows = body.match(/^[|>]\s+\S.*$/gm) ?? [];
422
- for (const row of claimRows) {
423
- const hasAnchor = /`[^`]+:\d+`|`[^`]+\.(md|ts|json)`|[a-z][a-z0-9_-]*\/[a-z][a-z0-9_./-]*:[0-9]+/i.test(
424
- row,
425
- );
426
- // Every claim is checked; one problem entry is enough to fail the gate.
427
- if (!hasAnchor) {
428
- problems.push('evidence-claim-without-anchor');
429
- break;
430
- }
431
- }
432
- }
433
-
434
- return { ok: problems.length === 0, problems };
435
- }
436
-
437
- // ── 4b. Provenance construction (0660 R7) ─────────────────────────────────────────────────────
438
-
439
- /** Literal for anything the evidence plane cannot supply — never a fabricated value. */
440
- const NOT_AVAILABLE = 'not available';
441
-
442
- /**
443
- * SHA-256 over a file, or over a directory's `.md` / `.yaml` files (names sorted, name and body
444
- * both folded in so a rename is a change). Missing paths digest to `not available` rather than
445
- * throwing — a logic digest we cannot derive must read as unknown, not as a match.
446
- */
447
- export function logicDigest(path: string | undefined): string {
448
- if (path === undefined || path === '' || !existsSync(path)) return NOT_AVAILABLE;
449
- try {
450
- const h = createHash('sha256');
451
- if (statSync(path).isDirectory()) {
452
- const walk = (dir: string): string[] =>
453
- readdirSync(dir, { withFileTypes: true })
454
- .flatMap((e) =>
455
- e.isDirectory()
456
- ? walk(join(dir, e.name))
457
- : /\.(md|ya?ml)$/.test(e.name)
458
- ? [join(dir, e.name)]
459
- : [],
460
- )
461
- .sort();
462
- for (const f of walk(path)) {
463
- h.update(f.slice(path.length));
464
- h.update(readFileSync(f));
465
- }
466
- } else {
467
- h.update(readFileSync(path));
468
- }
469
- return h.digest('hex');
470
- } catch {
471
- return NOT_AVAILABLE;
472
- }
473
- }
474
-
475
- /**
476
- * The visible "imported snapshot as of" instant: the **earliest** per-source `lastImportedAt`.
477
- * Taking the minimum is what makes the banner honest — the report never claims a source was
478
- * imported later than that source's own recorded timestamp (0660 R3 / feature scenario R8).
479
- */
480
- export function importedSnapshotAsOf(coverage: CacheProvenance['coverage']): string {
481
- const stamps = coverage.map((c) => c.lastImportedAt).filter((v): v is string => typeof v === 'string' && v !== '');
482
- if (stamps.length === 0 || stamps.length !== coverage.length) return NOT_AVAILABLE;
483
- return [...stamps].sort()[0] ?? NOT_AVAILABLE;
484
- }
485
-
486
- /** Local calendar day (YYYY-MM-DD) in the given IANA zone — the DST-safe way to name "today". */
487
- function localDay(tz: string, at: Date = new Date()): string {
488
- try {
489
- return new Intl.DateTimeFormat('en-CA', { timeZone: tz, dateStyle: 'short' }).format(at);
490
- } catch {
491
- return at.toISOString().slice(0, 10);
492
- }
493
- }
494
-
495
- /** Wall-clock offset of `tz` at instant `at`, ms east of UTC (0674 R2). */
496
- function tzOffsetMs(tz: string, at: Date): number {
497
- const parts = Object.fromEntries(
498
- new Intl.DateTimeFormat('en-US', {
499
- timeZone: tz,
500
- hour12: false,
501
- year: 'numeric',
502
- month: '2-digit',
503
- day: '2-digit',
504
- hour: '2-digit',
505
- minute: '2-digit',
506
- second: '2-digit',
507
- })
508
- .formatToParts(at)
509
- .filter((p) => p.type !== 'literal')
510
- .map((p) => [p.type, p.value]),
511
- );
512
- const asUtc = Date.UTC(
513
- Number(parts.year),
514
- Number(parts.month) - 1,
515
- Number(parts.day),
516
- parts.hour === '24' ? 0 : Number(parts.hour),
517
- Number(parts.minute),
518
- Number(parts.second),
519
- );
520
- // Millisecond-truncate the comparison instant — Intl parts carry second precision, and an
521
- // untruncated .999 epoch would leak into the offset (0674).
522
- const atSec = Math.floor(at.getTime() / 1000) * 1000;
523
- return asUtc - atSec;
524
- }
525
-
526
- /** First instant of local calendar day `ymd` (two-pass so the offset guess survives DST edges). */
527
- function zonedDayStart(tz: string, ymd: string): Date {
528
- const [y, m, d] = ymd.split('-').map(Number);
529
- const utcMidnight = Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1);
530
- const guess = new Date(utcMidnight - tzOffsetMs(tz, new Date(utcMidnight)));
531
- return new Date(utcMidnight - tzOffsetMs(tz, guess));
532
- }
533
-
534
- /** Instant rendered in `tz` wall clock with explicit offset: YYYY-MM-DDTHH:mm:ss.sss±HH:MM. */
535
- function formatZonedIso(tz: string, at: Date): string {
536
- const off = tzOffsetMs(tz, at);
537
- const abs = Math.abs(off);
538
- const pad = (n: number): string => String(n).padStart(2, '0');
539
- const offStr = `${off < 0 ? '-' : '+'}${pad(Math.floor(abs / 3_600_000))}:${pad(Math.floor((abs % 3_600_000) / 60_000))}`;
540
- // Shifting by the offset then formatting as UTC yields the zone's own wall clock.
541
- return `${new Date(at.getTime() + off).toISOString().slice(0, 23)}${offStr}`;
542
- }
543
-
544
- /** Inclusive bounds of one local calendar day; a DST day is 23/24/25h and both ends carry the real offset. */
545
- function dayBounds(tz: string, ymd: string): { since: string; until: string } {
546
- const start = zonedDayStart(tz, ymd);
547
- // start + 30h always lands inside the NEXT calendar day regardless of 23/24/25h lengths.
548
- const nextYmd = localDay(tz, new Date(start.getTime() + 30 * 3_600_000));
549
- return {
550
- since: formatZonedIso(tz, start),
551
- until: formatZonedIso(tz, new Date(zonedDayStart(tz, nextYmd).getTime() - 1)),
552
- };
553
- }
554
-
555
- /**
556
- * Resolve the run's fixed paths once (0660 R4/R15): the skill directory beside the helper, the
557
- * effective local date, and the publication target. Emitted as an env file so every downstream
558
- * stage stays a single helper invocation instead of repeating path arithmetic (ADR-069 R1).
559
- */
560
- export function resolvePaths(opts: {
561
- helper: string;
562
- reportDir: string;
563
- date?: string;
564
- output?: string;
565
- now?: Date;
566
- /** IANA zone override (test hook); defaults to the process zone. */
567
- tz?: string;
568
- mode?: string;
569
- since?: string;
570
- until?: string;
571
- }): string {
572
- // Layouts: monorepo `<root>/scripts/<file>` → skill `<root>/skills/history-anatomy`;
573
- // superskill-installed `<root>/scripts/<plugin>/<file>` → skill `<root>/skills/<plugin>-history-anatomy`.
574
- // (0660 dogfood 2026-08-26: the single-segment strip left HA_SKILL bogus on installed layouts,
575
- // silently degrading probe contract/skill digests to "not available".)
576
- const m = opts.helper.match(/\/scripts\/(?:([^/]+)\/)?[^/]+$/);
577
- const pluginRoot = m ? opts.helper.slice(0, m.index) : opts.helper;
578
- const skill = `${pluginRoot}/skills/${m?.[1] ? `${m[1]}-history-anatomy` : 'history-anatomy'}`;
579
- const tz = opts.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
580
- const date = opts.date !== undefined && opts.date !== '' ? opts.date : localDay(tz, opts.now ?? new Date());
581
- const target =
582
- opts.output !== undefined && opts.output !== '' ? opts.output : `${opts.reportDir}/${date}-history-anatomy.md`;
583
- // 0674 R1/R2: daily bounds are derived here — date arithmetic in a named zone is exactly
584
- // the "exceeds the shell composition threshold" case (ADR-069 R1), and DST makes a local
585
- // day 23/24/25h. Ad-hoc (0674 R3): operator bounds pass through untouched, no baseline pair.
586
- const adHoc = opts.mode === 'ad-hoc' && !!opts.since && !!opts.until;
587
- let env = `HA_HELPER=${opts.helper}\nHA_SKILL=${skill}\nHA_TARGET=${target}\nHA_DATE=${date}\n`;
588
- if (adHoc) {
589
- return `${env}HA_SINCE=${opts.since}\nHA_UNTIL=${opts.until}\n`;
590
- }
591
- const current = dayBounds(tz, date);
592
- const baseline = dayBounds(tz, localDay(tz, new Date(zonedDayStart(tz, date).getTime() - 12 * 3_600_000)));
593
- env += `HA_SINCE=${current.since}\nHA_UNTIL=${current.until}\n`;
594
- env += `HA_BASELINE_SINCE=${baseline.since}\nHA_BASELINE_UNTIL=${baseline.until}\n`;
595
- return env;
596
- }
597
-
598
- /**
599
- * Per-mode argument grammar for the `paths` command (0920): the deterministic owner of the
600
- * mode/window validation the resolve-scope model hop used to perform. An empty string means
601
- * "not provided" — the workflow passes every declared var, empty by default.
602
- */
603
- export type SelectorValidation =
604
- | {
605
- ok: true;
606
- mode: 'daily' | 'ad-hoc';
607
- date: string | null;
608
- focus: string | null;
609
- since: string | null;
610
- until: string | null;
611
- }
612
- | { ok: false; errors: string[] };
613
-
614
- /** Real calendar day (rejects e.g. 2026-02-30); tz-independent UTC round-trip. */
615
- function isRealDate(ymd: string): boolean {
616
- const m = ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/);
617
- if (!m) return false;
618
- const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
619
- const dt = new Date(Date.UTC(y, mo - 1, d));
620
- return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
621
- }
622
-
623
- export function validateSelector(opts: {
624
- mode?: string;
625
- date?: string;
626
- since?: string;
627
- until?: string;
628
- focus?: string;
629
- recompute?: string;
630
- output?: string;
631
- }): SelectorValidation {
632
- const errors: string[] = [];
633
- const has = (v?: string): v is string => v !== undefined && v !== '';
634
- const val = (v?: string) => (has(v) ? v : null);
635
- const mode = has(opts.mode) ? opts.mode : 'daily';
636
- if (mode !== 'daily' && mode !== 'ad-hoc') {
637
- return { ok: false, errors: [`--mode must be "daily" or "ad-hoc", got "${opts.mode}"`] };
638
- }
639
- const recompute = opts.recompute ?? '';
640
- if (recompute !== '' && recompute !== 'true' && recompute !== 'false') {
641
- errors.push(`--recompute must be "true" or "false", got "${recompute}"`);
642
- }
643
- if (mode === 'daily') {
644
- for (const [flag, v] of [
645
- ['--focus', opts.focus],
646
- ['--since', opts.since],
647
- ['--until', opts.until],
648
- ['--output', opts.output],
649
- ] as const) {
650
- if (has(v)) errors.push(`daily mode rejects ${flag}`);
651
- }
652
- if (has(opts.date) && !isRealDate(opts.date)) {
653
- errors.push(`--date must be a real YYYY-MM-DD calendar day, got "${opts.date}"`);
654
- }
655
- } else {
656
- if (has(opts.date)) errors.push('ad-hoc mode rejects --date');
657
- if (recompute === 'true') errors.push('ad-hoc mode rejects --recompute');
658
- if (!has(opts.focus)) errors.push('ad-hoc mode requires a non-empty --focus');
659
- const sinceOk = has(opts.since);
660
- const untilOk = has(opts.until);
661
- if (!sinceOk) errors.push('ad-hoc mode requires --since (inclusive ISO instant)');
662
- if (!untilOk) errors.push('ad-hoc mode requires --until (inclusive ISO instant)');
663
- if (sinceOk && untilOk) {
664
- const since = new Date(opts.since ?? '');
665
- const until = new Date(opts.until ?? '');
666
- if (Number.isNaN(since.getTime()))
667
- errors.push(`--since must be a parseable ISO instant, got "${opts.since}"`);
668
- if (Number.isNaN(until.getTime()))
669
- errors.push(`--until must be a parseable ISO instant, got "${opts.until}"`);
670
- if (!Number.isNaN(since.getTime()) && !Number.isNaN(until.getTime()) && since.getTime() > until.getTime()) {
671
- errors.push(`--since must not be after --until ("${opts.since}" > "${opts.until}")`);
672
- }
673
- }
674
- }
675
- if (errors.length) return { ok: false, errors };
676
- return {
677
- ok: true,
678
- mode,
679
- date: val(opts.date),
680
- focus: val(opts.focus),
681
- since: val(opts.since),
682
- until: val(opts.until),
683
- };
684
- }
685
-
686
- export interface ProbeOptions {
687
- artifact: string;
688
- target: string;
689
- baseline?: string;
690
- mode: 'daily' | 'ad-hoc';
691
- date?: string;
692
- recompute: boolean;
693
- executor?: string;
694
- model?: string;
695
- skillDir?: string;
696
- contractFile?: string;
697
- workflowFile?: string;
698
- helperFile?: string;
699
- contractVersion?: string;
700
- runId?: string;
701
- spurVersion?: string;
702
- now?: Date;
703
- }
10
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import * as core from '../lib/history-anatomy.generated.mjs';
704
12
 
705
- /**
706
- * Build the provenance describing the run that just produced `artifact`. Everything here is
707
- * derived from the fresh analyze artifact and the on-disk logic files — never from the cached
708
- * report, which is the thing being judged.
709
- */
710
- export function buildProvenance(opts: ProbeOptions): CacheProvenance {
711
- let raw: {
712
- selector?: { since?: string | null; until?: string | null };
713
- coverage?: Array<{ source?: unknown; status?: unknown; lastImportedAt?: unknown }>;
714
- schemaVersion?: unknown;
715
- };
716
- try {
717
- raw = JSON.parse(readFileSync(opts.artifact, 'utf8'));
718
- } catch (err) {
719
- throw new Error(`could not parse fresh analyze artifact at ${opts.artifact}: ${(err as Error).message}`);
720
- }
721
- const coverage = (raw.coverage ?? []).map((c) => ({
722
- source: String(c.source ?? ''),
723
- status: String(c.status ?? ''),
724
- lastImportedAt: c.lastImportedAt == null ? null : String(c.lastImportedAt),
725
- }));
726
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
727
- const now = opts.now ?? new Date();
728
- const date = opts.date !== undefined && opts.date !== '' ? opts.date : localDay(tz, now);
729
- // Ad-hoc windows are explicit and never cached, so they are closed by construction; a daily
730
- // window is provisional until its local calendar day has ended.
731
- const windowState: 'provisional' | 'closed' =
732
- opts.mode === 'ad-hoc' || date < localDay(tz, now) ? 'closed' : 'provisional';
733
- const nowIso = now.toISOString();
734
- return {
735
- identity: {
736
- contractVersion: opts.contractVersion ?? '1',
737
- mode: opts.mode,
738
- date,
739
- timezone: tz,
740
- bounds: { since: String(raw.selector?.since ?? ''), until: String(raw.selector?.until ?? '') },
741
- sources: coverage.map((c) => c.source).sort(),
742
- },
743
- windowState,
744
- generatedAt: nowIso,
745
- validatedAt: nowIso,
746
- artifactDigest: semanticArtifactDigest(raw),
747
- baselineArtifactDigest: ((): string | null => {
748
- if (!(opts.baseline !== undefined && existsSync(opts.baseline))) return null;
749
- try {
750
- return semanticArtifactDigest(JSON.parse(readFileSync(opts.baseline, 'utf8')));
751
- } catch (err) {
752
- throw new Error(`could not parse baseline artifact at ${opts.baseline}: ${(err as Error).message}`);
753
- }
754
- })(),
755
- contractDigest: logicDigest(opts.contractFile),
756
- skillDigest: logicDigest(opts.skillDir),
757
- workflowDigest: logicDigest(opts.workflowFile),
758
- helperDigest: logicDigest(opts.helperFile),
759
- coverage,
760
- runId: opts.runId,
761
- currentArtifactPath: opts.artifact,
762
- baselineArtifactPath: opts.baseline ?? null,
763
- spurVersion: opts.spurVersion ?? NOT_AVAILABLE,
764
- schemaVersion: typeof raw.schemaVersion === 'number' ? raw.schemaVersion : undefined,
765
- executor: opts.executor ?? NOT_AVAILABLE,
766
- model: opts.model ?? NOT_AVAILABLE,
767
- };
768
- }
769
-
770
- /** Read the cached report at `target` and decide reuse against a freshly built provenance. */
771
- export function probe(opts: ProbeOptions): { decision: CacheDecision; current: CacheProvenance } {
772
- const current = buildProvenance(opts);
773
- const cachedText = existsSync(opts.target) ? readFileSync(opts.target, 'utf8') : null;
774
- const cached = cachedText === null ? null : parseProvenance(cachedText);
775
- // Ad-hoc never reuses a cache (0658 modes.md); force the regeneration path.
776
- const decision =
777
- opts.mode === 'ad-hoc'
778
- ? { disposition: 'miss' as const, reasons: ['ad-hoc-never-cached'] }
779
- : decideCache(cached, current, {
780
- recompute: opts.recompute,
781
- dayClosed: current.windowState === 'closed',
782
- });
783
- current.cacheDisposition = decision.disposition;
784
- return { decision, current };
785
- }
786
-
787
- const YAML_KEYS: Array<keyof CacheProvenance> = [
788
- 'windowState',
789
- 'generatedAt',
790
- 'validatedAt',
791
- 'artifactDigest',
792
- 'baselineArtifactDigest',
793
- 'contractDigest',
794
- 'skillDigest',
795
- 'workflowDigest',
796
- 'helperDigest',
797
- 'runId',
798
- 'currentArtifactPath',
799
- 'baselineArtifactPath',
800
- 'spurVersion',
801
- 'schemaVersion',
802
- 'executor',
803
- 'model',
804
- 'cacheDisposition',
805
- ];
806
-
807
- function yamlScalar(v: unknown): string {
808
- if (v === null || v === undefined) return 'null';
809
- if (typeof v === 'number' || typeof v === 'boolean') return String(v);
810
- return `"${String(v).replaceAll('"', '\\"')}"`;
811
- }
812
-
813
- /** Render the full R7 provenance block as report frontmatter (parseable back by parseProvenance). */
814
- export function renderProvenanceFrontmatter(p: CacheProvenance): string {
815
- const lines = [
816
- '---',
817
- 'identity:',
818
- ` contractVersion: ${yamlScalar(p.identity.contractVersion)}`,
819
- ` mode: ${p.identity.mode}`,
820
- ` date: ${yamlScalar(p.identity.date)}`,
821
- ` timezone: ${p.identity.timezone}`,
822
- ' bounds:',
823
- ` since: ${p.identity.bounds.since}`,
824
- ` until: ${p.identity.bounds.until}`,
825
- ' sources:',
826
- ];
827
- for (const s of p.identity.sources) lines.push(` - source: ${s}`);
828
- for (const k of YAML_KEYS) {
829
- if (p[k] === undefined) continue;
830
- lines.push(`${k}: ${yamlScalar(p[k])}`);
831
- }
832
- lines.push('coverage:');
833
- for (const c of p.coverage) {
834
- lines.push(` - source: ${c.source}, status: ${c.status}, lastImportedAt: ${c.lastImportedAt ?? 'null'}`);
835
- }
836
- lines.push('---');
837
- return lines.join('\n');
838
- }
839
-
840
- /** The one-line freshness banner rendered under the frontmatter. */
841
- export function bannerLine(p: CacheProvenance): string {
842
- return `> imported snapshot as of ${importedSnapshotAsOf(p.coverage)} · window ${p.windowState} · cache ${p.cacheDisposition ?? NOT_AVAILABLE}`;
843
- }
844
-
845
- /** Strip any existing frontmatter + banner so stamping is idempotent. */
846
- function stripHeader(md: string): string {
847
- const body = md.replace(/^---\n[\s\S]*?\n---\n?/, '').replace(/^\n+/, '');
848
- return body.replace(/^> imported snapshot as of [^\n]*\n+/, '');
849
- }
850
-
851
- /** Attach the provenance frontmatter and freshness banner to a candidate report. */
852
- export function stampReport(candidateMarkdown: string, p: CacheProvenance): string {
853
- return `${renderProvenanceFrontmatter(p)}\n\n${bannerLine(p)}\n\n${stripHeader(candidateMarkdown).replace(/^\n+/, '')}`;
854
- }
855
-
856
- /**
857
- * Cache-hit path: keep the published model half verbatim, refresh only `validatedAt`, the
858
- * disposition, and the banner. The recorded digests and generation time are NOT touched — they
859
- * describe the evidence the model half was authored from.
860
- */
861
- export function refreshReport(publishedMarkdown: string, validatedAt: string, disposition: CacheDisposition): string {
862
- const cached = parseProvenance(publishedMarkdown);
863
- if (cached === null) return publishedMarkdown;
864
- const refreshed: CacheProvenance = { ...cached, validatedAt, cacheDisposition: disposition };
865
- return stampReport(publishedMarkdown, refreshed);
866
- }
867
-
868
- // ── 5. Atomic publication ──────────────────────────────────────────────────────────────────────
869
-
870
- /**
871
- * Publish a candidate atomically: write `<target>.tmp` in the same directory, fsync, then rename
872
- * onto the target. Same-directory rename is the atomicity guarantee; the target is left
873
- * byte-identical on any failure.
874
- */
875
- export function publishAtomically(candidatePath: string, targetPath: string): void {
876
- const tmpPath = `${targetPath}.tmp`;
877
- try {
878
- writeFileSync(tmpPath, readFileSync(candidatePath));
879
- const fd = openSync(tmpPath, 'r');
880
- try {
881
- fsyncSync(fd);
882
- } finally {
883
- closeSync(fd);
884
- }
885
- renameSync(tmpPath, targetPath);
886
- } catch (err) {
887
- try {
888
- rmSync(tmpPath, { force: true });
889
- } catch {
890
- // best-effort cleanup
891
- }
892
- throw err;
893
- }
894
- }
895
-
896
- // ── CLI entry ─────────────────────────────────────────────────────────────────────────────────
897
-
898
- export interface CacheCliResult {
899
- exitCode: number;
900
- stdout: string;
901
- stderr: string;
902
- }
903
-
904
- /** Parse a `git status --porcelain` text into its path set. */
905
- export function porcelainPaths(text: string): Set<string> {
906
- return new Set(
907
- text
908
- .split('\n')
909
- .map((line) => line.replace(/^\S+\s+/, '').trim())
910
- .filter((line) => line.length > 0),
911
- );
912
- }
913
-
914
- /** Paths present now but absent from the baseline and not declared outputs (0676 R3). */
915
- export function diffPorcelain(before: string, now: string, expects: Set<string>): string[] {
916
- const beforePaths = porcelainPaths(before);
917
- return [...porcelainPaths(now)].filter((p) => !beforePaths.has(p) && !expects.has(p)).sort();
918
- }
13
+ export * from '../lib/history-anatomy.generated.mjs';
919
14
 
920
15
  const VALID_COMMANDS = 'digest, check, paths, assert-clean, probe, stamp, refresh, publish';
921
16
  const PROBE_USAGE =
@@ -942,9 +37,9 @@ function parseFlags(args: string[]): Record<string, string | undefined> {
942
37
 
943
38
  /**
944
39
  * Run the CLI with captured stdout/stderr (data, not process side-effects) so unit tests invoke
945
- * it in-process without leaking into the test runner's own output (feature-sync-bounded pattern).
40
+ * it in-process without leaking into the test runner's own output.
946
41
  */
947
- export function runCacheCli(argv: string[]): CacheCliResult {
42
+ export function runCacheCli(argv: string[]): core.CacheCliResult {
948
43
  const [cmd, a, b] = argv;
949
44
  switch (cmd) {
950
45
  case 'digest': {
@@ -957,14 +52,14 @@ export function runCacheCli(argv: string[]): CacheCliResult {
957
52
  } catch {
958
53
  return { exitCode: 1, stdout: '', stderr: `could not parse artifact at ${a}\n` };
959
54
  }
960
- const digest = semanticArtifactDigest(artifact);
55
+ const digest = core.semanticArtifactDigest(artifact);
961
56
  return { exitCode: 0, stdout: `${digest}\n`, stderr: '' };
962
57
  }
963
58
  case 'check': {
964
59
  if (a === undefined) {
965
60
  return { exitCode: 1, stdout: '', stderr: 'usage: <script> check <report.md>\n' };
966
61
  }
967
- const result = checkReportStructure(readFileSync(a, 'utf8'));
62
+ const result = core.checkReportStructure(readFileSync(a, 'utf8'));
968
63
  const stdout = `${result.ok ? 'PASS' : 'FAIL'}\n${result.problems.map((p) => `- ${p}\n`).join('')}`;
969
64
  return { exitCode: result.ok ? 0 : 1, stdout, stderr: '' };
970
65
  }
@@ -972,7 +67,7 @@ export function runCacheCli(argv: string[]): CacheCliResult {
972
67
  if (a === undefined || b === undefined) {
973
68
  return { exitCode: 1, stdout: '', stderr: 'usage: <script> publish <candidate.md> <target.md>\n' };
974
69
  }
975
- publishAtomically(a, b);
70
+ core.publishAtomically(a, b);
976
71
  return { exitCode: 0, stdout: '', stderr: '' };
977
72
  }
978
73
  case 'assert-clean': {
@@ -1001,7 +96,7 @@ export function runCacheCli(argv: string[]): CacheCliResult {
1001
96
  } catch {
1002
97
  return { exitCode: 0, stdout: '', stderr: 'assert-clean: git unavailable; skipped\n' };
1003
98
  }
1004
- const undeclared = diffPorcelain(readFileSync(f.baseline, 'utf8'), now, expects);
99
+ const undeclared = core.diffPorcelain(readFileSync(f.baseline, 'utf8'), now, expects);
1005
100
  if (undeclared.length > 0) {
1006
101
  return {
1007
102
  exitCode: 1,
@@ -1020,7 +115,7 @@ export function runCacheCli(argv: string[]): CacheCliResult {
1020
115
  stderr: 'usage: <script> paths --helper <p> --out <env> [--report-dir <d>] [--date <d>] [--output <p>] [--mode <m>] [--since <s>] [--until <u>] [--focus <text>] [--recompute true|false] [--run-id <id>]\n',
1021
116
  };
1022
117
  }
1023
- const v = validateSelector({
118
+ const v = core.validateSelector({
1024
119
  mode: f.mode,
1025
120
  date: f.date,
1026
121
  since: f.since,
@@ -1030,7 +125,7 @@ export function runCacheCli(argv: string[]): CacheCliResult {
1030
125
  output: f.output,
1031
126
  });
1032
127
  if (!v.ok) return { exitCode: 1, stdout: '', stderr: `${v.errors.join('\n')}\n` };
1033
- const env = resolvePaths({
128
+ const env = core.resolvePaths({
1034
129
  helper: f.helper,
1035
130
  reportDir: f['report-dir'] ?? 'docs/report',
1036
131
  date: f.date,
@@ -1074,9 +169,9 @@ export function runCacheCli(argv: string[]): CacheCliResult {
1074
169
  if (f.artifact === undefined || f.target === undefined) {
1075
170
  return { exitCode: 1, stdout: '', stderr: `usage: ${PROBE_USAGE}\n` };
1076
171
  }
1077
- let result: ReturnType<typeof probe>;
172
+ let result: ReturnType<typeof core.probe>;
1078
173
  try {
1079
- result = probe({
174
+ result = core.probe({
1080
175
  artifact: f.artifact,
1081
176
  target: f.target,
1082
177
  baseline: f.baseline,
@@ -1110,8 +205,8 @@ export function runCacheCli(argv: string[]): CacheCliResult {
1110
205
  };
1111
206
  }
1112
207
  try {
1113
- const p = JSON.parse(readFileSync(f.provenance, 'utf8')) as CacheProvenance;
1114
- writeFileSync(f.out, `${stampReport(readFileSync(f.candidate, 'utf8'), p)}\n`);
208
+ const p = JSON.parse(readFileSync(f.provenance, 'utf8')) as core.CacheProvenance;
209
+ writeFileSync(f.out, `${core.stampReport(readFileSync(f.candidate, 'utf8'), p)}\n`);
1115
210
  } catch {
1116
211
  return { exitCode: 1, stdout: '', stderr: 'stamp: could not read candidate or provenance\n' };
1117
212
  }
@@ -1127,8 +222,8 @@ export function runCacheCli(argv: string[]): CacheCliResult {
1127
222
  };
1128
223
  }
1129
224
  try {
1130
- const disposition = (f.disposition ?? 'hit') as CacheDisposition;
1131
- const refreshed = refreshReport(
225
+ const disposition = (f.disposition ?? 'hit') as core.CacheDisposition;
226
+ const refreshed = core.refreshReport(
1132
227
  readFileSync(f.report, 'utf8'),
1133
228
  f['validated-at'] ?? new Date().toISOString(),
1134
229
  disposition,