@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/config.global.yaml +4 -2
- package/config/templates/AGENTS.md +1 -1
- package/config/workflows/wrapup-pipeline.yaml +1 -1
- package/package.json +10 -10
- package/plugins/sp/README.md +1 -2
- package/plugins/sp/agents/super-planner.md +5 -6
- package/plugins/sp/commands/dev-review-session.md +2 -1
- package/plugins/sp/commands/dev-runall.md +18 -9
- package/plugins/sp/hooks/context-owner.ts +56 -0
- package/plugins/sp/hooks/context-post-tool.ts +23 -0
- package/plugins/sp/hooks/context-session-start.ts +49 -27
- package/plugins/sp/hooks/context-session-stop.ts +131 -30
- package/plugins/sp/hooks/pi/guard-extension.ts +21 -8
- package/plugins/sp/lib/idea-handoff.generated.mjs +4 -2
- package/plugins/sp/lib/inline-run.generated.mjs +47 -30
- package/plugins/sp/lib/plan-projection.generated.d.mts +19 -0
- package/plugins/sp/lib/plan-projection.generated.mjs +113 -0
- package/plugins/sp/lib/quality-gate.generated.d.mts +0 -12
- package/plugins/sp/lib/quality-gate.generated.mjs +48 -230
- package/plugins/sp/lib/transcript.ts +49 -21
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +1 -1
- package/plugins/sp/scripts/batch-plan.mjs +154 -1
- package/plugins/sp/scripts/batch-plan.ts +48 -3
- package/plugins/sp/scripts/inline-run-setup.mjs +24 -13
- package/plugins/sp/scripts/pr-reviewing.mjs +19 -5
- package/plugins/sp/scripts/pr-reviewing.ts +34 -9
- package/plugins/sp/scripts/quality-gate.mjs +25 -207
- package/plugins/sp/scripts/run-summary.mjs +24 -13
- package/plugins/sp/scripts/session-timeline.mjs +26 -25
- package/plugins/sp/scripts/session-timeline.ts +4 -18
- package/plugins/sp/scripts/task-diffstat.mjs +32 -29
- package/plugins/sp/scripts/task-diffstat.ts +34 -34
- package/plugins/sp/skills/indexed-context/SKILL.md +14 -7
- package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
- package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +2 -2
- package/plugins/sp/skills/pr-reviewing/SKILL.md +7 -3
- package/plugins/sp/skills/session-review/SKILL.md +5 -3
- package/plugins/sp/skills/spur-cli/references/decision.md +9 -2
- package/plugins/sp/skills/spur-cli/references/projects.md +7 -5
- package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +27 -0
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +2 -3
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +5 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +43 -4
- package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +4 -1
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +13 -11
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +8 -13
- package/spur.js +14261 -2846
- package/web/_astro/{BoardApp.D66k_s_T.js → BoardApp.B3GQZvj8.js} +1 -1
- package/web/_astro/{BoardApp.D6sjAJr_.js → BoardApp.BT7L2kSb.js} +192 -192
- package/web/_astro/TaskDetail.Cxov1_N_.js +1 -0
- package/web/_astro/{arc._BUadrdH.js → arc.vaT7tPOB.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.Dh7xS1Pp.js → architectureDiagram-3BPJPVTR.DRMmTjkN.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.DFDVpfXm.js → blockDiagram-GPEHLZMM.cla0LO_q.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.V7_3_glh.js → c4Diagram-AAUBKEIU.D4YAIBKn.js} +1 -1
- package/web/_astro/channel.D4nG9YLf.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.De1yx7Jz.js → chunk-2J33WTMH.BrSJbMRZ.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.BDhHFuxx.js → chunk-4BX2VUAB.CuLMgKF5.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.DDLkWnmC.js → chunk-55IACEB6.zkvgu54F.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.CwfiLnA7.js → chunk-727SXJPM.DxWkACh8.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.CtGDmUnM.js → chunk-AQP2D5EJ.42XrmIlH.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.BOLjpw73.js → chunk-FMBD7UC4.Bao2wMK9.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.BDhQls6O.js → chunk-ND2GUHAM.BhydojD1.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.76qBynDF.js → chunk-QZHKN3VN.CmZ82g-H.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.N0mpPHT0.js → classDiagram-4FO5ZUOK.Blr83Gt_.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.N0mpPHT0.js → classDiagram-v2-Q7XG4LA2.Blr83Gt_.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.CKknFfjB.js → cose-bilkent-S5V4N54A.qmfv0SPn.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.Cw3ZWBAh.js → cynefin-OW5HDTMX.BtELhXPR.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.D3yIKCRD.js → dagre-BM42HDAG.AWmaAvXo.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.D51dO83Z.js → diagram-2AECGRRQ.DdH34KDi.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.Bb-FViDj.js → diagram-5GNKFQAL.BmNPix1k.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.D3mN8S6x.js → diagram-KO2AKTUF.BNQd_Ppo.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.dMMyPcbJ.js → diagram-LMA3HP47.BAPgDi0e.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.AzbvzY4C.js → diagram-OG6HWLK6.DZ4zyLdC.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.BR5I4OZb.js → erDiagram-TEJ5UH35.Q1TySMmw.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.DFqvhGkn.js → flowDiagram-I6XJVG4X.BXhFBicG.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.ByJbhH6J.js → ganttDiagram-6RSMTGT7.vHSgcuo2.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.CInHjQR3.js → gitGraphDiagram-PVQCEYII.Bi1f73C7.js} +1 -1
- package/web/_astro/{index.COh_kBxN.css → index.BhSwN8yY.css} +1 -1
- package/web/_astro/{infoDiagram-5YYISTIA.Cm0dGVjJ.js → infoDiagram-5YYISTIA.DPvXZEWm.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.C3nOP6Sj.js → ishikawaDiagram-YF4QCWOH.Cz829fsV.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.4IvLa2MW.js → journeyDiagram-JHISSGLW.enRnB9Me.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.DS87Cl-k.js → kanban-definition-UN3LZRKU.DnTNk6d2.js} +1 -1
- package/web/_astro/{linear.MzmRrNER.js → linear.BzGnBwNL.js} +1 -1
- package/web/_astro/{mermaid.core.fK-WNswS.js → mermaid.core.CSbTpIVz.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.BqipOHhY.js → mindmap-definition-RKZ34NQL.DGJDy8zT.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.0nBiPqFG.js → pieDiagram-4H26LBE5.HSPdi5_8.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.Szj7Zpx5.js → quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.D4UR6TWo.js → requirementDiagram-4Y6WPE33.CnBCtFwa.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.DAE7BdOs.js → sankeyDiagram-5OEKKPKP.5M8VWAu8.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.BP9ZVq25.js → sequenceDiagram-3UESZ5HK.DN41XN-U.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.BQW7mIxk.js → stateDiagram-AJRCARHV.ErOO3W8j.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.PbK4nDih.js → stateDiagram-v2-BHNVJYJU.B_ZHICM-.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.Ci_GvUpo.js → timeline-definition-PNZ67QCA.CWdosr07.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.DNw1Y5qx.js → vennDiagram-CIIHVFJN.BHzYjNV5.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.CwlkaaNe.js → wardleyDiagram-YWT4CUSO.h8x4IwPX.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.MI6ZuiH_.js → xychartDiagram-2RQKCTM6.RTapW2X8.js} +1 -1
- package/web/index.html +2 -2
- package/plugins/sp/commands/dev-parallel.md +0 -37
- package/web/_astro/TaskDetail.BynLnMNc.js +0 -1
- 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
|
-
/**
|
|
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
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
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 {
|
package/plugins/sp/plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sp",
|
|
3
|
-
"version": "0.4.
|
|
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-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
501
|
-
const
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
|
742
|
+
const observed = revalidateWaitTarget(args, number);
|
|
743
|
+
const reviews = fetchReviews(ctx, number);
|
|
722
744
|
const findings = normalizeFindings(
|
|
723
745
|
reviews,
|
|
724
|
-
fetchInlineComments(ctx,
|
|
725
|
-
fetchIssueComments(ctx,
|
|
746
|
+
fetchInlineComments(ctx, number),
|
|
747
|
+
fetchIssueComments(ctx, number),
|
|
726
748
|
since,
|
|
727
|
-
|
|
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,
|
|
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:
|
|
739
|
-
`Codex review completed cleanly for HEAD ${
|
|
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:
|
|
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'));
|