@gobing-ai/spur 0.4.0 → 0.4.1

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 (154) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/decisions/gates.yaml +36 -0
  3. package/config/decisions/history-anatomy.yaml +35 -0
  4. package/config/decisions/idea-pipeline.yaml +38 -0
  5. package/config/plugin-scripts.json +15 -0
  6. package/config/rules/docs/sourced-api-claims.yaml +60 -0
  7. package/config/rules/recommended-pre-check.yaml +8 -3
  8. package/config/rules/structure/scratch-evidence-pointer.yaml +35 -0
  9. package/config/script-placement-baseline.json +2 -2
  10. package/config/workflows/feature-lifecycle.yaml +9 -2
  11. package/config/workflows/history-anatomy.yaml +22 -0
  12. package/config/workflows/idea-pipeline.yaml +75 -0
  13. package/config/workflows/task-pipeline.yaml +216 -57
  14. package/config/workflows/wrapup-pipeline.yaml +106 -13
  15. package/package.json +9 -9
  16. package/plugins/sp/README.md +11 -1
  17. package/plugins/sp/agents/super-planner.md +11 -0
  18. package/plugins/sp/agents/super-reviewer.md +14 -0
  19. package/plugins/sp/bunfig.toml +8 -0
  20. package/plugins/sp/commands/dev-brainstorm.md +29 -16
  21. package/plugins/sp/commands/dev-find-way.md +80 -0
  22. package/plugins/sp/commands/dev-idea.md +6 -0
  23. package/plugins/sp/commands/dev-parallel.md +8 -2
  24. package/plugins/sp/commands/dev-plan.md +6 -0
  25. package/plugins/sp/commands/dev-review-session.md +3 -1
  26. package/plugins/sp/commands/dev-run.md +12 -2
  27. package/plugins/sp/commands/dev-runall.md +18 -10
  28. package/plugins/sp/hooks/context-session-start.ts +1 -1
  29. package/plugins/sp/lib/idea-handoff.generated.mjs +41 -5
  30. package/plugins/sp/lib/inline-run-app.ts +28 -0
  31. package/plugins/sp/lib/inline-run.generated.d.mts +5 -0
  32. package/plugins/sp/lib/inline-run.generated.mjs +333 -45
  33. package/plugins/sp/lib/plan-projection.generated.d.mts +28 -0
  34. package/plugins/sp/lib/plan-projection.generated.mjs +255 -0
  35. package/plugins/sp/lib/quality-gate.generated.d.mts +47 -1
  36. package/plugins/sp/lib/quality-gate.generated.mjs +30489 -82
  37. package/plugins/sp/lib/residual-scan.generated.d.mts +19 -0
  38. package/plugins/sp/lib/residual-scan.generated.mjs +66 -0
  39. package/plugins/sp/lib/run-summary-core.ts +561 -0
  40. package/plugins/sp/lib/transcript.ts +121 -6
  41. package/plugins/sp/plugin.json +1 -1
  42. package/plugins/sp/references/roles.md +1 -1
  43. package/plugins/sp/scripts/batch-plan.mjs +335 -0
  44. package/plugins/sp/scripts/batch-plan.ts +100 -0
  45. package/plugins/sp/scripts/batch-preflight.mjs +51 -3
  46. package/plugins/sp/scripts/batch-preflight.ts +72 -1
  47. package/plugins/sp/scripts/commit-guard.mjs +216 -0
  48. package/plugins/sp/scripts/commit-guard.ts +249 -0
  49. package/plugins/sp/scripts/inline-run-dispatch.mjs +130 -0
  50. package/plugins/sp/scripts/inline-run-dispatch.ts +135 -0
  51. package/plugins/sp/scripts/inline-run-setup.mjs +551 -80
  52. package/plugins/sp/scripts/inline-run-setup.ts +52 -78
  53. package/plugins/sp/scripts/persist-out-check.mjs +90 -41
  54. package/plugins/sp/scripts/persist-out-check.ts +112 -57
  55. package/plugins/sp/scripts/quality-gate.mjs +30407 -73
  56. package/plugins/sp/scripts/quality-gate.ts +21 -5
  57. package/plugins/sp/scripts/residual-scan.mjs +86 -31
  58. package/plugins/sp/scripts/residual-scan.ts +28 -30
  59. package/plugins/sp/scripts/run-summary.mjs +421 -14
  60. package/plugins/sp/scripts/run-summary.ts +41 -177
  61. package/plugins/sp/scripts/session-timeline.mjs +109 -12
  62. package/plugins/sp/scripts/session-timeline.ts +71 -19
  63. package/plugins/sp/scripts/wrapup-steps.mjs +150 -2
  64. package/plugins/sp/scripts/wrapup-steps.ts +203 -1
  65. package/plugins/sp/skills/brainstorm/SKILL.md +9 -47
  66. package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
  67. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +1 -0
  68. package/plugins/sp/skills/code-implementation/SKILL.md +48 -1
  69. package/plugins/sp/skills/code-verification/SKILL.md +3 -4
  70. package/plugins/sp/skills/code-verification/references/secu-review.md +3 -0
  71. package/plugins/sp/skills/code-verification/references/verdict-schema.md +68 -0
  72. package/plugins/sp/skills/fleet-join/SKILL.md +1 -1
  73. package/plugins/sp/skills/session-review/SKILL.md +38 -7
  74. package/plugins/sp/skills/source-driven-development/SKILL.md +5 -0
  75. package/plugins/sp/skills/spec-decomposition/references/decomposition.md +5 -3
  76. package/plugins/sp/skills/spur-cli/SKILL.md +5 -2
  77. package/plugins/sp/skills/spur-cli/references/agent.md +20 -15
  78. package/plugins/sp/skills/spur-cli/references/decision.md +7 -6
  79. package/plugins/sp/skills/spur-cli/references/message.md +7 -7
  80. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +19 -3
  81. package/plugins/sp/skills/spur-cli/references/tasks.md +10 -7
  82. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +7 -0
  83. package/plugins/sp/skills/spur-cli/references/workflows.md +7 -2
  84. package/plugins/sp/skills/spur-composer/SKILL.md +1 -1
  85. package/plugins/sp/skills/spur-dev/SKILL.md +18 -14
  86. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +6 -3
  87. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +49 -14
  88. package/plugins/sp/skills/spur-dev/references/dev-operations.md +37 -16
  89. package/plugins/sp/skills/spur-dev/references/execution-batch-continuation.md +68 -0
  90. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +194 -0
  91. package/plugins/sp/skills/spur-dev/references/execution-batch.md +113 -1020
  92. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +130 -0
  93. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +6 -0
  94. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +471 -0
  95. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +379 -0
  96. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +35 -5
  97. package/plugins/sp/skills/spur-dev/references/glossary.md +2 -2
  98. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +343 -163
  99. package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +178 -0
  100. package/plugins/sp/skills/wayfinder/SKILL.md +10 -14
  101. package/schemas/state-machine-workflow.schema.json +23 -0
  102. package/spur.js +20728 -16630
  103. package/web/_astro/{BoardApp.VeoxxLBy.js → BoardApp.D66k_s_T.js} +1 -1
  104. package/web/_astro/{BoardApp.Bzr4OXrW.js → BoardApp.D6sjAJr_.js} +173 -173
  105. package/web/_astro/{TaskDetail.LAyR_Pvn.js → TaskDetail.BynLnMNc.js} +1 -1
  106. package/web/_astro/{arc.f4e0OYLg.js → arc._BUadrdH.js} +1 -1
  107. package/web/_astro/{architectureDiagram-3BPJPVTR.SSIvxi-Z.js → architectureDiagram-3BPJPVTR.Dh7xS1Pp.js} +1 -1
  108. package/web/_astro/{blockDiagram-GPEHLZMM.DSXIHOFf.js → blockDiagram-GPEHLZMM.DFDVpfXm.js} +1 -1
  109. package/web/_astro/{c4Diagram-AAUBKEIU.DG9Ubu85.js → c4Diagram-AAUBKEIU.V7_3_glh.js} +1 -1
  110. package/web/_astro/channel.DZVfto-r.js +1 -0
  111. package/web/_astro/{chunk-2J33WTMH.C7QU88Qh.js → chunk-2J33WTMH.De1yx7Jz.js} +1 -1
  112. package/web/_astro/{chunk-4BX2VUAB.26Z94xBj.js → chunk-4BX2VUAB.BDhHFuxx.js} +1 -1
  113. package/web/_astro/{chunk-55IACEB6.DYRiBkqw.js → chunk-55IACEB6.DDLkWnmC.js} +1 -1
  114. package/web/_astro/{chunk-727SXJPM.D5Vfv5pe.js → chunk-727SXJPM.CwfiLnA7.js} +1 -1
  115. package/web/_astro/{chunk-AQP2D5EJ.DXHV47bN.js → chunk-AQP2D5EJ.CtGDmUnM.js} +1 -1
  116. package/web/_astro/{chunk-FMBD7UC4.D1suAfOs.js → chunk-FMBD7UC4.BOLjpw73.js} +1 -1
  117. package/web/_astro/{chunk-ND2GUHAM.D6BmdHEY.js → chunk-ND2GUHAM.BDhQls6O.js} +1 -1
  118. package/web/_astro/{chunk-QZHKN3VN.rnPU-Ak-.js → chunk-QZHKN3VN.76qBynDF.js} +1 -1
  119. package/web/_astro/{classDiagram-4FO5ZUOK.CvEfXIo1.js → classDiagram-4FO5ZUOK.N0mpPHT0.js} +1 -1
  120. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CvEfXIo1.js → classDiagram-v2-Q7XG4LA2.N0mpPHT0.js} +1 -1
  121. package/web/_astro/{cose-bilkent-S5V4N54A.DkMisLSm.js → cose-bilkent-S5V4N54A.CKknFfjB.js} +1 -1
  122. package/web/_astro/{cynefin-OW5HDTMX.C77Q-pF6.js → cynefin-OW5HDTMX.Cw3ZWBAh.js} +1 -1
  123. package/web/_astro/{dagre-BM42HDAG.YaV8ce27.js → dagre-BM42HDAG.D3yIKCRD.js} +1 -1
  124. package/web/_astro/{diagram-2AECGRRQ.CUyGEKge.js → diagram-2AECGRRQ.D51dO83Z.js} +1 -1
  125. package/web/_astro/{diagram-5GNKFQAL.CRgd8dgj.js → diagram-5GNKFQAL.Bb-FViDj.js} +1 -1
  126. package/web/_astro/{diagram-KO2AKTUF.Bmzys9hq.js → diagram-KO2AKTUF.D3mN8S6x.js} +1 -1
  127. package/web/_astro/{diagram-LMA3HP47.BvmJY4Lw.js → diagram-LMA3HP47.dMMyPcbJ.js} +1 -1
  128. package/web/_astro/{diagram-OG6HWLK6.0hEhD30d.js → diagram-OG6HWLK6.AzbvzY4C.js} +1 -1
  129. package/web/_astro/{erDiagram-TEJ5UH35.Bjt8lrnm.js → erDiagram-TEJ5UH35.BR5I4OZb.js} +1 -1
  130. package/web/_astro/{flowDiagram-I6XJVG4X.CJZTjRDV.js → flowDiagram-I6XJVG4X.DFqvhGkn.js} +1 -1
  131. package/web/_astro/{ganttDiagram-6RSMTGT7._VqoVafc.js → ganttDiagram-6RSMTGT7.ByJbhH6J.js} +1 -1
  132. package/web/_astro/{gitGraphDiagram-PVQCEYII.CEDb7tVo.js → gitGraphDiagram-PVQCEYII.CInHjQR3.js} +1 -1
  133. package/web/_astro/index.COh_kBxN.css +1 -0
  134. package/web/_astro/{infoDiagram-5YYISTIA.CgwifPpK.js → infoDiagram-5YYISTIA.Cm0dGVjJ.js} +1 -1
  135. package/web/_astro/{ishikawaDiagram-YF4QCWOH.B99wx2J1.js → ishikawaDiagram-YF4QCWOH.C3nOP6Sj.js} +1 -1
  136. package/web/_astro/{journeyDiagram-JHISSGLW.2hRwJbEo.js → journeyDiagram-JHISSGLW.4IvLa2MW.js} +1 -1
  137. package/web/_astro/{kanban-definition-UN3LZRKU.BahJkKNC.js → kanban-definition-UN3LZRKU.DS87Cl-k.js} +1 -1
  138. package/web/_astro/{linear.DguUUe6V.js → linear.MzmRrNER.js} +1 -1
  139. package/web/_astro/{mermaid.core.CvgfmqVr.js → mermaid.core.fK-WNswS.js} +4 -4
  140. package/web/_astro/{mindmap-definition-RKZ34NQL.B3qEA3x9.js → mindmap-definition-RKZ34NQL.BqipOHhY.js} +1 -1
  141. package/web/_astro/{pieDiagram-4H26LBE5.FlWA-_ru.js → pieDiagram-4H26LBE5.0nBiPqFG.js} +1 -1
  142. package/web/_astro/{quadrantDiagram-W4KKPZXB.9bVn35X7.js → quadrantDiagram-W4KKPZXB.Szj7Zpx5.js} +1 -1
  143. package/web/_astro/{requirementDiagram-4Y6WPE33.ChykIhct.js → requirementDiagram-4Y6WPE33.D4UR6TWo.js} +1 -1
  144. package/web/_astro/{sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js → sankeyDiagram-5OEKKPKP.DAE7BdOs.js} +1 -1
  145. package/web/_astro/{sequenceDiagram-3UESZ5HK.J7JsHWDt.js → sequenceDiagram-3UESZ5HK.BP9ZVq25.js} +1 -1
  146. package/web/_astro/{stateDiagram-AJRCARHV.DAPbiCSS.js → stateDiagram-AJRCARHV.BQW7mIxk.js} +1 -1
  147. package/web/_astro/{stateDiagram-v2-BHNVJYJU.D2jvSi0q.js → stateDiagram-v2-BHNVJYJU.PbK4nDih.js} +1 -1
  148. package/web/_astro/{timeline-definition-PNZ67QCA.psW2ci29.js → timeline-definition-PNZ67QCA.Ci_GvUpo.js} +1 -1
  149. package/web/_astro/{vennDiagram-CIIHVFJN.DlILYVmg.js → vennDiagram-CIIHVFJN.DNw1Y5qx.js} +1 -1
  150. package/web/_astro/{wardleyDiagram-YWT4CUSO.xNWwLJnn.js → wardleyDiagram-YWT4CUSO.CwlkaaNe.js} +1 -1
  151. package/web/_astro/{xychartDiagram-2RQKCTM6.EU_BuH7V.js → xychartDiagram-2RQKCTM6.MI6ZuiH_.js} +1 -1
  152. package/web/index.html +2 -2
  153. package/web/_astro/channel.CUFdtWhm.js +0 -1
  154. package/web/_astro/index.Dg_3bOaJ.css +0 -1
@@ -16,14 +16,35 @@ export interface Span {
16
16
  tokens: Tokens;
17
17
  }
18
18
 
19
- type Block = Partial<Record<'type' | 'id' | 'name' | 'text' | 'tool_use_id', string>>;
19
+ export interface Block {
20
+ type?: string;
21
+ id?: string;
22
+ name?: string;
23
+ text?: string;
24
+ tool_use_id?: string;
25
+ /** Claude tool_result error flag (1146 R4): a failed subagent call is counted. */
26
+ is_error?: boolean;
27
+ }
20
28
 
21
29
  export interface Row {
22
30
  type?: string;
31
+ /** Claude `system` rows carry the compaction subtype (1146 R4). */
32
+ subtype?: string;
23
33
  timestamp?: string;
24
34
  isMeta?: boolean;
25
35
  isCompactSummary?: boolean;
26
- message?: { id?: string; content?: unknown; usage?: Record<string, number | undefined> };
36
+ id?: string; // pi: the row id doubles as the once-per-message usage key
37
+ message?: {
38
+ id?: string;
39
+ role?: string;
40
+ content?: unknown;
41
+ usage?: Record<string, number | undefined>;
42
+ toolCallId?: string;
43
+ /** pi toolResult rows name their tool here (1146 R4). */
44
+ toolName?: string;
45
+ /** pi toolResult error flag (1146 R4). */
46
+ isError?: boolean;
47
+ };
27
48
  }
28
49
 
29
50
  /** Running measurement of one segment or window. */
@@ -33,11 +54,13 @@ export interface Acc {
33
54
  asks: Map<string, number>;
34
55
  toolIds: Set<string>;
35
56
  messages: Map<string, Tokens>;
57
+ compactions: number;
36
58
  }
37
59
 
38
60
  const SESSION_ID = /^[A-Za-z0-9_-]+$/;
39
61
  /** Host tools whose call→result gap is the operator answering, not the agent working. */
40
- const OPERATOR_TOOLS = new Set(['AskUserQuestion']);
62
+ const OPERATOR_TOOLS = new Set(['AskUserQuestion', 'ask_user_question']);
63
+ const PI_ROLES = new Set(['user', 'assistant', 'toolResult']);
41
64
 
42
65
  export const zeroTokens = (): Tokens => ({ input: 0, cacheCreate: 0, cacheRead: 0, output: 0 });
43
66
 
@@ -67,6 +90,16 @@ export function formatTokenSplit(t: Tokens | null): string {
67
90
 
68
91
  /** Operator prompt text, or undefined for tool results, meta rows and compaction summaries. */
69
92
  export function promptText(row: Row): string | undefined {
93
+ if (row.type === 'message') {
94
+ // pi (1130): only a user-role message with text content is a prompt; toolResult rows are not.
95
+ if (row.message?.role !== 'user') return undefined;
96
+ // pi (1138 R2): a skill-injected body is activity, not an operator prompt.
97
+ if (isInjectedPrompt(row)) return undefined;
98
+ const content = row.message.content;
99
+ if (typeof content === 'string') return content;
100
+ if (!Array.isArray(content)) return undefined;
101
+ return (content as Block[]).find((b) => b.type === 'text')?.text;
102
+ }
70
103
  if (row.type !== 'user' || row.isMeta || row.isCompactSummary) return undefined;
71
104
  const content = row.message?.content;
72
105
  if (typeof content === 'string') return content;
@@ -74,6 +107,24 @@ export function promptText(row: Row): string | undefined {
74
107
  return (content as Block[]).find((b) => b.type === 'text')?.text;
75
108
  }
76
109
 
110
+ /**
111
+ * 1138 R2: a pi user row whose first text block, after leading whitespace, opens with
112
+ * `<skill name="`, is an injected body (pi has no `isMeta` to key on). It is accumulated as
113
+ * activity and must not open a segment. Operator text that merely CONTAINS `<skill` later in the
114
+ * body is still a prompt. Claude rows are unaffected: their injected bodies already arrive as `isMeta`.
115
+ */
116
+ export function isInjectedPrompt(row: Row): boolean {
117
+ if (row.type !== 'message' || row.message?.role !== 'user') return false;
118
+ const content = row.message.content;
119
+ const text =
120
+ typeof content === 'string'
121
+ ? content
122
+ : Array.isArray(content)
123
+ ? (content as Block[]).find((b) => b.type === 'text')?.text
124
+ : undefined;
125
+ return typeof text === 'string' && text.replace(/^\s+/, '').startsWith('<skill name="');
126
+ }
127
+
77
128
  export function sumTokens(all: Tokens[]): Tokens {
78
129
  const total = zeroTokens();
79
130
  for (const t of all) for (const k of Object.keys(total) as (keyof Tokens)[]) total[k] += t[k];
@@ -95,11 +146,38 @@ export const newAcc = (ts: number): Acc => ({
95
146
  asks: new Map(),
96
147
  toolIds: new Set(),
97
148
  messages: new Map(),
149
+ compactions: 0,
98
150
  });
99
151
 
100
152
  /** Fold one timestamped non-prompt record into a segment or window. */
101
153
  export function accumulate(acc: Acc, row: Row, ts: number): void {
102
154
  acc.last = Math.max(acc.last, ts);
155
+ if (row.type === 'message') {
156
+ // pi (1130): mirrors the importer's field semantics (piRole / normalizeOmpToolCall /
157
+ // ompToolResultTiming) — toolCall blocks on assistant rows, results paired by toolCallId.
158
+ const m = row.message;
159
+ if (m?.role === 'assistant') {
160
+ const blocks = Array.isArray(m.content) ? (m.content as Block[]) : [];
161
+ for (const b of blocks) {
162
+ if (b.type === 'toolCall' && b.id) {
163
+ acc.toolIds.add(b.id);
164
+ if (b.name && OPERATOR_TOOLS.has(b.name)) acc.asks.set(b.id, ts);
165
+ }
166
+ }
167
+ const u = m.usage;
168
+ if (u && row.id)
169
+ acc.messages.set(row.id, {
170
+ input: u.input ?? 0,
171
+ cacheCreate: u.cacheWrite ?? 0,
172
+ cacheRead: u.cacheRead ?? 0,
173
+ output: u.output ?? 0,
174
+ });
175
+ } else if (m?.role === 'toolResult' && m.toolCallId && acc.asks.has(m.toolCallId)) {
176
+ acc.askMs += ts - (acc.asks.get(m.toolCallId) ?? ts);
177
+ }
178
+ return;
179
+ }
180
+ if (row.type === 'compaction') acc.compactions++; // pi-only row type (1130 R4)
103
181
  const blocks = Array.isArray(row.message?.content) ? (row.message.content as Block[]) : [];
104
182
  for (const b of blocks) {
105
183
  if (b.type === 'tool_use' && b.id) {
@@ -121,8 +199,29 @@ export function accumulate(acc: Acc, row: Row, ts: number): void {
121
199
  }
122
200
  }
123
201
 
202
+ /**
203
+ * 1146 R4: a compaction boundary in either host shape — pi writes a `compaction` row, Claude a
204
+ * `system` row with `subtype:"compact_boundary"`.
205
+ */
206
+ export function isCompaction(row: Row): boolean {
207
+ return row.type === 'compaction' || (row.type === 'system' && row.subtype === 'compact_boundary');
208
+ }
209
+
210
+ /** First row matching a known shape decides: `type:"message"` with a pi role → pi, else Claude. */
211
+ export function sniffFormat(rows: Row[]): 'claude' | 'pi' | 'unknown' {
212
+ for (const row of rows) {
213
+ if (row.type === 'message' && PI_ROLES.has(row.message?.role ?? '')) return 'pi';
214
+ if (row.type === 'user' || row.type === 'assistant') return 'claude';
215
+ }
216
+ return 'unknown';
217
+ }
218
+
124
219
  /** Timestamped rows in file order; malformed lines counted, timestamp-less rows dropped. */
125
- export function parseRows(lines: string[]): { rows: [Row, number][]; skippedLines: number } {
220
+ export function parseRows(lines: string[]): {
221
+ rows: [Row, number][];
222
+ skippedLines: number;
223
+ format: 'claude' | 'pi' | 'unknown';
224
+ } {
126
225
  const rows: [Row, number][] = [];
127
226
  let skippedLines = 0;
128
227
  for (const line of lines) {
@@ -137,7 +236,7 @@ export function parseRows(lines: string[]): { rows: [Row, number][]; skippedLine
137
236
  const ts = row.timestamp ? Date.parse(row.timestamp) : Number.NaN;
138
237
  if (!Number.isNaN(ts)) rows.push([row, ts]);
139
238
  }
140
- return { rows, skippedLines };
239
+ return { rows, skippedLines, format: sniffFormat(rows.map(([r]) => r)) };
141
240
  }
142
241
 
143
242
  export type Resolved = { ok: true; path: string } | { ok: false; reason: string };
@@ -149,7 +248,23 @@ export function resolveTranscript(
149
248
  ): Resolved {
150
249
  if (override) return existsSync(override) ? { ok: true, path: override } : { ok: false, reason: 'no transcript' };
151
250
  const id = env.CLAUDE_CODE_SESSION_ID;
152
- if (!id) return { ok: false, reason: 'no host session id (CLAUDE_CODE_SESSION_ID); pass --transcript <path>' };
251
+ if (!id) {
252
+ // 1138 R1: pi exports PI_SESSION_FILE (an exact path) and no Claude session id. Order is
253
+ // explicit override → Claude id → PI_SESSION_FILE; PI_SESSION_ID is never used for lookup
254
+ // because pi's filename embeds a timestamp, so the path is not derivable from the id.
255
+ const piFile = env.PI_SESSION_FILE;
256
+ if (piFile !== undefined && piFile !== '') {
257
+ return existsSync(piFile)
258
+ ? { ok: true, path: piFile }
259
+ : { ok: false, reason: `PI_SESSION_FILE ${piFile} does not exist` };
260
+ }
261
+ return {
262
+ ok: false,
263
+ reason:
264
+ 'no host session id; pass --transcript <path> (pi: ~/.pi/agent/sessions/<cwd-slug>/<file>.jsonl, ' +
265
+ 'or set PI_SESSION_FILE)',
266
+ };
267
+ }
153
268
  if (!SESSION_ID.test(id)) return { ok: false, reason: 'refusing a session id with path characters' };
154
269
  if (!existsSync(projectsRoot)) return { ok: false, reason: `no transcript root ${projectsRoot}` };
155
270
  for (const dir of readdirSync(projectsRoot)) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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"]
@@ -62,7 +62,7 @@ roles:
62
62
  stages: [verify, review, dogfood]
63
63
  - id: planner
64
64
  tier: capable-2
65
- commands: [dev-plan, dev-refine, dev-brainstorm, dev-idea, dev-runall, dev-parallel, dev-next, dev-job-resume, dev-arch, dev-refineall, dev-find-next, dev-feature-change]
65
+ commands: [dev-plan, dev-refine, dev-brainstorm, dev-find-way, dev-idea, dev-runall, dev-parallel, dev-next, dev-job-resume, dev-arch, dev-refineall, dev-find-next, dev-feature-change]
66
66
  stages: [plan, refine, brainstorm]
67
67
  ```
68
68
 
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // plugins/sp/scripts/batch-plan.ts
5
+ import { readFileSync } from "fs";
6
+
7
+ // plugins/sp/lib/plan-projection.generated.mjs
8
+ function planLetter(index) {
9
+ if (!Number.isInteger(index) || index < 0 || index > 25) {
10
+ throw new Error(`plan letter out of range: ${index} (expected 0..25)`);
11
+ }
12
+ return String.fromCharCode(65 + index);
13
+ }
14
+ function planChild(parent, index) {
15
+ if (!Number.isInteger(index) || index < 0 || index > 8) {
16
+ throw new Error(`plan child index out of range: ${index} (expected 0..8 under ${parent})`);
17
+ }
18
+ if (parent.length !== 1 || !/[A-Z]/.test(parent)) {
19
+ throw new Error(`plan child parent must be a single letter: ${parent}`);
20
+ }
21
+ return `${parent}${index + 1}`;
22
+ }
23
+ var PREPARE_ID = "prepare";
24
+ var REPORT_ID = "report";
25
+ var PHASE_ID_PREFIX = "phase.";
26
+ var TASK_ID_PREFIX = "task.";
27
+ var WAVE_SIZE = 24;
28
+ var TITLE_CAP = 60;
29
+ function isStateMachine(def) {
30
+ return def.kind !== "transition-flow" && def.kind !== "dag";
31
+ }
32
+ function hasDisplay(s) {
33
+ return s.display !== undefined;
34
+ }
35
+ function resolvePhaseTitle(states, phase) {
36
+ for (const s of states) {
37
+ if (s.display?.phase === phase && s.display.phaseTitle !== undefined)
38
+ return s.display.phaseTitle;
39
+ }
40
+ return phase;
41
+ }
42
+ function childText(label, title, id) {
43
+ return id.startsWith("prepare.") || id.startsWith("task.") || id === PREPARE_ID || id === REPORT_ID ? `${label} ${title}` : `${label} ${title} · ${id}`;
44
+ }
45
+ function buildPhasedPlan(def) {
46
+ if (!isStateMachine(def))
47
+ return null;
48
+ const annotated = def.states.filter(hasDisplay);
49
+ if (annotated.length === 0)
50
+ return null;
51
+ const items = [
52
+ { label: "A", id: PREPARE_ID, outcome: "pending", title: "Prepare", text: "A Prepare" },
53
+ {
54
+ label: "A1",
55
+ id: "prepare.1",
56
+ outcome: "pending",
57
+ parent: "A",
58
+ title: "Quick readiness",
59
+ text: "A1 Quick readiness"
60
+ },
61
+ { label: "A2", id: "prepare.2", outcome: "pending", parent: "A", title: "Prepare Git", text: "A2 Prepare Git" },
62
+ {
63
+ label: "A3",
64
+ id: "prepare.3",
65
+ outcome: "pending",
66
+ parent: "A",
67
+ title: "Publish plan",
68
+ text: "A3 Publish plan"
69
+ }
70
+ ];
71
+ const phases = [];
72
+ for (const s of def.states) {
73
+ if (s.display !== undefined && !phases.includes(s.display.phase))
74
+ phases.push(s.display.phase);
75
+ }
76
+ for (const [phaseIndex, phase] of phases.entries()) {
77
+ const letter = planLetter(phaseIndex + 1);
78
+ const phaseTitle = resolvePhaseTitle(def.states, phase);
79
+ items.push({
80
+ label: letter,
81
+ id: `${PHASE_ID_PREFIX}${phase}`,
82
+ outcome: "pending",
83
+ title: phaseTitle,
84
+ text: `${letter} ${phaseTitle}`
85
+ });
86
+ let childIndex = 0;
87
+ for (const s of def.states) {
88
+ if (s.display?.phase !== phase || s.display.show === "on-entry")
89
+ continue;
90
+ const label = planChild(letter, childIndex++);
91
+ const title = s.display.title ?? s.id;
92
+ items.push({
93
+ label,
94
+ id: s.id,
95
+ outcome: "pending",
96
+ parent: letter,
97
+ title,
98
+ text: childText(label, title, s.id)
99
+ });
100
+ }
101
+ }
102
+ return items;
103
+ }
104
+ function validatePhaseTable(def) {
105
+ if (!isStateMachine(def))
106
+ return [];
107
+ const annotated = def.states.filter(hasDisplay);
108
+ if (annotated.length === 0)
109
+ return [];
110
+ const errors = [];
111
+ const terminals = new Set(def.terminalStates ?? []);
112
+ for (const s of def.states) {
113
+ if (s.display === undefined && !terminals.has(s.id)) {
114
+ errors.push(`state "${s.id}" has no display phase — every non-terminal state must declare display when any state does`);
115
+ }
116
+ if (s.display !== undefined && terminals.has(s.id)) {
117
+ errors.push(`terminal state "${s.id}" must not declare display`);
118
+ }
119
+ }
120
+ const phases = [];
121
+ for (const s of annotated) {
122
+ if (!phases.includes(s.display.phase))
123
+ phases.push(s.display.phase);
124
+ }
125
+ if (phases.length > 25) {
126
+ errors.push(`${phases.length} phases exceed the 25 plan letters (B-Z); split the workflow or drop phase rows`);
127
+ }
128
+ for (const phase of phases) {
129
+ const members = annotated.filter((s) => s.display.phase === phase);
130
+ if (members.length > 9) {
131
+ errors.push(`phase "${phase}" has ${members.length} states — at most 9 digit children fit under one letter`);
132
+ }
133
+ const titles = [...new Set(members.map((s) => s.display.phaseTitle).filter((t) => t !== undefined))];
134
+ if (titles.length > 1) {
135
+ errors.push(`phase "${phase}" has conflicting phaseTitle values: ${titles.map((t) => `"${t}"`).join(", ")}`);
136
+ }
137
+ }
138
+ return errors;
139
+ }
140
+ function insertOnEntry(items, stateId, def) {
141
+ if (!isStateMachine(def))
142
+ return items;
143
+ if (items.some((i) => i.id === stateId))
144
+ return items;
145
+ const display = def.states.find((s) => s.id === stateId)?.display;
146
+ if (display === undefined || (def.terminalStates ?? []).includes(stateId))
147
+ return items;
148
+ const phaseRow = items.find((i) => i.id === `${PHASE_ID_PREFIX}${display.phase}`);
149
+ if (phaseRow === undefined)
150
+ return items;
151
+ const children = items.filter((i) => i.parent === phaseRow.label);
152
+ const label = planChild(phaseRow.label, children.length);
153
+ const title = display.title ?? stateId;
154
+ const item = {
155
+ label,
156
+ id: stateId,
157
+ outcome: "pending",
158
+ parent: phaseRow.label,
159
+ title,
160
+ text: childText(label, title, stateId)
161
+ };
162
+ const last = children.at(-1) ?? phaseRow;
163
+ const at = items.indexOf(last) + 1;
164
+ return [...items.slice(0, at), item, ...items.slice(at)];
165
+ }
166
+ function truncateTitle(name) {
167
+ if (name.length <= TITLE_CAP)
168
+ return name;
169
+ return `${name.slice(0, TITLE_CAP - 1).replace(/[\uD800-\uDBFF]$/, "")}…`;
170
+ }
171
+ function buildBatchPlan(tasks) {
172
+ const waves = [];
173
+ for (let offset = 0;offset < tasks.length; offset += WAVE_SIZE) {
174
+ const items = [
175
+ { label: "A", id: PREPARE_ID, outcome: "pending", title: "Prepare batch", text: "A Prepare batch" },
176
+ {
177
+ label: "A1",
178
+ id: "prepare.1",
179
+ outcome: "pending",
180
+ parent: "A",
181
+ title: "Resolve and freeze task set",
182
+ text: "A1 Resolve and freeze task set"
183
+ },
184
+ {
185
+ label: "A2",
186
+ id: "prepare.2",
187
+ outcome: "pending",
188
+ parent: "A",
189
+ title: "Order by dependencies",
190
+ text: "A2 Order by dependencies"
191
+ },
192
+ {
193
+ label: "A3",
194
+ id: "prepare.3",
195
+ outcome: "pending",
196
+ parent: "A",
197
+ title: "Prepare Git",
198
+ text: "A3 Prepare Git"
199
+ },
200
+ {
201
+ label: "A4",
202
+ id: "prepare.4",
203
+ outcome: "pending",
204
+ parent: "A",
205
+ title: "Publish plan",
206
+ text: "A4 Publish plan"
207
+ }
208
+ ];
209
+ tasks.slice(offset, offset + WAVE_SIZE).forEach((task, i) => {
210
+ const label = planLetter(i + 1);
211
+ const title = `${task.wbs} ${truncateTitle(task.name)}`;
212
+ items.push({
213
+ label,
214
+ id: `${TASK_ID_PREFIX}${task.wbs}`,
215
+ outcome: "pending",
216
+ title,
217
+ text: `${label} ${title}`
218
+ });
219
+ });
220
+ items.push({ label: "Z", id: REPORT_ID, outcome: "pending", title: "Batch report", text: "Z Batch report" });
221
+ waves.push(items);
222
+ }
223
+ return waves;
224
+ }
225
+ function taskPhaseChildren(taskLetter, plan) {
226
+ if (!Array.isArray(plan))
227
+ return [];
228
+ return plan.filter((i) => i.id.startsWith(PHASE_ID_PREFIX) && i.label.length === 1).map((row, i) => {
229
+ const label = planChild(taskLetter, i);
230
+ return {
231
+ label,
232
+ id: row.id,
233
+ outcome: row.outcome,
234
+ parent: taskLetter,
235
+ title: row.title,
236
+ text: `${label} ${row.title}`
237
+ };
238
+ });
239
+ }
240
+ function hostStatus(outcome) {
241
+ if (outcome === "completed")
242
+ return "completed";
243
+ if (outcome === "active")
244
+ return "in_progress";
245
+ return "pending";
246
+ }
247
+ function hostText(item) {
248
+ const hostDecided = item.outcome === "skipped" || item.outcome === "failed" || item.outcome === "unattempted" || item.outcome === "blocked";
249
+ return hostDecided ? `${item.text} [${item.outcome}]` : item.text;
250
+ }
251
+
252
+ // plugins/sp/scripts/batch-plan.ts
253
+ var BATCH_PLAN_USAGE = "usage: batch-plan.ts waves --tasks <tasks.json> | task-children --letter <A-Z> --plan <plan.json>";
254
+ function readJsonFile(path) {
255
+ return JSON.parse(readFileSync(path, "utf8"));
256
+ }
257
+ function isBatchTask(value) {
258
+ return typeof value === "object" && value !== null && typeof value.wbs === "string" && typeof value.name === "string";
259
+ }
260
+ function main(argv) {
261
+ const mode = argv[0];
262
+ if (mode === "waves") {
263
+ if (argv[1] !== "--tasks" || argv[2] === undefined) {
264
+ process.stderr.write(`${BATCH_PLAN_USAGE}
265
+ `);
266
+ return 2;
267
+ }
268
+ let tasks;
269
+ try {
270
+ tasks = readJsonFile(argv[2]);
271
+ } catch (error) {
272
+ process.stderr.write(`batch-plan: cannot read ${argv[2]} \u2014 ${error instanceof Error ? error.message : String(error)}
273
+ `);
274
+ return 1;
275
+ }
276
+ if (!Array.isArray(tasks) || !tasks.every(isBatchTask)) {
277
+ process.stderr.write(`batch-plan: --tasks file must be a JSON array of {wbs, name}
278
+ `);
279
+ return 1;
280
+ }
281
+ process.stdout.write(`${JSON.stringify({ waves: buildBatchPlan(tasks) })}
282
+ `);
283
+ return 0;
284
+ }
285
+ if (mode === "task-children") {
286
+ let letter = "";
287
+ let planPath = "";
288
+ for (let i = 1;i + 1 < argv.length; i += 2) {
289
+ if (argv[i] === "--letter")
290
+ letter = argv[i + 1] ?? "";
291
+ else if (argv[i] === "--plan")
292
+ planPath = argv[i + 1] ?? "";
293
+ }
294
+ if (!/^[A-Z]$/.test(letter) || planPath === "") {
295
+ process.stderr.write(`${BATCH_PLAN_USAGE}
296
+ `);
297
+ return 2;
298
+ }
299
+ let payload;
300
+ try {
301
+ payload = readJsonFile(planPath);
302
+ } catch (error) {
303
+ process.stderr.write(`batch-plan: cannot read ${planPath} \u2014 ${error instanceof Error ? error.message : String(error)}
304
+ `);
305
+ return 1;
306
+ }
307
+ const plan = Array.isArray(payload) ? payload : payload.plan;
308
+ if (!Array.isArray(plan)) {
309
+ process.stderr.write("batch-plan: --plan file must be a plan array or a todo --json payload with a `plan` field\n");
310
+ return 1;
311
+ }
312
+ process.stdout.write(`${JSON.stringify(taskPhaseChildren(letter, plan))}
313
+ `);
314
+ return 0;
315
+ }
316
+ process.stderr.write(`${BATCH_PLAN_USAGE}
317
+ `);
318
+ return 2;
319
+ }
320
+ {
321
+ process.exit(main(process.argv.slice(2)));
322
+ }
323
+ export {
324
+ validatePhaseTable,
325
+ taskPhaseChildren,
326
+ planLetter,
327
+ planChild,
328
+ main,
329
+ insertOnEntry,
330
+ hostText,
331
+ hostStatus,
332
+ buildPhasedPlan,
333
+ buildBatchPlan,
334
+ BATCH_PLAN_USAGE
335
+ };
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * batch-plan — letters-first batch plan surface (task 1104, feature I13).
4
+ *
5
+ * The projection logic lives in packages/app/src/workflow/plan-projection.ts (unit-tested
6
+ * there); this script is ADR-130 glue — argv/IO only — over the bundled core in
7
+ * ../lib/plan-projection.generated.* (regenerate: `bun run build:plugin-lib && bun run build:scripts`).
8
+ *
9
+ * Modes:
10
+ * waves --tasks <file> file: JSON array of {wbs, name}; prints `{ waves: [...] }` — waves
11
+ * of 24 tasks as A Prepare batch + A1..A4 + B..Y task rows + Z Batch report.
12
+ * task-children --letter <A-Z> --plan <file>
13
+ * file: a `workflow show --format todo --json` payload (or a bare plan
14
+ * array); prints the task's phase digits as a JSON array.
15
+ * Exit 0 on success, 1 on unreadable/invalid input, 2 on usage error. Runs under bare
16
+ * `node batch-plan.mjs` (committed twin) — keep the twin in lockstep.
17
+ */
18
+ import { readFileSync } from 'node:fs';
19
+ import type { BatchTask, PhasedPlanItem } from '../lib/plan-projection.generated.mjs';
20
+ import { buildBatchPlan, taskPhaseChildren } from '../lib/plan-projection.generated.mjs';
21
+
22
+ export * from '../lib/plan-projection.generated.mjs';
23
+
24
+ export const BATCH_PLAN_USAGE =
25
+ 'usage: batch-plan.ts waves --tasks <tasks.json> | task-children --letter <A-Z> --plan <plan.json>';
26
+
27
+ function readJsonFile(path: string): unknown {
28
+ return JSON.parse(readFileSync(path, 'utf8'));
29
+ }
30
+
31
+ function isBatchTask(value: unknown): value is BatchTask {
32
+ return (
33
+ typeof value === 'object' &&
34
+ value !== null &&
35
+ typeof (value as BatchTask).wbs === 'string' &&
36
+ typeof (value as BatchTask).name === 'string'
37
+ );
38
+ }
39
+
40
+ export function main(argv: string[]): number {
41
+ const mode = argv[0];
42
+ if (mode === 'waves') {
43
+ if (argv[1] !== '--tasks' || argv[2] === undefined) {
44
+ process.stderr.write(`${BATCH_PLAN_USAGE}\n`);
45
+ return 2;
46
+ }
47
+ let tasks: unknown;
48
+ try {
49
+ tasks = readJsonFile(argv[2]);
50
+ } catch (error) {
51
+ process.stderr.write(
52
+ `batch-plan: cannot read ${argv[2]} — ${error instanceof Error ? error.message : String(error)}\n`,
53
+ );
54
+ return 1;
55
+ }
56
+ if (!Array.isArray(tasks) || !tasks.every(isBatchTask)) {
57
+ process.stderr.write('batch-plan: --tasks file must be a JSON array of {wbs, name}\n');
58
+ return 1;
59
+ }
60
+ process.stdout.write(`${JSON.stringify({ waves: buildBatchPlan(tasks) })}\n`);
61
+ return 0;
62
+ }
63
+ if (mode === 'task-children') {
64
+ let letter = '';
65
+ let planPath = '';
66
+ for (let i = 1; i + 1 < argv.length; i += 2) {
67
+ if (argv[i] === '--letter') letter = argv[i + 1] ?? '';
68
+ else if (argv[i] === '--plan') planPath = argv[i + 1] ?? '';
69
+ }
70
+ if (!/^[A-Z]$/.test(letter) || planPath === '') {
71
+ process.stderr.write(`${BATCH_PLAN_USAGE}\n`);
72
+ return 2;
73
+ }
74
+ let payload: unknown;
75
+ try {
76
+ payload = readJsonFile(planPath);
77
+ } catch (error) {
78
+ process.stderr.write(
79
+ `batch-plan: cannot read ${planPath} — ${error instanceof Error ? error.message : String(error)}\n`,
80
+ );
81
+ return 1;
82
+ }
83
+ // Accept a bare plan array or a full `workflow show --format todo --json` payload.
84
+ const plan = Array.isArray(payload) ? payload : (payload as { plan?: PhasedPlanItem[] }).plan;
85
+ if (!Array.isArray(plan)) {
86
+ process.stderr.write(
87
+ 'batch-plan: --plan file must be a plan array or a todo --json payload with a `plan` field\n',
88
+ );
89
+ return 1;
90
+ }
91
+ process.stdout.write(`${JSON.stringify(taskPhaseChildren(letter, plan as PhasedPlanItem[]))}\n`);
92
+ return 0;
93
+ }
94
+ process.stderr.write(`${BATCH_PLAN_USAGE}\n`);
95
+ return 2;
96
+ }
97
+
98
+ if (import.meta.main) {
99
+ process.exit(main(process.argv.slice(2)));
100
+ }