@gobing-ai/spur 0.3.94 → 0.3.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +42 -0
- package/board/index.d.ts +38 -0
- package/config/plugin-scripts.json +3 -52
- package/config/rules/boundary/sp-script-placement.yaml +19 -0
- package/config/rules/strict/runtime-boundaries.yaml +5 -0
- package/config/rules/structure/test-location.yaml +2 -0
- package/config/rules/typescript/no-syscall-emulation-in-boundary-mock.yaml +1 -1
- package/config/rules/typescript/output-boundaries.yaml +15 -2
- package/config/script-placement-baseline.json +51 -0
- package/config/workflows/feature-verification.yaml +7 -4
- package/config/workflows/history-anatomy.yaml +23 -11
- package/config/workflows/idea-pipeline.yaml +87 -86
- package/config/workflows/pr-review.yaml +18 -9
- package/config/workflows/task-pipeline.yaml +84 -109
- package/config/workflows/wrapup-pipeline.yaml +173 -46
- package/package.json +15 -11
- package/plugins/sp/README.md +8 -10
- package/plugins/sp/agents/super-reviewer.md +43 -18
- package/plugins/sp/commands/dev-fixgha.md +83 -0
- package/plugins/sp/commands/dev-gitmsg.md +8 -6
- package/plugins/sp/commands/dev-gtd.md +2 -2
- package/plugins/sp/commands/dev-idea.md +22 -12
- package/plugins/sp/commands/dev-plan.md +8 -9
- package/plugins/sp/commands/dev-review.md +22 -13
- package/plugins/sp/commands/dev-verify.md +7 -4
- package/plugins/sp/commands/dev-verifyall.md +5 -4
- package/plugins/sp/commands/dev-wrap.md +1 -1
- package/plugins/sp/commands/spur-init.md +2 -2
- package/plugins/sp/hooks/context-post-tool.ts +2 -2
- package/plugins/sp/hooks/context-session-start.ts +2 -1
- package/plugins/sp/hooks/context-session-stop.ts +22 -14
- package/plugins/sp/hooks/pi/guard-extension.ts +109 -150
- package/plugins/sp/lib/history-anatomy.generated.d.mts +112 -0
- package/plugins/sp/lib/history-anatomy.generated.mjs +686 -0
- package/plugins/sp/lib/idea-handoff.generated.mjs +6 -5
- package/plugins/sp/lib/inline-run.generated.d.mts +13 -2
- package/plugins/sp/lib/inline-run.generated.mjs +33 -16
- package/plugins/sp/lib/quality-gate.generated.d.mts +104 -0
- package/plugins/sp/lib/quality-gate.generated.mjs +438 -0
- package/plugins/sp/lib/residual-scan.generated.d.mts +62 -0
- package/plugins/sp/lib/residual-scan.generated.mjs +210 -0
- package/plugins/sp/lib/spur-bin.ts +36 -0
- package/plugins/sp/lib/step-profile.generated.d.mts +71 -0
- package/plugins/sp/lib/step-profile.generated.mjs +174 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +1 -1
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +3 -3
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +3 -3
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +1 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +9 -7
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +8 -7
- package/plugins/sp/scripts/feature-verification-steps.ts +6 -6
- package/plugins/sp/scripts/history-anatomy-cache.mjs +23 -22
- package/plugins/sp/scripts/history-anatomy-cache.ts +23 -928
- package/plugins/sp/scripts/inline-run-setup.mjs +90 -315
- package/plugins/sp/scripts/inline-run-setup.ts +109 -639
- package/plugins/sp/scripts/pr-reviewing.mjs +2 -0
- package/plugins/sp/scripts/pr-reviewing.ts +1 -0
- package/plugins/sp/scripts/quality-gate.mjs +38 -21
- package/plugins/sp/scripts/quality-gate.ts +20 -660
- package/plugins/sp/scripts/residual-scan.mjs +138 -159
- package/plugins/sp/scripts/residual-scan.ts +102 -499
- package/plugins/sp/scripts/script-root.mjs +129 -0
- package/plugins/sp/scripts/script-root.ts +199 -0
- package/plugins/sp/scripts/workflow-step-profile.mjs +27 -16
- package/plugins/sp/scripts/workflow-step-profile.ts +22 -314
- package/plugins/sp/scripts/wrapup-drift-probe.mjs +12 -3
- package/plugins/sp/scripts/wrapup-drift-probe.ts +6 -3
- package/plugins/sp/scripts/wrapup-steps.mjs +42 -40
- package/plugins/sp/scripts/wrapup-steps.ts +64 -46
- package/plugins/sp/skills/code-improvement/SKILL.md +5 -4
- package/plugins/sp/skills/code-verification/SKILL.md +46 -16
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +13 -2
- package/plugins/sp/skills/functional-review/SKILL.md +8 -5
- package/plugins/sp/skills/functional-review/references/verdict-schema.md +1 -1
- package/plugins/sp/skills/history-anatomy/references/modes.md +2 -1
- package/plugins/sp/skills/next-feature/references/handoff-routing.md +1 -1
- package/plugins/sp/skills/next-router/references/routing-table.md +2 -2
- package/plugins/sp/skills/spur-cli/SKILL.md +3 -3
- package/plugins/sp/skills/spur-cli/references/agent.md +10 -10
- package/plugins/sp/skills/spur-cli/references/features.md +17 -6
- package/plugins/sp/skills/spur-cli/references/init.md +17 -16
- package/plugins/sp/skills/spur-cli/references/self.md +3 -2
- package/plugins/sp/skills/spur-cli/references/serve.md +10 -10
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +28 -5
- package/plugins/sp/skills/spur-cli/references/tasks.md +14 -8
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +4 -3
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +11 -9
- package/plugins/sp/skills/spur-dev/references/decision-brief.md +1 -1
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +86 -70
- package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +3 -3
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +107 -44
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +5 -9
- package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +3 -3
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +31 -21
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +19 -19
- package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +4 -3
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +35 -6
- package/plugins/sp/skills/spur-doctor/SKILL.md +1 -1
- package/plugins/sp/skills/sys-architecture/SKILL.md +3 -2
- package/plugins/sp/tsconfig.json +8 -0
- package/spur.js +6638 -4775
- package/web/_astro/BoardApp.BIjMatT1.js +1 -0
- package/web/_astro/{BoardApp.FTEs3-N8.js → BoardApp.GvjIe9Z6.js} +150 -153
- package/web/_astro/TaskDetail.BZM3EAFx.js +1 -0
- package/web/_astro/_commonjsHelpers.CqkleIqs.js +1 -0
- package/web/_astro/{arc.uuAf51IT.js → arc.HkRiZnoI.js} +1 -1
- package/web/_astro/architectureDiagram-3BPJPVTR.BDdK-tgZ.js +36 -0
- package/web/_astro/{blockDiagram-GPEHLZMM.D1mGCq3p.js → blockDiagram-GPEHLZMM.CZ9kOBxx.js} +1 -1
- package/web/_astro/board-facade-react-dom-client.B2U5wMvk.js +1 -0
- package/web/_astro/board-facade-react-dom.Cd2MuMhF.js +1 -0
- package/web/_astro/board-facade-react-jsx-dev-runtime.B_L3JzfC.js +1 -0
- package/web/_astro/board-facade-react-jsx-runtime.BY2yfHRi.js +1 -0
- package/web/_astro/board-facade-react-router-dom.Dy5pjzOw.js +1 -0
- package/web/_astro/board-facade-react-router.DjjqU27_.js +1 -0
- package/web/_astro/board-facade-react.Dgf4GRyj.js +1 -0
- package/web/_astro/{c4Diagram-AAUBKEIU.CMsolcde.js → c4Diagram-AAUBKEIU.DXK9qeRD.js} +1 -1
- package/web/_astro/channel.DlhX2MEt.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.CrGA3fik.js → chunk-2J33WTMH.BTD2WeX5.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.DsLVVla0.js → chunk-4BX2VUAB.Ci6Qbcvk.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.Dxc59Tfi.js → chunk-55IACEB6.yl3zsj7p.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.CaEVE2Wy.js → chunk-727SXJPM.ClXZsfyR.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.BiJ4HXeI.js → chunk-AQP2D5EJ.BmKWZBcP.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.Mxf1fru5.js → chunk-FMBD7UC4.By30fcb7.js} +1 -1
- package/web/_astro/chunk-JMJ3UQ3L.BQJEJHu4.js +34 -0
- package/web/_astro/{chunk-ND2GUHAM.pyOWQixH.js → chunk-ND2GUHAM.24NmrD-k.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.BmEsg4vr.js → chunk-QZHKN3VN.BN4sKdcS.js} +1 -1
- package/web/_astro/chunk-YNUBSHFH.CxeAygfi.js +7 -0
- package/web/_astro/{classDiagram-4FO5ZUOK.BHhhMFTO.js → classDiagram-4FO5ZUOK.CJpoMPb5.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.BHhhMFTO.js → classDiagram-v2-Q7XG4LA2.CJpoMPb5.js} +1 -1
- package/web/_astro/client.BhluLQqe.js +1 -0
- package/web/_astro/client.DBpSRLnx.js +9 -0
- package/web/_astro/cose-bilkent-S5V4N54A.BraxQ2Nt.js +1 -0
- package/web/_astro/{cynefin-OW5HDTMX.C2j1_lKL.js → cynefin-OW5HDTMX.gzoU73oL.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.hZ2NCTdT.js → dagre-BM42HDAG.wyDfWySU.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.Bfw5_EzK.js → diagram-2AECGRRQ.h2_EbMxV.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.Bt1V_Tmk.js → diagram-5GNKFQAL.Db6dcs89.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.DEk-YFwp.js → diagram-KO2AKTUF.NVwzxu_U.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.CDjndtQm.js → diagram-LMA3HP47.B-eNgDQk.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.cFnUHScG.js → diagram-OG6HWLK6.Bsa8ZRbJ.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.DlhYp7NV.js → erDiagram-TEJ5UH35.BospRE5Q.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.DNTpsxfx.js → flowDiagram-I6XJVG4X.DMzvHwAA.js} +1 -1
- package/web/_astro/ganttDiagram-6RSMTGT7.Dmdxgd-M.js +292 -0
- package/web/_astro/{gitGraphDiagram-PVQCEYII.DkEqNI0P.js → gitGraphDiagram-PVQCEYII.CaqdO0rL.js} +1 -1
- package/web/_astro/index.5dn-Zm-2.js +1 -0
- package/web/_astro/index.C-IqPpW2.js +1 -0
- package/web/_astro/index.Cyc_GA4f.js +1 -0
- package/web/_astro/index.EoZlzLK-.css +1 -0
- package/web/_astro/index.tZfqwSut.js +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.CrjioCTG.js → infoDiagram-5YYISTIA.DDVD9Wn7.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.BcK0CN8n.js → ishikawaDiagram-YF4QCWOH.EKyCUFsL.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.p0CbBDX1.js → journeyDiagram-JHISSGLW.By2-RkYB.js} +1 -1
- package/web/_astro/jsx-runtime.DWOiEOII.js +1 -0
- package/web/_astro/{kanban-definition-UN3LZRKU.puHIFt6J.js → kanban-definition-UN3LZRKU.ar-WmlyR.js} +1 -1
- package/web/_astro/{linear.Bb-3a1d7.js → linear.BnzMBgo_.js} +1 -1
- package/web/_astro/{mermaid.core.CJDgXOJs.js → mermaid.core.DSqeFu2Y.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.BoAZEI9t.js → mindmap-definition-RKZ34NQL.1XcF8Wh2.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.CJNSdqY7.js → pieDiagram-4H26LBE5.DnItHOdK.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.DtTy7_0Z.js → quadrantDiagram-W4KKPZXB.BQQyrnBt.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.BNYV98Xg.js → requirementDiagram-4Y6WPE33.Dmzxun82.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.DRq9DGHp.js → sankeyDiagram-5OEKKPKP.BBIeRKYB.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.B7KdBFCm.js → sequenceDiagram-3UESZ5HK.CUNVdBBf.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.CD62ZJ2H.js → stateDiagram-AJRCARHV.DlFf2sJm.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.CeVk1TAj.js → stateDiagram-v2-BHNVJYJU.DXR7I1H5.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.C_ZPA5tT.js → timeline-definition-PNZ67QCA.BcO-GyX2.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.CrThO_FQ.js → vennDiagram-CIIHVFJN.ChCECmmW.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.DSZCA5nl.js → wardleyDiagram-YWT4CUSO.CRaT12mD.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.KI7baTuk.js → xychartDiagram-2RQKCTM6.B3lsHVpj.js} +1 -1
- package/web/board-runtime.json +69 -0
- package/web/index.html +2 -2
- package/plugins/sp/lib/artifact-digest.generated.d.mts +0 -7
- package/plugins/sp/lib/artifact-digest.generated.mjs +0 -48
- package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -298
- package/plugins/sp/scripts/feature-sync-bounded.ts +0 -479
- package/plugins/sp/scripts/idea-coverage-check.ts +0 -168
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -298
- package/plugins/sp/scripts/record-feature-sync.mjs +0 -63
- package/plugins/sp/scripts/record-feature-sync.ts +0 -84
- package/plugins/sp/scripts/script-contract-check.ts +0 -377
- package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
- package/plugins/sp/scripts/surface-drift-inventory.ts +0 -989
- package/plugins/sp/scripts/task-evidence-precheck.ts +0 -187
- package/plugins/sp/scripts/task-size-precheck.ts +0 -210
- package/plugins/sp/scripts/transition-shim-check.ts +0 -238
- package/plugins/sp/scripts/validate-commands.ts +0 -689
- package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
- package/plugins/sp/scripts/verify-answer-lint.ts +0 -547
- package/web/_astro/BoardApp.C02hAHPO.js +0 -1
- package/web/_astro/TaskDetail.C-GdsS-t.js +0 -1
- package/web/_astro/architectureDiagram-3BPJPVTR.CGe629A1.js +0 -36
- package/web/_astro/channel.fsgl7o5j.js +0 -1
- package/web/_astro/client.yhYJvxCU.js +0 -9
- package/web/_astro/cose-bilkent-S5V4N54A.2fH4YOlp.js +0 -1
- package/web/_astro/ganttDiagram-6RSMTGT7.DC_p36PI.js +0 -292
- package/web/_astro/index.De90oHcH.js +0 -1
- package/web/_astro/index.Hjbr15fG.css +0 -1
|
@@ -1,921 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
1
2
|
/**
|
|
2
|
-
* history-anatomy-cache —
|
|
3
|
-
* report (feature I8, HA-S1 0659 / ADR-079).
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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 segMatch = row.match(/^\|\s*([^:|]+):/);
|
|
379
|
-
if (segMatch && !(FINDING_CATEGORIES as readonly string[]).includes(segMatch[1].trim())) {
|
|
380
|
-
problems.push(`finding-invalid-key-category:${segMatch[1].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 !== 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
|
|
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,
|