@gobing-ai/spur 0.4.24 → 0.4.26

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 (151) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +7 -0
  3. package/package.json +1 -1
  4. package/plugins/sp/README.md +52 -0
  5. package/plugins/sp/agents/expert-spur.md +1 -0
  6. package/plugins/sp/agents/super-coder.md +1 -0
  7. package/plugins/sp/agents/super-planner.md +1 -0
  8. package/plugins/sp/agents/super-reviewer.md +1 -0
  9. package/plugins/sp/commands/dev-arch.md +1 -0
  10. package/plugins/sp/commands/dev-brainstorm.md +1 -0
  11. package/plugins/sp/commands/dev-changelog.md +1 -0
  12. package/plugins/sp/commands/dev-daily.md +1 -0
  13. package/plugins/sp/commands/dev-debug.md +1 -0
  14. package/plugins/sp/commands/dev-dogfood.md +1 -0
  15. package/plugins/sp/commands/dev-feature-change.md +1 -0
  16. package/plugins/sp/commands/dev-find-conflict.md +1 -0
  17. package/plugins/sp/commands/dev-find-issue.md +1 -0
  18. package/plugins/sp/commands/dev-find-next.md +1 -0
  19. package/plugins/sp/commands/dev-find-way.md +1 -0
  20. package/plugins/sp/commands/dev-fixall.md +1 -0
  21. package/plugins/sp/commands/dev-fixgha.md +1 -0
  22. package/plugins/sp/commands/dev-gitmsg.md +1 -0
  23. package/plugins/sp/commands/dev-gtd.md +1 -0
  24. package/plugins/sp/commands/dev-handover.md +1 -0
  25. package/plugins/sp/commands/dev-idea.md +1 -0
  26. package/plugins/sp/commands/dev-job-dump.md +1 -0
  27. package/plugins/sp/commands/dev-job-resume.md +1 -0
  28. package/plugins/sp/commands/dev-next.md +1 -0
  29. package/plugins/sp/commands/dev-plan.md +1 -0
  30. package/plugins/sp/commands/dev-pr-review.md +1 -0
  31. package/plugins/sp/commands/dev-refactor.md +1 -0
  32. package/plugins/sp/commands/dev-refine.md +1 -0
  33. package/plugins/sp/commands/dev-refineall.md +1 -0
  34. package/plugins/sp/commands/dev-refresh.md +1 -0
  35. package/plugins/sp/commands/dev-reverse.md +1 -0
  36. package/plugins/sp/commands/dev-review-session.md +1 -0
  37. package/plugins/sp/commands/dev-review.md +1 -0
  38. package/plugins/sp/commands/dev-run.md +1 -0
  39. package/plugins/sp/commands/dev-runall.md +1 -0
  40. package/plugins/sp/commands/dev-simplify.md +1 -0
  41. package/plugins/sp/commands/dev-unit.md +1 -0
  42. package/plugins/sp/commands/dev-verify.md +1 -0
  43. package/plugins/sp/commands/dev-verifyall.md +1 -0
  44. package/plugins/sp/commands/dev-wrap.md +1 -0
  45. package/plugins/sp/commands/dev-wrapall.md +1 -0
  46. package/plugins/sp/commands/rule-add.md +1 -0
  47. package/plugins/sp/commands/rule-refine.md +1 -0
  48. package/plugins/sp/commands/rule-scan.md +1 -0
  49. package/plugins/sp/commands/spur-init.md +1 -0
  50. package/plugins/sp/commands/workflow-add.md +1 -0
  51. package/plugins/sp/commands/workflow-refine.md +1 -0
  52. package/plugins/sp/lib/destructive-policy.ts +521 -0
  53. package/plugins/sp/plugin.json +1 -1
  54. package/plugins/sp/scripts/hooks/careful-guard.ts +5 -0
  55. package/plugins/sp/skills/brainstorm/SKILL.md +1 -0
  56. package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
  57. package/plugins/sp/skills/code-implementation/SKILL.md +1 -0
  58. package/plugins/sp/skills/code-improvement/SKILL.md +1 -0
  59. package/plugins/sp/skills/code-refactoring/SKILL.md +1 -0
  60. package/plugins/sp/skills/code-review/SKILL.md +1 -0
  61. package/plugins/sp/skills/code-simplification/SKILL.md +1 -0
  62. package/plugins/sp/skills/code-testing/SKILL.md +1 -0
  63. package/plugins/sp/skills/code-verification/SKILL.md +1 -0
  64. package/plugins/sp/skills/conflict-finding/SKILL.md +1 -0
  65. package/plugins/sp/skills/daily-summary/SKILL.md +1 -0
  66. package/plugins/sp/skills/doc-evolve/SKILL.md +1 -0
  67. package/plugins/sp/skills/dogfood-testing/SKILL.md +1 -0
  68. package/plugins/sp/skills/doubt-driven-development/SKILL.md +1 -0
  69. package/plugins/sp/skills/fleet-join/SKILL.md +1 -0
  70. package/plugins/sp/skills/functional-review/SKILL.md +1 -0
  71. package/plugins/sp/skills/history-anatomy/SKILL.md +1 -0
  72. package/plugins/sp/skills/indexed-context/SKILL.md +1 -0
  73. package/plugins/sp/skills/issue-finding/SKILL.md +1 -0
  74. package/plugins/sp/skills/next-feature/SKILL.md +1 -0
  75. package/plugins/sp/skills/next-router/SKILL.md +1 -0
  76. package/plugins/sp/skills/parallel-execution/SKILL.md +1 -0
  77. package/plugins/sp/skills/pr-reviewing/SKILL.md +1 -0
  78. package/plugins/sp/skills/redesign-web-ui/SKILL.md +1 -0
  79. package/plugins/sp/skills/reverse-engineering/SKILL.md +1 -0
  80. package/plugins/sp/skills/session-review/SKILL.md +1 -0
  81. package/plugins/sp/skills/source-driven-development/SKILL.md +1 -0
  82. package/plugins/sp/skills/sp-bot-bootstrap/SKILL.md +75 -0
  83. package/plugins/sp/skills/spec-decomposition/SKILL.md +1 -0
  84. package/plugins/sp/skills/spur-check/SKILL.md +1 -0
  85. package/plugins/sp/skills/spur-cli/SKILL.md +1 -0
  86. package/plugins/sp/skills/spur-composer/SKILL.md +1 -0
  87. package/plugins/sp/skills/spur-dev/SKILL.md +1 -0
  88. package/plugins/sp/skills/spur-doctor/SKILL.md +1 -0
  89. package/plugins/sp/skills/sys-architecture/SKILL.md +1 -0
  90. package/plugins/sp/skills/sys-debugging/SKILL.md +1 -0
  91. package/plugins/sp/skills/taste-refactoring-api/SKILL.md +1 -0
  92. package/plugins/sp/skills/taste-refactoring-architect/SKILL.md +1 -0
  93. package/plugins/sp/skills/taste-refactoring-tests/SKILL.md +1 -0
  94. package/plugins/sp/skills/taste-refactoring-ui/SKILL.md +1 -0
  95. package/plugins/sp/skills/test-driven-development/SKILL.md +1 -0
  96. package/plugins/sp/skills/wayfinder/SKILL.md +1 -0
  97. package/spur.js +2235 -1292
  98. package/web/_astro/BoardApp.BpVPF1yi.js +1 -0
  99. package/web/_astro/{BoardApp.CNBpKuB6.js → BoardApp.H0GHCpML.js} +157 -157
  100. package/web/_astro/TaskDetail.C3fD4iNx.js +1 -0
  101. package/web/_astro/{arc.CLFbvaU4.js → arc.CwmuZ1Pw.js} +1 -1
  102. package/web/_astro/{architectureDiagram-3BPJPVTR.YYrMKtrm.js → architectureDiagram-3BPJPVTR.D2r2pAtD.js} +1 -1
  103. package/web/_astro/{blockDiagram-GPEHLZMM.D-Xzu32A.js → blockDiagram-GPEHLZMM.LRgyDihu.js} +1 -1
  104. package/web/_astro/{c4Diagram-AAUBKEIU.Cv7EV8QD.js → c4Diagram-AAUBKEIU.OscV13G7.js} +1 -1
  105. package/web/_astro/channel.OpZyofHt.js +1 -0
  106. package/web/_astro/{chunk-2J33WTMH.CkAKN-fF.js → chunk-2J33WTMH.BcbENXnC.js} +1 -1
  107. package/web/_astro/{chunk-4BX2VUAB.D-xu2Tgi.js → chunk-4BX2VUAB.CcRidR01.js} +1 -1
  108. package/web/_astro/{chunk-55IACEB6.yNQibVGY.js → chunk-55IACEB6.DxAFaIIp.js} +1 -1
  109. package/web/_astro/{chunk-727SXJPM.DlVIFHHZ.js → chunk-727SXJPM.Cjvuc3en.js} +1 -1
  110. package/web/_astro/{chunk-AQP2D5EJ.C-LGFaeS.js → chunk-AQP2D5EJ.B3GSEPXo.js} +1 -1
  111. package/web/_astro/{chunk-FMBD7UC4.Df2n26zT.js → chunk-FMBD7UC4.DbpwRXpL.js} +1 -1
  112. package/web/_astro/{chunk-ND2GUHAM.CV_k83Gj.js → chunk-ND2GUHAM.CdiLgZ9z.js} +1 -1
  113. package/web/_astro/{chunk-QZHKN3VN.BQQDO3h3.js → chunk-QZHKN3VN.DT_2ovxF.js} +1 -1
  114. package/web/_astro/{classDiagram-4FO5ZUOK.DkSMdA84.js → classDiagram-4FO5ZUOK.B4Y_Byb0.js} +1 -1
  115. package/web/_astro/{classDiagram-v2-Q7XG4LA2.DkSMdA84.js → classDiagram-v2-Q7XG4LA2.B4Y_Byb0.js} +1 -1
  116. package/web/_astro/{cose-bilkent-S5V4N54A.CCN7GwAZ.js → cose-bilkent-S5V4N54A.0UOSbCRW.js} +1 -1
  117. package/web/_astro/{cynefin-OW5HDTMX.CqQCttkM.js → cynefin-OW5HDTMX.iaVw7KUy.js} +1 -1
  118. package/web/_astro/{dagre-BM42HDAG.CCn6iftc.js → dagre-BM42HDAG.DzBLfYbl.js} +1 -1
  119. package/web/_astro/{diagram-2AECGRRQ.DImUVZK-.js → diagram-2AECGRRQ.CtoO_RXO.js} +1 -1
  120. package/web/_astro/{diagram-5GNKFQAL.BYNywZwV.js → diagram-5GNKFQAL.C1ah--0o.js} +1 -1
  121. package/web/_astro/{diagram-KO2AKTUF.LJZ9DjsJ.js → diagram-KO2AKTUF.DAawwAoM.js} +1 -1
  122. package/web/_astro/{diagram-LMA3HP47.CJ3YpVED.js → diagram-LMA3HP47.kC7Zg0ru.js} +1 -1
  123. package/web/_astro/{diagram-OG6HWLK6.CnVtq5_j.js → diagram-OG6HWLK6.B3dSXJ9m.js} +1 -1
  124. package/web/_astro/{erDiagram-TEJ5UH35.SdmuzZwT.js → erDiagram-TEJ5UH35.cVJXYJkv.js} +1 -1
  125. package/web/_astro/{flowDiagram-I6XJVG4X.C9V-B-nO.js → flowDiagram-I6XJVG4X.CwD6KgFD.js} +1 -1
  126. package/web/_astro/{ganttDiagram-6RSMTGT7.DT5v7SL5.js → ganttDiagram-6RSMTGT7.UB88xr_u.js} +1 -1
  127. package/web/_astro/{gitGraphDiagram-PVQCEYII.B3qURYBL.js → gitGraphDiagram-PVQCEYII.C4B3FNOY.js} +1 -1
  128. package/web/_astro/index.CKtaGArK.css +1 -0
  129. package/web/_astro/{infoDiagram-5YYISTIA.CHO0xT8l.js → infoDiagram-5YYISTIA.6QC564C4.js} +1 -1
  130. package/web/_astro/{ishikawaDiagram-YF4QCWOH.buALAOGh.js → ishikawaDiagram-YF4QCWOH.sQBtM0gc.js} +1 -1
  131. package/web/_astro/{journeyDiagram-JHISSGLW.DvKuIdbX.js → journeyDiagram-JHISSGLW.Dslglgu2.js} +1 -1
  132. package/web/_astro/{kanban-definition-UN3LZRKU.Dvoi9jdN.js → kanban-definition-UN3LZRKU.CPKaPyzi.js} +1 -1
  133. package/web/_astro/{linear.EsEfz8Ex.js → linear.DNHY6BCF.js} +1 -1
  134. package/web/_astro/{mermaid.core.Vk3ryVC7.js → mermaid.core.VoW0rvGv.js} +4 -4
  135. package/web/_astro/{mindmap-definition-RKZ34NQL.DG1pkymq.js → mindmap-definition-RKZ34NQL.jYZ-jCh7.js} +1 -1
  136. package/web/_astro/{pieDiagram-4H26LBE5.DAUmIdd6.js → pieDiagram-4H26LBE5.DQ0fapSh.js} +1 -1
  137. package/web/_astro/{quadrantDiagram-W4KKPZXB.Du3QUozl.js → quadrantDiagram-W4KKPZXB.CWF406ut.js} +1 -1
  138. package/web/_astro/{requirementDiagram-4Y6WPE33.neRi5GrQ.js → requirementDiagram-4Y6WPE33.BmUiiY0p.js} +1 -1
  139. package/web/_astro/{sankeyDiagram-5OEKKPKP.CMUtxA70.js → sankeyDiagram-5OEKKPKP.CzUeGGUK.js} +1 -1
  140. package/web/_astro/{sequenceDiagram-3UESZ5HK.CfYFLmYH.js → sequenceDiagram-3UESZ5HK.DKvkUeEk.js} +1 -1
  141. package/web/_astro/{stateDiagram-AJRCARHV.Bn4dmAad.js → stateDiagram-AJRCARHV.ByedXnFB.js} +1 -1
  142. package/web/_astro/{stateDiagram-v2-BHNVJYJU.BMNezvp-.js → stateDiagram-v2-BHNVJYJU.Bx7wyAlv.js} +1 -1
  143. package/web/_astro/{timeline-definition-PNZ67QCA.ByaDwsBX.js → timeline-definition-PNZ67QCA.Chzu_dUX.js} +1 -1
  144. package/web/_astro/{vennDiagram-CIIHVFJN.CUYU1Mc9.js → vennDiagram-CIIHVFJN.PuxG68DI.js} +1 -1
  145. package/web/_astro/{wardleyDiagram-YWT4CUSO.DpZZeJr2.js → wardleyDiagram-YWT4CUSO.C9ZKxfsC.js} +1 -1
  146. package/web/_astro/{xychartDiagram-2RQKCTM6.CWwI33U0.js → xychartDiagram-2RQKCTM6.B7qR-3HU.js} +1 -1
  147. package/web/index.html +2 -2
  148. package/web/_astro/BoardApp.DVWLxHcV.js +0 -1
  149. package/web/_astro/TaskDetail.BY6jO_3P.js +0 -1
  150. package/web/_astro/channel.DzHZuqj0.js +0 -1
  151. package/web/_astro/index.C8LjH5ri.css +0 -1
@@ -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.24",
3
+ "version": "0.4.26",
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.
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: brainstorm
3
3
  description: "Structured ideation: generate solution options with trade-offs and source citations, then delegate to research/task-creation skills. Triggers: \"brainstorm ideas\", \"explore solutions\", \"consider options\", \"research approaches\", \"what are my options\", \"how should I approach X\"."
4
+ requires: [cli:spur, local-fs]
4
5
  license: Apache-2.0
5
6
  version: 1.0.0
6
7
  created_at: 2026-03-25
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: branch-workflow
3
3
  description: "Branch lifecycle discipline: create, worktree, commit, self-review, merge, cleanup."
4
+ requires: [cli:spur, local-fs]
4
5
  disable-model-invocation: true
5
6
  license: Apache-2.0
6
7
  metadata:
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: code-implementation
3
3
  description: "The implementation competency — turn a task's requirements, AC, and design into production code: task-driven scope, stack-pattern selection, root-cause debugging, a Solution change-map. Triggers: \"implement\", \"write code\", \"add feature\", \"fix the bug\", \"refactor\", \"code this task\"."
4
+ requires: [cli:spur, local-fs]
4
5
  license: Apache-2.0
5
6
  metadata:
6
7
  author: spur
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: code-improvement
3
3
  description: "Surface architectural friction and propose deepening opportunities — refactors that turn shallow, tightly-coupled modules into deep, testable, AI-navigable ones. Triggers: \"improve architecture\", \"find refactoring opportunities\", \"shallow module\", \"hard to test\", \"reduce coupling\", \"deepen modules\"."
4
+ requires: [cli:spur, local-fs]
4
5
  license: Apache-2.0
5
6
  metadata:
6
7
  author: spur
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: code-refactoring
3
3
  description: "Refactoring coordinator with a preservation contract: routes the four taste lenses (api, architect, tests, ui), normalizes findings to a shared schema and P1–P4 severities, gates applies, runs a fix ladder with revert-on-regression. Use for 'dev-refactor', 'safe refactor', 'refactor while keeping every feature'."
4
+ requires: [cli:spur, local-fs]
4
5
  license: Apache-2.0
5
6
  metadata:
6
7
  author: spur
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: code-review
3
3
  description: "Pre-commit self-review checklist, structured review requests, and processing review findings into tasks. Triggers: \"review this\", \"self-review\", \"pre-commit check\", \"code review\", \"review my changes\", \"check before commit\"."
4
+ requires: [cli:spur, local-fs]
4
5
  license: Apache-2.0
5
6
  metadata:
6
7
  author: spur
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: code-simplification
3
3
  description: "Simplify code for clarity without changing behavior. Use when refactoring for readability or reducing complexity."
4
+ requires: [local-fs]
4
5
  license: Apache-2.0
5
6
  metadata:
6
7
  author: spur
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: code-testing
3
3
  description: "The testing competency — run tests, measure coverage, categorize gaps, extend the suite with targeted tests across Bun/TS, Python, Go. Triggers: \"write tests\", \"measure coverage\", \"what's untested\", \"coverage gap\", \"extend the test suite\", \"run the tests\"."
4
+ requires: [local-fs]
4
5
  license: Apache-2.0
5
6
  metadata:
6
7
  author: spur
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: code-verification
3
3
  description: "Verify a task's implementation against requirements and AC; SECUA code review. Produces a PASS/PARTIAL/FAIL verdict with per-requirement evidence. Triggers: \"verify task\", \"verify this\", \"check the requirements\", \"code review\", \"SECUA review\", \"requirements traceability\", \"review the diff\"."
4
+ requires: [cli:spur, local-fs]
4
5
  license: Apache-2.0
5
6
  metadata:
6
7
  author: spur
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: conflict-finding
3
3
  description: "Authority-aware semantic audit across source, task files, feature files, and project authority files — detect conflicts, resolve claim-specific authority, collect reproducible evidence, and route confirmed repairs through owner surfaces. Triggers: find conflict, conflict audit, semantic conflict, authority mismatch, stale projection."
4
+ requires: [cli:spur, local-fs]
4
5
  license: Apache-2.0
5
6
  version: 1.0.0
6
7
  metadata:
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: daily-summary
3
3
  description: "Generate a daily summary report from agent usage, git history, and your notes."
4
+ requires: [cli:spur, local-fs]
4
5
  disable-model-invocation: true
5
6
  license: Apache-2.0
6
7
  version: 1.0.0
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: doc-evolve
3
3
  description: "Evolve docs/00-05 + AGENTS.md per docs/99_PROJECT_CONSTITUTION.md: drift audits, same-commit sync checks, frontmatter-contract verification, lesson-append. Triggers: \"doc evolve\", \"doc drift\", \"sync docs\", \"drift audit\", \"are the docs stale\", \"append a lesson\"."
4
+ requires: [cli:spur, local-fs]
4
5
  license: Apache-2.0
5
6
  metadata:
6
7
  author: spur
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: dogfood-testing
3
3
  description: "Drive a testee (skill/command/CLI) end-to-end as a real user, fix within a bounded retry budget, monitor with a live ledger, emit a structured report. Triggers: \"dogfood this\", \"drive this command end-to-end\", \"test this skill as a user\", \"dogfood report\"."
4
+ requires: [cli:spur, local-fs]
4
5
  license: Apache-2.0
5
6
  metadata:
6
7
  author: spur