@gobing-ai/spur 0.3.62 → 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.
Files changed (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/corpus-baseline.json +200 -8
  3. package/config/workflows/history-anatomy.yaml +40 -11
  4. package/package.json +9 -9
  5. package/plugins/sp/README.md +2 -2
  6. package/plugins/sp/commands/dev-find-issue.md +4 -4
  7. package/plugins/sp/commands/dev-idea.md +3 -3
  8. package/plugins/sp/plugin.json +1 -1
  9. package/plugins/sp/references/environment-lens.md +66 -0
  10. package/plugins/sp/scripts/history-anatomy-cache.mjs +162 -13
  11. package/plugins/sp/scripts/history-anatomy-cache.ts +209 -13
  12. package/plugins/sp/skills/dogfood-testing/references/report-template.md +30 -0
  13. package/plugins/sp/skills/history-anatomy/SKILL.md +3 -3
  14. package/plugins/sp/skills/history-anatomy/references/operations.md +21 -8
  15. package/plugins/sp/skills/history-anatomy/references/report-contract.md +55 -5
  16. package/plugins/sp/skills/spur-cli/references/agent.md +15 -6
  17. package/plugins/sp/skills/spur-cli/references/message.md +4 -2
  18. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +6 -4
  19. package/spur.js +882 -325
  20. package/web/_astro/{BoardApp.rkWGVwwK.js → BoardApp.CKolAjUz.js} +56 -56
  21. package/web/_astro/BoardApp.DXD--ybM.js +1 -0
  22. package/web/_astro/{TaskDetail.B7ODt_bA.js → TaskDetail.Bre7G4gC.js} +1 -1
  23. package/web/_astro/{arc.C5QRz6AQ.js → arc.7luwOGiC.js} +1 -1
  24. package/web/_astro/{architectureDiagram-3BPJPVTR.CHptn8gZ.js → architectureDiagram-3BPJPVTR.F6KaHXp-.js} +1 -1
  25. package/web/_astro/{blockDiagram-GPEHLZMM.b1nEDkDw.js → blockDiagram-GPEHLZMM.CCGHeRVi.js} +1 -1
  26. package/web/_astro/{c4Diagram-AAUBKEIU.BWj8S_Qq.js → c4Diagram-AAUBKEIU.CpqewGmd.js} +1 -1
  27. package/web/_astro/channel.DxfOFf1l.js +1 -0
  28. package/web/_astro/{chunk-2J33WTMH.q0BBuE1n.js → chunk-2J33WTMH.CB9vKa5F.js} +1 -1
  29. package/web/_astro/{chunk-4BX2VUAB.DP2KDkAU.js → chunk-4BX2VUAB.ifGXoUA3.js} +1 -1
  30. package/web/_astro/{chunk-55IACEB6.CkCId72p.js → chunk-55IACEB6.VIaRo7l8.js} +1 -1
  31. package/web/_astro/{chunk-727SXJPM.oHWzxMot.js → chunk-727SXJPM.DzPE41OS.js} +1 -1
  32. package/web/_astro/{chunk-AQP2D5EJ.DkkgxfMP.js → chunk-AQP2D5EJ.UF2QRXYF.js} +1 -1
  33. package/web/_astro/{chunk-FMBD7UC4.lmFJDvRK.js → chunk-FMBD7UC4.D2zXKa1R.js} +1 -1
  34. package/web/_astro/{chunk-ND2GUHAM.52jt0eHK.js → chunk-ND2GUHAM.Cb9bDyvx.js} +1 -1
  35. package/web/_astro/{chunk-QZHKN3VN.Cg8KEFoD.js → chunk-QZHKN3VN.nUKFBiLD.js} +1 -1
  36. package/web/_astro/{classDiagram-4FO5ZUOK.BuOUhxcD.js → classDiagram-4FO5ZUOK.DZg9K9mO.js} +1 -1
  37. package/web/_astro/{classDiagram-v2-Q7XG4LA2.BuOUhxcD.js → classDiagram-v2-Q7XG4LA2.DZg9K9mO.js} +1 -1
  38. package/web/_astro/{cose-bilkent-S5V4N54A.BRUU8E_0.js → cose-bilkent-S5V4N54A.D9STo90d.js} +1 -1
  39. package/web/_astro/{dagre-BM42HDAG.GDbfFYVV.js → dagre-BM42HDAG.D3IbwhHz.js} +1 -1
  40. package/web/_astro/{diagram-2AECGRRQ.CvIDBeJF.js → diagram-2AECGRRQ.BWTDxBe9.js} +1 -1
  41. package/web/_astro/{diagram-5GNKFQAL.DMgJjWOX.js → diagram-5GNKFQAL.XnXlonHG.js} +1 -1
  42. package/web/_astro/{diagram-KO2AKTUF.njjl-0AP.js → diagram-KO2AKTUF.CywOngCO.js} +1 -1
  43. package/web/_astro/{diagram-LMA3HP47.CcCqgP8M.js → diagram-LMA3HP47.DY1D21Iu.js} +1 -1
  44. package/web/_astro/{diagram-OG6HWLK6.Btqd-YVE.js → diagram-OG6HWLK6.DQxb59KA.js} +1 -1
  45. package/web/_astro/{erDiagram-TEJ5UH35.BbhML_Xo.js → erDiagram-TEJ5UH35.CF2U-pQZ.js} +1 -1
  46. package/web/_astro/{flowDiagram-I6XJVG4X.CkvsIgY8.js → flowDiagram-I6XJVG4X.BJK4M3in.js} +1 -1
  47. package/web/_astro/{ganttDiagram-6RSMTGT7.m8IWD_wW.js → ganttDiagram-6RSMTGT7.BSniMzdB.js} +1 -1
  48. package/web/_astro/{gitGraphDiagram-PVQCEYII.5z87HXO-.js → gitGraphDiagram-PVQCEYII.Dxg-yRov.js} +1 -1
  49. package/web/_astro/index.BVXdIsZV.css +1 -0
  50. package/web/_astro/{infoDiagram-5YYISTIA.Bvyvsd7Q.js → infoDiagram-5YYISTIA.BY4CgO_n.js} +1 -1
  51. package/web/_astro/{ishikawaDiagram-YF4QCWOH.DdWVYO87.js → ishikawaDiagram-YF4QCWOH.BmWDZtwF.js} +1 -1
  52. package/web/_astro/{journeyDiagram-JHISSGLW.C3kOlYH1.js → journeyDiagram-JHISSGLW.CpB1YWDP.js} +1 -1
  53. package/web/_astro/{kanban-definition-UN3LZRKU.CfKTUHVC.js → kanban-definition-UN3LZRKU.k-fukQX9.js} +1 -1
  54. package/web/_astro/{linear.CMDHnzgX.js → linear.BNNCobvI.js} +1 -1
  55. package/web/_astro/{mermaid.core.DN7-WrsP.js → mermaid.core.DnpzzuPU.js} +4 -4
  56. package/web/_astro/{mindmap-definition-RKZ34NQL.DcQxLvqG.js → mindmap-definition-RKZ34NQL.D9NnlLBu.js} +1 -1
  57. package/web/_astro/{pieDiagram-4H26LBE5.Nu-Inbw6.js → pieDiagram-4H26LBE5.CKhoMiyC.js} +1 -1
  58. package/web/_astro/{quadrantDiagram-W4KKPZXB.Dck2EChs.js → quadrantDiagram-W4KKPZXB.BkAygRlm.js} +1 -1
  59. package/web/_astro/{requirementDiagram-4Y6WPE33.C--E5XuW.js → requirementDiagram-4Y6WPE33.CX8ibmwc.js} +1 -1
  60. package/web/_astro/{sankeyDiagram-5OEKKPKP.B_0iRNna.js → sankeyDiagram-5OEKKPKP.Dvurpa0Y.js} +1 -1
  61. package/web/_astro/{sequenceDiagram-3UESZ5HK.PKuKr9mk.js → sequenceDiagram-3UESZ5HK.veO8c2tk.js} +1 -1
  62. package/web/_astro/{stateDiagram-AJRCARHV.ClhGHb-O.js → stateDiagram-AJRCARHV.DpMr4CO3.js} +1 -1
  63. package/web/_astro/{stateDiagram-v2-BHNVJYJU.Bv6BBnvg.js → stateDiagram-v2-BHNVJYJU.CKjso86_.js} +1 -1
  64. package/web/_astro/{timeline-definition-PNZ67QCA.BZm2ZvAH.js → timeline-definition-PNZ67QCA.DayoPp_2.js} +1 -1
  65. package/web/_astro/{vennDiagram-CIIHVFJN.Dt6yZXry.js → vennDiagram-CIIHVFJN.XNHf04O9.js} +1 -1
  66. package/web/_astro/{wardley-L42UT6IY.rUs-E00M.js → wardley-L42UT6IY.CpM_031g.js} +1 -1
  67. package/web/_astro/{wardleyDiagram-YWT4CUSO.BcWgv4cA.js → wardleyDiagram-YWT4CUSO.lIAjSkZJ.js} +1 -1
  68. package/web/_astro/{xychartDiagram-2RQKCTM6.DH-M-cX9.js → xychartDiagram-2RQKCTM6.D8_2K6U1.js} +1 -1
  69. package/web/index.html +2 -2
  70. package/web/_astro/BoardApp.Z9jMwI9G.js +0 -1
  71. package/web/_astro/channel.BJtn6CGV.js +0 -1
  72. package/web/_astro/index.nWve6EHS.css +0 -1
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
5
  // plugins/sp/scripts/history-anatomy-cache.ts
6
+ import { spawnSync } from "node:child_process";
6
7
  import { createHash as createHash2 } from "node:crypto";
7
8
  import {
8
9
  closeSync,
@@ -63,7 +64,7 @@ function semanticArtifactDigest(artifactJson) {
63
64
  }
64
65
 
65
66
  // plugins/sp/scripts/history-anatomy-cache.ts
66
- var ELEVEN_SECTIONS = [
67
+ var REPORT_SECTIONS = [
67
68
  "Scope and provenance",
68
69
  "Executive summary",
69
70
  "Baseline comparison",
@@ -73,6 +74,7 @@ var ELEVEN_SECTIONS = [
73
74
  "Remediation options",
74
75
  "Performance analysis",
75
76
  "Workflow and process improvements",
77
+ "Report-only advisories",
76
78
  "Positive patterns",
77
79
  "Evidence ledger"
78
80
  ];
@@ -85,7 +87,10 @@ var FINDING_FIELDS = [
85
87
  "inference",
86
88
  "confidence",
87
89
  "contradictions",
88
- "evidenceAnchor"
90
+ "evidenceAnchor",
91
+ "severity",
92
+ "reproCommand",
93
+ "ownerSurface"
89
94
  ];
90
95
  function parseScalar(raw) {
91
96
  const t = raw.trim();
@@ -249,12 +254,21 @@ function decideCache(cached, current, opts) {
249
254
  function escapeRe(s) {
250
255
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
251
256
  }
257
+ var FINDING_CATEGORIES = [
258
+ "reliability",
259
+ "repetition",
260
+ "workflow",
261
+ "performance",
262
+ "coverage",
263
+ "telemetry",
264
+ "positive"
265
+ ];
252
266
  function checkReportStructure(reportMarkdown) {
253
267
  const problems = [];
254
268
  if (/TODO|PLACEHOLDER|FIXME|^\|\s*\|/im.test(reportMarkdown))
255
269
  problems.push("placeholder-or-todo-present");
256
270
  let lastIdx = -1;
257
- for (const section of ELEVEN_SECTIONS) {
271
+ for (const section of REPORT_SECTIONS) {
258
272
  const re = new RegExp(`^#{2,3}\\s+${escapeRe(section)}\\s*$`, "m");
259
273
  const m = reportMarkdown.match(re);
260
274
  if (m === null || (m.index ?? -1) <= lastIdx) {
@@ -263,11 +277,45 @@ function checkReportStructure(reportMarkdown) {
263
277
  lastIdx = m.index;
264
278
  }
265
279
  }
266
- const findingRows = reportMarkdown.match(/^\|\s*(reliability|repetition|workflow|performance|coverage|telemetry|positive):[^|]+/gm);
267
- for (const row of findingRows ?? []) {
268
- for (const field of FINDING_FIELDS) {
269
- if (!row.toLowerCase().includes(field))
270
- problems.push(`finding-missing-field:${field}`);
280
+ const findingsIdx = reportMarkdown.search(/^##\s+Findings\s*$/im);
281
+ if (findingsIdx !== -1) {
282
+ const tail = reportMarkdown.slice(findingsIdx);
283
+ const nextSection = tail.slice(1).search(/^##\s+/im);
284
+ const findingsBody = nextSection === -1 ? tail : tail.slice(0, nextSection + 1);
285
+ const catAlt = FINDING_CATEGORIES.join("|");
286
+ const knownRows = findingsBody.match(new RegExp(`^\\|\\s*(?:${catAlt}):[^|]+`, "gm")) ?? [];
287
+ const invalidRows = findingsBody.match(/^\|\s*([^|:\s][^|:]*):[^|]+/gm) ?? [];
288
+ for (const row of [...new Set([...knownRows, ...invalidRows])]) {
289
+ const segMatch = row.match(/^\|\s*([^:|]+):/);
290
+ if (segMatch && !FINDING_CATEGORIES.includes(segMatch[1].trim())) {
291
+ problems.push(`finding-invalid-key-category:${segMatch[1].trim()}`);
292
+ }
293
+ const rowLower = row.toLowerCase();
294
+ for (const field of FINDING_FIELDS) {
295
+ if (!rowLower.includes(field.toLowerCase()))
296
+ problems.push(`finding-missing-field:${field}`);
297
+ }
298
+ }
299
+ const blocks = findingsBody.split(/^###\s+/m).slice(1);
300
+ for (const block of blocks) {
301
+ if (!block.includes("`key`") && !/\|\s*key\s*:/.test(block))
302
+ continue;
303
+ for (const field of FINDING_FIELDS) {
304
+ if (!block.includes(field))
305
+ problems.push(`finding-missing-field:${field}`);
306
+ }
307
+ if (!/(^|[\s`])P[123]([\s`.]|$)/.test(block) && !block.includes("symbolic-severity")) {
308
+ problems.push("finding-invalid-severity");
309
+ }
310
+ const catValue = block.match(/`category`\s*[:=]\s*`?([^`\n]+?)`?\s*(?:\n|$)/)?.[1];
311
+ if (catValue && !FINDING_CATEGORIES.includes(catValue.trim())) {
312
+ problems.push(`finding-invalid-category:${catValue.trim()}`);
313
+ }
314
+ const keyValue = block.match(/`key`\s*[:=]\s*`?([^`\n]+?)`?\s*(?:\n|$)/)?.[1] ?? "";
315
+ const firstSegment = keyValue.split(":")[0]?.trim() ?? "";
316
+ if (keyValue !== "" && !FINDING_CATEGORIES.includes(firstSegment)) {
317
+ problems.push(`finding-invalid-key-category:${firstSegment}`);
318
+ }
271
319
  }
272
320
  }
273
321
  const ledgerIdx = reportMarkdown.search(/^#{2,3}\s+Evidence\s+ledger/im);
@@ -319,17 +367,68 @@ 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
407
  const pluginRoot = opts.helper.replace(/\/scripts\/[^/]+$/, "");
324
408
  const skill = `${pluginRoot}/skills/history-anatomy`;
325
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
409
+ const tz = opts.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
326
410
  const date = opts.date !== undefined && opts.date !== "" ? opts.date : localDay(tz, opts.now ?? new Date);
327
411
  const target = opts.output !== undefined && opts.output !== "" ? opts.output : `${opts.reportDir}/${date}-history-anatomy.md`;
328
- return `HA_HELPER=${opts.helper}
412
+ const adHoc = opts.mode === "ad-hoc" && !!opts.since && !!opts.until;
413
+ let env = `HA_HELPER=${opts.helper}
329
414
  HA_SKILL=${skill}
330
415
  HA_TARGET=${target}
331
416
  HA_DATE=${date}
332
417
  `;
418
+ if (adHoc) {
419
+ return `${env}HA_SINCE=${opts.since}
420
+ HA_UNTIL=${opts.until}
421
+ `;
422
+ }
423
+ const current = dayBounds(tz, date);
424
+ const baseline = dayBounds(tz, localDay(tz, new Date(zonedDayStart(tz, date).getTime() - 12 * 3600000)));
425
+ env += `HA_SINCE=${current.since}
426
+ HA_UNTIL=${current.until}
427
+ `;
428
+ env += `HA_BASELINE_SINCE=${baseline.since}
429
+ HA_BASELINE_UNTIL=${baseline.until}
430
+ `;
431
+ return env;
333
432
  }
334
433
  function buildProvenance(opts) {
335
434
  let raw;
@@ -486,7 +585,15 @@ function publishAtomically(candidatePath, targetPath) {
486
585
  throw err;
487
586
  }
488
587
  }
489
- var VALID_COMMANDS = "digest, check, paths, probe, stamp, refresh, publish";
588
+ function porcelainPaths(text) {
589
+ return new Set(text.split(`
590
+ `).map((line) => line.replace(/^\S+\s+/, "").trim()).filter((line) => line.length > 0));
591
+ }
592
+ function diffPorcelain(before, now, expects) {
593
+ const beforePaths = porcelainPaths(before);
594
+ return [...porcelainPaths(now)].filter((p) => !beforePaths.has(p) && !expects.has(p)).sort();
595
+ }
596
+ var VALID_COMMANDS = "digest, check, paths, assert-clean, probe, stamp, refresh, publish";
490
597
  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
598
  function parseFlags(args) {
492
599
  const out = {};
@@ -543,13 +650,50 @@ ${result.problems.map((p) => `- ${p}
543
650
  publishAtomically(a, b);
544
651
  return { exitCode: 0, stdout: "", stderr: "" };
545
652
  }
653
+ case "assert-clean": {
654
+ const f = parseFlags(argv.slice(1));
655
+ if (f.baseline === undefined) {
656
+ return {
657
+ exitCode: 1,
658
+ stdout: "",
659
+ stderr: `usage: <script> assert-clean --baseline <porcelain.txt> [--expect <path>]...
660
+ `
661
+ };
662
+ }
663
+ const expects = new Set;
664
+ for (const arg of argv.slice(1)) {
665
+ if (arg.startsWith("--expect="))
666
+ expects.add(arg.slice("--expect=".length));
667
+ }
668
+ let now;
669
+ try {
670
+ now = spawnSync("git", ["status", "--porcelain"], {
671
+ encoding: "utf8",
672
+ ...f.cwd !== undefined ? { cwd: f.cwd } : {}
673
+ }).stdout ?? "";
674
+ } catch {
675
+ return { exitCode: 0, stdout: "", stderr: `assert-clean: git unavailable; skipped
676
+ ` };
677
+ }
678
+ const undeclared = diffPorcelain(readFileSync(f.baseline, "utf8"), now, expects);
679
+ if (undeclared.length > 0) {
680
+ return {
681
+ exitCode: 1,
682
+ stdout: "",
683
+ stderr: undeclared.map((p) => `undeclared write: ${p}
684
+ `).join("")
685
+ };
686
+ }
687
+ return { exitCode: 0, stdout: `clean
688
+ `, stderr: "" };
689
+ }
546
690
  case "paths": {
547
691
  const f = parseFlags(argv.slice(1));
548
692
  if (f.helper === undefined || f.out === undefined) {
549
693
  return {
550
694
  exitCode: 1,
551
695
  stdout: "",
552
- stderr: `usage: <script> paths --helper <p> --out <env> [--report-dir <d>] [--date <d>] [--output <p>]
696
+ stderr: `usage: <script> paths --helper <p> --out <env> [--report-dir <d>] [--date <d>] [--output <p>] [--mode <m>] [--since <s>] [--until <u>]
553
697
  `
554
698
  };
555
699
  }
@@ -557,7 +701,10 @@ ${result.problems.map((p) => `- ${p}
557
701
  helper: f.helper,
558
702
  reportDir: f["report-dir"] ?? "docs/report",
559
703
  date: f.date,
560
- output: f.output
704
+ output: f.output,
705
+ mode: f.mode,
706
+ since: f.since,
707
+ until: f.until
561
708
  }));
562
709
  return { exitCode: 0, stdout: "", stderr: "" };
563
710
  }
@@ -659,9 +806,11 @@ export {
659
806
  refreshReport,
660
807
  publishAtomically,
661
808
  probe,
809
+ porcelainPaths,
662
810
  parseProvenance,
663
811
  logicDigest,
664
812
  importedSnapshotAsOf,
813
+ diffPorcelain,
665
814
  decideCache,
666
815
  checkReportStructure,
667
816
  buildProvenance,
@@ -27,6 +27,7 @@
27
27
  * `packages/` imports and no `Bun.*` globals, so the committed `.mjs` twin runs under bare `node`.
28
28
  */
29
29
 
30
+ import { spawnSync } from 'node:child_process';
30
31
  import { createHash } from 'node:crypto';
31
32
  import {
32
33
  closeSync,
@@ -89,7 +90,7 @@ export interface CacheDecision {
89
90
 
90
91
  // Frozen vocabulary from 0658's references/report-contract.md, kept local (not imported from
91
92
  // `packages/`, not re-read from the skill) so this deterministic script is self-contained.
92
- const ELEVEN_SECTIONS = [
93
+ const REPORT_SECTIONS = [
93
94
  'Scope and provenance',
94
95
  'Executive summary',
95
96
  'Baseline comparison',
@@ -99,6 +100,9 @@ const ELEVEN_SECTIONS = [
99
100
  'Remediation options',
100
101
  'Performance analysis',
101
102
  'Workflow and process improvements',
103
+ // 0680 R5: standing report-only advisory slot (repeated tool-and-argument
104
+ // signatures propose no automatic interruption).
105
+ 'Report-only advisories',
102
106
  'Positive patterns',
103
107
  'Evidence ledger',
104
108
  ];
@@ -113,6 +117,10 @@ const FINDING_FIELDS = [
113
117
  'confidence',
114
118
  'contradictions',
115
119
  'evidenceAnchor',
120
+ // 0680 R1-R3: triage fields — the gate fails a finding missing any of them.
121
+ 'severity',
122
+ 'reproCommand',
123
+ 'ownerSurface',
116
124
  ];
117
125
 
118
126
  // ── 1. Semantic artifact digest ────────────────────────────────────────────────────────────
@@ -311,8 +319,19 @@ function escapeRe(s: string): string {
311
319
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
312
320
  }
313
321
 
322
+ /** Closed finding categories (0686/I9). Kept next to FINDING_FIELDS — the gate parses them. */
323
+ const FINDING_CATEGORIES = [
324
+ 'reliability',
325
+ 'repetition',
326
+ 'workflow',
327
+ 'performance',
328
+ 'coverage',
329
+ 'telemetry',
330
+ 'positive',
331
+ ] as const;
332
+
314
333
  /**
315
- * Check a candidate report against the frozen report contract — the eleven sections in order,
334
+ * Check a candidate report against the frozen report contract — the twelve sections in order,
316
335
  * the nine per-finding fields, no placeholders/TODOs/empty bodies, and evidence-ledger anchors.
317
336
  */
318
337
  export function checkReportStructure(reportMarkdown: string): { ok: boolean; problems: string[] } {
@@ -321,7 +340,7 @@ export function checkReportStructure(reportMarkdown: string): { ok: boolean; pro
321
340
  if (/TODO|PLACEHOLDER|FIXME|^\|\s*\|/im.test(reportMarkdown)) problems.push('placeholder-or-todo-present');
322
341
 
323
342
  let lastIdx = -1;
324
- for (const section of ELEVEN_SECTIONS) {
343
+ for (const section of REPORT_SECTIONS) {
325
344
  const re = new RegExp(`^#{2,3}\\s+${escapeRe(section)}\\s*$`, 'm');
326
345
  const m = reportMarkdown.match(re);
327
346
  if (m === null || (m.index ?? -1) <= lastIdx) {
@@ -331,12 +350,57 @@ export function checkReportStructure(reportMarkdown: string): { ok: boolean; pro
331
350
  }
332
351
  }
333
352
 
334
- const findingRows = reportMarkdown.match(
335
- /^\|\s*(reliability|repetition|workflow|performance|coverage|telemetry|positive):[^|]+/gm,
336
- );
337
- for (const row of findingRows ?? []) {
338
- for (const field of FINDING_FIELDS) {
339
- if (!row.toLowerCase().includes(field)) problems.push(`finding-missing-field:${field}`);
353
+ // 0680 R1-R3: findings are authored as bullet blocks under `## Findings` (one `### <title>`
354
+ // block per finding, key-value bullets). Scan each block that carries a stable key and fail
355
+ // it missing any triage field — including the three new ones. Without this scan a bullet
356
+ // finding never matches the legacy pipe-row regex and the triage gate was vacuous.
357
+ // 0686/I9: category parsing is scoped to that same body — an explicit category outside the
358
+ // closed vocabulary (FINDING_CATEGORIES) or a stable key whose first segment does so fails
359
+ // rather than passing vacuously.
360
+ const findingsIdx = reportMarkdown.search(/^##\s+Findings\s*$/im);
361
+ if (findingsIdx !== -1) {
362
+ const tail = reportMarkdown.slice(findingsIdx);
363
+ const nextSection = tail.slice(1).search(/^##\s+/im);
364
+ const findingsBody = nextSection === -1 ? tail : tail.slice(0, nextSection + 1);
365
+
366
+ const catAlt = FINDING_CATEGORIES.join('|');
367
+ const knownRows = findingsBody.match(new RegExp(`^\\|\\s*(?:${catAlt}):[^|]+`, 'gm')) ?? [];
368
+ const invalidRows = findingsBody.match(/^\|\s*([^|:\s][^|:]*):[^|]+/gm) ?? [];
369
+ for (const row of [...new Set([...knownRows, ...invalidRows])]) {
370
+ // 0686/I9: a pipe row outside the closed first-segment set fails by name instead of
371
+ // being skipped silently by the legacy regex.
372
+ const segMatch = row.match(/^\|\s*([^:|]+):/);
373
+ if (segMatch && !(FINDING_CATEGORIES as readonly string[]).includes(segMatch[1].trim())) {
374
+ problems.push(`finding-invalid-key-category:${segMatch[1].trim()}`);
375
+ }
376
+ // Case-insensitive on BOTH sides — camelCase triage fields otherwise never match a
377
+ // lowercased row.
378
+ const rowLower = row.toLowerCase();
379
+ for (const field of FINDING_FIELDS) {
380
+ if (!rowLower.includes(field.toLowerCase())) problems.push(`finding-missing-field:${field}`);
381
+ }
382
+ }
383
+
384
+ const blocks = findingsBody.split(/^###\s+/m).slice(1);
385
+ for (const block of blocks) {
386
+ if (!block.includes('`key`') && !/\|\s*key\s*:/.test(block)) continue;
387
+ for (const field of FINDING_FIELDS) {
388
+ if (!block.includes(field)) problems.push(`finding-missing-field:${field}`);
389
+ }
390
+ // Severity vocabulary is closed (0680 R1): P1/P2/P3 only (symbolic placeholders allowed).
391
+ if (!/(^|[\s`])P[123]([\s`.]|$)/.test(block) && !block.includes('symbolic-severity')) {
392
+ problems.push('finding-invalid-severity');
393
+ }
394
+ // 0686/I9 closed-category enforcement on bullet findings.
395
+ const catValue = block.match(/`category`\s*[:=]\s*`?([^`\n]+?)`?\s*(?:\n|$)/)?.[1];
396
+ if (catValue && !(FINDING_CATEGORIES as readonly string[]).includes(catValue.trim())) {
397
+ problems.push(`finding-invalid-category:${catValue.trim()}`);
398
+ }
399
+ const keyValue = block.match(/`key`\s*[:=]\s*`?([^`\n]+?)`?\s*(?:\n|$)/)?.[1] ?? '';
400
+ const firstSegment = keyValue.split(':')[0]?.trim() ?? '';
401
+ if (keyValue !== '' && !(FINDING_CATEGORIES as readonly string[]).includes(firstSegment)) {
402
+ problems.push(`finding-invalid-key-category:${firstSegment}`);
403
+ }
340
404
  }
341
405
  }
342
406
 
@@ -422,6 +486,66 @@ function localDay(tz: string, at: Date = new Date()): string {
422
486
  }
423
487
  }
424
488
 
489
+ /** Wall-clock offset of `tz` at instant `at`, ms east of UTC (0674 R2). */
490
+ function tzOffsetMs(tz: string, at: Date): number {
491
+ const parts = Object.fromEntries(
492
+ new Intl.DateTimeFormat('en-US', {
493
+ timeZone: tz,
494
+ hour12: false,
495
+ year: 'numeric',
496
+ month: '2-digit',
497
+ day: '2-digit',
498
+ hour: '2-digit',
499
+ minute: '2-digit',
500
+ second: '2-digit',
501
+ })
502
+ .formatToParts(at)
503
+ .filter((p) => p.type !== 'literal')
504
+ .map((p) => [p.type, p.value]),
505
+ );
506
+ const asUtc = Date.UTC(
507
+ Number(parts.year),
508
+ Number(parts.month) - 1,
509
+ Number(parts.day),
510
+ parts.hour === '24' ? 0 : Number(parts.hour),
511
+ Number(parts.minute),
512
+ Number(parts.second),
513
+ );
514
+ // Millisecond-truncate the comparison instant — Intl parts carry second precision, and an
515
+ // untruncated .999 epoch would leak into the offset (0674).
516
+ const atSec = Math.floor(at.getTime() / 1000) * 1000;
517
+ return asUtc - atSec;
518
+ }
519
+
520
+ /** First instant of local calendar day `ymd` (two-pass so the offset guess survives DST edges). */
521
+ function zonedDayStart(tz: string, ymd: string): Date {
522
+ const [y, m, d] = ymd.split('-').map(Number);
523
+ const utcMidnight = Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1);
524
+ const guess = new Date(utcMidnight - tzOffsetMs(tz, new Date(utcMidnight)));
525
+ return new Date(utcMidnight - tzOffsetMs(tz, guess));
526
+ }
527
+
528
+ /** Instant rendered in `tz` wall clock with explicit offset: YYYY-MM-DDTHH:mm:ss.sss±HH:MM. */
529
+ function formatZonedIso(tz: string, at: Date): string {
530
+ const off = tzOffsetMs(tz, at);
531
+ const abs = Math.abs(off);
532
+ const pad = (n: number): string => String(n).padStart(2, '0');
533
+ const offStr = `${off < 0 ? '-' : '+'}${pad(Math.floor(abs / 3_600_000))}:${pad(Math.floor((abs % 3_600_000) / 60_000))}`;
534
+ // Shifting by the offset then formatting as UTC yields the zone's own wall clock.
535
+ return `${new Date(at.getTime() + off).toISOString().slice(0, 23)}${offStr}`;
536
+ }
537
+
538
+ /** Inclusive bounds of one local calendar day; a DST day is 23/24/25h and both ends carry the real offset. */
539
+ function dayBounds(tz: string, ymd: string): { since: string; until: string } {
540
+ const start = zonedDayStart(tz, ymd);
541
+ // start + 30h always lands inside the NEXT calendar day regardless of 23/24/25h lengths.
542
+ const nextYmd = localDay(tz, new Date(start.getTime() + 30 * 3_600_000));
543
+ return {
544
+ since: formatZonedIso(tz, start),
545
+ until: formatZonedIso(tz, new Date(zonedDayStart(tz, nextYmd).getTime() - 1)),
546
+ };
547
+ }
548
+
425
549
  /**
426
550
  * Resolve the run's fixed paths once (0660 R4/R15): the skill directory beside the helper, the
427
551
  * effective local date, and the publication target. Emitted as an env file so every downstream
@@ -433,14 +557,31 @@ 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
566
  const pluginRoot = opts.helper.replace(/\/scripts\/[^/]+$/, '');
438
567
  const skill = `${pluginRoot}/skills/history-anatomy`;
439
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
568
+ const tz = opts.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
440
569
  const date = opts.date !== undefined && opts.date !== '' ? opts.date : localDay(tz, opts.now ?? new Date());
441
570
  const target =
442
571
  opts.output !== undefined && opts.output !== '' ? opts.output : `${opts.reportDir}/${date}-history-anatomy.md`;
443
- return `HA_HELPER=${opts.helper}\nHA_SKILL=${skill}\nHA_TARGET=${target}\nHA_DATE=${date}\n`;
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;
444
585
  }
445
586
 
446
587
  export interface ProbeOptions {
@@ -658,7 +799,23 @@ export interface CacheCliResult {
658
799
  stderr: string;
659
800
  }
660
801
 
661
- const VALID_COMMANDS = 'digest, check, paths, probe, stamp, refresh, publish';
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';
662
819
  const PROBE_USAGE =
663
820
  '<script> probe --artifact <a.json> --target <report.md> [--baseline <b.json>] [--mode daily|ad-hoc] ' +
664
821
  '[--date <YYYY-MM-DD>] [--recompute true] [--out <prov.json>] [--skill-dir <d>] [--contract <f>] [--workflow <f>]';
@@ -716,13 +873,49 @@ export function runCacheCli(argv: string[]): CacheCliResult {
716
873
  publishAtomically(a, b);
717
874
  return { exitCode: 0, stdout: '', stderr: '' };
718
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
+ }
719
912
  case 'paths': {
720
913
  const f = parseFlags(argv.slice(1));
721
914
  if (f.helper === undefined || f.out === undefined) {
722
915
  return {
723
916
  exitCode: 1,
724
917
  stdout: '',
725
- stderr: 'usage: <script> paths --helper <p> --out <env> [--report-dir <d>] [--date <d>] [--output <p>]\n',
918
+ stderr: 'usage: <script> paths --helper <p> --out <env> [--report-dir <d>] [--date <d>] [--output <p>] [--mode <m>] [--since <s>] [--until <u>]\n',
726
919
  };
727
920
  }
728
921
  writeFileSync(
@@ -732,6 +925,9 @@ export function runCacheCli(argv: string[]): CacheCliResult {
732
925
  reportDir: f['report-dir'] ?? 'docs/report',
733
926
  date: f.date,
734
927
  output: f.output,
928
+ mode: f.mode,
929
+ since: f.since,
930
+ until: f.until,
735
931
  }),
736
932
  );
737
933
  return { exitCode: 0, stdout: '', stderr: '' };
@@ -237,6 +237,36 @@ downstream task creation does not inherit an unactionable acceptance criterion:
237
237
 
238
238
  The tag is a prompt to whoever turns findings into tasks: `[stale]` → drop, `[unverifiable]` →
239
239
  reframe or defer, `[feasible]` → proceed. A finding without a tag is treated as `[feasible]`.
240
+
241
+ **Optional class tag (environment lens, task 0686).** A finding line may carry one closed class
242
+ tag — `environment` | `testee` | `waste` — positioned immediately after the em dash and distinct
243
+ from the trailing feasibility tag:
244
+
245
+ ```
246
+ - **P2** — [environment] <what's wrong>. → **Action:** <concrete change>. (`file:line`, ~effort) `[feasible]`
247
+ ```
248
+
249
+ Omitting the class preserves the current line shape; untagged findings remain valid and the
250
+ protocol stays `sp:dogfood-testing@1.2` (the validator gains no required field; the cache-health
251
+ P3 above needs no class).
252
+
253
+ - `testee` — a defect in the testee's contract (the protocol the run grades). Bounded fix-mode
254
+ may repair it, unchanged.
255
+ - `environment` — a steering/placement/check-shaped observation projected from the
256
+ [environment-improvement lens](../../../references/environment-lens.md), which owns the
257
+ category table and the placement rule. **Proposal-only:** even under `--max-retry N`, bounded
258
+ retries never `Edit` or `Write` `AGENTS.md`, `CLAUDE.md`, skills, rules, numbered docs, or
259
+ plugin references for an environment-tagged finding — it stays a recommended action here.
260
+ - `waste` — a token/tool-waste diagnostic with no missing environment affordance behind it.
261
+
262
+ Class, not the cited file path, decides whether bounded fix may mutate. A session mistake a
263
+ linter, typechecker, test, or filesystem gate could have caught is `environment`, and its
264
+ recommended action proposes a new-or-tighter automated check — never another sentence in an
265
+ always-loaded steering file. Missed coding standards are `environment` owned by a review path
266
+ (`sp:code-verification`, `sp:code-review`, or pipeline review) — never the implementer skill;
267
+ navigation friction and dead always-loaded instructions are likewise `environment`, not
268
+ `testee`.
269
+
240
270
  Severity scale:
241
271
 
242
272
  - **P1** — blocks correct use or causes drift/wrong output; fix before shipping the testee.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: history-anatomy
3
- description: "Independent owner of diagnostic interpretation over already-imported history — the daily/ad-hoc mode contract, a closed finding taxonomy, the eleven-section report contract, and the enrich/validate rubrics. Triggers: history-anatomy, run the daily report, ad-hoc diagnosis, find issues over history."
3
+ description: "Independent owner of diagnostic interpretation over already-imported history — the daily/ad-hoc mode contract, a closed finding taxonomy, the twelve-section report contract, and the enrich/validate rubrics. Triggers: history-anatomy, run the daily report, ad-hoc diagnosis, find issues over history."
4
4
  license: Apache-2.0
5
5
  version: 1.0.0
6
6
  metadata:
@@ -37,7 +37,7 @@ fresh skill cannot be added to the BASELINE exemption map).
37
37
  **Choose the mode first.** The single entry is `--mode daily|ad-hoc` (default `daily`):
38
38
  `references/modes.md`.
39
39
 
40
- **Then hold the report to the contract.** Every report must contain the eleven sections and every
40
+ **Then hold the report to the contract.** Every report must contain the twelve sections and every
41
41
  finding the full field set, with evidence rules and an explicit comparability verdict:
42
42
  `references/report-contract.md`.
43
43
 
@@ -63,5 +63,5 @@ finding the full field set, with evidence rules and an explicit comparability ve
63
63
  | Reference | Owns |
64
64
  | --- | --- |
65
65
  | [`references/modes.md`](references/modes.md) | The daily/ad-hoc mode matrix, bounds normalization, the DST-aware calendar-day rule, and the fail-loud message shape. |
66
- | [`references/report-contract.md`](references/report-contract.md) | The eleven sections (in order), the per-finding field set, the closed category vocabulary, the stable-key grammar, the evidence rules, comparison semantics, recurrence classes, and the positive-pattern / remediation standards. |
66
+ | [`references/report-contract.md`](references/report-contract.md) | The twelve sections (in order), the per-finding field set, the closed category vocabulary, the stable-key grammar, the evidence rules, comparison semantics, recurrence classes, and the positive-pattern / remediation standards. |
67
67
  | [`references/operations.md`](references/operations.md) | The `enrich` and `validate` operation rubrics; neither launches a workflow. |