@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.
Files changed (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +7 -0
  3. package/config/rules/boundary/dao-boundary.yaml +2 -0
  4. package/config/rules/structure/test-location.yaml +2 -0
  5. package/package.json +2 -2
  6. package/plugins/sp/commands/dev-runall.md +6 -5
  7. package/plugins/sp/lib/destructive-policy.ts +521 -0
  8. package/plugins/sp/plugin.json +1 -1
  9. package/plugins/sp/scripts/hooks/careful-guard.ts +5 -0
  10. package/plugins/sp/skills/spur-dev/references/dev-operations.md +1 -1
  11. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +16 -8
  12. package/plugins/sp/skills/spur-dev/references/execution-batch.md +29 -5
  13. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +9 -0
  14. package/spur.js +2423 -1387
  15. package/web/_astro/BoardApp.BpVPF1yi.js +1 -0
  16. package/web/_astro/{BoardApp.CNBpKuB6.js → BoardApp.H0GHCpML.js} +157 -157
  17. package/web/_astro/TaskDetail.C3fD4iNx.js +1 -0
  18. package/web/_astro/{arc.CLFbvaU4.js → arc.CwmuZ1Pw.js} +1 -1
  19. package/web/_astro/{architectureDiagram-3BPJPVTR.YYrMKtrm.js → architectureDiagram-3BPJPVTR.D2r2pAtD.js} +1 -1
  20. package/web/_astro/{blockDiagram-GPEHLZMM.D-Xzu32A.js → blockDiagram-GPEHLZMM.LRgyDihu.js} +1 -1
  21. package/web/_astro/{c4Diagram-AAUBKEIU.Cv7EV8QD.js → c4Diagram-AAUBKEIU.OscV13G7.js} +1 -1
  22. package/web/_astro/channel.OpZyofHt.js +1 -0
  23. package/web/_astro/{chunk-2J33WTMH.CkAKN-fF.js → chunk-2J33WTMH.BcbENXnC.js} +1 -1
  24. package/web/_astro/{chunk-4BX2VUAB.D-xu2Tgi.js → chunk-4BX2VUAB.CcRidR01.js} +1 -1
  25. package/web/_astro/{chunk-55IACEB6.yNQibVGY.js → chunk-55IACEB6.DxAFaIIp.js} +1 -1
  26. package/web/_astro/{chunk-727SXJPM.DlVIFHHZ.js → chunk-727SXJPM.Cjvuc3en.js} +1 -1
  27. package/web/_astro/{chunk-AQP2D5EJ.C-LGFaeS.js → chunk-AQP2D5EJ.B3GSEPXo.js} +1 -1
  28. package/web/_astro/{chunk-FMBD7UC4.Df2n26zT.js → chunk-FMBD7UC4.DbpwRXpL.js} +1 -1
  29. package/web/_astro/{chunk-ND2GUHAM.CV_k83Gj.js → chunk-ND2GUHAM.CdiLgZ9z.js} +1 -1
  30. package/web/_astro/{chunk-QZHKN3VN.BQQDO3h3.js → chunk-QZHKN3VN.DT_2ovxF.js} +1 -1
  31. package/web/_astro/{classDiagram-4FO5ZUOK.DkSMdA84.js → classDiagram-4FO5ZUOK.B4Y_Byb0.js} +1 -1
  32. package/web/_astro/{classDiagram-v2-Q7XG4LA2.DkSMdA84.js → classDiagram-v2-Q7XG4LA2.B4Y_Byb0.js} +1 -1
  33. package/web/_astro/{cose-bilkent-S5V4N54A.CCN7GwAZ.js → cose-bilkent-S5V4N54A.0UOSbCRW.js} +1 -1
  34. package/web/_astro/{cynefin-OW5HDTMX.CqQCttkM.js → cynefin-OW5HDTMX.iaVw7KUy.js} +1 -1
  35. package/web/_astro/{dagre-BM42HDAG.CCn6iftc.js → dagre-BM42HDAG.DzBLfYbl.js} +1 -1
  36. package/web/_astro/{diagram-2AECGRRQ.DImUVZK-.js → diagram-2AECGRRQ.CtoO_RXO.js} +1 -1
  37. package/web/_astro/{diagram-5GNKFQAL.BYNywZwV.js → diagram-5GNKFQAL.C1ah--0o.js} +1 -1
  38. package/web/_astro/{diagram-KO2AKTUF.LJZ9DjsJ.js → diagram-KO2AKTUF.DAawwAoM.js} +1 -1
  39. package/web/_astro/{diagram-LMA3HP47.CJ3YpVED.js → diagram-LMA3HP47.kC7Zg0ru.js} +1 -1
  40. package/web/_astro/{diagram-OG6HWLK6.CnVtq5_j.js → diagram-OG6HWLK6.B3dSXJ9m.js} +1 -1
  41. package/web/_astro/{erDiagram-TEJ5UH35.SdmuzZwT.js → erDiagram-TEJ5UH35.cVJXYJkv.js} +1 -1
  42. package/web/_astro/{flowDiagram-I6XJVG4X.C9V-B-nO.js → flowDiagram-I6XJVG4X.CwD6KgFD.js} +1 -1
  43. package/web/_astro/{ganttDiagram-6RSMTGT7.DT5v7SL5.js → ganttDiagram-6RSMTGT7.UB88xr_u.js} +1 -1
  44. package/web/_astro/{gitGraphDiagram-PVQCEYII.B3qURYBL.js → gitGraphDiagram-PVQCEYII.C4B3FNOY.js} +1 -1
  45. package/web/_astro/index.CKtaGArK.css +1 -0
  46. package/web/_astro/{infoDiagram-5YYISTIA.CHO0xT8l.js → infoDiagram-5YYISTIA.6QC564C4.js} +1 -1
  47. package/web/_astro/{ishikawaDiagram-YF4QCWOH.buALAOGh.js → ishikawaDiagram-YF4QCWOH.sQBtM0gc.js} +1 -1
  48. package/web/_astro/{journeyDiagram-JHISSGLW.DvKuIdbX.js → journeyDiagram-JHISSGLW.Dslglgu2.js} +1 -1
  49. package/web/_astro/{kanban-definition-UN3LZRKU.Dvoi9jdN.js → kanban-definition-UN3LZRKU.CPKaPyzi.js} +1 -1
  50. package/web/_astro/{linear.EsEfz8Ex.js → linear.DNHY6BCF.js} +1 -1
  51. package/web/_astro/{mermaid.core.Vk3ryVC7.js → mermaid.core.VoW0rvGv.js} +4 -4
  52. package/web/_astro/{mindmap-definition-RKZ34NQL.DG1pkymq.js → mindmap-definition-RKZ34NQL.jYZ-jCh7.js} +1 -1
  53. package/web/_astro/{pieDiagram-4H26LBE5.DAUmIdd6.js → pieDiagram-4H26LBE5.DQ0fapSh.js} +1 -1
  54. package/web/_astro/{quadrantDiagram-W4KKPZXB.Du3QUozl.js → quadrantDiagram-W4KKPZXB.CWF406ut.js} +1 -1
  55. package/web/_astro/{requirementDiagram-4Y6WPE33.neRi5GrQ.js → requirementDiagram-4Y6WPE33.BmUiiY0p.js} +1 -1
  56. package/web/_astro/{sankeyDiagram-5OEKKPKP.CMUtxA70.js → sankeyDiagram-5OEKKPKP.CzUeGGUK.js} +1 -1
  57. package/web/_astro/{sequenceDiagram-3UESZ5HK.CfYFLmYH.js → sequenceDiagram-3UESZ5HK.DKvkUeEk.js} +1 -1
  58. package/web/_astro/{stateDiagram-AJRCARHV.Bn4dmAad.js → stateDiagram-AJRCARHV.ByedXnFB.js} +1 -1
  59. package/web/_astro/{stateDiagram-v2-BHNVJYJU.BMNezvp-.js → stateDiagram-v2-BHNVJYJU.Bx7wyAlv.js} +1 -1
  60. package/web/_astro/{timeline-definition-PNZ67QCA.ByaDwsBX.js → timeline-definition-PNZ67QCA.Chzu_dUX.js} +1 -1
  61. package/web/_astro/{vennDiagram-CIIHVFJN.CUYU1Mc9.js → vennDiagram-CIIHVFJN.PuxG68DI.js} +1 -1
  62. package/web/_astro/{wardleyDiagram-YWT4CUSO.DpZZeJr2.js → wardleyDiagram-YWT4CUSO.C9ZKxfsC.js} +1 -1
  63. package/web/_astro/{xychartDiagram-2RQKCTM6.CWwI33U0.js → xychartDiagram-2RQKCTM6.B7qR-3HU.js} +1 -1
  64. package/web/index.html +2 -2
  65. package/web/_astro/BoardApp.DVWLxHcV.js +0 -1
  66. package/web/_astro/TaskDetail.BY6jO_3P.js +0 -1
  67. package/web/_astro/channel.DzHZuqj0.js +0 -1
  68. package/web/_astro/index.C8LjH5ri.css +0 -1
@@ -7,7 +7,7 @@
7
7
  "plugins": [
8
8
  {
9
9
  "name": "sp",
10
- "version": "0.4.25",
10
+ "version": "0.4.27",
11
11
  "source": "./plugins/sp"
12
12
  }
13
13
  ]
@@ -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.25",
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), `--agent <inline|auto|name>`
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);
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.4.25",
3
+ "version": "0.4.27",
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
  }
@@ -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 halts on first failure). `--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).
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`).