@gobing-ai/spur 0.4.25 → 0.4.27
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.example.yaml +7 -0
- package/config/rules/boundary/dao-boundary.yaml +2 -0
- package/config/rules/structure/test-location.yaml +2 -0
- package/package.json +2 -2
- package/plugins/sp/commands/dev-runall.md +6 -5
- package/plugins/sp/lib/destructive-policy.ts +521 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/hooks/careful-guard.ts +5 -0
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +1 -1
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +16 -8
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +29 -5
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +9 -0
- package/spur.js +2423 -1387
- package/web/_astro/BoardApp.BpVPF1yi.js +1 -0
- package/web/_astro/{BoardApp.CNBpKuB6.js → BoardApp.H0GHCpML.js} +157 -157
- package/web/_astro/TaskDetail.C3fD4iNx.js +1 -0
- package/web/_astro/{arc.CLFbvaU4.js → arc.CwmuZ1Pw.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.YYrMKtrm.js → architectureDiagram-3BPJPVTR.D2r2pAtD.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.D-Xzu32A.js → blockDiagram-GPEHLZMM.LRgyDihu.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.Cv7EV8QD.js → c4Diagram-AAUBKEIU.OscV13G7.js} +1 -1
- package/web/_astro/channel.OpZyofHt.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.CkAKN-fF.js → chunk-2J33WTMH.BcbENXnC.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.D-xu2Tgi.js → chunk-4BX2VUAB.CcRidR01.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.yNQibVGY.js → chunk-55IACEB6.DxAFaIIp.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.DlVIFHHZ.js → chunk-727SXJPM.Cjvuc3en.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.C-LGFaeS.js → chunk-AQP2D5EJ.B3GSEPXo.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.Df2n26zT.js → chunk-FMBD7UC4.DbpwRXpL.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.CV_k83Gj.js → chunk-ND2GUHAM.CdiLgZ9z.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.BQQDO3h3.js → chunk-QZHKN3VN.DT_2ovxF.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.DkSMdA84.js → classDiagram-4FO5ZUOK.B4Y_Byb0.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.DkSMdA84.js → classDiagram-v2-Q7XG4LA2.B4Y_Byb0.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.CCN7GwAZ.js → cose-bilkent-S5V4N54A.0UOSbCRW.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.CqQCttkM.js → cynefin-OW5HDTMX.iaVw7KUy.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.CCn6iftc.js → dagre-BM42HDAG.DzBLfYbl.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.DImUVZK-.js → diagram-2AECGRRQ.CtoO_RXO.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.BYNywZwV.js → diagram-5GNKFQAL.C1ah--0o.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.LJZ9DjsJ.js → diagram-KO2AKTUF.DAawwAoM.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.CJ3YpVED.js → diagram-LMA3HP47.kC7Zg0ru.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.CnVtq5_j.js → diagram-OG6HWLK6.B3dSXJ9m.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.SdmuzZwT.js → erDiagram-TEJ5UH35.cVJXYJkv.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.C9V-B-nO.js → flowDiagram-I6XJVG4X.CwD6KgFD.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.DT5v7SL5.js → ganttDiagram-6RSMTGT7.UB88xr_u.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.B3qURYBL.js → gitGraphDiagram-PVQCEYII.C4B3FNOY.js} +1 -1
- package/web/_astro/index.CKtaGArK.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.CHO0xT8l.js → infoDiagram-5YYISTIA.6QC564C4.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.buALAOGh.js → ishikawaDiagram-YF4QCWOH.sQBtM0gc.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.DvKuIdbX.js → journeyDiagram-JHISSGLW.Dslglgu2.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.Dvoi9jdN.js → kanban-definition-UN3LZRKU.CPKaPyzi.js} +1 -1
- package/web/_astro/{linear.EsEfz8Ex.js → linear.DNHY6BCF.js} +1 -1
- package/web/_astro/{mermaid.core.Vk3ryVC7.js → mermaid.core.VoW0rvGv.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.DG1pkymq.js → mindmap-definition-RKZ34NQL.jYZ-jCh7.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.DAUmIdd6.js → pieDiagram-4H26LBE5.DQ0fapSh.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.Du3QUozl.js → quadrantDiagram-W4KKPZXB.CWF406ut.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.neRi5GrQ.js → requirementDiagram-4Y6WPE33.BmUiiY0p.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.CMUtxA70.js → sankeyDiagram-5OEKKPKP.CzUeGGUK.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.CfYFLmYH.js → sequenceDiagram-3UESZ5HK.DKvkUeEk.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.Bn4dmAad.js → stateDiagram-AJRCARHV.ByedXnFB.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.BMNezvp-.js → stateDiagram-v2-BHNVJYJU.Bx7wyAlv.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.ByaDwsBX.js → timeline-definition-PNZ67QCA.Chzu_dUX.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.CUYU1Mc9.js → vennDiagram-CIIHVFJN.PuxG68DI.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.DpZZeJr2.js → wardleyDiagram-YWT4CUSO.C9ZKxfsC.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.CWwI33U0.js → xychartDiagram-2RQKCTM6.B7qR-3HU.js} +1 -1
- package/web/index.html +2 -2
- package/web/_astro/BoardApp.DVWLxHcV.js +0 -1
- package/web/_astro/TaskDetail.BY6jO_3P.js +0 -1
- package/web/_astro/channel.DzHZuqj0.js +0 -1
- package/web/_astro/index.C8LjH5ri.css +0 -1
|
@@ -240,6 +240,13 @@ redaction:
|
|
|
240
240
|
# # none — only the stack-neutral `spur rule run` presets are bound; declare the
|
|
241
241
|
# # rest in `actions` (see below) or they stay unbound.
|
|
242
242
|
# preset: bun
|
|
243
|
+
# # Fresh `spur self init` detects root Bun first, then matching Make targets, exact declared
|
|
244
|
+
# # JavaScript scripts and Cargo/Go defaults under preset: none. Python needs explicit bindings.
|
|
245
|
+
# # A root manifest wins; otherwise one immediate child may be selected. Multiple roots stay
|
|
246
|
+
# # unbound with paths listed. Child commands prefix a shell-quoted cd; no cwd field is added.
|
|
247
|
+
# # Cargo/Go defaults require the tool on PATH; declared Make/JS proposals warn when unavailable.
|
|
248
|
+
# # Comments explain provenance, selected directory and unbound actions; re-init preserves config
|
|
249
|
+
# # unless --force is explicitly selected. Inspect with `spur self exec --list --json`.
|
|
243
250
|
# # action name -> one shell command, or a list of action names (composite, run in order,
|
|
244
251
|
# # fail-fast). A declared entry replaces the preset binding whole. Reserved names: build, dev,
|
|
245
252
|
# # clean, format, autofix, lint, typecheck, test, test-full, test-heavy, rules-pre, rules-post,
|
|
@@ -88,6 +88,8 @@ rules:
|
|
|
88
88
|
exclude:
|
|
89
89
|
# the CLI migration journal — 43 DDL statements (verified load-bearing)
|
|
90
90
|
- "packages/domain/src/migrations.ts"
|
|
91
|
+
# 0037's embedded mirror, generated verbatim from released drizzle SQL (task 1312)
|
|
92
|
+
- "packages/domain/src/migration-sql.generated.ts"
|
|
91
93
|
|
|
92
94
|
- id: raw-sql-only-in-domain
|
|
93
95
|
description: >
|
|
@@ -44,6 +44,8 @@ rules:
|
|
|
44
44
|
- "**/schema/**"
|
|
45
45
|
- "**/schema.ts"
|
|
46
46
|
- "**/migrations.ts"
|
|
47
|
+
# generated migration SQL; scripts/commands/emit-migration-sql.test.ts pins it (task 1312)
|
|
48
|
+
- "packages/domain/src/migration-sql.generated.ts"
|
|
47
49
|
- "**/db.ts"
|
|
48
50
|
## Entity declarations — contracts are transport DTOs tested by the single contract.test.ts
|
|
49
51
|
- "packages/contracts/src/**"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/spur",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.27",
|
|
4
4
|
"description": "Spur CLI — local-first harness for mainstream coding agents: constraint checking, workflow orchestration, agent health, and history analytics. Bun-native; exposes the `spur` command.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"spur",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"dev": "bun run src/index.ts",
|
|
51
51
|
"build": "bun run ../../scripts/spur-dev.ts build-cli",
|
|
52
52
|
"build:binaries": "bun run ../../scripts/spur-dev.ts build-binaries",
|
|
53
|
-
"build:bundle": "bun run ../../scripts/spur-dev.ts bundle-web && bun run ../../scripts/spur-dev.ts emit-board-types && bun build src/index.ts --target=bun --outfile spur.js --external @johnhenry/backend-webgpu && bun run ../../scripts/spur-dev.ts bundle-config config && bun run ../../scripts/spur-dev.ts bundle-laya-worker && bun run ../../scripts/spur-dev.ts bundle-plugins",
|
|
53
|
+
"build:bundle": "bun run ../../scripts/spur-dev.ts bundle-web && bun run ../../scripts/spur-dev.ts emit-board-types && bun run ../../scripts/spur-dev.ts emit-migration-sql && bun build src/index.ts --target=bun --outfile spur.js --external @johnhenry/backend-webgpu && bun run ../../scripts/spur-dev.ts bundle-config config && bun run ../../scripts/spur-dev.ts bundle-laya-worker && bun run ../../scripts/spur-dev.ts bundle-plugins",
|
|
54
54
|
"test": "bun test tests --coverage --coverage-dir=../../.coverage --pass-with-no-tests",
|
|
55
55
|
"typecheck": "tsc --noEmit",
|
|
56
56
|
"prepack": "bun run build:bundle",
|
|
@@ -17,9 +17,9 @@ Wraps the **sp:spur-dev** skill.
|
|
|
17
17
|
| `--tasks` `<selector>` | Task selector to run. | required |
|
|
18
18
|
| `--feature` `<id>` | Restrict to a feature. | omitted |
|
|
19
19
|
| `--mode` `<sequential\|parallel\|auto>` | Batch execution order. `auto` computes the frozen batch's dependency waves and runs parallel only when a wave holds ≥2 independent tasks (see below). | sequential |
|
|
20
|
-
| `--keep-going` | Continue past per-task failures. | off |
|
|
21
|
-
| `--auto` | Skip objective HITL gates. | off |
|
|
22
|
-
| `--agent` `<inline\|auto\|name>` | Who runs each task's pipeline stages. Interactive sequential omit/`inline` uses the host-session driver with unified inline semantics (task 0687 — see below). `auto`, a name, parallel mode, and headless invocation use subprocesses. The opt-in `fleet` selector (0942) maps each task run to `executor: 'fleet'` so `agent.run` stages dispatch through the fleet control plane (ADR-126) instead of a subprocess. | omit |
|
|
20
|
+
| `--keep-going` | Continue past per-task failures (implied by `--auto`). | off |
|
|
21
|
+
| `--auto` | Skip objective HITL gates, and treat the batch as unattended: `--auto` implies `--keep-going` (a failed task is `failed`, its in-batch dependents `skipped`, independents keep running). | off |
|
|
22
|
+
| `--agent` `<inline\|auto\|name>` | Who runs each task's pipeline stages. Interactive sequential omit/`inline` uses the host-session driver with unified inline semantics (task 0687 — see below). `auto`, a name, parallel mode, and headless invocation use subprocesses; `auto` is resolved **once per batch** — the first run's implement `result.executor` is pinned for every later task, and only a `failed-agent` ending re-resolves it, exactly once (1318 R2/R3). The opt-in `fleet` selector (0942) maps each task run to `executor: 'fleet'` so `agent.run` stages dispatch through the fleet control plane (ADR-126) instead of a subprocess. | omit |
|
|
23
23
|
| `--json` | Emit structured JSON. | off |
|
|
24
24
|
| `--wrap` | Run the wrap hop **once for the batch** over the `done` subset only (Step 6 of execution-batch.md): `vars.feature` is passed only when every frozen task is `done`/`cancelled`; an empty done subset skips the wrap with a reason. The `--agent` selector is preserved into the `/sp:dev-wrap` handoff when supplied; omission remains omission. | off |
|
|
25
25
|
| `--next` | Chain-to-completion via the next-router. | off |
|
|
@@ -50,8 +50,9 @@ see `execution-parallel-isolation.md`; `auto` picks one per batch — see below)
|
|
|
50
50
|
(parallel-mode worker bound — at most <n> task pipelines at once, default 2; a dependent task starts
|
|
51
51
|
only after its in-set dependencies are **integrated** onto the base ref), `--keep-going`
|
|
52
52
|
(batch failure policy — skip a failed task's in-batch dependents, continue independents; default
|
|
53
|
-
halts on first failure), `--auto`
|
|
54
|
-
(sets `profile=auto` on each per-task run, skipping the HITL approve gate
|
|
53
|
+
halts on first failure, and `--auto` implies `--keep-going`), `--auto`
|
|
54
|
+
(sets `profile=auto` on each per-task run, skipping the HITL approve gate; makes the batch
|
|
55
|
+
unattended, so it also implies `--keep-going`), `--agent <inline|auto|name>`
|
|
55
56
|
(names who runs the pipeline's stages — interactive sequential omit/`inline` uses the host driver;
|
|
56
57
|
`auto` or a name is merged into each per-task `vars.agent` and `vars.implementAgent`; `fleet` maps each task run to `executor: 'fleet'` instead (fleet dispatch, 0942); parallel mode
|
|
57
58
|
retains isolated subprocesses, ADR-047), `--json` (emit the
|
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Adapters own **I/O only** (payload shape, how a prompt is raised). They must not
|
|
16
16
|
* re-derive classification.
|
|
17
|
+
*
|
|
18
|
+
* **Executable positions (task 1313).** `classifyCommand` lexes quotes, escapes,
|
|
19
|
+
* command separators (including a redirection's `&`, which is not a separator),
|
|
20
|
+
* `$(...)` / backticks, the `SHELL_WRAPPERS` that forward a shell, and a `-c` script from
|
|
21
|
+
* any `SHELL_BINS` interpreter — matched on the basename, so `/bin/bash` counts, and
|
|
22
|
+
* `SHELL_OPTIONS_WITH_VALUE` options (`-O extglob`, `--rcfile x`) do not end the scan.
|
|
23
|
+
* Proven standalone `rg` / `grep` operands are data: single quotes never recurse, and
|
|
24
|
+
* only substitutions inside double quotes
|
|
25
|
+
* or unquoted text are classified again. `--pre` is not data, and neither is a search
|
|
26
|
+
* whose pipeline feeds anything but a known text consumer (`DATA_CONSUMERS`) — matched on
|
|
27
|
+
* the argv0 basename, so `/bin/sh` and `nice sh` count as execution. Malformed or
|
|
28
|
+
* unsupported syntax keeps the whole-string scan below — it never becomes an allow.
|
|
17
29
|
*/
|
|
18
30
|
|
|
19
31
|
/**
|
|
@@ -186,8 +198,517 @@ export function stripGitGlobalOptions(command: string): string {
|
|
|
186
198
|
return command.replace(GIT_WITH_GLOBAL_OPTIONS, 'git');
|
|
187
199
|
}
|
|
188
200
|
|
|
201
|
+
/** How many nested scripts or substitutions to follow before the whole-string scan. */
|
|
202
|
+
const MAX_LEX_DEPTH = 8;
|
|
203
|
+
|
|
204
|
+
interface ShellWord {
|
|
205
|
+
text: string;
|
|
206
|
+
substitutions: string[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface ShellSegment {
|
|
210
|
+
start: number;
|
|
211
|
+
end: number;
|
|
212
|
+
words: ShellWord[];
|
|
213
|
+
substitutionSpans: Array<[number, number]>;
|
|
214
|
+
/** Segments joined by `|` / `|&` share an id; `;`, newline, `&`, `&&`, `||` start a new one. */
|
|
215
|
+
pipeline: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Index of the `)` that closes a `$(` whose body starts at `start`, or -1.
|
|
222
|
+
* Quotes hide parens; a backslash hides the next character.
|
|
223
|
+
*/
|
|
224
|
+
function scanBalancedParen(source: string, start: number): number {
|
|
225
|
+
let depth = 1;
|
|
226
|
+
let i = start;
|
|
227
|
+
while (i < source.length) {
|
|
228
|
+
const ch = source[i];
|
|
229
|
+
if (ch === '\\') {
|
|
230
|
+
i += 2;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (ch === "'") {
|
|
234
|
+
i++;
|
|
235
|
+
while (i < source.length && source[i] !== "'") i++;
|
|
236
|
+
if (i >= source.length) return -1;
|
|
237
|
+
i++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (ch === '"') {
|
|
241
|
+
i++;
|
|
242
|
+
while (i < source.length) {
|
|
243
|
+
if (source[i] === '\\') {
|
|
244
|
+
i += 2;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (source[i] === '"') break;
|
|
248
|
+
i++;
|
|
249
|
+
}
|
|
250
|
+
if (i >= source.length) return -1;
|
|
251
|
+
i++;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (ch === '(') {
|
|
255
|
+
depth++;
|
|
256
|
+
i++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (ch === ')') {
|
|
260
|
+
depth--;
|
|
261
|
+
if (depth === 0) return i;
|
|
262
|
+
i++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
i++;
|
|
266
|
+
}
|
|
267
|
+
return -1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Index of the closing backtick, or -1. A backslash can escape a backtick. */
|
|
271
|
+
function scanBacktick(source: string, start: number): number {
|
|
272
|
+
let i = start + 1;
|
|
273
|
+
while (i < source.length) {
|
|
274
|
+
if (source[i] === '\\' && i + 1 < source.length) {
|
|
275
|
+
const next = source[i + 1];
|
|
276
|
+
if (next === '`' || next === '\\' || next === '$') {
|
|
277
|
+
i += 2;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (source[i] === '`') return i;
|
|
282
|
+
i++;
|
|
283
|
+
}
|
|
284
|
+
return -1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function readSubstitution(
|
|
288
|
+
source: string,
|
|
289
|
+
dollar: number,
|
|
290
|
+
word: ShellWord,
|
|
291
|
+
spans: Array<[number, number]>,
|
|
292
|
+
): number | null {
|
|
293
|
+
if (source[dollar + 2] === '(') return null;
|
|
294
|
+
const close = scanBalancedParen(source, dollar + 2);
|
|
295
|
+
if (close < 0) return null;
|
|
296
|
+
word.substitutions.push(source.slice(dollar + 2, close));
|
|
297
|
+
spans.push([dollar, close + 1]);
|
|
298
|
+
return close + 1;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function readBacktick(source: string, open: number, word: ShellWord, spans: Array<[number, number]>): number | null {
|
|
302
|
+
const close = scanBacktick(source, open);
|
|
303
|
+
if (close < 0) return null;
|
|
304
|
+
word.substitutions.push(source.slice(open + 1, close));
|
|
305
|
+
spans.push([open, close + 1]);
|
|
306
|
+
return close + 1;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Split a command into simple commands and words.
|
|
311
|
+
* Returns null for unclosed quotes, unclosed substitutions, or syntax this pass
|
|
312
|
+
* does not claim to understand (ANSI-C quotes, arithmetic, heredocs, process
|
|
313
|
+
* substitutions, subshells). Null means "use the whole-string scan".
|
|
314
|
+
*/
|
|
315
|
+
function lexShell(command: string): ShellSegment[] | null {
|
|
316
|
+
const segments: ShellSegment[] = [];
|
|
317
|
+
const n = command.length;
|
|
318
|
+
let i = 0;
|
|
319
|
+
let segStart = 0;
|
|
320
|
+
let pipeline = 0;
|
|
321
|
+
let words: ShellWord[] = [];
|
|
322
|
+
let spans: Array<[number, number]> = [];
|
|
323
|
+
let current: ShellWord | null = null;
|
|
324
|
+
|
|
325
|
+
const flushWord = (): void => {
|
|
326
|
+
if (current === null) return;
|
|
327
|
+
words.push(current);
|
|
328
|
+
current = null;
|
|
329
|
+
};
|
|
330
|
+
const flushSegment = (end: number): void => {
|
|
331
|
+
flushWord();
|
|
332
|
+
if (command.slice(segStart, end).trim().length > 0) {
|
|
333
|
+
segments.push({ start: segStart, end, words, substitutionSpans: spans, pipeline });
|
|
334
|
+
}
|
|
335
|
+
words = [];
|
|
336
|
+
spans = [];
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
while (i < n) {
|
|
340
|
+
const ch = command[i] ?? '';
|
|
341
|
+
if (current === null) {
|
|
342
|
+
if (ch === ' ' || ch === '\t' || ch === '\r') {
|
|
343
|
+
i++;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (ch === '\n' || ch === ';') {
|
|
347
|
+
flushSegment(i);
|
|
348
|
+
pipeline++;
|
|
349
|
+
i++;
|
|
350
|
+
segStart = i;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (ch === '|') {
|
|
354
|
+
flushSegment(i);
|
|
355
|
+
if (command[i + 1] === '|') pipeline++;
|
|
356
|
+
i += command[i + 1] === '|' || command[i + 1] === '&' ? 2 : 1;
|
|
357
|
+
segStart = i;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
// `&` only separates commands on its own: inside a redirection (`2>&1`, `&>log`,
|
|
361
|
+
// `<&3`) it is part of the operand. Treating it as a separator split `a 2>&1 | sh`
|
|
362
|
+
// into two pipelines, which silently disabled the "search piped into an executor"
|
|
363
|
+
// rule and let `rg 'rm -rf /' x 2>&1 | bash` through.
|
|
364
|
+
if (ch === '&') {
|
|
365
|
+
const redirectAmp = command[i - 1] === '>' || command[i - 1] === '<' || command[i + 1] === '>';
|
|
366
|
+
if (redirectAmp) {
|
|
367
|
+
// Redirect operand (`2>&1`, `&>log`): not a separator, but the index MUST
|
|
368
|
+
// advance or the segment scanner re-reads the same char forever.
|
|
369
|
+
i++;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
flushSegment(i);
|
|
373
|
+
pipeline++;
|
|
374
|
+
i += command[i + 1] === '&' ? 2 : 1;
|
|
375
|
+
segStart = i;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (ch === '#') {
|
|
379
|
+
while (i < n && command[i] !== '\n') i++;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (ch === '<' || ch === '>') {
|
|
383
|
+
if (command[i + 1] === '(' || (ch === '<' && command[i + 1] === '<')) return null;
|
|
384
|
+
i += command[i + 1] === '>' ? 2 : 1;
|
|
385
|
+
if (command[i] === '&') i++;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (ch === '(' || ch === ')') return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
current ??= { text: '', substitutions: [] };
|
|
392
|
+
if (ch === "'") {
|
|
393
|
+
i++;
|
|
394
|
+
while (i < n && command[i] !== "'") {
|
|
395
|
+
current.text += command[i] ?? '';
|
|
396
|
+
i++;
|
|
397
|
+
}
|
|
398
|
+
if (i >= n) return null;
|
|
399
|
+
i++;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (ch === '"') {
|
|
403
|
+
i++;
|
|
404
|
+
let closed = false;
|
|
405
|
+
while (i < n) {
|
|
406
|
+
const quoted = command[i] ?? '';
|
|
407
|
+
if (quoted === '\\') {
|
|
408
|
+
const next = command[i + 1];
|
|
409
|
+
if (next === undefined) return null;
|
|
410
|
+
if (next === '\n') {
|
|
411
|
+
i += 2;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
current.text += next === '$' || next === '`' || next === '"' || next === '\\' ? next : `\\${next}`;
|
|
415
|
+
i += 2;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (quoted === '"') {
|
|
419
|
+
i++;
|
|
420
|
+
closed = true;
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
if (quoted === '$' && (command[i + 1] === "'" || command[i + 1] === '"')) return null;
|
|
424
|
+
if (quoted === '$' && command[i + 1] === '(') {
|
|
425
|
+
const next = readSubstitution(command, i, current, spans);
|
|
426
|
+
if (next === null) return null;
|
|
427
|
+
i = next;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (quoted === '`') {
|
|
431
|
+
const next = readBacktick(command, i, current, spans);
|
|
432
|
+
if (next === null) return null;
|
|
433
|
+
i = next;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
current.text += quoted;
|
|
437
|
+
i++;
|
|
438
|
+
}
|
|
439
|
+
if (!closed) return null;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (ch === '\\') {
|
|
443
|
+
const next = command[i + 1];
|
|
444
|
+
if (next === undefined) return null;
|
|
445
|
+
if (next !== '\n') current.text += next;
|
|
446
|
+
i += 2;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (ch === '$' && (command[i + 1] === "'" || command[i + 1] === '"')) return null;
|
|
450
|
+
if (ch === '$' && command[i + 1] === '(') {
|
|
451
|
+
const next = readSubstitution(command, i, current, spans);
|
|
452
|
+
if (next === null) return null;
|
|
453
|
+
i = next;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (ch === '`') {
|
|
457
|
+
const next = readBacktick(command, i, current, spans);
|
|
458
|
+
if (next === null) return null;
|
|
459
|
+
i = next;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
|
|
463
|
+
flushWord();
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (ch === '(' || ch === ')') return null;
|
|
467
|
+
if ((ch === '<' || ch === '>') && current.text === '' && current.substitutions.length === 0) {
|
|
468
|
+
current = null;
|
|
469
|
+
if (command[i + 1] === '(' || (ch === '<' && command[i + 1] === '<')) return null;
|
|
470
|
+
i += command[i + 1] === '>' ? 2 : 1;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
current.text += ch;
|
|
474
|
+
i++;
|
|
475
|
+
}
|
|
476
|
+
flushSegment(n);
|
|
477
|
+
return segments;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Drop leading `command` / `env` wrappers this pass understands. Unknown flags stay. */
|
|
481
|
+
function stripWrappers(words: readonly ShellWord[]): ShellWord[] {
|
|
482
|
+
let i = 0;
|
|
483
|
+
while (i < words.length) {
|
|
484
|
+
const name = words[i]?.text ?? '';
|
|
485
|
+
const substituted = (words[i]?.substitutions.length ?? 0) > 0;
|
|
486
|
+
if (substituted) break;
|
|
487
|
+
if (ENV_ASSIGNMENT.test(name)) {
|
|
488
|
+
i++;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (name === 'command') {
|
|
492
|
+
i++;
|
|
493
|
+
if (words[i]?.text === '-p' || words[i]?.text === '--') i++;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (argvName(words[i]) === 'env') {
|
|
497
|
+
const envAt = i;
|
|
498
|
+
let j = i + 1;
|
|
499
|
+
let known = true;
|
|
500
|
+
while (j < words.length && (words[j]?.text ?? '').startsWith('-')) {
|
|
501
|
+
const flag = words[j]?.text ?? '';
|
|
502
|
+
if (flag === '--') {
|
|
503
|
+
j++;
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
if (flag === '-i' || flag === '-0' || flag === '--ignore-environment') {
|
|
507
|
+
j++;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (flag === '-u' || flag === '--unset') {
|
|
511
|
+
j += 2;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (flag.startsWith('--unset=')) {
|
|
515
|
+
j++;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
known = false;
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
// An unrecognized flag is not a wrapper. Leave argv0 at `env` so the
|
|
522
|
+
// whole-string scan still sees the original command.
|
|
523
|
+
if (!known) {
|
|
524
|
+
i = envAt;
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
while (j < words.length && ENV_ASSIGNMENT.test(words[j]?.text ?? '')) j++;
|
|
528
|
+
i = j;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
return words.slice(i);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Shell interpreters whose `-c` operand is code, matched on the basename so `/bin/bash` counts. */
|
|
537
|
+
const SHELL_BINS = new Set(['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh', 'mksh', 'busybox-sh']);
|
|
538
|
+
|
|
539
|
+
/** True for a bare or path-qualified shell interpreter (`bash`, `/bin/bash`, `/usr/bin/env`-less). */
|
|
540
|
+
function isShellBin(name: string): boolean {
|
|
541
|
+
const base = name.slice(name.lastIndexOf('/') + 1);
|
|
542
|
+
return SHELL_BINS.has(base);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* These wrappers can forward a shell invocation. Their option grammar is deliberately
|
|
547
|
+
* not used to grant exemptions: inspect possible shell operands for destructive code,
|
|
548
|
+
* then retain the conservative scan even when every inspected script appears safe.
|
|
549
|
+
*/
|
|
550
|
+
const SHELL_WRAPPERS = new Set(['env', 'sudo', 'exec', 'nice', 'xargs']);
|
|
551
|
+
|
|
552
|
+
function classifyWrappedShell(words: readonly ShellWord[], depth: number): string | null {
|
|
553
|
+
const name = argvName(words[0]);
|
|
554
|
+
if (name === null || !SHELL_WRAPPERS.has(name)) return null;
|
|
555
|
+
for (let i = 1; i < words.length; i++) {
|
|
556
|
+
const word = words[i];
|
|
557
|
+
if (word === undefined || word.substitutions.length > 0 || !isShellBin(word.text)) continue;
|
|
558
|
+
const script = extractDashC(words.slice(i));
|
|
559
|
+
if (script === null || script === 'missing') continue;
|
|
560
|
+
const hit = classifyLexical(script, depth + 1);
|
|
561
|
+
if (hit !== null) return hit;
|
|
562
|
+
}
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** The `-c` script, `missing` when `-c` has no operand, or null when this is not `-c`. */
|
|
567
|
+
/** Shell options whose operand is a separate word, so `-c` detection must step over it. */
|
|
568
|
+
const SHELL_OPTIONS_WITH_VALUE = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file', '--rc-file']);
|
|
569
|
+
|
|
570
|
+
function extractDashC(words: readonly ShellWord[]): string | null | 'missing' {
|
|
571
|
+
let i = 1;
|
|
572
|
+
while (i < words.length) {
|
|
573
|
+
const token = words[i]?.text ?? '';
|
|
574
|
+
if (token === '--') return null;
|
|
575
|
+
// A bare word (`bash script.sh -c x`) ends the option list: `-c` after it is the script's
|
|
576
|
+
// own argument, not the shell's.
|
|
577
|
+
if ((!token.startsWith('-') && !token.startsWith('+')) || token === '-') break;
|
|
578
|
+
if (token.startsWith('--')) {
|
|
579
|
+
i += SHELL_OPTIONS_WITH_VALUE.has(token) ? 2 : 1;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (token.includes('c')) {
|
|
583
|
+
const script = words[i + 1];
|
|
584
|
+
if (script === undefined) return 'missing';
|
|
585
|
+
return script.text;
|
|
586
|
+
}
|
|
587
|
+
// `-O extglob -c …` / `-o noglob -c …`: the operand is a value, not the command.
|
|
588
|
+
i += SHELL_OPTIONS_WITH_VALUE.has(token) ? 2 : 1;
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** True when every operand is data for a standalone rg/grep, with no `--pre` command. */
|
|
594
|
+
function isLiteralSearch(words: readonly ShellWord[]): boolean {
|
|
595
|
+
const bin = words[0]?.text;
|
|
596
|
+
if ((bin !== 'rg' && bin !== 'grep') || (words[0]?.substitutions.length ?? 0) > 0) return false;
|
|
597
|
+
let options = true;
|
|
598
|
+
for (let i = 1; i < words.length; i++) {
|
|
599
|
+
const token = words[i]?.text ?? '';
|
|
600
|
+
if (options && token === '--') {
|
|
601
|
+
options = false;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (options && (token === '--pre' || token.startsWith('--pre='))) return false;
|
|
605
|
+
}
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function maskedRaw(command: string, segment: ShellSegment): string {
|
|
610
|
+
const chars = [...command.slice(segment.start, segment.end)];
|
|
611
|
+
for (const [start, end] of segment.substitutionSpans) {
|
|
612
|
+
for (let i = start; i < end; i++) {
|
|
613
|
+
const local = i - segment.start;
|
|
614
|
+
if (local >= 0 && local < chars.length) chars[local] = ' ';
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return chars.join('');
|
|
618
|
+
}
|
|
619
|
+
|
|
189
620
|
/** Return a human label for the destructive command, or null when the command is safe. */
|
|
190
621
|
export function classifyCommand(command: string): string | null {
|
|
622
|
+
return classifyLexical(command, 0);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function classifyLexical(command: string, depth: number): string | null {
|
|
626
|
+
if (depth > MAX_LEX_DEPTH) return classifyConservatively(command);
|
|
627
|
+
const segments = lexShell(command);
|
|
628
|
+
if (segments === null) return classifyConservatively(command);
|
|
629
|
+
const codePipelines = new Set(
|
|
630
|
+
segments.filter((segment) => !isDataConsumer(segment)).map((segment) => segment.pipeline),
|
|
631
|
+
);
|
|
632
|
+
for (const segment of segments) {
|
|
633
|
+
const hit = classifySegment(command, segment, depth, !codePipelines.has(segment.pipeline));
|
|
634
|
+
if (hit !== null) return hit;
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Commands that read a pipeline's flow as text: filters, viewers and plain formatters.
|
|
641
|
+
* A strict allowlist, so an unnamed consumer folds toward `ask` — the same search piped
|
|
642
|
+
* into a shell, an interpreter or an unknown wrapper emits code, not data.
|
|
643
|
+
*/
|
|
644
|
+
const DATA_CONSUMERS = new Set([
|
|
645
|
+
'cat',
|
|
646
|
+
'cut',
|
|
647
|
+
'env',
|
|
648
|
+
'grep',
|
|
649
|
+
'head',
|
|
650
|
+
'less',
|
|
651
|
+
'more',
|
|
652
|
+
'nl',
|
|
653
|
+
'rg',
|
|
654
|
+
'sort',
|
|
655
|
+
'tac',
|
|
656
|
+
'tail',
|
|
657
|
+
'tee',
|
|
658
|
+
'tr',
|
|
659
|
+
'uniq',
|
|
660
|
+
'wc',
|
|
661
|
+
]);
|
|
662
|
+
|
|
663
|
+
/** Basename of an argv0 word, or null when a substitution leaves the head unresolved. */
|
|
664
|
+
function argvName(word: ShellWord | undefined): string | null {
|
|
665
|
+
if (word === undefined || word.substitutions.length > 0) return null;
|
|
666
|
+
const slash = word.text.lastIndexOf('/');
|
|
667
|
+
return slash < 0 ? word.text : word.text.slice(slash + 1);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* True when this segment treats what flows into it as text. The argv0 basename is used so a
|
|
672
|
+
* path-qualified shell (`/bin/sh`) or a wrapper (`nice sh`) is not mistaken for a consumer;
|
|
673
|
+
* stripping the whole head away (`| env`) falls back to the original word.
|
|
674
|
+
*/
|
|
675
|
+
function isDataConsumer(segment: ShellSegment): boolean {
|
|
676
|
+
const stripped = stripWrappers(segment.words);
|
|
677
|
+
const name = argvName(stripped[0] ?? segment.words[0]);
|
|
678
|
+
return name !== null && DATA_CONSUMERS.has(name);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function classifySegment(
|
|
682
|
+
command: string,
|
|
683
|
+
segment: ShellSegment,
|
|
684
|
+
depth: number,
|
|
685
|
+
searchExemptable: boolean,
|
|
686
|
+
): string | null {
|
|
687
|
+
for (const word of segment.words) {
|
|
688
|
+
for (const body of word.substitutions) {
|
|
689
|
+
const hit = classifyLexical(body, depth + 1);
|
|
690
|
+
if (hit !== null) return hit;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const words = stripWrappers(segment.words);
|
|
694
|
+
if (words.length === 0) return null;
|
|
695
|
+
const head = words[0];
|
|
696
|
+
if (head !== undefined && head.substitutions.length === 0 && isShellBin(head.text)) {
|
|
697
|
+
const script = extractDashC(words);
|
|
698
|
+
if (script === 'missing') return classifyConservatively(maskedRaw(command, segment));
|
|
699
|
+
if (script !== null) return classifyLexical(script, depth + 1);
|
|
700
|
+
}
|
|
701
|
+
// A search piped into an executor (`rg 'rm -rf /' x | sh`) emits code, not data:
|
|
702
|
+
// drop the exemption and keep the whole-string scan.
|
|
703
|
+
if (searchExemptable && isLiteralSearch(words)) return null;
|
|
704
|
+
const wrappedHit = classifyWrappedShell(words, depth);
|
|
705
|
+
if (wrappedHit !== null) return wrappedHit;
|
|
706
|
+
const rawHit = classifyConservatively(command.slice(segment.start, segment.end));
|
|
707
|
+
if (rawHit !== null) return rawHit;
|
|
708
|
+
return classifyConservatively(words.map((word) => word.text).join(' '));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function classifyConservatively(command: string): string | null {
|
|
191
712
|
for (const rmMatch of command.matchAll(/\brm\b([^\n&|;]*)/g)) {
|
|
192
713
|
const args = rmMatch[1] ?? '';
|
|
193
714
|
const flags = parseFlags(args);
|
package/plugins/sp/plugin.json
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* `git push --force`; the lexical pass only removes that option span and never executes git or
|
|
18
18
|
* expands a substitution. The run cannot cross a command boundary (`;`, `&`, `|`, newline).
|
|
19
19
|
*
|
|
20
|
+
* Quoted operands of a standalone `rg` or `grep` are literal data, so a search
|
|
21
|
+
* pattern that only names a destructive command does not prompt. Substitutions,
|
|
22
|
+
* backticks, and a `-c` script from any shell interpreter (bare or path-qualified)
|
|
23
|
+
* are still classified. The decision stays in `classifyCommand`.
|
|
24
|
+
*
|
|
20
25
|
* **Safe exceptions:** `rm -rf` of well-known build/dependency caches (`node_modules`, `dist`,
|
|
21
26
|
* `.next`, `coverage`, `build`, `.turbo`, `.cache`) passes without a warning — deleting a rebuildable
|
|
22
27
|
* cache is routine, not dangerous.
|
|
@@ -481,7 +481,7 @@ gate commands, pending questions and approval requirements.>
|
|
|
481
481
|
### 13. runall
|
|
482
482
|
|
|
483
483
|
- **Purpose:** Run a batch of tasks through their pipelines in dependency-correct order — resolve a set, topo-sort, run each via `task-pipeline.yaml`, inspect verdicts, apply the failure policy, emit a batch report.
|
|
484
|
-
- **Inputs:** `--tasks <selector>` (required — explicit WBS list, status pseudo-list, `feature:<id>`, or `ready`). `--mode <sequential|parallel>` (default `sequential`; `parallel` fans out a proven-independent subset per `execution-batch.md` § Parallel Execution). `--keep-going` skips a failed task's in-batch dependents and continues independents (default
|
|
484
|
+
- **Inputs:** `--tasks <selector>` (required — explicit WBS list, status pseudo-list, `feature:<id>`, or `ready`). `--mode <sequential|parallel>` (default `sequential`; `parallel` fans out a proven-independent subset per `execution-batch.md` § Parallel Execution). `--keep-going` skips a failed task's in-batch dependents and continues independents (default off; `--auto` implies `--keep-going`, so an unattended batch never stops for a human — 1318 R1). `--auto` sets `profile=auto` on each per-task run (skips the HITL approve gate). `--feature <id>` is sugar for `--tasks feature:<id>`; when the effective selector is feature-derived, the batch runs `spur feature check <id> --fix --json` then `spur feature check <id> --strict --json` **once** before task resolution — a non-zero strict check aborts with verdict `aborted`, zero attempted tasks, and the structured findings (task 0510 R2); scoped: `L4.scenario-unverified` and `L4.verifying-incomplete-tasks` (expected pre-run states of a not-yet-run or reopened feature) are reported verbatim but do not abort — the non-aborting set is `NON_ABORTING_PREFLIGHT_CODES` behind `spur task preflight --classify-feature` and `--fix` repairs are listed under `autoRepairs` — any other strict error aborts. Explicit WBS/status/`ready` selectors add no feature check. The orchestrator loop itself continues in this session; interactive sequential omit/`inline` uses the host driver — host-controlled with eligible `agent.run` stages dispatching once to a native subagent and host fallback (task 0508) — while `--agent auto`/a name, parallel mode, and headless invocation keep the isolated per-task workflow subprocess boundary. `--agent <inline|auto|name>` pins the executor for the per-task stages (see [SSOT](cross-cutting.md#inline-default-execution-surface)); the value crosses into per-task `vars.agent`, not the orchestrator. `--json` emits the report as JSON. `--wrap` triggers `wrapup-pipeline.yaml` **once for the batch** over the `done` subset only (execution-batch.md Step 6: `vars.feature` only when every frozen task is `done`/`cancelled`; empty done subset skips with a reason), `--next` chains each task to terminal status then runs the same batch-once wrap, `--continue` resumes from checkpoint. `--no-summary` skips the [execution summary](#execution-summary) printed after the batch report (one row per attempted run).
|
|
485
485
|
- **Three orthogonal axes (do not confuse):** `--keep-going` = batch failure policy (halt vs skip dependents); `--continue` = resume from checkpoint against the batch's original frozen identity (execution-batch.md § Batch continuation, task 0919); `--next` = per-task lifecycle chaining (advance status on a verdict — `dev-verify`/`dev-verifyall` only). `routing-table.md` offers `--continue` and `--next` as competing options for the same situation only when the batch was interrupted mid-run; otherwise they address different problems.
|
|
486
486
|
- **Backing:** `sp:spur-dev` skill, `runall` operation → delegates the driver loop to the **`sp:super-planner`** agent (the batch orchestrator).
|
|
487
487
|
- **Behavior:** The orchestrator reads [execution-batch.md](execution-batch.md) and drives: resolve selector → freeze set → topo-sort by `dependencies[]` (Kahn, WBS-ascending tie-break; cycle aborts) → resolve out-of-set deps by status (done → allow, else → block subtree) → run each task via `spur workflow run task-pipeline.yaml --async` + `spur workflow trace` polling → inspect terminal state + `.spur/run/<wbs>-verdict.json` → stop-the-batch default or `--keep-going` subtree skip → emit batch report. Per-task pipeline is invoked **verbatim** — no new FSM, no step edits. `--auto`/`--agent` are the only flags that cross the orchestrator→pipeline boundary (both into per-task `--vars`).
|