@gobing-ai/spur 0.4.1 → 0.4.2

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 (104) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.global.yaml +4 -2
  3. package/config/templates/AGENTS.md +1 -1
  4. package/config/workflows/wrapup-pipeline.yaml +1 -1
  5. package/package.json +10 -10
  6. package/plugins/sp/README.md +1 -2
  7. package/plugins/sp/agents/super-planner.md +5 -6
  8. package/plugins/sp/commands/dev-review-session.md +2 -1
  9. package/plugins/sp/commands/dev-runall.md +18 -9
  10. package/plugins/sp/hooks/context-owner.ts +56 -0
  11. package/plugins/sp/hooks/context-post-tool.ts +23 -0
  12. package/plugins/sp/hooks/context-session-start.ts +49 -27
  13. package/plugins/sp/hooks/context-session-stop.ts +131 -30
  14. package/plugins/sp/hooks/pi/guard-extension.ts +21 -8
  15. package/plugins/sp/lib/idea-handoff.generated.mjs +4 -2
  16. package/plugins/sp/lib/inline-run.generated.mjs +47 -30
  17. package/plugins/sp/lib/plan-projection.generated.d.mts +19 -0
  18. package/plugins/sp/lib/plan-projection.generated.mjs +113 -0
  19. package/plugins/sp/lib/quality-gate.generated.d.mts +0 -12
  20. package/plugins/sp/lib/quality-gate.generated.mjs +48 -230
  21. package/plugins/sp/lib/transcript.ts +49 -21
  22. package/plugins/sp/plugin.json +1 -1
  23. package/plugins/sp/references/roles.md +1 -1
  24. package/plugins/sp/scripts/batch-plan.mjs +154 -1
  25. package/plugins/sp/scripts/batch-plan.ts +48 -3
  26. package/plugins/sp/scripts/inline-run-setup.mjs +24 -13
  27. package/plugins/sp/scripts/pr-reviewing.mjs +19 -5
  28. package/plugins/sp/scripts/pr-reviewing.ts +34 -9
  29. package/plugins/sp/scripts/quality-gate.mjs +25 -207
  30. package/plugins/sp/scripts/run-summary.mjs +24 -13
  31. package/plugins/sp/scripts/session-timeline.mjs +26 -25
  32. package/plugins/sp/scripts/session-timeline.ts +4 -18
  33. package/plugins/sp/scripts/task-diffstat.mjs +32 -29
  34. package/plugins/sp/scripts/task-diffstat.ts +34 -34
  35. package/plugins/sp/skills/indexed-context/SKILL.md +14 -7
  36. package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
  37. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
  38. package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +2 -2
  39. package/plugins/sp/skills/pr-reviewing/SKILL.md +7 -3
  40. package/plugins/sp/skills/session-review/SKILL.md +5 -3
  41. package/plugins/sp/skills/spur-cli/references/decision.md +9 -2
  42. package/plugins/sp/skills/spur-cli/references/projects.md +7 -5
  43. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  44. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +27 -0
  45. package/plugins/sp/skills/spur-dev/references/dev-operations.md +2 -3
  46. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +5 -0
  47. package/plugins/sp/skills/spur-dev/references/execution-batch.md +43 -4
  48. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +4 -1
  49. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +13 -11
  50. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +8 -13
  51. package/spur.js +14261 -2846
  52. package/web/_astro/{BoardApp.D66k_s_T.js → BoardApp.B3GQZvj8.js} +1 -1
  53. package/web/_astro/{BoardApp.D6sjAJr_.js → BoardApp.BT7L2kSb.js} +192 -192
  54. package/web/_astro/TaskDetail.Cxov1_N_.js +1 -0
  55. package/web/_astro/{arc._BUadrdH.js → arc.vaT7tPOB.js} +1 -1
  56. package/web/_astro/{architectureDiagram-3BPJPVTR.Dh7xS1Pp.js → architectureDiagram-3BPJPVTR.DRMmTjkN.js} +1 -1
  57. package/web/_astro/{blockDiagram-GPEHLZMM.DFDVpfXm.js → blockDiagram-GPEHLZMM.cla0LO_q.js} +1 -1
  58. package/web/_astro/{c4Diagram-AAUBKEIU.V7_3_glh.js → c4Diagram-AAUBKEIU.D4YAIBKn.js} +1 -1
  59. package/web/_astro/channel.D4nG9YLf.js +1 -0
  60. package/web/_astro/{chunk-2J33WTMH.De1yx7Jz.js → chunk-2J33WTMH.BrSJbMRZ.js} +1 -1
  61. package/web/_astro/{chunk-4BX2VUAB.BDhHFuxx.js → chunk-4BX2VUAB.CuLMgKF5.js} +1 -1
  62. package/web/_astro/{chunk-55IACEB6.DDLkWnmC.js → chunk-55IACEB6.zkvgu54F.js} +1 -1
  63. package/web/_astro/{chunk-727SXJPM.CwfiLnA7.js → chunk-727SXJPM.DxWkACh8.js} +1 -1
  64. package/web/_astro/{chunk-AQP2D5EJ.CtGDmUnM.js → chunk-AQP2D5EJ.42XrmIlH.js} +1 -1
  65. package/web/_astro/{chunk-FMBD7UC4.BOLjpw73.js → chunk-FMBD7UC4.Bao2wMK9.js} +1 -1
  66. package/web/_astro/{chunk-ND2GUHAM.BDhQls6O.js → chunk-ND2GUHAM.BhydojD1.js} +1 -1
  67. package/web/_astro/{chunk-QZHKN3VN.76qBynDF.js → chunk-QZHKN3VN.CmZ82g-H.js} +1 -1
  68. package/web/_astro/{classDiagram-4FO5ZUOK.N0mpPHT0.js → classDiagram-4FO5ZUOK.Blr83Gt_.js} +1 -1
  69. package/web/_astro/{classDiagram-v2-Q7XG4LA2.N0mpPHT0.js → classDiagram-v2-Q7XG4LA2.Blr83Gt_.js} +1 -1
  70. package/web/_astro/{cose-bilkent-S5V4N54A.CKknFfjB.js → cose-bilkent-S5V4N54A.qmfv0SPn.js} +1 -1
  71. package/web/_astro/{cynefin-OW5HDTMX.Cw3ZWBAh.js → cynefin-OW5HDTMX.BtELhXPR.js} +1 -1
  72. package/web/_astro/{dagre-BM42HDAG.D3yIKCRD.js → dagre-BM42HDAG.AWmaAvXo.js} +1 -1
  73. package/web/_astro/{diagram-2AECGRRQ.D51dO83Z.js → diagram-2AECGRRQ.DdH34KDi.js} +1 -1
  74. package/web/_astro/{diagram-5GNKFQAL.Bb-FViDj.js → diagram-5GNKFQAL.BmNPix1k.js} +1 -1
  75. package/web/_astro/{diagram-KO2AKTUF.D3mN8S6x.js → diagram-KO2AKTUF.BNQd_Ppo.js} +1 -1
  76. package/web/_astro/{diagram-LMA3HP47.dMMyPcbJ.js → diagram-LMA3HP47.BAPgDi0e.js} +1 -1
  77. package/web/_astro/{diagram-OG6HWLK6.AzbvzY4C.js → diagram-OG6HWLK6.DZ4zyLdC.js} +1 -1
  78. package/web/_astro/{erDiagram-TEJ5UH35.BR5I4OZb.js → erDiagram-TEJ5UH35.Q1TySMmw.js} +1 -1
  79. package/web/_astro/{flowDiagram-I6XJVG4X.DFqvhGkn.js → flowDiagram-I6XJVG4X.BXhFBicG.js} +1 -1
  80. package/web/_astro/{ganttDiagram-6RSMTGT7.ByJbhH6J.js → ganttDiagram-6RSMTGT7.vHSgcuo2.js} +1 -1
  81. package/web/_astro/{gitGraphDiagram-PVQCEYII.CInHjQR3.js → gitGraphDiagram-PVQCEYII.Bi1f73C7.js} +1 -1
  82. package/web/_astro/{index.COh_kBxN.css → index.BhSwN8yY.css} +1 -1
  83. package/web/_astro/{infoDiagram-5YYISTIA.Cm0dGVjJ.js → infoDiagram-5YYISTIA.DPvXZEWm.js} +1 -1
  84. package/web/_astro/{ishikawaDiagram-YF4QCWOH.C3nOP6Sj.js → ishikawaDiagram-YF4QCWOH.Cz829fsV.js} +1 -1
  85. package/web/_astro/{journeyDiagram-JHISSGLW.4IvLa2MW.js → journeyDiagram-JHISSGLW.enRnB9Me.js} +1 -1
  86. package/web/_astro/{kanban-definition-UN3LZRKU.DS87Cl-k.js → kanban-definition-UN3LZRKU.DnTNk6d2.js} +1 -1
  87. package/web/_astro/{linear.MzmRrNER.js → linear.BzGnBwNL.js} +1 -1
  88. package/web/_astro/{mermaid.core.fK-WNswS.js → mermaid.core.CSbTpIVz.js} +4 -4
  89. package/web/_astro/{mindmap-definition-RKZ34NQL.BqipOHhY.js → mindmap-definition-RKZ34NQL.DGJDy8zT.js} +1 -1
  90. package/web/_astro/{pieDiagram-4H26LBE5.0nBiPqFG.js → pieDiagram-4H26LBE5.HSPdi5_8.js} +1 -1
  91. package/web/_astro/{quadrantDiagram-W4KKPZXB.Szj7Zpx5.js → quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js} +1 -1
  92. package/web/_astro/{requirementDiagram-4Y6WPE33.D4UR6TWo.js → requirementDiagram-4Y6WPE33.CnBCtFwa.js} +1 -1
  93. package/web/_astro/{sankeyDiagram-5OEKKPKP.DAE7BdOs.js → sankeyDiagram-5OEKKPKP.5M8VWAu8.js} +1 -1
  94. package/web/_astro/{sequenceDiagram-3UESZ5HK.BP9ZVq25.js → sequenceDiagram-3UESZ5HK.DN41XN-U.js} +1 -1
  95. package/web/_astro/{stateDiagram-AJRCARHV.BQW7mIxk.js → stateDiagram-AJRCARHV.ErOO3W8j.js} +1 -1
  96. package/web/_astro/{stateDiagram-v2-BHNVJYJU.PbK4nDih.js → stateDiagram-v2-BHNVJYJU.B_ZHICM-.js} +1 -1
  97. package/web/_astro/{timeline-definition-PNZ67QCA.Ci_GvUpo.js → timeline-definition-PNZ67QCA.CWdosr07.js} +1 -1
  98. package/web/_astro/{vennDiagram-CIIHVFJN.DNw1Y5qx.js → vennDiagram-CIIHVFJN.BHzYjNV5.js} +1 -1
  99. package/web/_astro/{wardleyDiagram-YWT4CUSO.CwlkaaNe.js → wardleyDiagram-YWT4CUSO.h8x4IwPX.js} +1 -1
  100. package/web/_astro/{xychartDiagram-2RQKCTM6.MI6ZuiH_.js → xychartDiagram-2RQKCTM6.RTapW2X8.js} +1 -1
  101. package/web/index.html +2 -2
  102. package/plugins/sp/commands/dev-parallel.md +0 -37
  103. package/web/_astro/TaskDetail.BynLnMNc.js +0 -1
  104. package/web/_astro/channel.DZVfto-r.js +0 -1
@@ -34,6 +34,12 @@ export interface Row {
34
34
  isMeta?: boolean;
35
35
  isCompactSummary?: boolean;
36
36
  id?: string; // pi: the row id doubles as the once-per-message usage key
37
+ /** pi `custom_message` body: a hook-relayed prompt's text. */
38
+ content?: string;
39
+ /** pi `custom_message` visibility; `false` keeps the row out of the operator-visible transcript. */
40
+ display?: boolean;
41
+ /** pi `custom_message` producer id (e.g. a hook), carried for diagnostics only. */
42
+ customType?: string;
37
43
  message?: {
38
44
  id?: string;
39
45
  role?: string;
@@ -88,17 +94,32 @@ export function formatTokenSplit(t: Tokens | null): string {
88
94
  return `${formatTokens(nonCached + t.cacheRead)} / ${formatTokens(nonCached)}`;
89
95
  }
90
96
 
91
- /** Operator prompt text, or undefined for tool results, meta rows and compaction summaries. */
97
+ /**
98
+ * Operator prompt text, or undefined for tool results, meta rows and compaction summaries.
99
+ *
100
+ * pi (1151 R1/R3): pi records the OPERATOR'S own `/skill` invocation as a user message that opens
101
+ * with `<skill name="X" …>` and whose arguments follow the closing tag — it does not inject skill
102
+ * bodies as user rows (a 2,452-row census of local transcripts found none; the one synthetic
103
+ * body-only fixture row had no real counterpart). Every such row is therefore an operator prompt,
104
+ * rendered `/X <args>`; an ARGUMENT-LESS wrapper is still an invocation and renders `/X` (the skill
105
+ * name is the stage label the review groups by, so arguments alone would lose which command ran).
106
+ *
107
+ * pi (1151 follow-up): a hook-relayed `custom_message` (`display: true`) is a real operator prompt —
108
+ * the host shows it and the agent answers it — so it opens a segment like typed text; a
109
+ * `display: false` row is a host-only notice and stays non-segmenting activity.
110
+ */
92
111
  export function promptText(row: Row): string | undefined {
112
+ if (row.type === 'custom_message') {
113
+ if (row.display === false) return undefined;
114
+ const text = typeof row.content === 'string' ? row.content.trim() : '';
115
+ return text === '' ? undefined : text;
116
+ }
93
117
  if (row.type === 'message') {
94
118
  // pi (1130): only a user-role message with text content is a prompt; toolResult rows are not.
95
119
  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;
120
+ const text = firstTextContent(row);
121
+ if (text === undefined) return undefined;
122
+ return skillInvocationPrompt(text) ?? text;
102
123
  }
103
124
  if (row.type !== 'user' || row.isMeta || row.isCompactSummary) return undefined;
104
125
  const content = row.message?.content;
@@ -107,22 +128,29 @@ export function promptText(row: Row): string | undefined {
107
128
  return (content as Block[]).find((b) => b.type === 'text')?.text;
108
129
  }
109
130
 
131
+ /** First `type:text` block of a pi message, or its string content. */
132
+ function firstTextContent(row: Row): string | undefined {
133
+ const content = row.message?.content;
134
+ if (typeof content === 'string') return content;
135
+ if (!Array.isArray(content)) return undefined;
136
+ return (content as Block[]).find((b) => b.type === 'text')?.text;
137
+ }
138
+
110
139
  /**
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`.
140
+ * 1151 R1: `/X <args>` for a pi operator `/skill` invocation wrapper, undefined for anything else.
141
+ *
142
+ * The wrapper is `^\s*<skill name="X"` — the anchored, complete quoted name is the only signal;
143
+ * text that merely CONTAINS `<skill` later in the body stays plain operator text. Arguments are the
144
+ * text after the LAST `</skill>` (a body may itself quote the tag); a wrapper with no closing tag
145
+ * renders `/X` alone rather than dumping the body as the prompt. A malformed name (no closing quote)
146
+ * is not a wrapper.
115
147
  */
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="');
148
+ export function skillInvocationPrompt(text: string): string | undefined {
149
+ const name = /^\s*<skill name="([^"]+)"/.exec(text)?.[1];
150
+ if (name === undefined) return undefined;
151
+ const close = text.lastIndexOf('</skill>');
152
+ const args = close < 0 ? '' : text.slice(close + '</skill>'.length).trim();
153
+ return args === '' ? `/${name}` : `/${name} ${args}`;
126
154
  }
127
155
 
128
156
  export function sumTokens(all: Tokens[]): Tokens {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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-find-way, 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-next, dev-job-resume, dev-arch, dev-refineall, dev-find-next, dev-feature-change]
66
66
  stages: [plan, refine, brainstorm]
67
67
  ```
68
68
 
@@ -222,6 +222,118 @@ function buildBatchPlan(tasks) {
222
222
  }
223
223
  return waves;
224
224
  }
225
+ var byWbs = (a, b) => a.localeCompare(b, "en", { numeric: true });
226
+ function kahnOrder(nodes, preds) {
227
+ const indegree = new Map(nodes.map((n) => [n, preds.get(n)?.size ?? 0]));
228
+ const succ = new Map(nodes.map((n) => [n, []]));
229
+ for (const [n, ps] of preds)
230
+ for (const p of ps)
231
+ succ.get(p)?.push(n);
232
+ const ready = nodes.filter((n) => indegree.get(n) === 0).sort(byWbs);
233
+ const order = [];
234
+ for (let n = ready.shift();n !== undefined; n = ready.shift()) {
235
+ order.push(n);
236
+ for (const s of succ.get(n) ?? []) {
237
+ const d = (indegree.get(s) ?? 0) - 1;
238
+ indegree.set(s, d);
239
+ if (d === 0) {
240
+ ready.push(s);
241
+ ready.sort(byWbs);
242
+ }
243
+ }
244
+ }
245
+ return order;
246
+ }
247
+ function findCycle(remaining, preds) {
248
+ const state = new Map;
249
+ const stack = [];
250
+ const visit = (n) => {
251
+ state.set(n, "visiting");
252
+ stack.push(n);
253
+ for (const p of [...preds.get(n) ?? []].sort(byWbs)) {
254
+ if (!remaining.has(p))
255
+ continue;
256
+ if (state.get(p) === "visiting")
257
+ return [...stack.slice(stack.indexOf(p)), p];
258
+ if (!state.has(p)) {
259
+ const cycle = visit(p);
260
+ if (cycle)
261
+ return cycle;
262
+ }
263
+ }
264
+ stack.pop();
265
+ state.set(n, "done");
266
+ return null;
267
+ };
268
+ for (const n of [...remaining].sort(byWbs)) {
269
+ if (state.has(n))
270
+ continue;
271
+ const cycle = visit(n);
272
+ if (cycle)
273
+ return cycle;
274
+ }
275
+ return [];
276
+ }
277
+ function scheduleBatch(tasks, opts = {}) {
278
+ const byId = new Map;
279
+ for (const t of tasks)
280
+ if (!byId.has(t.wbs))
281
+ byId.set(t.wbs, t);
282
+ const nodes = [...byId.keys()].sort(byWbs);
283
+ const preds = new Map(nodes.map((n) => [n, new Set((byId.get(n)?.dependencies ?? []).filter((d) => byId.has(d)))]));
284
+ const depOrder = kahnOrder(nodes, preds);
285
+ if (depOrder.length < nodes.length) {
286
+ const cycle = findCycle(new Set(nodes.filter((n) => !depOrder.includes(n))), preds);
287
+ return {
288
+ order: [],
289
+ waves: [],
290
+ cycle,
291
+ overlaps: [],
292
+ mode: "sequential",
293
+ reason: `dependency cycle ${cycle.join(" → ")}`
294
+ };
295
+ }
296
+ const files = new Map(nodes.map((n) => [n, new Set((byId.get(n)?.files ?? []).map(normalizeTaskPath))]));
297
+ const overlaps = [];
298
+ for (let i = 0;i < depOrder.length; i++) {
299
+ const before = depOrder[i];
300
+ const beforeFiles = files.get(before) ?? new Set;
301
+ for (const after of depOrder.slice(i + 1)) {
302
+ const shared = [...beforeFiles].filter((f) => files.get(after)?.has(f)).sort();
303
+ if (shared.length === 0)
304
+ continue;
305
+ overlaps.push({ before, after, files: shared });
306
+ preds.get(after)?.add(before);
307
+ }
308
+ }
309
+ const order = kahnOrder(nodes, preds);
310
+ const level = new Map;
311
+ for (const n of order) {
312
+ let l = 0;
313
+ for (const p of preds.get(n) ?? [])
314
+ l = Math.max(l, (level.get(p) ?? 0) + 1);
315
+ level.set(n, l);
316
+ }
317
+ const waves = [];
318
+ for (const n of order) {
319
+ const l = level.get(n) ?? 0;
320
+ waves[l] = [...waves[l] ?? [], n].sort(byWbs);
321
+ }
322
+ const widest = Math.max(0, ...waves.map((w) => w.length));
323
+ const concurrency = opts.concurrency ?? 2;
324
+ let reason = `widest wave holds ${widest} independent tasks`;
325
+ if (nodes.length < 2)
326
+ reason = "fewer than two tasks";
327
+ else if (concurrency < 2)
328
+ reason = "concurrency is 1";
329
+ else if (widest < 2)
330
+ reason = "every wave holds one task (dependency or file-overlap chain)";
331
+ const mode = nodes.length >= 2 && concurrency >= 2 && widest >= 2 ? "parallel" : "sequential";
332
+ return { order, waves, cycle: null, overlaps, mode, reason };
333
+ }
334
+ function normalizeTaskPath(path) {
335
+ return path.trim().replace(/^\.\//, "");
336
+ }
225
337
  function taskPhaseChildren(taskLetter, plan) {
226
338
  if (!Array.isArray(plan))
227
339
  return [];
@@ -250,13 +362,22 @@ function hostText(item) {
250
362
  }
251
363
 
252
364
  // 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>";
365
+ var BATCH_PLAN_USAGE = "usage: batch-plan.ts waves --tasks <tasks.json> | order --tasks <tasks.json> [--concurrency <n>] | task-children --letter <A-Z> --plan <plan.json>";
254
366
  function readJsonFile(path) {
255
367
  return JSON.parse(readFileSync(path, "utf8"));
256
368
  }
257
369
  function isBatchTask(value) {
258
370
  return typeof value === "object" && value !== null && typeof value.wbs === "string" && typeof value.name === "string";
259
371
  }
372
+ function isStringArray(value) {
373
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
374
+ }
375
+ function isScheduleTask(value) {
376
+ if (typeof value !== "object" || value === null)
377
+ return false;
378
+ const t = value;
379
+ return typeof t.wbs === "string" && (t.dependencies === undefined || isStringArray(t.dependencies)) && (t.files === undefined || isStringArray(t.files));
380
+ }
260
381
  function main(argv) {
261
382
  const mode = argv[0];
262
383
  if (mode === "waves") {
@@ -279,6 +400,37 @@ function main(argv) {
279
400
  return 1;
280
401
  }
281
402
  process.stdout.write(`${JSON.stringify({ waves: buildBatchPlan(tasks) })}
403
+ `);
404
+ return 0;
405
+ }
406
+ if (mode === "order") {
407
+ let tasksPath = "";
408
+ let concurrency = 2;
409
+ for (let i = 1;i + 1 < argv.length; i += 2) {
410
+ if (argv[i] === "--tasks")
411
+ tasksPath = argv[i + 1] ?? "";
412
+ else if (argv[i] === "--concurrency")
413
+ concurrency = Number(argv[i + 1]);
414
+ }
415
+ if (tasksPath === "" || !Number.isInteger(concurrency) || concurrency < 1) {
416
+ process.stderr.write(`${BATCH_PLAN_USAGE}
417
+ `);
418
+ return 2;
419
+ }
420
+ let tasks;
421
+ try {
422
+ tasks = readJsonFile(tasksPath);
423
+ } catch (error) {
424
+ process.stderr.write(`batch-plan: cannot read ${tasksPath} \u2014 ${error instanceof Error ? error.message : String(error)}
425
+ `);
426
+ return 1;
427
+ }
428
+ if (!Array.isArray(tasks) || !tasks.every(isScheduleTask)) {
429
+ process.stderr.write(`batch-plan: --tasks file must be a JSON array of {wbs, dependencies?, files?}
430
+ `);
431
+ return 1;
432
+ }
433
+ process.stdout.write(`${JSON.stringify(scheduleBatch(tasks, { concurrency }))}
282
434
  `);
283
435
  return 0;
284
436
  }
@@ -323,6 +475,7 @@ function main(argv) {
323
475
  export {
324
476
  validatePhaseTable,
325
477
  taskPhaseChildren,
478
+ scheduleBatch,
326
479
  planLetter,
327
480
  planChild,
328
481
  main,
@@ -9,6 +9,10 @@
9
9
  * Modes:
10
10
  * waves --tasks <file> file: JSON array of {wbs, name}; prints `{ waves: [...] }` — waves
11
11
  * of 24 tasks as A Prepare batch + A1..A4 + B..Y task rows + Z Batch report.
12
+ * order --tasks <file> [--concurrency <n>]
13
+ * file: JSON array of {wbs, dependencies?, files?}; prints the scheduleBatch
14
+ * result `{ order, waves, cycle, overlaps, mode, reason }` (dev-runall §2.4
15
+ * order + the --mode auto recommendation). A cycle is data, not an error.
12
16
  * task-children --letter <A-Z> --plan <file>
13
17
  * file: a `workflow show --format todo --json` payload (or a bare plan
14
18
  * array); prints the task's phase digits as a JSON array.
@@ -16,13 +20,13 @@
16
20
  * `node batch-plan.mjs` (committed twin) — keep the twin in lockstep.
17
21
  */
18
22
  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';
23
+ import type { BatchTask, PhasedPlanItem, ScheduleTask } from '../lib/plan-projection.generated.mjs';
24
+ import { buildBatchPlan, scheduleBatch, taskPhaseChildren } from '../lib/plan-projection.generated.mjs';
21
25
 
22
26
  export * from '../lib/plan-projection.generated.mjs';
23
27
 
24
28
  export const BATCH_PLAN_USAGE =
25
- 'usage: batch-plan.ts waves --tasks <tasks.json> | task-children --letter <A-Z> --plan <plan.json>';
29
+ 'usage: batch-plan.ts waves --tasks <tasks.json> | order --tasks <tasks.json> [--concurrency <n>] | task-children --letter <A-Z> --plan <plan.json>';
26
30
 
27
31
  function readJsonFile(path: string): unknown {
28
32
  return JSON.parse(readFileSync(path, 'utf8'));
@@ -37,6 +41,20 @@ function isBatchTask(value: unknown): value is BatchTask {
37
41
  );
38
42
  }
39
43
 
44
+ function isStringArray(value: unknown): value is string[] {
45
+ return Array.isArray(value) && value.every((v) => typeof v === 'string');
46
+ }
47
+
48
+ function isScheduleTask(value: unknown): value is ScheduleTask {
49
+ if (typeof value !== 'object' || value === null) return false;
50
+ const t = value as ScheduleTask;
51
+ return (
52
+ typeof t.wbs === 'string' &&
53
+ (t.dependencies === undefined || isStringArray(t.dependencies)) &&
54
+ (t.files === undefined || isStringArray(t.files))
55
+ );
56
+ }
57
+
40
58
  export function main(argv: string[]): number {
41
59
  const mode = argv[0];
42
60
  if (mode === 'waves') {
@@ -60,6 +78,33 @@ export function main(argv: string[]): number {
60
78
  process.stdout.write(`${JSON.stringify({ waves: buildBatchPlan(tasks) })}\n`);
61
79
  return 0;
62
80
  }
81
+ if (mode === 'order') {
82
+ let tasksPath = '';
83
+ let concurrency = 2;
84
+ for (let i = 1; i + 1 < argv.length; i += 2) {
85
+ if (argv[i] === '--tasks') tasksPath = argv[i + 1] ?? '';
86
+ else if (argv[i] === '--concurrency') concurrency = Number(argv[i + 1]);
87
+ }
88
+ if (tasksPath === '' || !Number.isInteger(concurrency) || concurrency < 1) {
89
+ process.stderr.write(`${BATCH_PLAN_USAGE}\n`);
90
+ return 2;
91
+ }
92
+ let tasks: unknown;
93
+ try {
94
+ tasks = readJsonFile(tasksPath);
95
+ } catch (error) {
96
+ process.stderr.write(
97
+ `batch-plan: cannot read ${tasksPath} — ${error instanceof Error ? error.message : String(error)}\n`,
98
+ );
99
+ return 1;
100
+ }
101
+ if (!Array.isArray(tasks) || !tasks.every(isScheduleTask)) {
102
+ process.stderr.write('batch-plan: --tasks file must be a JSON array of {wbs, dependencies?, files?}\n');
103
+ return 1;
104
+ }
105
+ process.stdout.write(`${JSON.stringify(scheduleBatch(tasks, { concurrency }))}\n`);
106
+ return 0;
107
+ }
63
108
  if (mode === 'task-children') {
64
109
  let letter = '';
65
110
  let planPath = '';
@@ -60,17 +60,19 @@ function formatTokenSplit(t) {
60
60
  return `${formatTokens(nonCached + t.cacheRead)} / ${formatTokens(nonCached)}`;
61
61
  }
62
62
  function promptText(row) {
63
+ if (row.type === "custom_message") {
64
+ if (row.display === false)
65
+ return;
66
+ const text = typeof row.content === "string" ? row.content.trim() : "";
67
+ return text === "" ? undefined : text;
68
+ }
63
69
  if (row.type === "message") {
64
70
  if (row.message?.role !== "user")
65
71
  return;
66
- if (isInjectedPrompt(row))
67
- return;
68
- const content2 = row.message.content;
69
- if (typeof content2 === "string")
70
- return content2;
71
- if (!Array.isArray(content2))
72
+ const text = firstTextContent(row);
73
+ if (text === undefined)
72
74
  return;
73
- return content2.find((b) => b.type === "text")?.text;
75
+ return skillInvocationPrompt(text) ?? text;
74
76
  }
75
77
  if (row.type !== "user" || row.isMeta || row.isCompactSummary)
76
78
  return;
@@ -81,12 +83,21 @@ function promptText(row) {
81
83
  return;
82
84
  return content.find((b) => b.type === "text")?.text;
83
85
  }
84
- function isInjectedPrompt(row) {
85
- if (row.type !== "message" || row.message?.role !== "user")
86
- return false;
87
- const content = row.message.content;
88
- const text = typeof content === "string" ? content : Array.isArray(content) ? content.find((b) => b.type === "text")?.text : undefined;
89
- return typeof text === "string" && text.replace(/^\s+/, "").startsWith('<skill name="');
86
+ function firstTextContent(row) {
87
+ const content = row.message?.content;
88
+ if (typeof content === "string")
89
+ return content;
90
+ if (!Array.isArray(content))
91
+ return;
92
+ return content.find((b) => b.type === "text")?.text;
93
+ }
94
+ function skillInvocationPrompt(text) {
95
+ const name = /^\s*<skill name="([^"]+)"/.exec(text)?.[1];
96
+ if (name === undefined)
97
+ return;
98
+ const close = text.lastIndexOf("</skill>");
99
+ const args = close < 0 ? "" : text.slice(close + "</skill>".length).trim();
100
+ return args === "" ? `/${name}` : `/${name} ${args}`;
90
101
  }
91
102
  function sumTokens(all) {
92
103
  const total = zeroTokens();
@@ -480,6 +480,15 @@ ${requestMarker(pr.headRefOid)}`;
480
480
  writeStatus(args, "REQUESTED");
481
481
  emit(args, { ok: true, requested: true, pr: pr.number, url: pr.url, head: pr.headRefOid, requestedAt, body }, `Requested GitHub Codex review on PR #${pr.number} (${pr.url}) at HEAD ${pr.headRefOid.slice(0, 7)}.`);
482
482
  }
483
+ function revalidateWaitTarget(args, number) {
484
+ const fresh = viewPr();
485
+ if (!fresh || fresh.number !== number) {
486
+ writeStatus(args, "FAIL");
487
+ fail(args, `PR #${number} is no longer the branch's PR (view reports ${fresh ? `#${fresh.number}` : "none"}) \u2014 request a fresh review`, 2);
488
+ }
489
+ requireExpectedHead(args, fresh);
490
+ return fresh;
491
+ }
483
492
  function cmdWait(args) {
484
493
  const since = args.flags.get("--since") ?? new Date().toISOString();
485
494
  const timeoutSec = Number(args.flags.get("--timeout") ?? "600");
@@ -494,24 +503,29 @@ function cmdWait(args) {
494
503
  writeStatus(args, "FAIL");
495
504
  fail(args, "no PR for the current branch", 2);
496
505
  }
506
+ const number = pr.number;
497
507
  requireExpectedHead(args, pr);
498
508
  const deadline = Date.now() + timeoutSec * 1000;
499
509
  for (;; ) {
500
- const reviews = fetchReviews(ctx, pr.number);
501
- const findings = normalizeFindings(reviews, fetchInlineComments(ctx, pr.number), fetchIssueComments(ctx, pr.number), since, pr.headRefOid);
510
+ const observed = revalidateWaitTarget(args, number);
511
+ const reviews = fetchReviews(ctx, number);
512
+ const findings = normalizeFindings(reviews, fetchInlineComments(ctx, number), fetchIssueComments(ctx, number), since, observed.headRefOid);
502
513
  if (findings.length > 0) {
514
+ revalidateWaitTarget(args, number);
503
515
  writeStatus(args, "FOUND");
504
516
  emit(args, { ok: true, verdict: "FOUND", findings }, renderFindings(findings));
505
517
  return;
506
518
  }
507
- if (hasCurrentCleanReview(reviews, since, pr.headRefOid)) {
519
+ if (hasCurrentCleanReview(reviews, since, observed.headRefOid)) {
520
+ const fresh = revalidateWaitTarget(args, number);
508
521
  writeStatus(args, "CLEAN");
509
- emit(args, { ok: true, verdict: "CLEAN", pr: pr.number, url: pr.url, head: pr.headRefOid, findings: [] }, `Codex review completed cleanly for HEAD ${pr.headRefOid.slice(0, 7)}.`);
522
+ emit(args, { ok: true, verdict: "CLEAN", pr: fresh.number, url: fresh.url, head: fresh.headRefOid, findings: [] }, `Codex review completed cleanly for HEAD ${fresh.headRefOid.slice(0, 7)}.`);
510
523
  return;
511
524
  }
512
525
  if (Date.now() >= deadline) {
526
+ revalidateWaitTarget(args, number);
513
527
  writeStatus(args, "TIMEOUT");
514
- emit(args, { ok: true, verdict: "TIMEOUT", pr: pr.number, url: pr.url }, `No Codex review within ${timeoutSec}s \u2014 still pending. Collect later with /sp:dev-pr-review collect.
528
+ emit(args, { ok: true, verdict: "TIMEOUT", pr: number, url: pr.url }, `No Codex review within ${timeoutSec}s \u2014 still pending. Collect later with /sp:dev-pr-review collect.
515
529
  PR: ${pr.url}`);
516
530
  throw new ScriptExit("", 3, args.booleans.has("--json"));
517
531
  }
@@ -703,6 +703,26 @@ function cmdRequest(args: ParsedArgs): void {
703
703
  );
704
704
  }
705
705
 
706
+ /**
707
+ * 1161 R1: re-read the PR and re-assert the pinned identity + expected head. Called at the start of
708
+ * every poll and again immediately before any FOUND/CLEAN/TIMEOUT status or output, because a head
709
+ * captured once at wait start lets a later push return CLEAN for an old commit. A vanished PR, a
710
+ * different PR on the branch, or a moved head is FAIL/exit2 through the existing error channel.
711
+ */
712
+ function revalidateWaitTarget(args: ParsedArgs, number: number): GhPr {
713
+ const fresh = viewPr();
714
+ if (!fresh || fresh.number !== number) {
715
+ writeStatus(args, 'FAIL');
716
+ fail(
717
+ args,
718
+ `PR #${number} is no longer the branch's PR (view reports ${fresh ? `#${fresh.number}` : 'none'}) — request a fresh review`,
719
+ 2,
720
+ );
721
+ }
722
+ requireExpectedHead(args, fresh);
723
+ return fresh;
724
+ }
725
+
706
726
  function cmdWait(args: ParsedArgs): void {
707
727
  const since = args.flags.get('--since') ?? new Date().toISOString();
708
728
  const timeoutSec = Number(args.flags.get('--timeout') ?? '600');
@@ -715,36 +735,41 @@ function cmdWait(args: ParsedArgs): void {
715
735
  writeStatus(args, 'FAIL');
716
736
  fail(args, 'no PR for the current branch', 2);
717
737
  }
738
+ const number = pr.number; // 1161 R1: pinned — a switched PR number is a failure, not a retarget.
718
739
  requireExpectedHead(args, pr);
719
- const deadline = Date.now() + timeoutSec * 1000;
740
+ const deadline = Date.now() + timeoutSec * 1000; // fixed once; validation never resets it
720
741
  for (;;) {
721
- const reviews = fetchReviews(ctx, pr.number);
742
+ const observed = revalidateWaitTarget(args, number);
743
+ const reviews = fetchReviews(ctx, number);
722
744
  const findings = normalizeFindings(
723
745
  reviews,
724
- fetchInlineComments(ctx, pr.number),
725
- fetchIssueComments(ctx, pr.number),
746
+ fetchInlineComments(ctx, number),
747
+ fetchIssueComments(ctx, number),
726
748
  since,
727
- pr.headRefOid,
749
+ observed.headRefOid,
728
750
  );
729
751
  if (findings.length > 0) {
752
+ revalidateWaitTarget(args, number); // pre-return recheck: never FOUND for a moved head
730
753
  writeStatus(args, 'FOUND');
731
754
  emit(args, { ok: true, verdict: 'FOUND', findings }, renderFindings(findings));
732
755
  return;
733
756
  }
734
- if (hasCurrentCleanReview(reviews, since, pr.headRefOid)) {
757
+ if (hasCurrentCleanReview(reviews, since, observed.headRefOid)) {
758
+ const fresh = revalidateWaitTarget(args, number); // pre-return recheck: never CLEAN for a moved head
735
759
  writeStatus(args, 'CLEAN');
736
760
  emit(
737
761
  args,
738
- { ok: true, verdict: 'CLEAN', pr: pr.number, url: pr.url, head: pr.headRefOid, findings: [] },
739
- `Codex review completed cleanly for HEAD ${pr.headRefOid.slice(0, 7)}.`,
762
+ { ok: true, verdict: 'CLEAN', pr: fresh.number, url: fresh.url, head: fresh.headRefOid, findings: [] },
763
+ `Codex review completed cleanly for HEAD ${fresh.headRefOid.slice(0, 7)}.`,
740
764
  );
741
765
  return;
742
766
  }
743
767
  if (Date.now() >= deadline) {
768
+ revalidateWaitTarget(args, number); // a head moved while pending is a failure, not a timeout
744
769
  writeStatus(args, 'TIMEOUT');
745
770
  emit(
746
771
  args,
747
- { ok: true, verdict: 'TIMEOUT', pr: pr.number, url: pr.url },
772
+ { ok: true, verdict: 'TIMEOUT', pr: number, url: pr.url },
748
773
  `No Codex review within ${timeoutSec}s — still pending. Collect later with /sp:dev-pr-review collect.\nPR: ${pr.url}`,
749
774
  );
750
775
  throw new ScriptExit('', 3, args.booleans.has('--json'));