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