@gobing-ai/spur 0.3.91 → 0.3.92

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 (120) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/pipeline-budgets.json +2 -2
  3. package/config/plugin-scripts.json +17 -1
  4. package/config/workflows/feature-lifecycle.yaml +14 -5
  5. package/config/workflows/feature-verification.yaml +35 -27
  6. package/config/workflows/history-anatomy.yaml +14 -25
  7. package/config/workflows/idea-pipeline.yaml +9 -5
  8. package/config/workflows/task-pipeline.yaml +185 -4
  9. package/config/workflows/wrapup-pipeline.yaml +47 -5
  10. package/package.json +9 -9
  11. package/plugins/sp/agents/super-planner.md +14 -5
  12. package/plugins/sp/commands/dev-dogfood.md +4 -4
  13. package/plugins/sp/commands/dev-fixall.md +8 -5
  14. package/plugins/sp/commands/dev-run.md +6 -0
  15. package/plugins/sp/commands/dev-runall.md +12 -6
  16. package/plugins/sp/commands/dev-verify.md +9 -0
  17. package/plugins/sp/commands/dev-verifyall.md +5 -0
  18. package/plugins/sp/lib/idea-handoff.generated.mjs +301 -300
  19. package/plugins/sp/lib/inline-run.generated.d.mts +17 -0
  20. package/plugins/sp/lib/inline-run.generated.mjs +1460 -0
  21. package/plugins/sp/plugin.json +1 -1
  22. package/plugins/sp/references/environment-lens.md +1 -1
  23. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +136 -0
  24. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +196 -2
  25. package/plugins/sp/scripts/feature-verification-steps.mjs +174 -0
  26. package/plugins/sp/scripts/feature-verification-steps.ts +275 -0
  27. package/plugins/sp/scripts/history-anatomy-cache.mjs +104 -4
  28. package/plugins/sp/scripts/history-anatomy-cache.ts +137 -13
  29. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +1 -0
  30. package/plugins/sp/scripts/inline-run-setup.mjs +349 -0
  31. package/plugins/sp/scripts/inline-run-setup.ts +192 -75
  32. package/plugins/sp/scripts/record-feature-sync.mjs +63 -0
  33. package/plugins/sp/scripts/record-feature-sync.ts +84 -0
  34. package/plugins/sp/scripts/residual-scan.mjs +476 -0
  35. package/plugins/sp/scripts/residual-scan.ts +614 -0
  36. package/plugins/sp/scripts/task-evidence-precheck.ts +8 -3
  37. package/plugins/sp/scripts/task-size-precheck.ts +8 -3
  38. package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
  39. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +2 -0
  40. package/plugins/sp/skills/code-implementation/SKILL.md +17 -0
  41. package/plugins/sp/skills/code-verification/SKILL.md +21 -0
  42. package/plugins/sp/skills/code-verification/references/verdict-schema.md +1 -0
  43. package/plugins/sp/skills/dogfood-testing/SKILL.md +5 -3
  44. package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +63 -26
  45. package/plugins/sp/skills/dogfood-testing/references/report-template.md +33 -10
  46. package/plugins/sp/skills/history-anatomy/references/modes.md +5 -3
  47. package/plugins/sp/skills/next-feature/references/ranking-rubric.md +1 -1
  48. package/plugins/sp/skills/next-router/references/routing-table.md +7 -0
  49. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
  50. package/plugins/sp/skills/session-review/SKILL.md +12 -2
  51. package/plugins/sp/skills/spur-cli/references/features.md +1 -1
  52. package/plugins/sp/skills/spur-cli/references/projects.md +3 -1
  53. package/plugins/sp/skills/spur-cli/references/workflows.md +39 -19
  54. package/plugins/sp/skills/spur-dev/SKILL.md +11 -4
  55. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +4 -1
  56. package/plugins/sp/skills/spur-dev/references/dev-operations.md +2 -2
  57. package/plugins/sp/skills/spur-dev/references/execution-batch.md +285 -57
  58. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +1 -1
  59. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +19 -4
  60. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +43 -16
  61. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +5 -0
  62. package/spur.js +21827 -19615
  63. package/web/_astro/BoardApp.CerSBgis.js +192 -0
  64. package/web/_astro/BoardApp.eoTz0pZs.js +1 -0
  65. package/web/_astro/{TaskDetail.CXGltuT_.js → TaskDetail.DCqiC-OZ.js} +1 -1
  66. package/web/_astro/arc.CPwg6Rw0.js +1 -0
  67. package/web/_astro/{architectureDiagram-3BPJPVTR.DJ8DHkWE.js → architectureDiagram-3BPJPVTR.DM_vp_hO.js} +1 -1
  68. package/web/_astro/{blockDiagram-GPEHLZMM.D8DHK3Jl.js → blockDiagram-GPEHLZMM.DXVIiv0p.js} +1 -1
  69. package/web/_astro/{c4Diagram-AAUBKEIU.BugQbX9u.js → c4Diagram-AAUBKEIU.BbF_zCxW.js} +1 -1
  70. package/web/_astro/channel.MYZLKNwy.js +1 -0
  71. package/web/_astro/{chunk-2J33WTMH.Mv26KlVn.js → chunk-2J33WTMH.CAgQHpPC.js} +1 -1
  72. package/web/_astro/{chunk-4BX2VUAB.CD51JoT_.js → chunk-4BX2VUAB.BN-5tpw4.js} +1 -1
  73. package/web/_astro/{chunk-55IACEB6.D_PFaIEe.js → chunk-55IACEB6.CnPkEEr0.js} +1 -1
  74. package/web/_astro/{chunk-727SXJPM.DemMW1ao.js → chunk-727SXJPM.BQzQeMVm.js} +4 -4
  75. package/web/_astro/{chunk-AQP2D5EJ.Iu2V5-ex.js → chunk-AQP2D5EJ.B6xNyDnL.js} +1 -1
  76. package/web/_astro/{chunk-FMBD7UC4.MsNgSP-E.js → chunk-FMBD7UC4.C7f9Ih78.js} +1 -1
  77. package/web/_astro/{chunk-ND2GUHAM.DfbRaAlm.js → chunk-ND2GUHAM.CNV1dFXT.js} +1 -1
  78. package/web/_astro/{chunk-QZHKN3VN.BbJAQ4h-.js → chunk-QZHKN3VN.Cudn2TkJ.js} +1 -1
  79. package/web/_astro/{classDiagram-4FO5ZUOK.CPurtiC2.js → classDiagram-4FO5ZUOK.D1NwP50q.js} +1 -1
  80. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CPurtiC2.js → classDiagram-v2-Q7XG4LA2.D1NwP50q.js} +1 -1
  81. package/web/_astro/{cose-bilkent-S5V4N54A.CKKdx1bM.js → cose-bilkent-S5V4N54A.B1wSL-Xb.js} +1 -1
  82. package/web/_astro/{cynefin-OW5HDTMX.CoKMTg-R.js → cynefin-OW5HDTMX.BmK52w8G.js} +1 -1
  83. package/web/_astro/{dagre-BM42HDAG.D4h4_k56.js → dagre-BM42HDAG.Bfy5CTDT.js} +2 -2
  84. package/web/_astro/diagram-2AECGRRQ.DhNnvUvX.js +43 -0
  85. package/web/_astro/diagram-5GNKFQAL.lTX5KwnS.js +10 -0
  86. package/web/_astro/{diagram-KO2AKTUF.BFoCkiCr.js → diagram-KO2AKTUF.CW_vMJ4z.js} +3 -3
  87. package/web/_astro/{diagram-LMA3HP47.exHn9OVx.js → diagram-LMA3HP47.B_8ZGF67.js} +1 -1
  88. package/web/_astro/{diagram-OG6HWLK6.CeqO34nN.js → diagram-OG6HWLK6.BppnHsdS.js} +1 -1
  89. package/web/_astro/{erDiagram-TEJ5UH35.D_v7HqxR.js → erDiagram-TEJ5UH35.BEuHXcjJ.js} +5 -5
  90. package/web/_astro/{flowDiagram-I6XJVG4X.EUmrpbwh.js → flowDiagram-I6XJVG4X.CH-UlnGr.js} +4 -4
  91. package/web/_astro/{ganttDiagram-6RSMTGT7.BOCF5lII.js → ganttDiagram-6RSMTGT7.BO81S85v.js} +1 -1
  92. package/web/_astro/{gitGraphDiagram-PVQCEYII.Di7otYZD.js → gitGraphDiagram-PVQCEYII.XnPxPPZN.js} +1 -1
  93. package/web/_astro/index.Hjbr15fG.css +1 -0
  94. package/web/_astro/{infoDiagram-5YYISTIA.TcBkCAJk.js → infoDiagram-5YYISTIA.JyjYRu_T.js} +1 -1
  95. package/web/_astro/{ishikawaDiagram-YF4QCWOH.D-2y4M0c.js → ishikawaDiagram-YF4QCWOH.BBRBF-Fo.js} +5 -5
  96. package/web/_astro/{journeyDiagram-JHISSGLW.DPbJI_n2.js → journeyDiagram-JHISSGLW.C_iymSyp.js} +1 -1
  97. package/web/_astro/{kanban-definition-UN3LZRKU.EFxhQ9Fj.js → kanban-definition-UN3LZRKU.DdfW-Oqt.js} +7 -7
  98. package/web/_astro/{linear.DSAsQLzs.js → linear.C2_IkbZT.js} +1 -1
  99. package/web/_astro/mermaid.core.GAOYeSR0.js +303 -0
  100. package/web/_astro/{mindmap-definition-RKZ34NQL.CJY1N_7V.js → mindmap-definition-RKZ34NQL.DAZIxQSK.js} +2 -2
  101. package/web/_astro/{pieDiagram-4H26LBE5.567ZNoL2.js → pieDiagram-4H26LBE5.CN8sIhKM.js} +3 -3
  102. package/web/_astro/{quadrantDiagram-W4KKPZXB.mqfz9-MY.js → quadrantDiagram-W4KKPZXB.3dGcX5GP.js} +1 -1
  103. package/web/_astro/{requirementDiagram-4Y6WPE33.Bv1Gv9In.js → requirementDiagram-4Y6WPE33.BV2y4dd6.js} +3 -3
  104. package/web/_astro/{sankeyDiagram-5OEKKPKP.B6Gs4X4r.js → sankeyDiagram-5OEKKPKP.Cqo15Tvo.js} +4 -4
  105. package/web/_astro/{sequenceDiagram-3UESZ5HK.BhYj4v-m.js → sequenceDiagram-3UESZ5HK.CROCPMJB.js} +1 -1
  106. package/web/_astro/{stateDiagram-AJRCARHV.BPbBnkpw.js → stateDiagram-AJRCARHV.RfXZrkFE.js} +1 -1
  107. package/web/_astro/{stateDiagram-v2-BHNVJYJU.C4squMNK.js → stateDiagram-v2-BHNVJYJU.CPXmbBs9.js} +1 -1
  108. package/web/_astro/{timeline-definition-PNZ67QCA.C_SwIHgl.js → timeline-definition-PNZ67QCA.DdgKTiO8.js} +3 -3
  109. package/web/_astro/{vennDiagram-CIIHVFJN.Bz4NZGpQ.js → vennDiagram-CIIHVFJN.CPNVSHF1.js} +5 -5
  110. package/web/_astro/{wardleyDiagram-YWT4CUSO.CozMVZ3i.js → wardleyDiagram-YWT4CUSO.CQhA0Jyr.js} +3 -3
  111. package/web/_astro/{xychartDiagram-2RQKCTM6.BwMGBwjB.js → xychartDiagram-2RQKCTM6.n61BWyy4.js} +1 -1
  112. package/web/index.html +2 -2
  113. package/web/_astro/BoardApp.BEDWpzsr.js +0 -188
  114. package/web/_astro/BoardApp.DQG2xfEz.js +0 -1
  115. package/web/_astro/arc.C0rrflm_.js +0 -1
  116. package/web/_astro/channel.SRrg1P-w.js +0 -1
  117. package/web/_astro/diagram-2AECGRRQ.DJ0h9zgw.js +0 -43
  118. package/web/_astro/diagram-5GNKFQAL.DvPk1jYd.js +0 -10
  119. package/web/_astro/index.Bx6GY4RH.css +0 -1
  120. package/web/_astro/mermaid.core.kAZjgJHG.js +0 -301
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.91",
3
+ "version": "0.3.92",
4
4
  "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
5
5
  "extensions": {
6
6
  "pi": ["./hooks/pi/guard-extension.ts"]
@@ -24,7 +24,7 @@ field named by each column — never as a history-anatomy `category` (that vocab
24
24
  | automated checks | `reliability` | `automated-checks` | `environment`; action is a gate, not prose |
25
25
  | coding standards | `workflow` | `coding-standards` | `environment`; owner surface is review, never implementer |
26
26
  | AGENTS.md placement | `workflow` | `agents-md-placement` | `environment`; action = move to skill/reference/check |
27
- | tool economy | `performance` | `tool-economy` | existing cache-health P3; `environment` when the tool itself is the waste |
27
+ | tool economy | `performance` | `tool-economy` | existing evidence-based reuse-observation P3 (task 0913); `environment` when the tool itself is the waste |
28
28
  | no-ops | `workflow` | `no-ops` | `environment`; `file:line` of the dead instruction |
29
29
  | information access | `telemetry` | `information-access` | existing chained-step `~unknown` P3; `environment` when access is missing |
30
30
 
@@ -25,6 +25,135 @@ function declaredExecutedSteps(markdown) {
25
25
  const match = markdown.match(/\*\*Steps:\*\*\s*\d+\s+derived,\s*(\d+)\s+executed/);
26
26
  return match ? Number.parseInt(match[1], 10) : null;
27
27
  }
28
+ var UNKNOWN_CELLS = new Set(["~unknown", "unknown", "—", "-", "?", "n/a", "~n/a"]);
29
+ var CACHE_SHARE_TOLERANCE = 1;
30
+ function sectionBody(markdown, headingRe) {
31
+ const heading = markdown.match(headingRe);
32
+ if (!heading || heading.index === undefined)
33
+ return null;
34
+ const after = markdown.slice(heading.index + heading[0].length);
35
+ const nextHeading = after.search(/^###? /m);
36
+ return nextHeading === -1 ? after : after.slice(0, nextHeading);
37
+ }
38
+ function parseTokensLine(line) {
39
+ const match = line.match(/~(?<total>n\/a|\d+)\s+total\s*\|\s*~(?<cached>n\/a|\d+)\s+cached(?:\s*\(\s*~?(?<pct>n\/a|\d+(?:\.\d+)?)%\s*hit rate\))?/i);
40
+ if (!match)
41
+ return null;
42
+ const num = (v) => v === undefined ? null : v === "n/a" ? "n/a" : Number.parseInt(v, 10);
43
+ return { total: num(match.groups?.total), cached: num(match.groups?.cached), pct: num(match.groups?.pct) };
44
+ }
45
+ function parseTokenCell(cell) {
46
+ const t = cell.trim();
47
+ if (UNKNOWN_CELLS.has(t))
48
+ return null;
49
+ const m = t.match(/~?(\d+)/);
50
+ return m ? Number.parseInt(m[1], 10) : null;
51
+ }
52
+ function parsePctCell(cell) {
53
+ const t = cell.trim();
54
+ if (UNKNOWN_CELLS.has(t))
55
+ return null;
56
+ const m = t.match(/(\d+(?:\.\d+)?)\s*%/);
57
+ return m ? Number.parseFloat(m[1]) : null;
58
+ }
59
+ function ledgerCostRows(markdown) {
60
+ const body = sectionBody(markdown, /^### 3\. Monitor Ledger\s*$/m);
61
+ if (body === null)
62
+ return [];
63
+ return body.split(`
64
+ `).filter((line) => line.trim().startsWith("|")).filter((line) => !/^\|[\s:|-]+\|?\s*$/.test(line.trim())).filter((line) => !/^\|\s*`?drift:/.test(line.trim())).slice(1).map((line) => {
65
+ const cells = line.split("|").map((c) => c.trim()).filter((c, i, a) => !(i === 0 && c === "") && !(i === a.length - 1 && c === ""));
66
+ const freshCell = cells[5] ?? "";
67
+ const cachedCell = cells[6] ?? "";
68
+ return {
69
+ step: cells[0] ?? "",
70
+ fresh: parseTokenCell(freshCell),
71
+ cached: parseTokenCell(cachedCell),
72
+ cachePct: parsePctCell(cells[7] ?? ""),
73
+ unknownCell: UNKNOWN_CELLS.has(freshCell) || UNKNOWN_CELLS.has(cachedCell)
74
+ };
75
+ });
76
+ }
77
+ function validateCostEvidence(markdown, errors) {
78
+ const costBlock = sectionBody(markdown, /^#### Cost\s*$/m);
79
+ if (costBlock === null) {
80
+ errors.push("missing_cost_block");
81
+ return;
82
+ }
83
+ for (const field of ["Ledger estimate", "Method", "Meter"]) {
84
+ if (!costBlock.includes(`**${field}:**`))
85
+ errors.push(`missing_cost_field:${field}`);
86
+ }
87
+ const ledgerLine = costBlock.split(`
88
+ `).find((l) => l.includes("**Ledger estimate:**")) ?? "";
89
+ const costTotals = parseTokensLine(ledgerLine);
90
+ if (ledgerLine !== "" && costTotals === null)
91
+ errors.push("malformed_cost_line");
92
+ const footerLine = markdown.split(`
93
+ `).find((l) => l.trim().startsWith("Tokens:"));
94
+ const footerTotals = footerLine ? parseTokensLine(footerLine) : null;
95
+ if (footerLine !== undefined && footerTotals === null)
96
+ errors.push("malformed_footer");
97
+ if (footerLine === undefined && markdown.includes("── Dogfood Summary ──")) {
98
+ errors.push("missing_footer_tokens");
99
+ }
100
+ const pctScopes = [costBlock, footerLine ?? ""];
101
+ for (const row of ledgerCostRows(markdown)) {
102
+ if (row.cachePct !== null)
103
+ pctScopes.push(`${row.cachePct}%`);
104
+ }
105
+ for (const scope of pctScopes) {
106
+ for (const m of scope.matchAll(/(\d+(?:\.\d+)?)\s*%/g)) {
107
+ const pct = Number.parseFloat(m[1]);
108
+ if (pct < 0 || pct > 100)
109
+ errors.push(`impossible_percentage:${m[0].trim()}`);
110
+ }
111
+ }
112
+ const rows = ledgerCostRows(markdown);
113
+ for (const row of rows) {
114
+ if (row.unknownCell && row.cachePct !== null)
115
+ errors.push("unknown_row_with_numeric_cache");
116
+ }
117
+ for (const row of rows) {
118
+ if (row.fresh === null || row.cached === null || row.cachePct === null)
119
+ continue;
120
+ const basis = row.fresh + row.cached;
121
+ if (basis === 0)
122
+ continue;
123
+ const expected = Math.round(row.cached / basis * 100);
124
+ if (Math.abs(row.cachePct - expected) > CACHE_SHARE_TOLERANCE) {
125
+ errors.push(`cache_pct_mismatch:${row.step}_expected_${expected}`);
126
+ }
127
+ }
128
+ const observable = rows.filter((r) => r.fresh !== null && r.cached !== null);
129
+ const sumFresh = observable.reduce((acc, r) => acc + (r.fresh ?? 0), 0);
130
+ const sumCached = observable.reduce((acc, r) => acc + (r.cached ?? 0), 0);
131
+ const checkTotals = (label, totals) => {
132
+ if (totals === null)
133
+ return;
134
+ if (observable.length > 0) {
135
+ const expectedTotal = sumFresh + sumCached;
136
+ if (typeof totals.total === "number" && totals.total !== expectedTotal) {
137
+ errors.push(`cost_total_mismatch:${label}_expected_${expectedTotal}`);
138
+ }
139
+ if (typeof totals.cached === "number" && totals.cached !== sumCached) {
140
+ errors.push(`cost_total_mismatch:${label}_cached_expected_${sumCached}`);
141
+ }
142
+ const expectedPct = expectedTotal > 0 ? Math.round(sumCached / expectedTotal * 100) : null;
143
+ if (expectedPct !== null) {
144
+ if (totals.pct === "n/a" || totals.pct === null) {
145
+ errors.push(`missing_cache_share:${label}`);
146
+ } else if (typeof totals.pct === "number" && Math.abs(totals.pct - expectedPct) > CACHE_SHARE_TOLERANCE) {
147
+ errors.push(`cache_share_mismatch:${label}_expected_${expectedPct}`);
148
+ }
149
+ }
150
+ } else if (typeof totals.total === "number" || typeof totals.cached === "number" || typeof totals.pct === "number") {
151
+ errors.push(`fabricated_cache_share:${label}`);
152
+ }
153
+ };
154
+ checkTotals("cost", costTotals);
155
+ checkTotals("footer", footerTotals);
156
+ }
28
157
  function validateReport(markdown) {
29
158
  const errors = [];
30
159
  if (!markdown.includes("── Dogfood Summary ──"))
@@ -56,6 +185,7 @@ function validateReport(markdown) {
56
185
  errors.push("ledger_cardinality");
57
186
  }
58
187
  }
188
+ validateCostEvidence(markdown, errors);
59
189
  return { ok: errors.length === 0, errors };
60
190
  }
61
191
  function parseValidateCliArgs(argv) {
@@ -83,6 +213,12 @@ Exit codes:
83
213
  2 validation failed (errors on stdout / --json)
84
214
  1 usage error
85
215
 
216
+ Cost evidence (task 0913): a complete report must carry the Cost block
217
+ (Ledger estimate / Method / Meter), percentages within 0..100, totals equal to
218
+ the ledger sums of observable rows (fresh + cached), and 'n/a' (never a
219
+ fabricated number) when no observable rows exist. Display-rounding tolerance
220
+ for the cache share is ±1 percentage point.
221
+
86
222
  Phase 4 finalize MUST run this before status: complete (task 0278 R6).
87
223
  On exit 2: set status: aborted and list error codes under #### Unresolved.`;
88
224
  function runValidateCli(argv, readFile) {
@@ -5,8 +5,13 @@
5
5
  * codes, callable from tests and by agents finalizing a run. It validates the
6
6
  * *complete-report* shape: the six unique section headings, Issues subheads, the
7
7
  * mandatory summary footer with both delivery paths, frontmatter protocol string,
8
- * and ledger↔declared-steps cardinality. Aborted/partial reports are out of scope —
9
- * they legitimately lack the footer and Steps line.
8
+ * ledger↔declared-steps cardinality, and the Cost evidence contract (task 0913):
9
+ * a complete report must carry a Cost block (Ledger estimate / Method / Meter),
10
+ * percentages within 0..100, observable token totals consistent with the ledger
11
+ * row sums (fresh + cached), and no fabricated share when no observable rows
12
+ * exist. Unknown cells are never folded into zero; all-unknown evidence must
13
+ * render `n/a`. Aborted/partial reports remain out of scope — they legitimately
14
+ * lack the footer and Steps line.
10
15
  *
11
16
  * CLI (task 0278 R6): `bun …/validate-report.ts --file <report.md> [--json]`
12
17
  */
@@ -49,6 +54,185 @@ function declaredExecutedSteps(markdown: string): number | null {
49
54
  return match ? Number.parseInt(match[1], 10) : null;
50
55
  }
51
56
 
57
+ // ── Cost evidence contract (task 0913, R2) ──────────────────────────────────
58
+
59
+ const UNKNOWN_CELLS = new Set(['~unknown', 'unknown', '—', '-', '?', 'n/a', '~n/a']);
60
+
61
+ /** Display-rounding tolerance: a whole-percent share may differ from the exact
62
+ * value by at most 1 point (e.g. 700/2100 = 33.3% may display as 33%). */
63
+ const CACHE_SHARE_TOLERANCE = 1;
64
+
65
+ function sectionBody(markdown: string, headingRe: RegExp): string | null {
66
+ const heading = markdown.match(headingRe);
67
+ if (!heading || heading.index === undefined) return null;
68
+ const after = markdown.slice(heading.index + heading[0].length);
69
+ const nextHeading = after.search(/^###? /m);
70
+ return nextHeading === -1 ? after : after.slice(0, nextHeading);
71
+ }
72
+
73
+ interface CostTotals {
74
+ total: number | 'n/a' | null;
75
+ cached: number | 'n/a' | null;
76
+ pct: number | 'n/a' | null;
77
+ }
78
+
79
+ function parseTokensLine(line: string): CostTotals | null {
80
+ const match = line.match(
81
+ /~(?<total>n\/a|\d+)\s+total\s*\|\s*~(?<cached>n\/a|\d+)\s+cached(?:\s*\(\s*~?(?<pct>n\/a|\d+(?:\.\d+)?)%\s*hit rate\))?/i,
82
+ );
83
+ if (!match) return null;
84
+ const num = (v: string | undefined): number | 'n/a' | null =>
85
+ v === undefined ? null : v === 'n/a' ? 'n/a' : Number.parseInt(v, 10);
86
+ return { total: num(match.groups?.total), cached: num(match.groups?.cached), pct: num(match.groups?.pct) };
87
+ }
88
+
89
+ interface LedgerCostRow {
90
+ step: string;
91
+ fresh: number | null;
92
+ cached: number | null;
93
+ cachePct: number | null;
94
+ unknownCell: boolean;
95
+ }
96
+
97
+ function parseTokenCell(cell: string): number | null {
98
+ const t = cell.trim();
99
+ if (UNKNOWN_CELLS.has(t)) return null;
100
+ const m = t.match(/~?(\d+)/);
101
+ return m ? Number.parseInt(m[1], 10) : null;
102
+ }
103
+
104
+ function parsePctCell(cell: string): number | null {
105
+ const t = cell.trim();
106
+ if (UNKNOWN_CELLS.has(t)) return null;
107
+ const m = t.match(/(\d+(?:\.\d+)?)\s*%/);
108
+ return m ? Number.parseFloat(m[1]) : null;
109
+ }
110
+
111
+ function ledgerCostRows(markdown: string): LedgerCostRow[] {
112
+ const body = sectionBody(markdown, /^### 3\. Monitor Ledger\s*$/m);
113
+ if (body === null) return [];
114
+ return body
115
+ .split('\n')
116
+ .filter((line) => line.trim().startsWith('|'))
117
+ .filter((line) => !/^\|[\s:|-]+\|?\s*$/.test(line.trim()))
118
+ .filter((line) => !/^\|\s*`?drift:/.test(line.trim()))
119
+ .slice(1) // drop header row
120
+ .map((line) => {
121
+ const cells = line
122
+ .split('|')
123
+ .map((c) => c.trim())
124
+ .filter((c, i, a) => !(i === 0 && c === '') && !(i === a.length - 1 && c === ''));
125
+ // Columns: 0 Step … 5 Fresh, 6 Cached, 7 Cache %
126
+ const freshCell = cells[5] ?? '';
127
+ const cachedCell = cells[6] ?? '';
128
+ return {
129
+ step: cells[0] ?? '',
130
+ fresh: parseTokenCell(freshCell),
131
+ cached: parseTokenCell(cachedCell),
132
+ cachePct: parsePctCell(cells[7] ?? ''),
133
+ unknownCell: UNKNOWN_CELLS.has(freshCell) || UNKNOWN_CELLS.has(cachedCell),
134
+ };
135
+ });
136
+ }
137
+
138
+ function validateCostEvidence(markdown: string, errors: string[]): void {
139
+ const costBlock = sectionBody(markdown, /^#### Cost\s*$/m);
140
+ if (costBlock === null) {
141
+ errors.push('missing_cost_block');
142
+ return;
143
+ }
144
+ for (const field of ['Ledger estimate', 'Method', 'Meter']) {
145
+ if (!costBlock.includes(`**${field}:**`)) errors.push(`missing_cost_field:${field}`);
146
+ }
147
+
148
+ const ledgerLine = costBlock.split('\n').find((l) => l.includes('**Ledger estimate:**')) ?? '';
149
+ const costTotals = parseTokensLine(ledgerLine);
150
+ // A label present but unparseable is its own defect (task 0913 review P3-1): report it and
151
+ // keep validating — an early return here disabled every downstream cost check, including
152
+ // the per-row impossible-percentage scan. An absent label is already reported above as
153
+ // missing_cost_field; with no line to parse there is nothing further to check for it.
154
+ if (ledgerLine !== '' && costTotals === null) errors.push('malformed_cost_line');
155
+ const footerLine = markdown.split('\n').find((l) => l.trim().startsWith('Tokens:'));
156
+ const footerTotals = footerLine ? parseTokensLine(footerLine) : null;
157
+ // Same contract for the footer: a present-but-unparseable Tokens: line must not
158
+ // silently skip the footer totals comparison. An absent Tokens: line with the
159
+ // summary block present is its own defect (task 0913 verify advisory).
160
+ if (footerLine !== undefined && footerTotals === null) errors.push('malformed_footer');
161
+ if (footerLine === undefined && markdown.includes('── Dogfood Summary ──')) {
162
+ errors.push('missing_footer_tokens');
163
+ }
164
+
165
+ // Percentages must be within 0..100 wherever cost evidence renders one.
166
+ const pctScopes = [costBlock, footerLine ?? ''];
167
+ for (const row of ledgerCostRows(markdown)) {
168
+ if (row.cachePct !== null) pctScopes.push(`${row.cachePct}%`);
169
+ }
170
+ for (const scope of pctScopes) {
171
+ for (const m of scope.matchAll(/(\d+(?:\.\d+)?)\s*%/g)) {
172
+ const pct = Number.parseFloat(m[1]);
173
+ if (pct < 0 || pct > 100) errors.push(`impossible_percentage:${m[0].trim()}`);
174
+ }
175
+ }
176
+
177
+ const rows = ledgerCostRows(markdown);
178
+
179
+ // Unknown cells are never folded into a numeric share: a row whose basis is
180
+ // unknown must carry `—`, never a computed percent.
181
+ for (const row of rows) {
182
+ if (row.unknownCell && row.cachePct !== null) errors.push('unknown_row_with_numeric_cache');
183
+ }
184
+
185
+ // Per-row arithmetic: a numeric Cache % must match the row's own fresh/cached cells
186
+ // (±1pt display rounding), not merely the aggregate share (task 0913 verify advisory).
187
+ for (const row of rows) {
188
+ if (row.fresh === null || row.cached === null || row.cachePct === null) continue;
189
+ const basis = row.fresh + row.cached;
190
+ if (basis === 0) continue;
191
+ const expected = Math.round((row.cached / basis) * 100);
192
+ if (Math.abs(row.cachePct - expected) > CACHE_SHARE_TOLERANCE) {
193
+ errors.push(`cache_pct_mismatch:${row.step}_expected_${expected}`);
194
+ }
195
+ }
196
+
197
+ const observable = rows.filter((r) => r.fresh !== null && r.cached !== null);
198
+ const sumFresh = observable.reduce((acc, r) => acc + (r.fresh ?? 0), 0);
199
+ const sumCached = observable.reduce((acc, r) => acc + (r.cached ?? 0), 0);
200
+
201
+ const checkTotals = (label: string, totals: CostTotals | null): void => {
202
+ if (totals === null) return;
203
+ if (observable.length > 0) {
204
+ const expectedTotal = sumFresh + sumCached;
205
+ if (typeof totals.total === 'number' && totals.total !== expectedTotal) {
206
+ errors.push(`cost_total_mismatch:${label}_expected_${expectedTotal}`);
207
+ }
208
+ if (typeof totals.cached === 'number' && totals.cached !== sumCached) {
209
+ errors.push(`cost_total_mismatch:${label}_cached_expected_${sumCached}`);
210
+ }
211
+ const expectedPct = expectedTotal > 0 ? Math.round((sumCached / expectedTotal) * 100) : null;
212
+ if (expectedPct !== null) {
213
+ if (totals.pct === 'n/a' || totals.pct === null) {
214
+ errors.push(`missing_cache_share:${label}`);
215
+ } else if (
216
+ typeof totals.pct === 'number' &&
217
+ Math.abs(totals.pct - expectedPct) > CACHE_SHARE_TOLERANCE
218
+ ) {
219
+ errors.push(`cache_share_mismatch:${label}_expected_${expectedPct}`);
220
+ }
221
+ }
222
+ } else if (
223
+ typeof totals.total === 'number' ||
224
+ typeof totals.cached === 'number' ||
225
+ typeof totals.pct === 'number'
226
+ ) {
227
+ // No observable denominator: numeric totals/shares fabricate evidence
228
+ // from unknown rows (unknown folded into zero). `n/a` is the honest form.
229
+ errors.push(`fabricated_cache_share:${label}`);
230
+ }
231
+ };
232
+ checkTotals('cost', costTotals);
233
+ checkTotals('footer', footerTotals);
234
+ }
235
+
52
236
  export function validateReport(markdown: string): ReportValidation {
53
237
  const errors: string[] = [];
54
238
 
@@ -86,6 +270,10 @@ export function validateReport(markdown: string): ReportValidation {
86
270
  }
87
271
  }
88
272
 
273
+ // Cost evidence (task 0913, R2) — required block, arithmetic consistency,
274
+ // honest unknowns.
275
+ validateCostEvidence(markdown, errors);
276
+
89
277
  return { ok: errors.length === 0, errors };
90
278
  }
91
279
 
@@ -119,6 +307,12 @@ Exit codes:
119
307
  2 validation failed (errors on stdout / --json)
120
308
  1 usage error
121
309
 
310
+ Cost evidence (task 0913): a complete report must carry the Cost block
311
+ (Ledger estimate / Method / Meter), percentages within 0..100, totals equal to
312
+ the ledger sums of observable rows (fresh + cached), and 'n/a' (never a
313
+ fabricated number) when no observable rows exist. Display-rounding tolerance
314
+ for the cache share is ±1 percentage point.
315
+
122
316
  Phase 4 finalize MUST run this before status: complete (task 0278 R6).
123
317
  On exit 2: set status: aborted and list error codes under #### Unresolved.`;
124
318
 
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // plugins/sp/scripts/feature-verification-steps.ts
5
+ import { spawnSync } from "child_process";
6
+ import {
7
+ closeSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ openSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ renameSync,
14
+ writeFileSync
15
+ } from "fs";
16
+ import { join } from "path";
17
+ import { fileURLToPath } from "url";
18
+
19
+ // plugins/sp/lib/env.ts
20
+ function getEnvVar(name, fallback) {
21
+ const raw = process.env[name];
22
+ return raw === undefined ? fallback : raw;
23
+ }
24
+
25
+ // plugins/sp/scripts/feature-verification-steps.ts
26
+ var SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
27
+ function bundleEntry() {
28
+ return fileURLToPath(new URL("../lib/inline-run.generated.mjs", import.meta.url));
29
+ }
30
+ function sourceModeError(spurBin) {
31
+ return new Error(`source mode: refusing to load packages/app/src/index.ts (spurBin=${JSON.stringify(spurBin)}). ` + "That entry is not the feature-verification contract surface \u2014 it does not export " + "splitLaunchCommand / ArtifactDao. Fix: run in bundle mode so " + "plugins/sp/lib/inline-run.generated.mjs is loaded (omit --module-mode, or pass --module-mode bundle) " + "and regenerate that bundle if it is missing. Reinstalling the sp plugin does not add these seams to the app source entry.");
32
+ }
33
+ function resolveModuleMode(raw) {
34
+ if (raw === "" || raw === "bundle")
35
+ return "bundle";
36
+ if (raw === "source")
37
+ return "source";
38
+ throw new Error(`unknown --module-mode ${JSON.stringify(raw)}; expected "source" or "bundle"`);
39
+ }
40
+ async function loadModule(spurBin, mode) {
41
+ if (mode === "source")
42
+ throw sourceModeError(spurBin);
43
+ const bundle = bundleEntry();
44
+ if (!existsSync(bundle)) {
45
+ throw new Error(`feature-verification-steps: mode=bundle \u2014 the generated inline bundle is missing at ${bundle}` + `${spurBin === "" ? "" : ` (spurBin=${spurBin})`}. ` + "Fix: in a source checkout run `bun run build:bundle`; for an installed plugin reinstall it.");
46
+ }
47
+ return requireModule(bundle, mode);
48
+ }
49
+ async function requireModule(entry, mode) {
50
+ const mod = await import(entry);
51
+ const missing = [
52
+ "startFeatureVerificationReceipt",
53
+ "completeFeatureVerificationReceipt",
54
+ "captureFeatureReceiptDigest",
55
+ "resolveWorkflowDefinition",
56
+ "splitLaunchCommand",
57
+ "openInlineRunProjectDb",
58
+ "ArtifactDao"
59
+ ].filter((k) => typeof mod[k] !== "function");
60
+ if (missing.length > 0) {
61
+ throw new Error(`feature-verification-steps: mode=${mode} \u2014 application entry ${entry} is missing feature-verification seams (${missing.join(", ")}). ` + "Fix: the generated inline bundle is stale or incomplete \u2014 run `bun run build:bundle` in a source checkout, or reinstall the sp plugin.");
62
+ }
63
+ return mod;
64
+ }
65
+ function assertSafeId(kind, id) {
66
+ if (!SAFE_ID_RE.test(id) || id.includes("..")) {
67
+ throw new Error(`refusing unsafe ${kind}: ${id}`);
68
+ }
69
+ }
70
+ function findFeatureFile(featureDir, featureId) {
71
+ if (!existsSync(featureDir))
72
+ return;
73
+ const name = readdirSync(featureDir).find((n) => n.startsWith(`${featureId}_`) && n.endsWith(".md"));
74
+ return name === undefined ? undefined : join(featureDir, name);
75
+ }
76
+ var nodeFsShim = {
77
+ ensureDir: (dir) => {
78
+ mkdirSync(dir, { recursive: true });
79
+ },
80
+ writeFile: (path, body) => {
81
+ writeFileSync(path, body);
82
+ },
83
+ rename: (src, dest) => {
84
+ renameSync(src, dest);
85
+ },
86
+ readFile: (path) => {
87
+ return readFileSync(path, "utf8");
88
+ }
89
+ };
90
+ async function verify(featureId, runId, cmdOverride, spurBin, mode) {
91
+ assertSafeId("feature id", featureId);
92
+ assertSafeId("run id", runId);
93
+ const cwd = process.cwd();
94
+ const mod = await loadModule(spurBin, mode);
95
+ const runDir = join(cwd, ".spur", "run");
96
+ mkdirSync(runDir, { recursive: true });
97
+ const selected = await mod.resolveWorkflowDefinition(cwd, "feature-verification");
98
+ const vars = selected.workflow.vars;
99
+ const configuredCmd = typeof vars?.verificationCmd === "string" && vars.verificationCmd.length > 0 ? vars.verificationCmd : "bun run spur-check-feature";
100
+ const effectiveCmd = cmdOverride !== "" ? cmdOverride : configuredCmd;
101
+ const featureFile = findFeatureFile(join(cwd, "docs", "features"), featureId);
102
+ if (featureFile === undefined) {
103
+ throw new Error(`feature ${featureId} not found under ${join(cwd, "docs", "features")}`);
104
+ }
105
+ const featureContent = readFileSync(featureFile, "utf8");
106
+ const learningsPath = join(cwd, ".spur", "context", "learnings.md");
107
+ const learningsContent = existsSync(learningsPath) ? readFileSync(learningsPath, "utf8") : undefined;
108
+ const beforeDigest = await mod.captureFeatureReceiptDigest(cwd, featureContent, learningsContent);
109
+ const receipt = await mod.startFeatureVerificationReceipt(nodeFsShim, runDir, {
110
+ featureId,
111
+ runId,
112
+ workdir: cwd,
113
+ verifier: {
114
+ name: "feature-verification",
115
+ sourcePath: selected.path,
116
+ layer: selected.layer,
117
+ definitionDigest: selected.digest
118
+ },
119
+ verificationCmd: effectiveCmd,
120
+ inputDigest: beforeDigest
121
+ });
122
+ const db = await mod.openInlineRunProjectDb(cwd);
123
+ try {
124
+ await new mod.ArtifactDao(db.adapter).record({
125
+ runId,
126
+ path: join(runDir, `${runId}-feature-verification.json`),
127
+ kind: "feature-verification"
128
+ });
129
+ } finally {
130
+ db.close();
131
+ }
132
+ const logPath = join(runDir, `${runId}-feature-verification.log`);
133
+ const launch = mod.splitLaunchCommand(effectiveCmd, "feature-verification verificationCmd");
134
+ if ("error" in launch) {
135
+ await mod.completeFeatureVerificationReceipt(nodeFsShim, runDir, receipt, {
136
+ status: "FAIL",
137
+ inputDigest: beforeDigest
138
+ });
139
+ console.log(`feature-verification-steps: ${featureId} verification FAIL (${launch.error})`);
140
+ return;
141
+ }
142
+ const logFd = openSync(logPath, "a");
143
+ try {
144
+ const result = spawnSync(launch.command, launch.leadingArgs, {
145
+ cwd,
146
+ stdio: ["ignore", logFd, logFd],
147
+ timeout: 14400000
148
+ });
149
+ const exit = result.status ?? 1;
150
+ const afterDigest = await mod.captureFeatureReceiptDigest(cwd, featureContent, learningsContent);
151
+ const status = exit === 0 && afterDigest === beforeDigest ? "PASS" : "FAIL";
152
+ await mod.completeFeatureVerificationReceipt(nodeFsShim, runDir, receipt, { status, inputDigest: afterDigest });
153
+ console.log(`feature-verification-steps: ${featureId} verification ${status} (run ${runId}, log ${logPath})`);
154
+ } finally {
155
+ closeSync(logFd);
156
+ }
157
+ }
158
+ function main() {
159
+ const args = process.argv.slice(2);
160
+ const command = args[0];
161
+ const flag = (name) => {
162
+ const i = args.indexOf(name);
163
+ return i >= 0 && i + 1 < args.length ? args[i + 1] ?? "" : "";
164
+ };
165
+ if (command !== "verify") {
166
+ console.error("Usage: feature-verification-steps.ts verify --feature-id <id> --run-id <id> [--cmd <command>]");
167
+ process.exit(2);
168
+ }
169
+ verify(flag("--feature-id") || getEnvVar("featureId") || "", flag("--run-id") || getEnvVar("__runId") || "", flag("--cmd") || getEnvVar("verificationCmd") || "", flag("--spur-bin") || getEnvVar("spurBin") || "", resolveModuleMode(flag("--module-mode"))).then(() => process.exit(0)).catch((err) => {
170
+ console.error(`feature-verification-steps: ${String(err)}`);
171
+ process.exit(1);
172
+ });
173
+ }
174
+ main();