@gobing-ai/spur 0.3.47 → 0.3.49

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 (148) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +94 -15
  3. package/config/transition-shims.json +33 -0
  4. package/config/workflows/basic.yaml +2 -0
  5. package/config/workflows/docs-pipeline.yaml +2 -0
  6. package/config/workflows/feature-dev.yaml +8 -0
  7. package/config/workflows/idea-pipeline.yaml +10 -0
  8. package/config/workflows/planning-pipeline.yaml +4 -0
  9. package/config/workflows/pr-review.yaml +338 -0
  10. package/config/workflows/task-pipeline.yaml +8 -0
  11. package/config/workflows/wayfinder-resolution.yaml +4 -0
  12. package/config/workflows/wrapup-pipeline.yaml +18 -1
  13. package/package.json +8 -8
  14. package/plugins/sp/README.md +9 -6
  15. package/plugins/sp/agents/expert-spur.md +1 -0
  16. package/plugins/sp/commands/dev-arch.md +2 -1
  17. package/plugins/sp/commands/dev-brainstorm.md +2 -1
  18. package/plugins/sp/commands/dev-changelog.md +1 -0
  19. package/plugins/sp/commands/dev-daily.md +1 -0
  20. package/plugins/sp/commands/dev-debug.md +2 -1
  21. package/plugins/sp/commands/dev-dogfood.md +2 -1
  22. package/plugins/sp/commands/{dev-featurechange.md → dev-feature-change.md} +8 -10
  23. package/plugins/sp/commands/dev-find-conflict.md +2 -1
  24. package/plugins/sp/commands/dev-find-issue.md +36 -43
  25. package/plugins/sp/commands/dev-find-next.md +5 -4
  26. package/plugins/sp/commands/dev-fixall.md +1 -0
  27. package/plugins/sp/commands/dev-gitmsg.md +1 -0
  28. package/plugins/sp/commands/dev-gtd.md +12 -12
  29. package/plugins/sp/commands/dev-handover.md +1 -0
  30. package/plugins/sp/commands/dev-history-load.md +63 -0
  31. package/plugins/sp/commands/dev-idea.md +1 -0
  32. package/plugins/sp/commands/dev-next.md +2 -1
  33. package/plugins/sp/commands/dev-parallel.md +2 -1
  34. package/plugins/sp/commands/dev-plan.md +2 -1
  35. package/plugins/sp/commands/dev-pr-review.md +39 -0
  36. package/plugins/sp/commands/dev-refine.md +5 -3
  37. package/plugins/sp/commands/dev-refineall.md +2 -1
  38. package/plugins/sp/commands/dev-refresh.md +2 -1
  39. package/plugins/sp/commands/dev-reverse.md +2 -1
  40. package/plugins/sp/commands/dev-review.md +2 -1
  41. package/plugins/sp/commands/dev-run.md +3 -2
  42. package/plugins/sp/commands/dev-runall.md +3 -2
  43. package/plugins/sp/commands/dev-simplify.md +2 -1
  44. package/plugins/sp/commands/dev-unit.md +2 -1
  45. package/plugins/sp/commands/dev-verify.md +2 -1
  46. package/plugins/sp/commands/dev-verifyall.md +2 -1
  47. package/plugins/sp/commands/dev-wrap.md +7 -5
  48. package/plugins/sp/commands/dev-wrapall.md +7 -5
  49. package/plugins/sp/commands/rule-add.md +1 -0
  50. package/plugins/sp/commands/rule-refine.md +1 -0
  51. package/plugins/sp/commands/rule-scan.md +1 -0
  52. package/plugins/sp/commands/spur-init.md +1 -0
  53. package/plugins/sp/commands/workflow-add.md +1 -0
  54. package/plugins/sp/commands/workflow-refine.md +1 -0
  55. package/plugins/sp/hooks/careful-guard.ts +5 -80
  56. package/plugins/sp/hooks/destructive-policy.ts +146 -0
  57. package/plugins/sp/hooks/pi/guard-extension.ts +33 -46
  58. package/plugins/sp/hooks/task-file-policy.ts +31 -0
  59. package/plugins/sp/hooks/task-write-guard.ts +4 -0
  60. package/plugins/sp/plugin.json +1 -1
  61. package/plugins/sp/references/roles.md +106 -0
  62. package/plugins/sp/scripts/feature-sync-bounded.ts +28 -2
  63. package/plugins/sp/scripts/history-load.ts +400 -0
  64. package/plugins/sp/scripts/pr-reviewing.ts +867 -0
  65. package/plugins/sp/scripts/stage-registry-adapter.ts +66 -31
  66. package/plugins/sp/scripts/surface-drift-inventory.ts +908 -0
  67. package/plugins/sp/scripts/task-size-precheck.ts +30 -4
  68. package/plugins/sp/scripts/transition-shim-check.ts +238 -0
  69. package/plugins/sp/scripts/validate-commands.ts +33 -2
  70. package/plugins/sp/scripts/validate-flag-contracts.ts +5 -2
  71. package/plugins/sp/skills/code-implementation/SKILL.md +9 -1
  72. package/plugins/sp/skills/code-verification/SKILL.md +29 -28
  73. package/plugins/sp/skills/issue-finding/SKILL.md +123 -141
  74. package/plugins/sp/skills/issue-finding/examples/expected-findings.json +1 -1
  75. package/plugins/sp/skills/issue-finding/references/session-formats.md +87 -90
  76. package/plugins/sp/skills/next-feature/SKILL.md +6 -6
  77. package/plugins/sp/skills/next-feature/references/handoff-routing.md +5 -5
  78. package/plugins/sp/skills/next-feature/references/signal-derivation.md +7 -2
  79. package/plugins/sp/skills/next-router/SKILL.md +1 -1
  80. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +40 -2
  81. package/plugins/sp/skills/pr-reviewing/SKILL.md +285 -0
  82. package/plugins/sp/skills/spur-cli/SKILL.md +3 -0
  83. package/plugins/sp/skills/spur-cli/references/agent.md +12 -7
  84. package/plugins/sp/skills/spur-cli/references/features/hierarchy-mece.md +5 -5
  85. package/plugins/sp/skills/spur-cli/references/features.md +1 -1
  86. package/plugins/sp/skills/spur-cli/references/team.md +10 -3
  87. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  88. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +17 -0
  89. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +44 -23
  90. package/plugins/sp/skills/spur-dev/references/dev-operations.md +14 -11
  91. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +14 -12
  92. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +28 -7
  93. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +2 -0
  94. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +12 -2
  95. package/schemas/spur-config.schema.json +47 -3
  96. package/spur.js +12223 -7716
  97. package/web/_astro/BoardApp.8hiqShQn.js +1 -0
  98. package/web/_astro/{BoardApp.DKyrGxdo.js → BoardApp.BjQUNhuj.js} +74 -74
  99. package/web/_astro/{TaskDetail.6-27_LMa.js → TaskDetail.CVBuD6dF.js} +1 -1
  100. package/web/_astro/{arc.Df-9AQvS.js → arc.BMMjdODi.js} +1 -1
  101. package/web/_astro/{architectureDiagram-3BPJPVTR.VAI_-paS.js → architectureDiagram-3BPJPVTR.BU5ShzXf.js} +1 -1
  102. package/web/_astro/{blockDiagram-GPEHLZMM.DFpUY1ue.js → blockDiagram-GPEHLZMM.Bj1iEqPD.js} +1 -1
  103. package/web/_astro/{c4Diagram-AAUBKEIU.CF8doOpg.js → c4Diagram-AAUBKEIU.vX8wepCL.js} +1 -1
  104. package/web/_astro/channel.EwdSemIC.js +1 -0
  105. package/web/_astro/{chunk-2J33WTMH.BnjK3fjt.js → chunk-2J33WTMH.BKAipTym.js} +1 -1
  106. package/web/_astro/{chunk-4BX2VUAB.x6ZDnJKq.js → chunk-4BX2VUAB.B68XkPG7.js} +1 -1
  107. package/web/_astro/{chunk-55IACEB6.zY-0uu7w.js → chunk-55IACEB6.BmeDLcrc.js} +1 -1
  108. package/web/_astro/{chunk-727SXJPM.BZxKg_Vi.js → chunk-727SXJPM.PDuBA3Kw.js} +1 -1
  109. package/web/_astro/{chunk-AQP2D5EJ.Cpi9G9Td.js → chunk-AQP2D5EJ.C7A044za.js} +1 -1
  110. package/web/_astro/{chunk-FMBD7UC4.DWTB-Pif.js → chunk-FMBD7UC4.BtzKKFqR.js} +1 -1
  111. package/web/_astro/{chunk-ND2GUHAM.BPDQbiOG.js → chunk-ND2GUHAM.BJuDeeOy.js} +1 -1
  112. package/web/_astro/{chunk-QZHKN3VN.BRWIcuoM.js → chunk-QZHKN3VN.DSeMDgcQ.js} +1 -1
  113. package/web/_astro/{classDiagram-4FO5ZUOK.mGTCZsDO.js → classDiagram-4FO5ZUOK.D53Q4tCw.js} +1 -1
  114. package/web/_astro/{classDiagram-v2-Q7XG4LA2.mGTCZsDO.js → classDiagram-v2-Q7XG4LA2.D53Q4tCw.js} +1 -1
  115. package/web/_astro/{cose-bilkent-S5V4N54A.D1GEut-z.js → cose-bilkent-S5V4N54A.c712AFRH.js} +1 -1
  116. package/web/_astro/{dagre-BM42HDAG.BV0XG9Do.js → dagre-BM42HDAG.D-idisph.js} +1 -1
  117. package/web/_astro/{diagram-2AECGRRQ.DzpYxsjo.js → diagram-2AECGRRQ.DLgnsJCU.js} +1 -1
  118. package/web/_astro/{diagram-5GNKFQAL.Cm9YzJh4.js → diagram-5GNKFQAL.BiaxBVqx.js} +1 -1
  119. package/web/_astro/{diagram-KO2AKTUF.BjhottUj.js → diagram-KO2AKTUF.C8HX1vd8.js} +1 -1
  120. package/web/_astro/{diagram-LMA3HP47.BFsQW5kb.js → diagram-LMA3HP47.CfqDLLes.js} +1 -1
  121. package/web/_astro/{diagram-OG6HWLK6.8pdpzSWO.js → diagram-OG6HWLK6.15SDiEed.js} +1 -1
  122. package/web/_astro/{erDiagram-TEJ5UH35.Bd7KUJmJ.js → erDiagram-TEJ5UH35.DksYtOYM.js} +1 -1
  123. package/web/_astro/{flowDiagram-I6XJVG4X.7LWffkaE.js → flowDiagram-I6XJVG4X.DR_Au-HV.js} +1 -1
  124. package/web/_astro/{ganttDiagram-6RSMTGT7.BeDcO5tI.js → ganttDiagram-6RSMTGT7.CHhHrffI.js} +1 -1
  125. package/web/_astro/{gitGraphDiagram-PVQCEYII.Ca4n730A.js → gitGraphDiagram-PVQCEYII.B2Xehvam.js} +1 -1
  126. package/web/_astro/{index.Dbvuw6d4.css → index.DAxu50UF.css} +1 -1
  127. package/web/_astro/{infoDiagram-5YYISTIA.B0OakQYb.js → infoDiagram-5YYISTIA.C9c3CNNN.js} +1 -1
  128. package/web/_astro/{ishikawaDiagram-YF4QCWOH.DSmNQe-1.js → ishikawaDiagram-YF4QCWOH.BibUHkh8.js} +1 -1
  129. package/web/_astro/{journeyDiagram-JHISSGLW.Cy5ruEUu.js → journeyDiagram-JHISSGLW.BYoVHiyO.js} +1 -1
  130. package/web/_astro/{kanban-definition-UN3LZRKU.CUJXub0p.js → kanban-definition-UN3LZRKU.CM1K5wHE.js} +1 -1
  131. package/web/_astro/{linear.DC1jCCXn.js → linear.SPpjJUb-.js} +1 -1
  132. package/web/_astro/{mermaid.core.DxVP99Ab.js → mermaid.core.BAgx3nnb.js} +4 -4
  133. package/web/_astro/{mindmap-definition-RKZ34NQL.D0MaV6sJ.js → mindmap-definition-RKZ34NQL.D35oPG1R.js} +1 -1
  134. package/web/_astro/{pieDiagram-4H26LBE5.DCC6_q32.js → pieDiagram-4H26LBE5.DiWuRwk7.js} +1 -1
  135. package/web/_astro/{quadrantDiagram-W4KKPZXB.BeUOAM7C.js → quadrantDiagram-W4KKPZXB.B9PBzTWn.js} +1 -1
  136. package/web/_astro/{requirementDiagram-4Y6WPE33.Dbl4MASO.js → requirementDiagram-4Y6WPE33.CYuuamFN.js} +1 -1
  137. package/web/_astro/{sankeyDiagram-5OEKKPKP.HsLg0VS4.js → sankeyDiagram-5OEKKPKP.W24UhhtD.js} +1 -1
  138. package/web/_astro/{sequenceDiagram-3UESZ5HK.DT7DJTnZ.js → sequenceDiagram-3UESZ5HK.BpbNjA51.js} +1 -1
  139. package/web/_astro/{stateDiagram-AJRCARHV.d_ju1Vr1.js → stateDiagram-AJRCARHV.DqVsHudf.js} +1 -1
  140. package/web/_astro/{stateDiagram-v2-BHNVJYJU.DMCAjMJ4.js → stateDiagram-v2-BHNVJYJU.CzwHYX81.js} +1 -1
  141. package/web/_astro/{timeline-definition-PNZ67QCA.DNOHr62_.js → timeline-definition-PNZ67QCA.Bc3B6djw.js} +1 -1
  142. package/web/_astro/{vennDiagram-CIIHVFJN.B7dUy-1W.js → vennDiagram-CIIHVFJN.C-D5rh8O.js} +1 -1
  143. package/web/_astro/{wardley-L42UT6IY.DEqOXvBh.js → wardley-L42UT6IY.D7PdYCqn.js} +1 -1
  144. package/web/_astro/{wardleyDiagram-YWT4CUSO.BCRb2p6x.js → wardleyDiagram-YWT4CUSO.CwmJKXF3.js} +1 -1
  145. package/web/_astro/{xychartDiagram-2RQKCTM6.NxVQLdBh.js → xychartDiagram-2RQKCTM6.avDYnLsb.js} +1 -1
  146. package/web/index.html +2 -2
  147. package/web/_astro/BoardApp.Ce6zJYAH.js +0 -1
  148. package/web/_astro/channel.Uhm9O3UV.js +0 -1
@@ -24,12 +24,17 @@ import { homedir } from 'node:os';
24
24
  import { isAbsolute, join, resolve } from 'node:path';
25
25
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
26
26
  import { resolveAgentHint as resolveAgentHintShared, resolveModelHint as resolveModelHintShared } from '../agent-hint';
27
+ import { classifyCommand } from '../destructive-policy';
28
+ import { couldBeTaskFile } from '../task-file-policy';
27
29
 
28
30
  // ─── Constants ───────────────────────────────────────────────────────────
29
31
 
30
- const SPUR_CONTEXT_DIR = join(process.cwd(), '.spur', 'context');
31
- const SESSION_FILE = join(SPUR_CONTEXT_DIR, '.session.json');
32
- const LEDGER_FILE = join(SPUR_CONTEXT_DIR, 'token-ledger.jsonl');
32
+ // Resolved per call, not cached at module load: Pi's cwd is fixed for the
33
+ // process lifetime, and lazy resolution keeps the extension testable (tests
34
+ // chdir into a temp project before driving handlers).
35
+ const spurContextDir = (): string => join(process.cwd(), '.spur', 'context');
36
+ const sessionFilePath = (): string => join(spurContextDir(), '.session.json');
37
+ const ledgerFilePath = (): string => join(spurContextDir(), 'token-ledger.jsonl');
33
38
  const REDACTION_CAP = 4096;
34
39
  const SUMMARY_MAX_CHARS = 200;
35
40
 
@@ -101,33 +106,12 @@ function resolveSpurTaskOwnership(filePath: string): TaskOwnership {
101
106
  return 'unknown';
102
107
  }
103
108
 
104
- // Destructive command patterns (mirrors careful-guard.ts)
105
- const DESTRUCTIVE_PATTERNS: RegExp[] = [
106
- /\brm\s+(?:-[rRf]*[rf]+\s*|\s*--recursive\s*)/,
107
- /\bDROP\s+(?:TABLE|DATABASE)\b/i,
108
- /\bTRUNCATE\b/i,
109
- /\bgit\s+push\s+--force\b/,
110
- /\bgit\s+reset\s+--hard\b/,
111
- /\bgit\s+checkout\s+\.\b/,
112
- /\bgit\s+restore\s+\.\b/,
113
- /\bkubectl\s+delete\b/,
114
- /\bdocker\s+system\s+prune\b/,
115
- ];
116
-
117
- // Safe destructive paths (rm -rf of build caches is routine)
118
- const SAFE_CACHE_PATTERNS = [/node_modules/, /dist\b/, /\.next\b/, /coverage\b/, /build\b/, /\.turbo\b/, /\.cache\b/];
119
-
120
- function isDestructiveCommand(command: string): boolean {
121
- return DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
122
- }
123
-
124
- function isSafeDestructivePath(command: string): boolean {
125
- // Check if rm -rf targets a known safe cache directory
126
- const rmMatch = command.match(/\brm\s+(?:-[rRf]*[rf]+\s*|\s*--recursive\s*)(.+)/);
127
- if (!rmMatch) return false;
128
- const target = rmMatch[1] ?? '';
129
- return SAFE_CACHE_PATTERNS.some((p) => p.test(target));
130
- }
109
+ // Destructive-command classification is imported from `../destructive-policy`, the
110
+ // single cross-platform policy. This file previously carried its own regex copy; it
111
+ // diverged from the Claude matrix on 7 of 10 pinned cases (it allowed
112
+ // `rm -rf node_modules /etc/nginx`, `rm -R --force /var/data`, `git push -f` and
113
+ // `git push origin +main`, and warned on `git push --force-with-lease`). Do not
114
+ // re-introduce a local copy — add cases to `destructive-policy.test.ts` instead.
131
115
 
132
116
  // ─── Token ledger helpers (mirrors context-post-tool.ts) ──────────────────
133
117
 
@@ -172,7 +156,7 @@ function redactText(text: string): string {
172
156
 
173
157
  function appendToLedger(event: ToolEvent, command: string | undefined): void {
174
158
  try {
175
- if (!existsSync(SPUR_CONTEXT_DIR)) return;
159
+ if (!existsSync(spurContextDir())) return;
176
160
  const sessionId = readSessionId();
177
161
  if (!sessionId) return;
178
162
 
@@ -189,7 +173,7 @@ function appendToLedger(event: ToolEvent, command: string | undefined): void {
189
173
  timestamp: new Date().toISOString(),
190
174
  });
191
175
 
192
- appendFileSync(LEDGER_FILE, `${ledgerEntry}\n`);
176
+ appendFileSync(ledgerFilePath(), `${ledgerEntry}\n`);
193
177
  } catch {
194
178
  // fail-open: skip ledger writes on error
195
179
  }
@@ -197,8 +181,8 @@ function appendToLedger(event: ToolEvent, command: string | undefined): void {
197
181
 
198
182
  function readSessionId(): string | undefined {
199
183
  try {
200
- if (!existsSync(SESSION_FILE)) return undefined;
201
- const data = JSON.parse(readFileSync(SESSION_FILE, 'utf-8')) as { session_id?: string };
184
+ if (!existsSync(sessionFilePath())) return undefined;
185
+ const data = JSON.parse(readFileSync(sessionFilePath(), 'utf-8')) as { session_id?: string };
202
186
  return data.session_id;
203
187
  } catch {
204
188
  return undefined;
@@ -215,7 +199,7 @@ function generateSessionId(): string {
215
199
 
216
200
  function initSession(): void {
217
201
  try {
218
- mkdirSync(SPUR_CONTEXT_DIR, { recursive: true });
202
+ mkdirSync(spurContextDir(), { recursive: true });
219
203
  const sessionId = generateSessionId();
220
204
  const session = {
221
205
  session_id: sessionId,
@@ -223,7 +207,7 @@ function initSession(): void {
223
207
  model: resolveModelHintShared(process.env),
224
208
  started_at: new Date().toISOString(),
225
209
  };
226
- writeFileSync(SESSION_FILE, `${JSON.stringify(session, null, 2)}\n`);
210
+ writeFileSync(sessionFilePath(), `${JSON.stringify(session, null, 2)}\n`);
227
211
 
228
212
  // Append session_start event to ledger
229
213
  const startEntry = JSON.stringify({
@@ -233,7 +217,7 @@ function initSession(): void {
233
217
  model: session.model,
234
218
  timestamp: session.started_at,
235
219
  });
236
- appendFileSync(LEDGER_FILE, `${startEntry}\n`);
220
+ appendFileSync(ledgerFilePath(), `${startEntry}\n`);
237
221
  } catch {
238
222
  // fail-open
239
223
  }
@@ -241,16 +225,16 @@ function initSession(): void {
241
225
 
242
226
  function cleanupSession(): void {
243
227
  try {
244
- if (!existsSync(SESSION_FILE)) return;
245
- const session = JSON.parse(readFileSync(SESSION_FILE, 'utf-8')) as { session_id?: string };
228
+ if (!existsSync(sessionFilePath())) return;
229
+ const session = JSON.parse(readFileSync(sessionFilePath(), 'utf-8')) as { session_id?: string };
246
230
  const sessionId = session.session_id;
247
231
 
248
232
  // Compute rollup totals from ledger
249
233
  let reads = 0;
250
234
  let writes = 0;
251
235
  let tokens = 0;
252
- if (sessionId && existsSync(LEDGER_FILE)) {
253
- for (const line of readFileSync(LEDGER_FILE, 'utf-8').split('\n')) {
236
+ if (sessionId && existsSync(ledgerFilePath())) {
237
+ for (const line of readFileSync(ledgerFilePath(), 'utf-8').split('\n')) {
254
238
  if (!line.trim()) continue;
255
239
  try {
256
240
  const evt = JSON.parse(line) as { session?: string; type?: string; tokens?: number };
@@ -273,10 +257,10 @@ function cleanupSession(): void {
273
257
  tokens,
274
258
  timestamp: new Date().toISOString(),
275
259
  });
276
- appendFileSync(LEDGER_FILE, `${endEntry}\n`);
260
+ appendFileSync(ledgerFilePath(), `${endEntry}\n`);
277
261
 
278
262
  // Cleanup session file
279
- rmSync(SESSION_FILE, { force: true });
263
+ rmSync(sessionFilePath(), { force: true });
280
264
  } catch {
281
265
  // fail-open
282
266
  }
@@ -292,7 +276,9 @@ export default function (pi: ExtensionAPI): void {
292
276
  const input = event.input as Record<string, unknown> | undefined;
293
277
  // Pi's write/edit tools use `path` (not Claude Code's `file_path`)
294
278
  const filePath = resolveInputPath(input);
295
- if (filePath && resolveSpurTaskOwnership(filePath) === 'owned') {
279
+ // Skip the `spur task resolve` subprocess for paths that cannot name a
280
+ // task file (shared predicate — see task-file-policy.ts).
281
+ if (filePath && couldBeTaskFile(filePath) && resolveSpurTaskOwnership(filePath) === 'owned') {
296
282
  const msg = `Denied: ${filePath} is a Spur task file. Use 'spur task update' instead.`;
297
283
  ctx.ui.notify(msg, 'error');
298
284
  return { block: true, reason: msg };
@@ -303,8 +289,9 @@ export default function (pi: ExtensionAPI): void {
303
289
  if (event.toolName === 'bash') {
304
290
  const input = event.input as Record<string, unknown> | undefined;
305
291
  const command = typeof input?.command === 'string' ? input.command : '';
306
- if (command && isDestructiveCommand(command) && !isSafeDestructivePath(command)) {
307
- const msg = `Warning: destructive command: ${command.slice(0, 120)}`;
292
+ const hit = command ? classifyCommand(command) : null;
293
+ if (hit !== null) {
294
+ const msg = `Warning: destructive command — ${hit}: ${command.slice(0, 120)}`;
308
295
  ctx.ui.notify(msg, 'warning');
309
296
  // Ask for confirmation
310
297
  const ok = await ctx.ui.confirm('Destructive command', msg);
@@ -0,0 +1,31 @@
1
+ /**
2
+ * task-file-policy — the cheap local answer to "could this path possibly be a
3
+ * task file?", shared by every platform's write guard.
4
+ *
5
+ * **Why this exists.** The write guards decide ownership by shelling
6
+ * `spur task resolve --strict --json`, which costs a full CLI start — measured
7
+ * 119-122ms with an installed binary, 187-238ms source-local — on *every* `Write`
8
+ * and `Edit` tool call, the overwhelming majority of which target ordinary source
9
+ * files that could never be task files. This predicate skips the subprocess for
10
+ * those without weakening the guard.
11
+ *
12
+ * **Why it cannot produce a false allow.** `--strict` resolution matches a corpus
13
+ * file through `TASK_FILENAME_RE` in `packages/app/src/services/task-locator.ts`
14
+ * (`^(\d{4})_(.+)\.md$`), so a basename failing that pattern can never be reported
15
+ * owned. This predicate is the same test, applied locally. If the corpus filename
16
+ * convention ever changes, this must change with it — `task-file-policy.test.ts`
17
+ * pins the two together.
18
+ */
19
+
20
+ /** The corpus task filename convention: `<4-digit wbs>_<slug>.md`. */
21
+ const TASK_FILENAME_RE = /^(\d{4})_(.+)\.md$/;
22
+
23
+ /**
24
+ * True when `filePath`'s basename could name a task file. A `false` result is
25
+ * authoritative — no `spur task resolve --strict` call is needed. A `true` result
26
+ * only means "ask the CLI"; folder membership is still the CLI's decision.
27
+ */
28
+ export function couldBeTaskFile(filePath: string): boolean {
29
+ const basename = filePath.split(/[\\/]/).pop() ?? '';
30
+ return TASK_FILENAME_RE.test(basename);
31
+ }
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { spawnSync } from 'node:child_process';
23
+ import { couldBeTaskFile } from './task-file-policy';
23
24
 
24
25
  interface ToolPayload {
25
26
  tool_name?: string;
@@ -68,6 +69,9 @@ async function main(): Promise<void> {
68
69
 
69
70
  const filePath = payload.tool_input?.file_path ?? '';
70
71
  if (filePath === '') preToolUseDecision('allow');
72
+ // Skip the `spur task resolve` subprocess (~120ms) for paths whose basename
73
+ // cannot name a task file — that is every ordinary source edit.
74
+ if (!couldBeTaskFile(filePath)) preToolUseDecision('allow');
71
75
 
72
76
  const ownership = resolveSpurTaskOwnership(filePath, process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
73
77
  if (ownership === 'owned') {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.47",
3
+ "version": "0.3.49",
4
4
  "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
5
5
  "extensions": {
6
6
  "pi": ["./hooks/pi/guard-extension.ts"]
@@ -0,0 +1,106 @@
1
+ ---
2
+ name: roles
3
+ description: "Layer-1 role→tier table for /sp:dev-* dispatch — four roles (scribe, coder, reviewer, planner), one per tier; command→role mapping closed over plugins/sp/commands/; stage-floor reconciliation. Consumed by sp:spur-dev, sp:spur-cli, sp:code-verification."
4
+ see_also:
5
+ - spur-dev
6
+ - spur-cli
7
+ - code-verification
8
+ ---
9
+
10
+ # Roles — the Layer-1 role-to-tier table
11
+
12
+ The executor-selection contract is two layers. **Layer 1 (this file)** projects *role → tier*;
13
+ its SSOT is `DEFAULT_AGENT_ROLES` in `packages/config/src/index.ts` (task 0572 / ADR-061) — this
14
+ file is the agent/human-facing view plus the plugin-owned command→role mapping. **Layer 2** maps
15
+ *tier → executor* and is owned by the operator in `.spur/config.yaml`. This file never names an
16
+ executor, a model, or a vendor — it declares only what tier a role's work needs, and the
17
+ operator's config decides which executor serves that tier.
18
+
19
+ The vocabulary is four roles, one per tier:
20
+
21
+ | Role | Tier | Stage floor source |
22
+ | --- | --- | --- |
23
+ | `scribe` | cheap | changelog (`min_tier: cheap`) |
24
+ | `coder` | standard | implement / test / wrap (`min_tier: standard`) |
25
+ | `reviewer` | capable-1 | verify / review / dogfood (highest fold `capable-1`) |
26
+ | `planner` | capable-2 | plan / refine / brainstorm (highest fold `capable-2`) |
27
+
28
+ **The one-role-per-tier property is the invariant**, not a coincidence: two roles sharing a tier
29
+ resolve to the same eligible executor set and are one role with two names. A proposed fifth role
30
+ must bring a fifth tier. The tiers are the live vocabulary
31
+ `cheap | standard | capable-1 | capable-2 | capable-3` (packages/config/src/index.ts).
32
+
33
+ This table supersedes the eight-intention vocabulary recorded in task 0344 § Solution D1/D2. That
34
+ decision named eight intentions, but against the stage registry they carried only four distinct
35
+ tier floors (`plan` capable-2; `verify`/`dogfood` capable-1; `changelog` cheap; everything else
36
+ standard) — four of the eight names had no routing consequence. The four roles below are that
37
+ collapse, named as people so they stay addressable in `--agent`.
38
+
39
+ ## The table
40
+
41
+ <!-- PROJECTION (task 0572 / ADR-061): the tier/stages half of the block below is a generated view
42
+ of DEFAULT_AGENT_ROLES in packages/config/src/index.ts — edit that constant, not this file.
43
+ plugins/sp/tests/roles.test.ts (R9) fails the suite on any drift between the two. The
44
+ `commands:` half is plugin data (command frontmatter is its SSOT). -->
45
+
46
+ ```yaml
47
+ version: 1
48
+ roles:
49
+ - id: scribe
50
+ tier: cheap
51
+ commands: [dev-gitmsg, dev-handover, dev-daily, dev-history-load, dev-changelog, dev-refresh, rule-add, rule-refine, workflow-add, workflow-refine, spur-init]
52
+ stages: [changelog]
53
+ - id: coder
54
+ tier: standard
55
+ commands: [dev-run, dev-unit, dev-debug, dev-simplify, dev-fixall, dev-reverse, dev-wrap, dev-wrapall, dev-gtd]
56
+ stages: [implement, test, wrap]
57
+ - id: reviewer
58
+ tier: capable-1
59
+ commands: [dev-verify, dev-verifyall, dev-review, dev-pr-review, dev-dogfood, rule-scan, dev-find-conflict, dev-find-issue]
60
+ stages: [verify, review, dogfood]
61
+ - id: planner
62
+ tier: capable-2
63
+ commands: [dev-plan, dev-refine, dev-brainstorm, dev-idea, dev-runall, dev-parallel, dev-next, dev-arch, dev-refineall, dev-find-next, dev-feature-change]
64
+ stages: [plan, refine, brainstorm]
65
+ ```
66
+
67
+ `commands` is the closed command→role mapping: every file under `plugins/sp/commands/` appears in
68
+ exactly one row, and a new command must be added to exactly one row (or bring a fifth role with a
69
+ fifth tier). `stages` lists the canonical stages the role folds (ids from
70
+ `REGISTERED_CANONICAL_STAGES` in `packages/domain/src/stage-registry/schema.ts`); a role's `tier`
71
+ must not sit below the highest `min_tier` among its folded stages.
72
+
73
+ ## Role annotations
74
+
75
+ - **`scribe` (cheap).** Writing derived text — commit messages, changelogs, handovers, daily
76
+ summaries — plus template scaffolding (`spur init`, rule/workflow authoring). Mechanical,
77
+ high-volume, cheap-tier work. Folds the `changelog` stage.
78
+ - **`coder` (standard).** Implementation and delivery: running the pipeline, unit tests, debugging,
79
+ simplification, fix-everything sweeps, reverse engineering, wrap-up, and the end-to-end
80
+ delivery flow (`dev-gtd`). Folds `implement`, `test`, `wrap`.
81
+ - **`reviewer` (capable-1).** Verification and analysis: per-task verify/review, batch verify,
82
+ dogfooding, anti-pattern scanning (`rule-scan`), and the two audit commands (`dev-find-conflict`,
83
+ `dev-find-issue`) — those analyse rather than transcribe, which is why they sit here and not
84
+ under `scribe`. Folds `verify`, `review`, `dogfood`.
85
+ - **`planner` (capable-2).** The planning half: feature planning, requirement refinement (single
86
+ and batch), brainstorm, idea intake, batch run/parallel orchestration, next-step routing,
87
+ architecture survey, feature-frontier prioritization, and feature-tree restructure. Folds `plan`,
88
+ `refine`, `brainstorm`.
89
+
90
+ **Placement notes (directory closure, task 0535).** The decided four-row table listed 31 commands;
91
+ the live `plugins/sp/commands/` directory has 39. The six additional commands were placed by the
92
+ same stage logic: `dev-refineall` folds `refine` → planner; `dev-find-next` is planning-side
93
+ frontier work → planner; `dev-feature-change` is planning-half corpus surgery on the feature tree →
94
+ planner; `dev-gtd` is the execution/delivery flow → coder; `dev-find-conflict` and `dev-find-issue`
95
+ are audits/analysis → reviewer (same reasoning as `rule-scan`). Later additions: `dev-history-load`
96
+ is mechanical load+analyze orchestration → scribe; `dev-pr-review` is review orchestration —
97
+ driving the external PR review and triaging its findings folds the `review` stage → reviewer.
98
+
99
+ **Consistency is a test, not a convention.** `plugins/sp/tests/roles.test.ts` parses this YAML and
100
+ asserts the tier-distinctness, command closure, stage-floor, and boundary invariants against the
101
+ real command directory, the real stage registry, and the real operator config — plus parity with
102
+ `DEFAULT_AGENT_ROLES` (R9, 0572): the table above must equal the code SSOT byte-for-byte on
103
+ id/tier/stages. When the table and the registry disagree, fix the table or the registry — never the
104
+ test. When the table and `DEFAULT_AGENT_ROLES` disagree, fix the constant (or regenerate this view).
105
+ A project may re-tier/re-stage a role at config time via `agent.roles` (closed vocabulary) — that
106
+ override never flows back into this file.
@@ -20,8 +20,9 @@
20
20
  */
21
21
 
22
22
  import { createHash } from 'node:crypto';
23
- import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
23
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
24
24
  import { dirname } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
25
26
 
26
27
  // ── Local types (match packages/app FeatureService shapes; no package import) ───────────
27
28
 
@@ -241,9 +242,24 @@ through unchanged.
241
242
 
242
243
  Exit: 0 = sync handled (applied / no-op / suppressed-blocked / live-blocked).`;
243
244
 
245
+ /**
246
+ * Resolve the spur CLI command in a monorepo-safe way:
247
+ * --spur-bin > SPUR_BIN > monorepo-local CLI entry > PATH `spur`.
248
+ * The plugin's own CI always passes an explicit --spur-bin; this fallback chain
249
+ * keeps ad-hoc invocations from silently hitting a stale PATH install.
250
+ */
251
+ export function defaultSpurBin(): string {
252
+ if (process.env.SPUR_BIN) return process.env.SPUR_BIN;
253
+ // scripts/ -> plugins/sp/ -> <repo>/apps/cli/src/index.ts (fileURLToPath — raw pathname breaks
254
+ // on %-encoded paths, e.g. spaces in the checkout directory)
255
+ const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
256
+ if (existsSync(local)) return `bun ${local}`;
257
+ return 'spur';
258
+ }
259
+
244
260
  export function parseBoundedSyncCliArgs(argv: string[]): BoundedSyncCliArgs {
245
261
  let featureId = '';
246
- let spurBin = 'spur';
262
+ let spurBin = defaultSpurBin();
247
263
  let runDir = '.spur/run';
248
264
  let json = false;
249
265
  let help = false;
@@ -412,6 +428,16 @@ function invokeLiveSync(
412
428
  // Unparseable sync output — surface it raw rather than guessing.
413
429
  return { exitCode: r.exitCode, stdout: r.stdout, stderr: '' };
414
430
  }
431
+ // Shape guard (review finding 1): a parseable envelope that is not a FeatureSyncResult
432
+ // (missing `proposal`) must fail loudly with a message, not a TypeError in
433
+ // classifySyncResult. Mirrors the validation the read side already performs.
434
+ if (!result || typeof result !== 'object' || !result.proposal) {
435
+ return {
436
+ exitCode: r.exitCode,
437
+ stdout: r.stdout,
438
+ stderr: 'unrecognized feature sync envelope: missing proposal',
439
+ };
440
+ }
415
441
 
416
442
  // If we have no fingerprint (pre-check fallback path), recompute minimal signals so we can
417
443
  // still persist a blocked record. Missing signals yield an empty-string fingerprint, which