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