@gobing-ai/spur 0.3.46 → 0.3.48

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 (137) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +42 -11
  3. package/config/templates/docs/99_PROJECT_CONSTITUTION.md +16 -4
  4. package/config/transition-shims.json +33 -0
  5. package/config/workflows/basic.yaml +2 -0
  6. package/config/workflows/docs-pipeline.yaml +2 -0
  7. package/config/workflows/feature-dev.yaml +8 -0
  8. package/config/workflows/idea-pipeline.yaml +10 -0
  9. package/config/workflows/planning-pipeline.yaml +4 -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 +2 -2
  14. package/plugins/sp/agents/expert-spur.md +1 -0
  15. package/plugins/sp/commands/dev-arch.md +2 -1
  16. package/plugins/sp/commands/dev-brainstorm.md +2 -1
  17. package/plugins/sp/commands/dev-changelog.md +1 -0
  18. package/plugins/sp/commands/dev-daily.md +1 -0
  19. package/plugins/sp/commands/dev-debug.md +2 -1
  20. package/plugins/sp/commands/dev-dogfood.md +2 -1
  21. package/plugins/sp/commands/dev-featurechange.md +1 -0
  22. package/plugins/sp/commands/dev-find-conflict.md +2 -1
  23. package/plugins/sp/commands/dev-find-issue.md +20 -32
  24. package/plugins/sp/commands/dev-find-next.md +2 -1
  25. package/plugins/sp/commands/dev-fixall.md +1 -0
  26. package/plugins/sp/commands/dev-gitmsg.md +1 -0
  27. package/plugins/sp/commands/dev-gtd.md +1 -0
  28. package/plugins/sp/commands/dev-handover.md +1 -0
  29. package/plugins/sp/commands/dev-idea.md +1 -0
  30. package/plugins/sp/commands/dev-next.md +2 -1
  31. package/plugins/sp/commands/dev-parallel.md +2 -1
  32. package/plugins/sp/commands/dev-plan.md +2 -1
  33. package/plugins/sp/commands/dev-refine.md +5 -3
  34. package/plugins/sp/commands/dev-refineall.md +2 -1
  35. package/plugins/sp/commands/dev-refresh.md +2 -1
  36. package/plugins/sp/commands/dev-reverse.md +2 -1
  37. package/plugins/sp/commands/dev-review.md +2 -1
  38. package/plugins/sp/commands/dev-run.md +3 -2
  39. package/plugins/sp/commands/dev-runall.md +3 -2
  40. package/plugins/sp/commands/dev-simplify.md +2 -1
  41. package/plugins/sp/commands/dev-unit.md +2 -1
  42. package/plugins/sp/commands/dev-verify.md +2 -1
  43. package/plugins/sp/commands/dev-verifyall.md +2 -1
  44. package/plugins/sp/commands/dev-wrap.md +7 -5
  45. package/plugins/sp/commands/dev-wrapall.md +7 -5
  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/hooks/careful-guard.ts +5 -80
  53. package/plugins/sp/hooks/destructive-policy.ts +146 -0
  54. package/plugins/sp/hooks/pi/guard-extension.ts +33 -46
  55. package/plugins/sp/hooks/task-file-policy.ts +31 -0
  56. package/plugins/sp/hooks/task-write-guard.ts +4 -0
  57. package/plugins/sp/plugin.json +1 -1
  58. package/plugins/sp/references/roles.md +93 -0
  59. package/plugins/sp/scripts/feature-sync-bounded.ts +28 -2
  60. package/plugins/sp/scripts/stage-registry-adapter.ts +66 -31
  61. package/plugins/sp/scripts/surface-drift-inventory.ts +908 -0
  62. package/plugins/sp/scripts/task-size-precheck.ts +30 -4
  63. package/plugins/sp/scripts/transition-shim-check.ts +238 -0
  64. package/plugins/sp/scripts/validate-flag-contracts.ts +5 -2
  65. package/plugins/sp/skills/code-verification/SKILL.md +2 -0
  66. package/plugins/sp/skills/issue-finding/SKILL.md +124 -143
  67. package/plugins/sp/skills/issue-finding/examples/expected-findings.json +1 -1
  68. package/plugins/sp/skills/issue-finding/references/session-formats.md +85 -83
  69. package/plugins/sp/skills/next-router/SKILL.md +1 -1
  70. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +40 -2
  71. package/plugins/sp/skills/spur-cli/SKILL.md +3 -0
  72. package/plugins/sp/skills/spur-cli/references/agent.md +12 -7
  73. package/plugins/sp/skills/spur-cli/references/features.md +3 -0
  74. package/plugins/sp/skills/spur-cli/references/tasks.md +3 -0
  75. package/plugins/sp/skills/spur-cli/references/team.md +10 -3
  76. package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
  77. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +17 -0
  78. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +44 -23
  79. package/plugins/sp/skills/spur-dev/references/dev-operations.md +14 -11
  80. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +14 -12
  81. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +14 -3
  82. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +2 -0
  83. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +12 -2
  84. package/schemas/spur-config.schema.json +27 -3
  85. package/spur.js +11648 -7595
  86. package/web/_astro/BoardApp.8hiqShQn.js +1 -0
  87. package/web/_astro/{BoardApp.DKyrGxdo.js → BoardApp.BjQUNhuj.js} +74 -74
  88. package/web/_astro/{TaskDetail.6-27_LMa.js → TaskDetail.CVBuD6dF.js} +1 -1
  89. package/web/_astro/{arc.Df-9AQvS.js → arc.BMMjdODi.js} +1 -1
  90. package/web/_astro/{architectureDiagram-3BPJPVTR.VAI_-paS.js → architectureDiagram-3BPJPVTR.BU5ShzXf.js} +1 -1
  91. package/web/_astro/{blockDiagram-GPEHLZMM.DFpUY1ue.js → blockDiagram-GPEHLZMM.Bj1iEqPD.js} +1 -1
  92. package/web/_astro/{c4Diagram-AAUBKEIU.CF8doOpg.js → c4Diagram-AAUBKEIU.vX8wepCL.js} +1 -1
  93. package/web/_astro/channel.EwdSemIC.js +1 -0
  94. package/web/_astro/{chunk-2J33WTMH.BnjK3fjt.js → chunk-2J33WTMH.BKAipTym.js} +1 -1
  95. package/web/_astro/{chunk-4BX2VUAB.x6ZDnJKq.js → chunk-4BX2VUAB.B68XkPG7.js} +1 -1
  96. package/web/_astro/{chunk-55IACEB6.zY-0uu7w.js → chunk-55IACEB6.BmeDLcrc.js} +1 -1
  97. package/web/_astro/{chunk-727SXJPM.BZxKg_Vi.js → chunk-727SXJPM.PDuBA3Kw.js} +1 -1
  98. package/web/_astro/{chunk-AQP2D5EJ.Cpi9G9Td.js → chunk-AQP2D5EJ.C7A044za.js} +1 -1
  99. package/web/_astro/{chunk-FMBD7UC4.DWTB-Pif.js → chunk-FMBD7UC4.BtzKKFqR.js} +1 -1
  100. package/web/_astro/{chunk-ND2GUHAM.BPDQbiOG.js → chunk-ND2GUHAM.BJuDeeOy.js} +1 -1
  101. package/web/_astro/{chunk-QZHKN3VN.BRWIcuoM.js → chunk-QZHKN3VN.DSeMDgcQ.js} +1 -1
  102. package/web/_astro/{classDiagram-4FO5ZUOK.mGTCZsDO.js → classDiagram-4FO5ZUOK.D53Q4tCw.js} +1 -1
  103. package/web/_astro/{classDiagram-v2-Q7XG4LA2.mGTCZsDO.js → classDiagram-v2-Q7XG4LA2.D53Q4tCw.js} +1 -1
  104. package/web/_astro/{cose-bilkent-S5V4N54A.D1GEut-z.js → cose-bilkent-S5V4N54A.c712AFRH.js} +1 -1
  105. package/web/_astro/{dagre-BM42HDAG.BV0XG9Do.js → dagre-BM42HDAG.D-idisph.js} +1 -1
  106. package/web/_astro/{diagram-2AECGRRQ.DzpYxsjo.js → diagram-2AECGRRQ.DLgnsJCU.js} +1 -1
  107. package/web/_astro/{diagram-5GNKFQAL.Cm9YzJh4.js → diagram-5GNKFQAL.BiaxBVqx.js} +1 -1
  108. package/web/_astro/{diagram-KO2AKTUF.BjhottUj.js → diagram-KO2AKTUF.C8HX1vd8.js} +1 -1
  109. package/web/_astro/{diagram-LMA3HP47.BFsQW5kb.js → diagram-LMA3HP47.CfqDLLes.js} +1 -1
  110. package/web/_astro/{diagram-OG6HWLK6.8pdpzSWO.js → diagram-OG6HWLK6.15SDiEed.js} +1 -1
  111. package/web/_astro/{erDiagram-TEJ5UH35.Bd7KUJmJ.js → erDiagram-TEJ5UH35.DksYtOYM.js} +1 -1
  112. package/web/_astro/{flowDiagram-I6XJVG4X.7LWffkaE.js → flowDiagram-I6XJVG4X.DR_Au-HV.js} +1 -1
  113. package/web/_astro/{ganttDiagram-6RSMTGT7.BeDcO5tI.js → ganttDiagram-6RSMTGT7.CHhHrffI.js} +1 -1
  114. package/web/_astro/{gitGraphDiagram-PVQCEYII.Ca4n730A.js → gitGraphDiagram-PVQCEYII.B2Xehvam.js} +1 -1
  115. package/web/_astro/{index.Dbvuw6d4.css → index.DAxu50UF.css} +1 -1
  116. package/web/_astro/{infoDiagram-5YYISTIA.B0OakQYb.js → infoDiagram-5YYISTIA.C9c3CNNN.js} +1 -1
  117. package/web/_astro/{ishikawaDiagram-YF4QCWOH.DSmNQe-1.js → ishikawaDiagram-YF4QCWOH.BibUHkh8.js} +1 -1
  118. package/web/_astro/{journeyDiagram-JHISSGLW.Cy5ruEUu.js → journeyDiagram-JHISSGLW.BYoVHiyO.js} +1 -1
  119. package/web/_astro/{kanban-definition-UN3LZRKU.CUJXub0p.js → kanban-definition-UN3LZRKU.CM1K5wHE.js} +1 -1
  120. package/web/_astro/{linear.DC1jCCXn.js → linear.SPpjJUb-.js} +1 -1
  121. package/web/_astro/{mermaid.core.DxVP99Ab.js → mermaid.core.BAgx3nnb.js} +4 -4
  122. package/web/_astro/{mindmap-definition-RKZ34NQL.D0MaV6sJ.js → mindmap-definition-RKZ34NQL.D35oPG1R.js} +1 -1
  123. package/web/_astro/{pieDiagram-4H26LBE5.DCC6_q32.js → pieDiagram-4H26LBE5.DiWuRwk7.js} +1 -1
  124. package/web/_astro/{quadrantDiagram-W4KKPZXB.BeUOAM7C.js → quadrantDiagram-W4KKPZXB.B9PBzTWn.js} +1 -1
  125. package/web/_astro/{requirementDiagram-4Y6WPE33.Dbl4MASO.js → requirementDiagram-4Y6WPE33.CYuuamFN.js} +1 -1
  126. package/web/_astro/{sankeyDiagram-5OEKKPKP.HsLg0VS4.js → sankeyDiagram-5OEKKPKP.W24UhhtD.js} +1 -1
  127. package/web/_astro/{sequenceDiagram-3UESZ5HK.DT7DJTnZ.js → sequenceDiagram-3UESZ5HK.BpbNjA51.js} +1 -1
  128. package/web/_astro/{stateDiagram-AJRCARHV.d_ju1Vr1.js → stateDiagram-AJRCARHV.DqVsHudf.js} +1 -1
  129. package/web/_astro/{stateDiagram-v2-BHNVJYJU.DMCAjMJ4.js → stateDiagram-v2-BHNVJYJU.CzwHYX81.js} +1 -1
  130. package/web/_astro/{timeline-definition-PNZ67QCA.DNOHr62_.js → timeline-definition-PNZ67QCA.Bc3B6djw.js} +1 -1
  131. package/web/_astro/{vennDiagram-CIIHVFJN.B7dUy-1W.js → vennDiagram-CIIHVFJN.C-D5rh8O.js} +1 -1
  132. package/web/_astro/{wardley-L42UT6IY.DEqOXvBh.js → wardley-L42UT6IY.D7PdYCqn.js} +1 -1
  133. package/web/_astro/{wardleyDiagram-YWT4CUSO.BCRb2p6x.js → wardleyDiagram-YWT4CUSO.CwmJKXF3.js} +1 -1
  134. package/web/_astro/{xychartDiagram-2RQKCTM6.NxVQLdBh.js → xychartDiagram-2RQKCTM6.avDYnLsb.js} +1 -1
  135. package/web/index.html +2 -2
  136. package/web/_astro/BoardApp.Ce6zJYAH.js +0 -1
  137. package/web/_astro/channel.Uhm9O3UV.js +0 -1
@@ -16,8 +16,13 @@
16
16
  * emits an `allow` decision. A broken guard must never wedge an agent tool call.
17
17
  *
18
18
  * **Escape hatch:** `SPUR_CAREFUL=off` short-circuits to allow.
19
+ *
20
+ * Classification itself lives in `destructive-policy.ts` — one policy, every platform
21
+ * adapter. This file owns the Claude Code payload/decision shape only.
19
22
  */
20
23
 
24
+ import { classifyCommand } from './destructive-policy';
25
+
21
26
  interface ToolPayload {
22
27
  tool_name?: string;
23
28
  tool_input?: { command?: string };
@@ -37,86 +42,6 @@ function preToolUseDecision(decision: Decision, reason?: string): never {
37
42
  process.exit(0);
38
43
  }
39
44
 
40
- /** Well-known rebuildable caches a `rm -rf` may target without a warning. */
41
- const SAFE_RM_TARGET =
42
- /^(?:\.?\/)?(?:[\w.@-]+\/)*(?:node_modules|dist|\.next|coverage|build|\.turbo|\.cache|\.parcel-cache|out)\/?\*?$/;
43
-
44
- /**
45
- * Expand an argument string into the set of flags it sets, splitting short-flag
46
- * clusters into their individual letters: `-Rf --force` → `{R, f, --force}`.
47
- *
48
- * Matching flags with ad-hoc regexes per call site is what let `rm -R` through —
49
- * `-\w*r` only ever matched the lowercase spelling, even though `man rm` defines
50
- * `-r` as "Equivalent to -R". Parsing once, case-preserved, makes that class of
51
- * miss unrepresentable: a caller names every spelling it cares about explicitly.
52
- */
53
- function parseFlags(args: string): Set<string> {
54
- const flags = new Set<string>();
55
- for (const token of args.trim().split(/\s+/)) {
56
- if (token.length < 2 || !token.startsWith('-') || token === '--') continue;
57
- if (token.startsWith('--')) {
58
- flags.add(token.split('=')[0] as string); // `--force=x` → `--force`
59
- continue;
60
- }
61
- for (const ch of token.slice(1)) flags.add(ch);
62
- }
63
- return flags;
64
- }
65
-
66
- /** True when a `rm` invocation is both recursive and forced (any flag spelling). */
67
- function isRecursiveForceRm(args: string): boolean {
68
- const flags = parseFlags(args);
69
- // POSIX rm accepts -r and -R interchangeably; --recursive is the GNU long form.
70
- const recursive = flags.has('r') || flags.has('R') || flags.has('--recursive');
71
- const force = flags.has('f') || flags.has('--force');
72
- return recursive && force;
73
- }
74
-
75
- /** True when every non-flag target of a `rm` invocation is a known-safe cache path. */
76
- function rmTargetsAllSafe(args: string): boolean {
77
- const targets = args
78
- .trim()
79
- .split(/\s+/)
80
- .filter((t) => t.length > 0 && !t.startsWith('-'));
81
- if (targets.length === 0) return false;
82
- return targets.every((t) => SAFE_RM_TARGET.test(t));
83
- }
84
-
85
- /** Always-warn destructive patterns (no safe exception). */
86
- const DESTRUCTIVE: Array<{ label: string; re: RegExp }> = [
87
- {
88
- label: 'a SQL DROP/TRUNCATE (DROP TABLE/DATABASE, TRUNCATE)',
89
- re: /\b(?:DROP\s+(?:TABLE|DATABASE)|TRUNCATE(?:\s+TABLE)?)\b/i,
90
- },
91
- { label: 'a force push (git push --force / -f)', re: /\bgit\s+push\b[^\n]*(?:--force(?!-with-lease)|\s-f\b)/i },
92
- {
93
- // `git push origin +main` forces that ref without any --force flag.
94
- label: 'a force push via a + refspec (git push … +ref)',
95
- re: /\bgit\s+push\b[^\n]*\s\+[\w./-]+/i,
96
- },
97
- { label: 'a hard reset (git reset --hard)', re: /\bgit\s+reset\b[^\n]*--hard\b/i },
98
- {
99
- label: 'a working-tree discard (git checkout . / git restore .)',
100
- re: /\bgit\s+(?:checkout|restore)\s+(?:--\s+)?\.(?:\s|$)/i,
101
- },
102
- { label: 'a cluster delete (kubectl delete)', re: /\bkubectl\s+delete\b/i },
103
- { label: 'a docker prune (docker system prune)', re: /\bdocker\s+system\s+prune\b/i },
104
- ];
105
-
106
- /** Return a human label for the destructive command, or null when the command is safe. */
107
- function classifyCommand(command: string): string | null {
108
- for (const rmMatch of command.matchAll(/\brm\b([^\n&|;]*)/g)) {
109
- const args = rmMatch[1] ?? '';
110
- if (isRecursiveForceRm(args) && !rmTargetsAllSafe(args)) {
111
- return 'a recursive force remove (rm -rf)';
112
- }
113
- }
114
- for (const { label, re } of DESTRUCTIVE) {
115
- if (re.test(command)) return label;
116
- }
117
- return null;
118
- }
119
-
120
45
  async function main(): Promise<void> {
121
46
  if (process.env.SPUR_CAREFUL === 'off') preToolUseDecision('allow');
122
47
 
@@ -0,0 +1,146 @@
1
+ /**
2
+ * destructive-policy — the single destructive-command classifier shared by every
3
+ * platform guard (task 0215 R3; extracted from `careful-guard.ts`).
4
+ *
5
+ * **Why this module exists.** The policy used to live inline in `careful-guard.ts`
6
+ * (Claude Code) and was re-implemented with hand-rolled regexes in
7
+ * `pi/guard-extension.ts` (Pi). The two drifted: the Pi copy allowed
8
+ * `rm -rf node_modules /etc/nginx`, `rm -R --force /var/data`, `git push -f`, and
9
+ * `git push origin +main` — all of which the Claude matrix pins as `ask` — while
10
+ * warning on `git push --force-with-lease` and `rm -f config.json`, which it pins as
11
+ * `allow`. Seven of ten pinned cases diverged. A safety control with one
12
+ * implementation per platform is a safety control that is wrong on all but one of
13
+ * them, so the policy is defined once, here, and every adapter imports it.
14
+ *
15
+ * Adapters own **I/O only** (payload shape, how a prompt is raised). They must not
16
+ * re-derive classification.
17
+ */
18
+
19
+ /**
20
+ * Well-known rebuildable caches a `rm -rf` may target without a warning.
21
+ *
22
+ * Project-relative only — the leading `/` this used to accept made the exception
23
+ * match by *basename anywhere on the filesystem*, so `rm -rf /Users/me/dist` was
24
+ * treated as routine. Escaping targets are rejected by {@link escapesProject}
25
+ * before this is consulted; keeping the anchor tight is belt-and-braces.
26
+ */
27
+ const SAFE_RM_TARGET =
28
+ /^(?:\.\/)?(?:[\w.@-]+\/)*(?:node_modules|dist|\.next|coverage|build|\.turbo|\.cache|\.parcel-cache|out)\/?\*?$/;
29
+
30
+ /**
31
+ * True when a target points outside the project tree: an absolute path, a `~`
32
+ * home path, or one that walks out via `..`.
33
+ *
34
+ * Recursive deletion inside the project is routine (build caches, scratch dirs) and
35
+ * recoverable from git; recursive deletion *outside* it is neither, whatever the
36
+ * directory happens to be named. This is the axis that decides a bare `rm -r`,
37
+ * which is otherwise unguarded — `rm -r ./tmpdir` stays routine while
38
+ * `rm -r /Users/me/photos` prompts.
39
+ */
40
+ function escapesProject(target: string): boolean {
41
+ const t = target.replace(/^['"]|['"]$/g, '');
42
+ if (t.startsWith('/') || t.startsWith('~')) return true;
43
+ if (t.startsWith('$')) return true; // `$HOME/...`, `"$HOME"/...` — unknown expansion
44
+ return t.split('/').includes('..');
45
+ }
46
+
47
+ /**
48
+ * Expand an argument string into the set of flags it sets, splitting short-flag
49
+ * clusters into their individual letters: `-Rf --force` → `{R, f, --force}`.
50
+ *
51
+ * Matching flags with ad-hoc regexes per call site is what let `rm -R` through —
52
+ * `-\w*r` only ever matched the lowercase spelling, even though `man rm` defines
53
+ * `-r` as "Equivalent to -R". Parsing once, case-preserved, makes that class of
54
+ * miss unrepresentable: a caller names every spelling it cares about explicitly.
55
+ */
56
+ export function parseFlags(args: string): Set<string> {
57
+ const flags = new Set<string>();
58
+ for (const token of args.trim().split(/\s+/)) {
59
+ if (token.length < 2 || !token.startsWith('-') || token === '--') continue;
60
+ if (token.startsWith('--')) {
61
+ flags.add(token.split('=')[0] as string); // `--force=x` → `--force`
62
+ continue;
63
+ }
64
+ for (const ch of token.slice(1)) flags.add(ch);
65
+ }
66
+ return flags;
67
+ }
68
+
69
+ /** True when a `rm` invocation is both recursive and forced (any flag spelling). */
70
+ export function isRecursiveForceRm(args: string): boolean {
71
+ const flags = parseFlags(args);
72
+ // POSIX rm accepts -r and -R interchangeably; --recursive is the GNU long form.
73
+ const recursive = flags.has('r') || flags.has('R') || flags.has('--recursive');
74
+ const force = flags.has('f') || flags.has('--force');
75
+ return recursive && force;
76
+ }
77
+
78
+ /**
79
+ * True when **every** non-flag target of a `rm` invocation is a known-safe cache
80
+ * path. Every, not some: `rm -rf node_modules /etc/nginx` must still warn, and a
81
+ * substring test over the whole argument string (the Pi copy's approach) let one
82
+ * cache path whitelist every other target in the same command.
83
+ */
84
+ export function rmTargetsAllSafe(args: string): boolean {
85
+ const targets = rmTargets(args);
86
+ if (targets.length === 0) return false;
87
+ return targets.every((t) => SAFE_RM_TARGET.test(t));
88
+ }
89
+
90
+ /** Non-flag targets of a `rm` invocation. */
91
+ function rmTargets(args: string): string[] {
92
+ return args
93
+ .trim()
94
+ .split(/\s+/)
95
+ .filter((t) => t.length > 0 && !t.startsWith('-'));
96
+ }
97
+
98
+ /** Always-warn destructive patterns (no safe exception). */
99
+ const DESTRUCTIVE: Array<{ label: string; re: RegExp }> = [
100
+ {
101
+ label: 'a SQL DROP/TRUNCATE (DROP TABLE/DATABASE, TRUNCATE)',
102
+ re: /\b(?:DROP\s+(?:TABLE|DATABASE)|TRUNCATE(?:\s+TABLE)?)\b/i,
103
+ },
104
+ { label: 'a force push (git push --force / -f)', re: /\bgit\s+push\b[^\n]*(?:--force(?!-with-lease)|\s-f\b)/i },
105
+ {
106
+ // `git push origin +main` forces that ref without any --force flag.
107
+ label: 'a force push via a + refspec (git push … +ref)',
108
+ re: /\bgit\s+push\b[^\n]*\s\+[\w./-]+/i,
109
+ },
110
+ { label: 'a hard reset (git reset --hard)', re: /\bgit\s+reset\b[^\n]*--hard\b/i },
111
+ {
112
+ label: 'a working-tree discard (git checkout . / git restore .)',
113
+ re: /\bgit\s+(?:checkout|restore)\s+(?:--\s+)?\.(?:\s|$)/i,
114
+ },
115
+ {
116
+ // `git clean` destroys UNTRACKED files — nothing in git to recover them from,
117
+ // which makes it the least recoverable command in this family. `-n`/`--dry-run`
118
+ // only lists, so the prompt is gated on the force flag git itself requires.
119
+ label: 'an untracked-file delete (git clean -f)',
120
+ re: /\bgit\s+clean\b[^\n]*(?:\s-[a-zA-Z]*f|\s--force\b)/i,
121
+ },
122
+ { label: 'a cluster delete (kubectl delete)', re: /\bkubectl\s+delete\b/i },
123
+ { label: 'a docker prune (docker system prune)', re: /\bdocker\s+system\s+prune\b/i },
124
+ ];
125
+
126
+ /** Return a human label for the destructive command, or null when the command is safe. */
127
+ export function classifyCommand(command: string): string | null {
128
+ for (const rmMatch of command.matchAll(/\brm\b([^\n&|;]*)/g)) {
129
+ const args = rmMatch[1] ?? '';
130
+ const flags = parseFlags(args);
131
+ const recursive = flags.has('r') || flags.has('R') || flags.has('--recursive');
132
+ // A recursive delete reaching outside the project prompts whether or not
133
+ // `--force` was passed: `rm -r` deletes a whole tree without prompting for
134
+ // any writable file, and nothing outside the project is recoverable from git.
135
+ if (recursive && rmTargets(args).some(escapesProject)) {
136
+ return 'a recursive remove outside the project (rm -r on an absolute, ~, or ../ path)';
137
+ }
138
+ if (isRecursiveForceRm(args) && !rmTargetsAllSafe(args)) {
139
+ return 'a recursive force remove (rm -rf)';
140
+ }
141
+ }
142
+ for (const { label, re } of DESTRUCTIVE) {
143
+ if (re.test(command)) return label;
144
+ }
145
+ return null;
146
+ }
@@ -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.46",
3
+ "version": "0.3.48",
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,93 @@
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)** maps *role → tier* and is
13
+ owned by plugin `sp`; **Layer 2** maps *tier → executor* and is owned by the operator in
14
+ `.spur/config.yaml`. This file never names an executor, a model, or a vendor — it declares only
15
+ what tier a role's work needs, and the operator's config decides which executor serves that tier.
16
+
17
+ The vocabulary is four roles, one per tier:
18
+
19
+ | Role | Tier | Stage floor source |
20
+ | --- | --- | --- |
21
+ | `scribe` | cheap | changelog (`min_tier: cheap`) |
22
+ | `coder` | standard | implement / test / wrap (`min_tier: standard`) |
23
+ | `reviewer` | capable-1 | verify / review / dogfood (highest fold `capable-1`) |
24
+ | `planner` | capable-2 | plan / refine / brainstorm (highest fold `capable-2`) |
25
+
26
+ **The one-role-per-tier property is the invariant**, not a coincidence: two roles sharing a tier
27
+ resolve to the same eligible executor set and are one role with two names. A proposed fifth role
28
+ must bring a fifth tier. The tiers are the live vocabulary
29
+ `cheap | standard | capable-1 | capable-2 | capable-3` (packages/config/src/index.ts).
30
+
31
+ This table supersedes the eight-intention vocabulary recorded in task 0344 § Solution D1/D2. That
32
+ decision named eight intentions, but against the stage registry they carried only four distinct
33
+ tier floors (`plan` capable-2; `verify`/`dogfood` capable-1; `changelog` cheap; everything else
34
+ standard) — four of the eight names had no routing consequence. The four roles below are that
35
+ collapse, named as people so they stay addressable in `--agent`.
36
+
37
+ ## The table
38
+
39
+ ```yaml
40
+ version: 1
41
+ roles:
42
+ - id: scribe
43
+ tier: cheap
44
+ commands: [dev-gitmsg, dev-handover, dev-daily, dev-changelog, dev-refresh, rule-add, rule-refine, workflow-add, workflow-refine, spur-init]
45
+ stages: [changelog]
46
+ - id: coder
47
+ tier: standard
48
+ commands: [dev-run, dev-unit, dev-debug, dev-simplify, dev-fixall, dev-reverse, dev-wrap, dev-wrapall, dev-gtd]
49
+ stages: [implement, test, wrap]
50
+ - id: reviewer
51
+ tier: capable-1
52
+ commands: [dev-verify, dev-verifyall, dev-review, dev-dogfood, rule-scan, dev-find-conflict, dev-find-issue]
53
+ stages: [verify, review, dogfood]
54
+ - id: planner
55
+ tier: capable-2
56
+ commands: [dev-plan, dev-refine, dev-brainstorm, dev-idea, dev-runall, dev-parallel, dev-next, dev-arch, dev-refineall, dev-find-next, dev-featurechange]
57
+ stages: [plan, refine, brainstorm]
58
+ ```
59
+
60
+ `commands` is the closed command→role mapping: every file under `plugins/sp/commands/` appears in
61
+ exactly one row, and a new command must be added to exactly one row (or bring a fifth role with a
62
+ fifth tier). `stages` lists the canonical stages the role folds (ids from
63
+ `REGISTERED_CANONICAL_STAGES` in `packages/domain/src/stage-registry/schema.ts`); a role's `tier`
64
+ must not sit below the highest `min_tier` among its folded stages.
65
+
66
+ ## Role annotations
67
+
68
+ - **`scribe` (cheap).** Writing derived text — commit messages, changelogs, handovers, daily
69
+ summaries — plus template scaffolding (`spur init`, rule/workflow authoring). Mechanical,
70
+ high-volume, cheap-tier work. Folds the `changelog` stage.
71
+ - **`coder` (standard).** Implementation and delivery: running the pipeline, unit tests, debugging,
72
+ simplification, fix-everything sweeps, reverse engineering, wrap-up, and the end-to-end
73
+ delivery flow (`dev-gtd`). Folds `implement`, `test`, `wrap`.
74
+ - **`reviewer` (capable-1).** Verification and analysis: per-task verify/review, batch verify,
75
+ dogfooding, anti-pattern scanning (`rule-scan`), and the two audit commands (`dev-find-conflict`,
76
+ `dev-find-issue`) — those analyse rather than transcribe, which is why they sit here and not
77
+ under `scribe`. Folds `verify`, `review`, `dogfood`.
78
+ - **`planner` (capable-2).** The planning half: feature planning, requirement refinement (single
79
+ and batch), brainstorm, idea intake, batch run/parallel orchestration, next-step routing,
80
+ architecture survey, feature-frontier prioritization, and feature-tree restructure. Folds `plan`,
81
+ `refine`, `brainstorm`.
82
+
83
+ **Placement notes (directory closure, task 0535).** The decided four-row table listed 31 commands;
84
+ the live `plugins/sp/commands/` directory has 37. The six additional commands were placed by the
85
+ same stage logic: `dev-refineall` folds `refine` → planner; `dev-find-next` is planning-side
86
+ frontier work → planner; `dev-featurechange` is planning-half corpus surgery on the feature tree →
87
+ planner; `dev-gtd` is the execution/delivery flow → coder; `dev-find-conflict` and `dev-find-issue`
88
+ are audits/analysis → reviewer (same reasoning as `rule-scan`).
89
+
90
+ **Consistency is a test, not a convention.** `plugins/sp/tests/roles.test.ts` parses this YAML and
91
+ asserts the tier-distinctness, command closure, stage-floor, and boundary invariants against the
92
+ real command directory, the real stage registry, and the real operator config. When the table and
93
+ the registry disagree, fix the table or the registry — never the test.
@@ -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