@gobing-ai/spur 0.3.95 → 0.3.97

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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/plugin-scripts.json +0 -54
  3. package/config/rules/boundary/sp-script-placement.yaml +19 -0
  4. package/config/rules/strict/runtime-boundaries.yaml +6 -0
  5. package/config/rules/structure/test-location.yaml +2 -0
  6. package/config/rules/typescript/no-syscall-emulation-in-boundary-mock.yaml +1 -1
  7. package/config/rules/typescript/output-boundaries.yaml +15 -2
  8. package/config/script-placement-baseline.json +51 -0
  9. package/config/templates/AGENTS.md +3 -3
  10. package/config/workflows/feature-verification.yaml +1 -1
  11. package/config/workflows/history-anatomy.yaml +23 -11
  12. package/config/workflows/idea-pipeline.yaml +80 -85
  13. package/config/workflows/pr-review.yaml +18 -9
  14. package/config/workflows/task-pipeline.yaml +42 -95
  15. package/config/workflows/wrapup-pipeline.yaml +91 -38
  16. package/package.json +2 -2
  17. package/plugins/sp/README.md +14 -10
  18. package/plugins/sp/agents/super-reviewer.md +43 -18
  19. package/plugins/sp/commands/dev-fixgha.md +83 -0
  20. package/plugins/sp/commands/dev-gitmsg.md +8 -6
  21. package/plugins/sp/commands/dev-gtd.md +2 -2
  22. package/plugins/sp/commands/dev-idea.md +22 -12
  23. package/plugins/sp/commands/dev-job-dump.md +29 -0
  24. package/plugins/sp/commands/dev-job-resume.md +29 -0
  25. package/plugins/sp/commands/dev-plan.md +8 -9
  26. package/plugins/sp/commands/dev-review.md +22 -13
  27. package/plugins/sp/commands/dev-verifyall.md +1 -1
  28. package/plugins/sp/commands/spur-init.md +2 -2
  29. package/plugins/sp/lib/history-anatomy.generated.d.mts +112 -0
  30. package/plugins/sp/lib/history-anatomy.generated.mjs +686 -0
  31. package/plugins/sp/lib/idea-handoff.generated.mjs +5 -4
  32. package/plugins/sp/lib/inline-run.generated.d.mts +11 -0
  33. package/plugins/sp/lib/inline-run.generated.mjs +39 -13
  34. package/plugins/sp/lib/quality-gate.generated.d.mts +104 -0
  35. package/plugins/sp/lib/quality-gate.generated.mjs +445 -0
  36. package/plugins/sp/lib/residual-scan.generated.d.mts +63 -0
  37. package/plugins/sp/lib/residual-scan.generated.mjs +226 -0
  38. package/plugins/sp/lib/spur-bin.ts +36 -0
  39. package/plugins/sp/lib/step-profile.generated.d.mts +71 -0
  40. package/plugins/sp/lib/step-profile.generated.mjs +174 -0
  41. package/plugins/sp/plugin.json +1 -1
  42. package/plugins/sp/references/roles.md +3 -3
  43. package/plugins/sp/scripts/feature-verification-steps.mjs +14 -1
  44. package/plugins/sp/scripts/feature-verification-steps.ts +14 -1
  45. package/plugins/sp/scripts/history-anatomy-cache.mjs +20 -19
  46. package/plugins/sp/scripts/history-anatomy-cache.ts +23 -928
  47. package/plugins/sp/scripts/inline-run-setup.mjs +85 -320
  48. package/plugins/sp/scripts/inline-run-setup.ts +104 -667
  49. package/plugins/sp/scripts/quality-gate.mjs +46 -24
  50. package/plugins/sp/scripts/quality-gate.ts +13 -659
  51. package/plugins/sp/scripts/residual-scan.mjs +179 -174
  52. package/plugins/sp/scripts/residual-scan.ts +125 -517
  53. package/plugins/sp/scripts/script-root.mjs +5 -1
  54. package/plugins/sp/scripts/script-root.ts +5 -1
  55. package/plugins/sp/scripts/workflow-step-profile.mjs +25 -17
  56. package/plugins/sp/scripts/workflow-step-profile.ts +21 -315
  57. package/plugins/sp/scripts/wrapup-drift-probe.mjs +8 -2
  58. package/plugins/sp/scripts/wrapup-drift-probe.ts +3 -2
  59. package/plugins/sp/scripts/wrapup-steps.mjs +72 -33
  60. package/plugins/sp/scripts/wrapup-steps.ts +128 -37
  61. package/plugins/sp/skills/code-improvement/SKILL.md +5 -4
  62. package/plugins/sp/skills/code-verification/SKILL.md +34 -9
  63. package/plugins/sp/skills/code-verification/references/verdict-schema.md +3 -3
  64. package/plugins/sp/skills/functional-review/SKILL.md +7 -4
  65. package/plugins/sp/skills/functional-review/references/verdict-schema.md +1 -1
  66. package/plugins/sp/skills/history-anatomy/references/modes.md +2 -1
  67. package/plugins/sp/skills/next-feature/references/handoff-routing.md +1 -1
  68. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  69. package/plugins/sp/skills/source-driven-development/SKILL.md +11 -0
  70. package/plugins/sp/skills/spur-cli/SKILL.md +3 -3
  71. package/plugins/sp/skills/spur-cli/references/agent.md +10 -10
  72. package/plugins/sp/skills/spur-cli/references/features.md +17 -6
  73. package/plugins/sp/skills/spur-cli/references/init.md +17 -16
  74. package/plugins/sp/skills/spur-cli/references/self.md +3 -2
  75. package/plugins/sp/skills/spur-cli/references/serve.md +10 -10
  76. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +9 -5
  77. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +21 -6
  78. package/plugins/sp/skills/spur-cli/references/tasks.md +14 -8
  79. package/plugins/sp/skills/spur-cli/references/workflows.md +17 -14
  80. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  81. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +4 -3
  82. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +14 -10
  83. package/plugins/sp/skills/spur-dev/references/decision-brief.md +1 -1
  84. package/plugins/sp/skills/spur-dev/references/dev-operations.md +165 -75
  85. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +7 -5
  86. package/plugins/sp/skills/spur-dev/references/execution-batch.md +84 -39
  87. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +8 -12
  88. package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +3 -3
  89. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +40 -21
  90. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +19 -19
  91. package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +4 -3
  92. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +72 -15
  93. package/plugins/sp/skills/spur-doctor/SKILL.md +1 -1
  94. package/plugins/sp/skills/sys-architecture/SKILL.md +3 -2
  95. package/spur.js +4461 -2085
  96. package/web/_astro/{BoardApp.Cpxntzad.js → BoardApp.Bmv5WkJ9.js} +1 -1
  97. package/web/_astro/BoardApp.vveLOdDq.js +322 -0
  98. package/web/_astro/{TaskDetail.C5bW4WGV.js → TaskDetail.IKc3uDYV.js} +1 -1
  99. package/web/_astro/{arc.CyjRvNMY.js → arc.C63Ufg35.js} +1 -1
  100. package/web/_astro/{architectureDiagram-3BPJPVTR.B4lWRzJA.js → architectureDiagram-3BPJPVTR.CP8Hyldk.js} +1 -1
  101. package/web/_astro/{blockDiagram-GPEHLZMM.Be38USQ2.js → blockDiagram-GPEHLZMM.Bo_48MRA.js} +1 -1
  102. package/web/_astro/{c4Diagram-AAUBKEIU.DT9Fj5Qx.js → c4Diagram-AAUBKEIU.Ch61sw-O.js} +1 -1
  103. package/web/_astro/channel.BKCmJpWM.js +1 -0
  104. package/web/_astro/{chunk-2J33WTMH.SeSBWLg5.js → chunk-2J33WTMH.CHpzS7xf.js} +1 -1
  105. package/web/_astro/{chunk-4BX2VUAB.DoO4VE14.js → chunk-4BX2VUAB.DzMNNWIw.js} +1 -1
  106. package/web/_astro/{chunk-55IACEB6.DrmTFA53.js → chunk-55IACEB6.BqPrDw_Q.js} +1 -1
  107. package/web/_astro/{chunk-727SXJPM.Bi-TUb_V.js → chunk-727SXJPM.ZzBHhNw-.js} +1 -1
  108. package/web/_astro/{chunk-AQP2D5EJ.B-xp8Uhi.js → chunk-AQP2D5EJ.1_O_LLkx.js} +1 -1
  109. package/web/_astro/{chunk-FMBD7UC4.DGWrDnUU.js → chunk-FMBD7UC4.GS4UHg7_.js} +1 -1
  110. package/web/_astro/{chunk-ND2GUHAM._BagvPDy.js → chunk-ND2GUHAM.BnqotN4g.js} +1 -1
  111. package/web/_astro/{chunk-QZHKN3VN.VOboYWQ0.js → chunk-QZHKN3VN.Ghuh8zoJ.js} +1 -1
  112. package/web/_astro/{classDiagram-4FO5ZUOK.D_apfHS7.js → classDiagram-4FO5ZUOK.BW9vC3zg.js} +1 -1
  113. package/web/_astro/{classDiagram-v2-Q7XG4LA2.D_apfHS7.js → classDiagram-v2-Q7XG4LA2.BW9vC3zg.js} +1 -1
  114. package/web/_astro/{cose-bilkent-S5V4N54A.DNLU_L-x.js → cose-bilkent-S5V4N54A.D6jNC0ti.js} +1 -1
  115. package/web/_astro/{cynefin-OW5HDTMX.D8borhV-.js → cynefin-OW5HDTMX.BOGoCs4L.js} +1 -1
  116. package/web/_astro/{dagre-BM42HDAG.Uc0lvjcV.js → dagre-BM42HDAG.8iU5VcWi.js} +1 -1
  117. package/web/_astro/{diagram-2AECGRRQ.DDIxtDBo.js → diagram-2AECGRRQ.CVt88HwH.js} +1 -1
  118. package/web/_astro/{diagram-5GNKFQAL.DVoDnD3H.js → diagram-5GNKFQAL.C9HxUUMl.js} +1 -1
  119. package/web/_astro/{diagram-KO2AKTUF.CRubTJ-0.js → diagram-KO2AKTUF.4ywkQN3t.js} +1 -1
  120. package/web/_astro/{diagram-LMA3HP47.m9SeYcXQ.js → diagram-LMA3HP47.C48A6gpz.js} +1 -1
  121. package/web/_astro/{diagram-OG6HWLK6.C6s2ZjV5.js → diagram-OG6HWLK6.DxFieQW4.js} +1 -1
  122. package/web/_astro/{erDiagram-TEJ5UH35.nSzr5KTj.js → erDiagram-TEJ5UH35.CtjtQqYs.js} +1 -1
  123. package/web/_astro/{flowDiagram-I6XJVG4X.DrZcys16.js → flowDiagram-I6XJVG4X.C5zAiahU.js} +1 -1
  124. package/web/_astro/{ganttDiagram-6RSMTGT7.Do4M1b-K.js → ganttDiagram-6RSMTGT7.D_L6zsny.js} +1 -1
  125. package/web/_astro/{gitGraphDiagram-PVQCEYII.BWc9oJ4l.js → gitGraphDiagram-PVQCEYII.Ignnrk3V.js} +1 -1
  126. package/web/_astro/index.CbNz17Sx.css +1 -0
  127. package/web/_astro/{infoDiagram-5YYISTIA.BIdOctFk.js → infoDiagram-5YYISTIA.DLv8-P5u.js} +1 -1
  128. package/web/_astro/{ishikawaDiagram-YF4QCWOH.CV5SuG2t.js → ishikawaDiagram-YF4QCWOH.p0OTpwFO.js} +1 -1
  129. package/web/_astro/{journeyDiagram-JHISSGLW.CxE-rEJ-.js → journeyDiagram-JHISSGLW.CIaBt-DD.js} +1 -1
  130. package/web/_astro/{kanban-definition-UN3LZRKU.BPRdKWs_.js → kanban-definition-UN3LZRKU.DGySIntq.js} +1 -1
  131. package/web/_astro/{linear.zRsuuDTE.js → linear.BATV4RXu.js} +1 -1
  132. package/web/_astro/{mermaid.core.jAJTcMKc.js → mermaid.core.DzkwM3VX.js} +4 -4
  133. package/web/_astro/{mindmap-definition-RKZ34NQL.BffJxfCr.js → mindmap-definition-RKZ34NQL.3piBRRzA.js} +1 -1
  134. package/web/_astro/{pieDiagram-4H26LBE5.CacwWaE3.js → pieDiagram-4H26LBE5.DUNueLub.js} +1 -1
  135. package/web/_astro/{quadrantDiagram-W4KKPZXB.hXELD06-.js → quadrantDiagram-W4KKPZXB.9bihVbrt.js} +1 -1
  136. package/web/_astro/{requirementDiagram-4Y6WPE33.DdEbPcqT.js → requirementDiagram-4Y6WPE33.CkjsC-YT.js} +1 -1
  137. package/web/_astro/{sankeyDiagram-5OEKKPKP.DgaDocKv.js → sankeyDiagram-5OEKKPKP.Dbq4vaoQ.js} +1 -1
  138. package/web/_astro/{sequenceDiagram-3UESZ5HK.B0KScnAu.js → sequenceDiagram-3UESZ5HK.BivrH99P.js} +1 -1
  139. package/web/_astro/{stateDiagram-AJRCARHV.CV9M_WNc.js → stateDiagram-AJRCARHV.CqLQ1Vzz.js} +1 -1
  140. package/web/_astro/{stateDiagram-v2-BHNVJYJU.BOAo74Et.js → stateDiagram-v2-BHNVJYJU.DvvoLD3b.js} +1 -1
  141. package/web/_astro/{timeline-definition-PNZ67QCA.CqqepUMe.js → timeline-definition-PNZ67QCA.B-KR78gv.js} +1 -1
  142. package/web/_astro/{vennDiagram-CIIHVFJN.DDqe4dOG.js → vennDiagram-CIIHVFJN.Ck0Eieb5.js} +1 -1
  143. package/web/_astro/{wardleyDiagram-YWT4CUSO.B3R9OPdO.js → wardleyDiagram-YWT4CUSO.CRfdd_do.js} +1 -1
  144. package/web/_astro/{xychartDiagram-2RQKCTM6.B3SvgXpQ.js → xychartDiagram-2RQKCTM6.Ck_Jxk9C.js} +1 -1
  145. package/web/index.html +2 -2
  146. package/plugins/sp/lib/artifact-digest.generated.d.mts +0 -7
  147. package/plugins/sp/lib/artifact-digest.generated.mjs +0 -48
  148. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -301
  149. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -481
  150. package/plugins/sp/scripts/idea-coverage-check.ts +0 -168
  151. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -298
  152. package/plugins/sp/scripts/record-feature-sync.mjs +0 -63
  153. package/plugins/sp/scripts/record-feature-sync.ts +0 -84
  154. package/plugins/sp/scripts/script-contract-check.ts +0 -506
  155. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  156. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -989
  157. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -189
  158. package/plugins/sp/scripts/task-size-precheck.ts +0 -212
  159. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  160. package/plugins/sp/scripts/validate-commands.ts +0 -689
  161. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -890
  162. package/plugins/sp/scripts/verify-answer-lint.ts +0 -549
  163. package/web/_astro/BoardApp.BxJuwD7I.js +0 -191
  164. package/web/_astro/channel.CI6N_tCg.js +0 -1
  165. package/web/_astro/index.CENnIEqT.css +0 -1
@@ -96,7 +96,7 @@ function runScriptRoot(env, options = {}) {
96
96
  return write({ mode: "unresolved", error: `installed digest failed: ${String(error)}` });
97
97
  }
98
98
  }
99
- var SCRIPT_ROOT_USAGE = "usage: script-root --run-id <id>";
99
+ var SCRIPT_ROOT_USAGE = "usage: script-root --run-id <id> [--spur-bin <path>]";
100
100
  function main(argv, env = getEnvVars(), options = {}) {
101
101
  let runId = env.__runId ?? "";
102
102
  for (let i = 0;i < argv.length; i++) {
@@ -105,6 +105,10 @@ function main(argv, env = getEnvVars(), options = {}) {
105
105
  i++;
106
106
  continue;
107
107
  }
108
+ if (argv[i] === "--spur-bin") {
109
+ i++;
110
+ continue;
111
+ }
108
112
  process.stderr.write(`${SCRIPT_ROOT_USAGE}
109
113
  `);
110
114
  return 2;
@@ -174,7 +174,7 @@ export function runScriptRoot(env: ScriptRootEnv, options: ScriptRootOptions = {
174
174
  }
175
175
  }
176
176
 
177
- export const SCRIPT_ROOT_USAGE = 'usage: script-root --run-id <id>';
177
+ export const SCRIPT_ROOT_USAGE = 'usage: script-root --run-id <id> [--spur-bin <path>]';
178
178
 
179
179
  export function main(argv: string[], env: ScriptRootEnv = getEnvVars(), options: ScriptRootOptions = {}): number {
180
180
  let runId = env.__runId ?? '';
@@ -184,6 +184,10 @@ export function main(argv: string[], env: ScriptRootEnv = getEnvVars(), options:
184
184
  i++;
185
185
  continue;
186
186
  }
187
+ if (argv[i] === '--spur-bin') {
188
+ i++;
189
+ continue;
190
+ }
187
191
  process.stderr.write(`${SCRIPT_ROOT_USAGE}\n`);
188
192
  return 2;
189
193
  }
@@ -3,8 +3,10 @@
3
3
 
4
4
  // plugins/sp/scripts/workflow-step-profile.ts
5
5
  import { spawnSync } from "child_process";
6
- import { existsSync } from "fs";
7
- import { fileURLToPath } from "url";
6
+
7
+ // plugins/sp/lib/spur-bin.ts
8
+ import { existsSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
8
10
 
9
11
  // plugins/sp/lib/env.ts
10
12
  function getEnvVar(name, fallback) {
@@ -12,7 +14,22 @@ function getEnvVar(name, fallback) {
12
14
  return raw === undefined ? fallback : raw;
13
15
  }
14
16
 
15
- // plugins/sp/scripts/workflow-step-profile.ts
17
+ // plugins/sp/lib/spur-bin.ts
18
+ function spurCommand(spurBin) {
19
+ const parts = (spurBin ?? "spur").trim().split(/\s+/).filter((p) => p.length > 0);
20
+ return { cmd: parts[0] ?? "spur", prefix: parts.slice(1) };
21
+ }
22
+ function defaultSpurBin() {
23
+ const fromEnv = getEnvVar("SPUR_BIN");
24
+ if (fromEnv)
25
+ return fromEnv;
26
+ const local = fileURLToPath(new URL("../../../apps/cli/src/index.ts", import.meta.url));
27
+ if (existsSync(local))
28
+ return `bun ${local}`;
29
+ return "spur";
30
+ }
31
+
32
+ // plugins/sp/lib/step-profile.generated.mjs
16
33
  var DEFAULT_LAST = 20;
17
34
  var DEFAULT_WINDOW_SEC = 300;
18
35
  function nearestRankP50(values) {
@@ -156,7 +173,7 @@ function ratio(value) {
156
173
  }
157
174
  function formatStepProfileHuman(profile) {
158
175
  const lines = [
159
- `step profile \u2014 ${profile.workflow} (window ${profile.windowSec}s, ${profile.sampledRuns} sampled runs)`
176
+ `step profile — ${profile.workflow} (window ${profile.windowSec}s, ${profile.sampledRuns} sampled runs)`
160
177
  ];
161
178
  for (const row of profile.rows) {
162
179
  lines.push([
@@ -175,15 +192,8 @@ function formatStepProfileHuman(profile) {
175
192
  `)}
176
193
  `;
177
194
  }
178
- function defaultSpurBin() {
179
- const fromEnv = getEnvVar("SPUR_BIN");
180
- if (fromEnv)
181
- return fromEnv;
182
- const local = fileURLToPath(new URL("../../../apps/cli/src/index.ts", import.meta.url));
183
- if (existsSync(local))
184
- return `bun ${local}`;
185
- return "spur";
186
- }
195
+
196
+ // plugins/sp/scripts/workflow-step-profile.ts
187
197
  function positiveInt(raw, fallback) {
188
198
  const parsed = Number.parseInt(raw ?? "", 10);
189
199
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
@@ -215,9 +225,8 @@ function parseStepProfileCliArgs(argv) {
215
225
  return { workflow, last, windowSec, spurBin, json, help };
216
226
  }
217
227
  function runSpurJson(spurBin, args) {
218
- const binParts = spurBin.split(/\s+/).filter(Boolean);
219
- const cmd = binParts[0] ?? "spur";
220
- const cmdArgs = [...binParts.slice(1), ...args];
228
+ const { cmd, prefix } = spurCommand(spurBin);
229
+ const cmdArgs = [...prefix, ...args];
221
230
  const r = spawnSync(cmd, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
222
231
  const decode = (b) => typeof b === "string" ? b : Buffer.from(b ?? []).toString("utf8");
223
232
  return {
@@ -321,7 +330,6 @@ export {
321
330
  main,
322
331
  formatStepProfileHuman,
323
332
  extractExecutions,
324
- defaultSpurBin,
325
333
  buildStepProfile,
326
334
  buildRows,
327
335
  STEP_PROFILE_USAGE,
@@ -2,311 +2,31 @@
2
2
  /**
3
3
  * workflow-step-profile — per-node step evidence for `sp:spur-doctor` (task 0827, feature I21).
4
4
  *
5
- * ADR-115 sets step boundaries by the provider cache window, not the clock, and satellite §10 owns
6
- * the four budgets that follow from it. Trace already records each action event's `durationMs`,
7
- * `startedAt`/`completedAt`, `invocation` and `cost`, but nothing aggregates it per node. This
8
- * read-only plugin script (ADR-065) composes the existing `spur workflow trace --json` surface
9
- * (governance §2) into per-node/action-kind rows so doctor maps flag ids to proposals instead of
10
- * re-deriving numbers from prose.
11
- *
12
- * Statelessness: the script computes the flags arithmetically and holds no thresholds of its own
13
- * beyond the §10 window `W` (default 300 s). Unknown evidence stays `null` and never becomes zero:
14
- * a step with no recorded `cost.exact.cacheHit` reports `{p50: null, known: 0}`, not a 0 hit rate.
15
- * `cost.estimated` (retroactive mappings) never mixes into `cacheHit`.
5
+ * IO glue only (task 1005 R4, ADR-130 lib rule): argv parsing, `spur workflow trace --json`
6
+ * spawning and the two output modes. The aggregation core (nearestRankP50, extractExecutions,
7
+ * rowFlags, buildRows, buildStepProfile, nonDryRuns, formatStepProfileHuman and their types) lives
8
+ * in packages/app/src/workflow/step-profile.ts and reaches this script through the generated
9
+ * standalone bundle `plugins/sp/lib/step-profile.generated.mjs` — node-builtin only, so the
10
+ * committed `.mjs` twin runs under bare `node`. Regenerate with `bun run build:scripts`.
16
11
  *
17
12
  * Exit: 0 whenever a profile is produced (including `sampledRuns: 0` and flagged rows). Flags are
18
13
  * evidence, not a gate. 1, with a stderr message, only when a `spur` call fails, its JSON does not
19
14
  * parse, or the workflow argument is missing.
20
- *
21
- * Node-builtin imports only; the pure builders are exported for unit testing, `runStepProfileCli`
22
- * and `main` do the I/O (mirrors the batch-preflight / feature-sync-bounded script shape).
23
15
  */
24
-
25
16
  import { spawnSync } from 'node:child_process';
26
- import { existsSync } from 'node:fs';
27
- import { fileURLToPath } from 'node:url';
28
- import { getEnvVar } from '../lib/env';
29
-
30
- // ── Input shapes (a subset of the live `spur workflow trace --json` payload) ─────────────
31
-
32
- /** One `cost.exact` / `cost.estimated` bucket; only `cacheHit` is read. */
33
- export interface ActionCostLike {
34
- cacheHit: number | null;
35
- }
36
-
37
- /**
38
- * `ActionCostAttribution` as trace emits it. Both classes are read structurally so the script
39
- * carries no import of the domain package (twins must stay self-contained).
40
- */
41
- export interface ActionCostAttributionLike {
42
- exact: ActionCostLike | null;
43
- estimated: ActionCostLike | null;
44
- }
45
-
46
- /** One timeline event. Non-`action` events are ignored; the rest is optional on purpose. */
47
- export interface TraceActionEvent {
48
- kind: string;
49
- node?: string;
50
- actionKind?: string;
51
- durationMs?: number | null;
52
- startedAt?: string | null;
53
- completedAt?: string | null;
54
- invocation?: Record<string, string | number | boolean> | null;
55
- cost?: ActionCostAttributionLike | null;
56
- }
57
-
58
- /** One sampled run: the run id plus its timeline events. */
59
- export interface StepProfileRun {
60
- runId: string;
61
- events: readonly TraceActionEvent[];
62
- }
63
-
64
- /** A `spur workflow trace --json` list entry (the fields the profile needs). */
65
- export interface TraceRunEntry {
66
- runId: string;
67
- isDryRun?: boolean;
68
- status?: string;
69
- workflowName?: string;
70
- }
71
-
72
- export type SessionMode = 'fresh' | 'resumed' | 'mixed';
73
-
74
- // ── Output shapes ──────────────────────────────────────────────────────────────────────
75
-
76
- /** One execution of a node's action kind inside one run. */
77
- export interface StepProfileExecution {
78
- runId: string;
79
- node: string;
80
- actionKind: string;
81
- durationMs: number | null;
82
- idleGapMs: number | null;
83
- /** `invocation.continue` as recorded; null when the event predates the field. */
84
- session: 'fresh' | 'resumed' | null;
85
- cacheHit: number | null;
86
- }
87
-
88
- /** One (node, actionKind) row — the R1 grain. */
89
- export interface StepProfileRow {
90
- node: string;
91
- actionKind: string;
92
- runs: number;
93
- executions: number;
94
- durationMs: { p50: number | null; max: number | null };
95
- idleGapMs: { p50: number | null };
96
- session: SessionMode | null;
97
- cacheHit: { p50: number | null; known: number; of: number };
98
- flags: string[];
99
- }
100
-
101
- /** The `--json` envelope. */
102
- export interface StepProfile {
103
- workflow: string;
104
- windowSec: number;
105
- sampledRuns: number;
106
- rows: StepProfileRow[];
107
- }
108
-
109
- // ── Aggregation ────────────────────────────────────────────────────────────────────────
110
-
111
- export const DEFAULT_LAST = 20;
112
- /** §10 window `W`: the shortest common provider cache default, in seconds. */
113
- export const DEFAULT_WINDOW_SEC = 300;
114
-
115
- /**
116
- * Nearest-rank median: `sorted[Math.ceil(n / 2) - 1]`, so every reported value is one that was
117
- * actually observed. Empty input is unknown (`null`), never 0.
118
- */
119
- export function nearestRankP50(values: readonly number[]): number | null {
120
- if (values.length === 0) return null;
121
- const sorted = [...values].sort((a, b) => a - b);
122
- return sorted[Math.ceil(sorted.length / 2) - 1] ?? null;
123
- }
124
-
125
- function numeric(value: number | null | undefined): number | null {
126
- return typeof value === 'number' && Number.isFinite(value) ? value : null;
127
- }
128
-
129
- function msBetween(from: string | null | undefined, to: string | null | undefined): number | null {
130
- if (typeof from !== 'string' || typeof to !== 'string') return null;
131
- const start = Date.parse(from);
132
- const end = Date.parse(to);
133
- if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
134
- return end - start;
135
- }
136
-
137
- function sessionOf(event: TraceActionEvent): 'fresh' | 'resumed' | null {
138
- const value = event.invocation?.continue;
139
- if (value === true) return 'resumed';
140
- if (value === false) return 'fresh';
141
- return null;
142
- }
143
-
144
- function cacheHitOf(event: TraceActionEvent): number | null {
145
- // `cost.estimated` is deliberately unread: retroactive mappings never mix with exact evidence.
146
- return numeric(event.cost?.exact?.cacheHit);
147
- }
148
-
149
- /** Sort key: a parseable `startedAt`, with the unknown (`unavailable` / null) ordering last. */
150
- function startKey(event: TraceActionEvent): number {
151
- const parsed = typeof event.startedAt === 'string' ? Date.parse(event.startedAt) : Number.NaN;
152
- return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
153
- }
154
-
155
- /**
156
- * The executions of one run: `kind === 'action'` events in `startedAt` order (trace emits them in
157
- * row-creation order), each carrying its idle gap (from the previous action's `completedAt` in the
158
- * same run), session mode and cacheHit. The run's first action has no previous action, so its gap
159
- * is unknown — never 0.
160
- */
161
- export function extractExecutions(runId: string, events: readonly TraceActionEvent[]): StepProfileExecution[] {
162
- const actions = events
163
- .filter((e) => e.kind === 'action')
164
- .sort((a, b) => {
165
- const left = startKey(a);
166
- const right = startKey(b);
167
- return left === right ? 0 : left - right;
168
- });
169
- const executions: StepProfileExecution[] = [];
170
- for (const [index, event] of actions.entries()) {
171
- const previous = index === 0 ? undefined : actions[index - 1];
172
- executions.push({
173
- runId,
174
- node: event.node ?? '',
175
- actionKind: event.actionKind ?? '',
176
- durationMs: numeric(event.durationMs),
177
- idleGapMs: msBetween(previous?.completedAt, event.startedAt),
178
- session: sessionOf(event),
179
- cacheHit: cacheHitOf(event),
180
- });
181
- }
182
- return executions;
183
- }
184
-
185
- function foldSession(executions: readonly StepProfileExecution[]): SessionMode | null {
186
- const known = new Set(executions.map((e) => e.session).filter((s): s is 'fresh' | 'resumed' => s !== null));
187
- if (known.size === 0) return null;
188
- if (known.size === 1) return [...known][0] ?? null;
189
- return 'mixed';
190
- }
191
-
192
- /**
193
- * The four §10 cache-window flags for one row. `W` is in seconds and durations compare as
194
- * `ms > W * 1000`, so a step exactly at the budget is inside it.
195
- */
196
- export function rowFlags(row: StepProfileRow, windowSec: number): string[] {
197
- const wMs = windowSec * 1000;
198
- const flags: string[] = [];
199
- const agentRun = row.actionKind === 'agent.run';
200
- const resumed = row.session === 'resumed' || row.session === 'mixed';
201
- const p50 = row.durationMs.p50;
202
- if (!agentRun && p50 !== null && p50 > wMs) flags.push('step-over-window');
203
- const idle = row.idleGapMs.p50;
204
- if (agentRun && resumed && idle !== null && idle > wMs) flags.push('resume-after-idle');
205
- const hit = row.cacheHit.p50;
206
- if (agentRun && resumed && row.cacheHit.known > 0 && hit !== null && hit < 0.5) flags.push('resume-cold-cache');
207
- if (agentRun && p50 !== null && p50 > 2 * wMs) flags.push('agent-run-over-2w');
208
- return flags;
209
- }
210
-
211
- /** Fold per-run executions into the sorted (node, actionKind) rows. */
212
- export function buildRows(executions: readonly StepProfileExecution[], windowSec: number): StepProfileRow[] {
213
- const grouped = new Map<string, StepProfileExecution[]>();
214
- for (const execution of executions) {
215
- const key = `${execution.node}\u0000${execution.actionKind}`;
216
- const bucket = grouped.get(key);
217
- if (bucket === undefined) grouped.set(key, [execution]);
218
- else bucket.push(execution);
219
- }
220
-
221
- const rows: StepProfileRow[] = [];
222
- for (const bucket of grouped.values()) {
223
- const first = bucket[0];
224
- if (first === undefined) continue;
225
- const durations = bucket.map((e) => e.durationMs).filter((d): d is number => d !== null);
226
- const gaps = bucket.map((e) => e.idleGapMs).filter((g): g is number => g !== null);
227
- const hits = bucket.map((e) => e.cacheHit).filter((h): h is number => h !== null);
228
- const row: StepProfileRow = {
229
- node: first.node,
230
- actionKind: first.actionKind,
231
- runs: new Set(bucket.map((e) => e.runId)).size,
232
- executions: bucket.length,
233
- durationMs: {
234
- p50: nearestRankP50(durations),
235
- max: durations.length === 0 ? null : Math.max(...durations),
236
- },
237
- idleGapMs: { p50: nearestRankP50(gaps) },
238
- session: first.actionKind === 'agent.run' ? foldSession(bucket) : null,
239
- cacheHit: {
240
- p50: nearestRankP50(hits),
241
- known: hits.length,
242
- of: bucket.length,
243
- },
244
- flags: [],
245
- };
246
- row.flags = rowFlags(row, windowSec);
247
- rows.push(row);
248
- }
249
-
250
- return rows.sort((a, b) =>
251
- a.node === b.node ? a.actionKind.localeCompare(b.actionKind) : a.node.localeCompare(b.node),
252
- );
253
- }
254
-
255
- /** Build the profile over the sampled (already dry-run-filtered) runs. */
256
- export function buildStepProfile(input: {
257
- workflow: string;
258
- windowSec: number;
259
- runs: readonly StepProfileRun[];
260
- }): StepProfile {
261
- const executions = input.runs.flatMap((run) => extractExecutions(run.runId, run.events));
262
- return {
263
- workflow: input.workflow,
264
- windowSec: input.windowSec,
265
- sampledRuns: input.runs.length,
266
- rows: buildRows(executions, input.windowSec),
267
- };
268
- }
269
-
270
- /** Drop `isDryRun: true` entries (and anything without a run id) before sampling. */
271
- export function nonDryRuns(entries: readonly TraceRunEntry[]): TraceRunEntry[] {
272
- return entries.filter((e) => e.isDryRun !== true && typeof e.runId === 'string' && e.runId.length > 0);
273
- }
274
-
275
- // ── Output rendering ───────────────────────────────────────────────────────────────────
276
-
277
- function seconds(value: number | null): string {
278
- return value === null ? '?' : `${(value / 1000).toFixed(1)}s`;
279
- }
280
-
281
- function ratio(value: number | null): string {
282
- return value === null ? '?' : value.toFixed(2);
283
- }
284
-
285
- /** One line per row; seconds carry one decimal and `?` marks an unknown value. */
286
- export function formatStepProfileHuman(profile: StepProfile): string {
287
- const lines = [
288
- `step profile — ${profile.workflow} (window ${profile.windowSec}s, ${profile.sampledRuns} sampled runs)`,
289
- ];
290
- for (const row of profile.rows) {
291
- lines.push(
292
- [
293
- row.node.padEnd(20),
294
- row.actionKind.padEnd(14),
295
- `${row.runs}/${row.executions}`.padEnd(8),
296
- `p50=${seconds(row.durationMs.p50)}`.padEnd(12),
297
- `max=${seconds(row.durationMs.max)}`.padEnd(12),
298
- `idle=${seconds(row.idleGapMs.p50)}`.padEnd(12),
299
- `session=${row.session ?? '?'}`.padEnd(15),
300
- `cacheHit=${ratio(row.cacheHit.p50)} (${row.cacheHit.known}/${row.cacheHit.of})`.padEnd(20),
301
- `flags=${row.flags.length === 0 ? '-' : row.flags.join(',')}`,
302
- ].join(' '),
303
- );
304
- }
305
- return `${lines.join('\n')}\n`;
306
- }
307
-
308
- // ── CLI ────────────────────────────────────────────────────────────────────────────────
309
-
17
+ import { defaultSpurBin, spurCommand } from '../lib/spur-bin';
18
+ import {
19
+ buildStepProfile,
20
+ DEFAULT_LAST,
21
+ DEFAULT_WINDOW_SEC,
22
+ formatStepProfileHuman,
23
+ nonDryRuns,
24
+ type StepProfileRun,
25
+ type TraceActionEvent,
26
+ type TraceRunEntry,
27
+ } from '../lib/step-profile.generated.mjs';
28
+
29
+ export * from '../lib/step-profile.generated.mjs';
310
30
  export interface StepProfileCliArgs {
311
31
  workflow: string;
312
32
  last: number;
@@ -316,19 +36,6 @@ export interface StepProfileCliArgs {
316
36
  help: boolean;
317
37
  }
318
38
 
319
- /**
320
- * Resolve the spur CLI monorepo-safely (copied from feature-sync-bounded: twins stay
321
- * self-contained and no plugin script imports another):
322
- * --spur-bin > SPUR_BIN > monorepo-local CLI entry > PATH `spur`.
323
- */
324
- export function defaultSpurBin(): string {
325
- const fromEnv = getEnvVar('SPUR_BIN');
326
- if (fromEnv) return fromEnv;
327
- const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
328
- if (existsSync(local)) return `bun ${local}`;
329
- return 'spur';
330
- }
331
-
332
39
  function positiveInt(raw: string | undefined, fallback: number): number {
333
40
  const parsed = Number.parseInt(raw ?? '', 10);
334
41
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
@@ -358,9 +65,8 @@ export function parseStepProfileCliArgs(argv: string[]): StepProfileCliArgs {
358
65
  type SpawnResult = { stdout: string; stderr: string; exitCode: number; ok: boolean };
359
66
 
360
67
  function runSpurJson(spurBin: string, args: string[]): SpawnResult {
361
- const binParts = spurBin.split(/\s+/).filter(Boolean);
362
- const cmd = binParts[0] ?? 'spur';
363
- const cmdArgs = [...binParts.slice(1), ...args];
68
+ const { cmd, prefix } = spurCommand(spurBin);
69
+ const cmdArgs = [...prefix, ...args];
364
70
  const r = spawnSync(cmd, cmdArgs, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
365
71
  const decode = (b: unknown): string =>
366
72
  typeof b === 'string' ? b : Buffer.from((b as Uint8Array) ?? []).toString('utf8');
@@ -11,6 +11,12 @@ function getEnvVars() {
11
11
  return process.env;
12
12
  }
13
13
 
14
+ // plugins/sp/lib/spur-bin.ts
15
+ function spurCommand(spurBin) {
16
+ const parts = (spurBin ?? "spur").trim().split(/\s+/).filter((p) => p.length > 0);
17
+ return { cmd: parts[0] ?? "spur", prefix: parts.slice(1) };
18
+ }
19
+
14
20
  // plugins/sp/scripts/wrapup-drift-probe.ts
15
21
  var WORKFLOWS_GLOB = `${join("config", "workflows")}/**`;
16
22
  var DOC_OWNED_SURFACES = [
@@ -83,9 +89,9 @@ function driftReasonForPath(path) {
83
89
  return match ? `matches doc-owned surface ${match.glob}` : null;
84
90
  }
85
91
  function defaultSpurRunner(env, cwd) {
86
- const parts = (env.spurBin ?? "spur").split(/\s+/).filter((part) => part.length > 0);
92
+ const { cmd, prefix } = spurCommand(env.spurBin);
87
93
  return (args) => {
88
- const run = spawnSync(parts[0] ?? "spur", [...parts.slice(1), ...args], {
94
+ const run = spawnSync(cmd, [...prefix, ...args], {
89
95
  cwd,
90
96
  encoding: "utf8",
91
97
  env: getEnvVars()
@@ -28,6 +28,7 @@ import { spawnSync } from 'node:child_process';
28
28
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
29
29
  import { join } from 'node:path';
30
30
  import { getEnvVars } from '../lib/env';
31
+ import { spurCommand } from '../lib/spur-bin';
31
32
 
32
33
  export interface DriftProbeEnv {
33
34
  __runId?: string;
@@ -158,9 +159,9 @@ export function driftReasonForPath(path: string): string | null {
158
159
  }
159
160
 
160
161
  function defaultSpurRunner(env: DriftProbeEnv, cwd?: string): SpurRunner {
161
- const parts = (env.spurBin ?? 'spur').split(/\s+/).filter((part) => part.length > 0);
162
+ const { cmd, prefix } = spurCommand(env.spurBin);
162
163
  return (args: string[]): SpurShowResult => {
163
- const run = spawnSync(parts[0] ?? 'spur', [...parts.slice(1), ...args], {
164
+ const run = spawnSync(cmd, [...prefix, ...args], {
164
165
  cwd,
165
166
  encoding: 'utf8',
166
167
  env: getEnvVars(),