@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/decisions/gates.yaml +36 -0
- package/config/decisions/history-anatomy.yaml +35 -0
- package/config/decisions/idea-pipeline.yaml +38 -0
- package/config/plugin-scripts.json +15 -0
- package/config/rules/docs/sourced-api-claims.yaml +60 -0
- package/config/rules/recommended-pre-check.yaml +8 -3
- package/config/rules/structure/scratch-evidence-pointer.yaml +35 -0
- package/config/script-placement-baseline.json +2 -2
- package/config/workflows/feature-lifecycle.yaml +9 -2
- package/config/workflows/history-anatomy.yaml +22 -0
- package/config/workflows/idea-pipeline.yaml +75 -0
- package/config/workflows/task-pipeline.yaml +216 -57
- package/config/workflows/wrapup-pipeline.yaml +106 -13
- package/package.json +9 -9
- package/plugins/sp/README.md +11 -1
- package/plugins/sp/agents/super-planner.md +11 -0
- package/plugins/sp/agents/super-reviewer.md +14 -0
- package/plugins/sp/bunfig.toml +8 -0
- package/plugins/sp/commands/dev-brainstorm.md +29 -16
- package/plugins/sp/commands/dev-find-way.md +80 -0
- package/plugins/sp/commands/dev-idea.md +6 -0
- package/plugins/sp/commands/dev-parallel.md +8 -2
- package/plugins/sp/commands/dev-plan.md +6 -0
- package/plugins/sp/commands/dev-review-session.md +3 -1
- package/plugins/sp/commands/dev-run.md +12 -2
- package/plugins/sp/commands/dev-runall.md +18 -10
- package/plugins/sp/hooks/context-session-start.ts +1 -1
- package/plugins/sp/lib/idea-handoff.generated.mjs +41 -5
- package/plugins/sp/lib/inline-run-app.ts +28 -0
- package/plugins/sp/lib/inline-run.generated.d.mts +5 -0
- package/plugins/sp/lib/inline-run.generated.mjs +333 -45
- package/plugins/sp/lib/plan-projection.generated.d.mts +28 -0
- package/plugins/sp/lib/plan-projection.generated.mjs +255 -0
- package/plugins/sp/lib/quality-gate.generated.d.mts +47 -1
- package/plugins/sp/lib/quality-gate.generated.mjs +30489 -82
- package/plugins/sp/lib/residual-scan.generated.d.mts +19 -0
- package/plugins/sp/lib/residual-scan.generated.mjs +66 -0
- package/plugins/sp/lib/run-summary-core.ts +561 -0
- package/plugins/sp/lib/transcript.ts +121 -6
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +1 -1
- package/plugins/sp/scripts/batch-plan.mjs +335 -0
- package/plugins/sp/scripts/batch-plan.ts +100 -0
- package/plugins/sp/scripts/batch-preflight.mjs +51 -3
- package/plugins/sp/scripts/batch-preflight.ts +72 -1
- package/plugins/sp/scripts/commit-guard.mjs +216 -0
- package/plugins/sp/scripts/commit-guard.ts +249 -0
- package/plugins/sp/scripts/inline-run-dispatch.mjs +130 -0
- package/plugins/sp/scripts/inline-run-dispatch.ts +135 -0
- package/plugins/sp/scripts/inline-run-setup.mjs +551 -80
- package/plugins/sp/scripts/inline-run-setup.ts +52 -78
- package/plugins/sp/scripts/persist-out-check.mjs +90 -41
- package/plugins/sp/scripts/persist-out-check.ts +112 -57
- package/plugins/sp/scripts/quality-gate.mjs +30407 -73
- package/plugins/sp/scripts/quality-gate.ts +21 -5
- package/plugins/sp/scripts/residual-scan.mjs +86 -31
- package/plugins/sp/scripts/residual-scan.ts +28 -30
- package/plugins/sp/scripts/run-summary.mjs +421 -14
- package/plugins/sp/scripts/run-summary.ts +41 -177
- package/plugins/sp/scripts/session-timeline.mjs +109 -12
- package/plugins/sp/scripts/session-timeline.ts +71 -19
- package/plugins/sp/scripts/wrapup-steps.mjs +150 -2
- package/plugins/sp/scripts/wrapup-steps.ts +203 -1
- package/plugins/sp/skills/brainstorm/SKILL.md +9 -47
- package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
- package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +1 -0
- package/plugins/sp/skills/code-implementation/SKILL.md +48 -1
- package/plugins/sp/skills/code-verification/SKILL.md +3 -4
- package/plugins/sp/skills/code-verification/references/secu-review.md +3 -0
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +68 -0
- package/plugins/sp/skills/fleet-join/SKILL.md +1 -1
- package/plugins/sp/skills/session-review/SKILL.md +38 -7
- package/plugins/sp/skills/source-driven-development/SKILL.md +5 -0
- package/plugins/sp/skills/spec-decomposition/references/decomposition.md +5 -3
- package/plugins/sp/skills/spur-cli/SKILL.md +5 -2
- package/plugins/sp/skills/spur-cli/references/agent.md +20 -15
- package/plugins/sp/skills/spur-cli/references/decision.md +7 -6
- package/plugins/sp/skills/spur-cli/references/message.md +7 -7
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +19 -3
- package/plugins/sp/skills/spur-cli/references/tasks.md +10 -7
- package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +7 -0
- package/plugins/sp/skills/spur-cli/references/workflows.md +7 -2
- package/plugins/sp/skills/spur-composer/SKILL.md +1 -1
- package/plugins/sp/skills/spur-dev/SKILL.md +18 -14
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +6 -3
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +49 -14
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +37 -16
- package/plugins/sp/skills/spur-dev/references/execution-batch-continuation.md +68 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +194 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +113 -1020
- package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +130 -0
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +6 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +471 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +379 -0
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +35 -5
- package/plugins/sp/skills/spur-dev/references/glossary.md +2 -2
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +343 -163
- package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +178 -0
- package/plugins/sp/skills/wayfinder/SKILL.md +10 -14
- package/schemas/state-machine-workflow.schema.json +23 -0
- package/spur.js +20728 -16630
- package/web/_astro/{BoardApp.VeoxxLBy.js → BoardApp.D66k_s_T.js} +1 -1
- package/web/_astro/{BoardApp.Bzr4OXrW.js → BoardApp.D6sjAJr_.js} +173 -173
- package/web/_astro/{TaskDetail.LAyR_Pvn.js → TaskDetail.BynLnMNc.js} +1 -1
- package/web/_astro/{arc.f4e0OYLg.js → arc._BUadrdH.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.SSIvxi-Z.js → architectureDiagram-3BPJPVTR.Dh7xS1Pp.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.DSXIHOFf.js → blockDiagram-GPEHLZMM.DFDVpfXm.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.DG9Ubu85.js → c4Diagram-AAUBKEIU.V7_3_glh.js} +1 -1
- package/web/_astro/channel.DZVfto-r.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.C7QU88Qh.js → chunk-2J33WTMH.De1yx7Jz.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.26Z94xBj.js → chunk-4BX2VUAB.BDhHFuxx.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.DYRiBkqw.js → chunk-55IACEB6.DDLkWnmC.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.D5Vfv5pe.js → chunk-727SXJPM.CwfiLnA7.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.DXHV47bN.js → chunk-AQP2D5EJ.CtGDmUnM.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.D1suAfOs.js → chunk-FMBD7UC4.BOLjpw73.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.D6BmdHEY.js → chunk-ND2GUHAM.BDhQls6O.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.rnPU-Ak-.js → chunk-QZHKN3VN.76qBynDF.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.CvEfXIo1.js → classDiagram-4FO5ZUOK.N0mpPHT0.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.CvEfXIo1.js → classDiagram-v2-Q7XG4LA2.N0mpPHT0.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.DkMisLSm.js → cose-bilkent-S5V4N54A.CKknFfjB.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.C77Q-pF6.js → cynefin-OW5HDTMX.Cw3ZWBAh.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.YaV8ce27.js → dagre-BM42HDAG.D3yIKCRD.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.CUyGEKge.js → diagram-2AECGRRQ.D51dO83Z.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.CRgd8dgj.js → diagram-5GNKFQAL.Bb-FViDj.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.Bmzys9hq.js → diagram-KO2AKTUF.D3mN8S6x.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.BvmJY4Lw.js → diagram-LMA3HP47.dMMyPcbJ.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.0hEhD30d.js → diagram-OG6HWLK6.AzbvzY4C.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.Bjt8lrnm.js → erDiagram-TEJ5UH35.BR5I4OZb.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.CJZTjRDV.js → flowDiagram-I6XJVG4X.DFqvhGkn.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7._VqoVafc.js → ganttDiagram-6RSMTGT7.ByJbhH6J.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.CEDb7tVo.js → gitGraphDiagram-PVQCEYII.CInHjQR3.js} +1 -1
- package/web/_astro/index.COh_kBxN.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.CgwifPpK.js → infoDiagram-5YYISTIA.Cm0dGVjJ.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.B99wx2J1.js → ishikawaDiagram-YF4QCWOH.C3nOP6Sj.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.2hRwJbEo.js → journeyDiagram-JHISSGLW.4IvLa2MW.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.BahJkKNC.js → kanban-definition-UN3LZRKU.DS87Cl-k.js} +1 -1
- package/web/_astro/{linear.DguUUe6V.js → linear.MzmRrNER.js} +1 -1
- package/web/_astro/{mermaid.core.CvgfmqVr.js → mermaid.core.fK-WNswS.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.B3qEA3x9.js → mindmap-definition-RKZ34NQL.BqipOHhY.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.FlWA-_ru.js → pieDiagram-4H26LBE5.0nBiPqFG.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.9bVn35X7.js → quadrantDiagram-W4KKPZXB.Szj7Zpx5.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.ChykIhct.js → requirementDiagram-4Y6WPE33.D4UR6TWo.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js → sankeyDiagram-5OEKKPKP.DAE7BdOs.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.J7JsHWDt.js → sequenceDiagram-3UESZ5HK.BP9ZVq25.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.DAPbiCSS.js → stateDiagram-AJRCARHV.BQW7mIxk.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.D2jvSi0q.js → stateDiagram-v2-BHNVJYJU.PbK4nDih.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.psW2ci29.js → timeline-definition-PNZ67QCA.Ci_GvUpo.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.DlILYVmg.js → vennDiagram-CIIHVFJN.DNw1Y5qx.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.xNWwLJnn.js → wardleyDiagram-YWT4CUSO.CwlkaaNe.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.EU_BuH7V.js → xychartDiagram-2RQKCTM6.MI6ZuiH_.js} +1 -1
- package/web/index.html +2 -2
- package/web/_astro/channel.CUFdtWhm.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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[]): {
|
|
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)
|
|
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)) {
|
package/plugins/sp/plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sp",
|
|
3
|
-
"version": "0.4.
|
|
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
|
+
}
|