@gobing-ai/spur 0.4.7 → 0.4.9

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 (145) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/pipeline-budgets.json +2 -2
  3. package/config/rules/boundary/config-loading-ownership.yaml +1 -0
  4. package/config/rules/boundary/sp-plugin-standalone.yaml +1 -1
  5. package/config/rules/boundary/sp-script-placement.yaml +1 -1
  6. package/config/rules/docs/sourced-api-claims.yaml +45 -7
  7. package/config/rules/strict/runtime-boundaries.yaml +40 -1
  8. package/config/rules/structure/protected-files.yaml +1 -1
  9. package/config/rules/surface/check-cli-surface.yaml +33 -0
  10. package/config/rules/typescript/no-debugger.yaml +3 -0
  11. package/config/workflow-candidates.json +2 -2
  12. package/config/workflows/feature-verification.yaml +6 -11
  13. package/config/workflows/history-anatomy.yaml +14 -28
  14. package/config/workflows/idea-pipeline.yaml +6 -19
  15. package/config/workflows/pr-review.yaml +9 -16
  16. package/config/workflows/task-pipeline.yaml +16 -23
  17. package/config/workflows/wrapup-pipeline.yaml +21 -63
  18. package/package.json +3 -3
  19. package/plugins/sp/README.md +70 -37
  20. package/plugins/sp/agents/super-planner.md +5 -5
  21. package/plugins/sp/agents/super-reviewer.md +2 -2
  22. package/plugins/sp/commands/dev-changelog.md +1 -1
  23. package/plugins/sp/commands/dev-daily.md +1 -1
  24. package/plugins/sp/commands/dev-pr-review.md +1 -1
  25. package/plugins/sp/commands/dev-review-session.md +1 -1
  26. package/plugins/sp/commands/dev-runall.md +4 -4
  27. package/plugins/sp/commands/dev-verify.md +4 -6
  28. package/plugins/sp/commands/dev-verifyall.md +4 -5
  29. package/plugins/sp/hooks/hooks.json +11 -1
  30. package/plugins/sp/lib/context-post-tool.ts +1 -1
  31. package/plugins/sp/lib/context-session-start.ts +1 -1
  32. package/plugins/sp/lib/context-session-stop.ts +1 -1
  33. package/plugins/sp/lib/destructive-policy.ts +65 -1
  34. package/plugins/sp/plugin.json +2 -5
  35. package/plugins/sp/{hooks → scripts/hooks}/agent-lifecycle.ts +1 -1
  36. package/plugins/sp/scripts/hooks/careful-guard.ts +94 -0
  37. package/plugins/sp/{hooks → scripts/hooks}/context-post-tool.ts +2 -2
  38. package/plugins/sp/{hooks → scripts/hooks}/context-session-start.ts +1 -1
  39. package/plugins/sp/{hooks → scripts/hooks}/context-session-stop.ts +1 -1
  40. package/plugins/sp/{hooks → scripts/hooks}/fleet-guest-stop.ts +6 -4
  41. package/plugins/sp/{hooks → scripts/hooks}/task-write-guard.ts +38 -21
  42. package/plugins/sp/skills/code-implementation/SKILL.md +1 -1
  43. package/plugins/sp/skills/code-verification/SKILL.md +8 -8
  44. package/plugins/sp/skills/code-verification/references/secu-review.md +4 -3
  45. package/plugins/sp/skills/code-verification/references/verdict-schema.md +9 -1
  46. package/plugins/sp/skills/daily-summary/SKILL.md +4 -5
  47. package/plugins/sp/skills/dogfood-testing/SKILL.md +9 -9
  48. package/plugins/sp/skills/dogfood-testing/references/report-template.md +1 -1
  49. package/plugins/sp/skills/history-anatomy/references/modes.md +2 -2
  50. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  51. package/plugins/sp/skills/parallel-execution/SKILL.md +1 -1
  52. package/plugins/sp/skills/pr-reviewing/SKILL.md +14 -15
  53. package/plugins/sp/skills/session-review/SKILL.md +3 -3
  54. package/plugins/sp/skills/source-driven-development/SKILL.md +7 -4
  55. package/plugins/sp/skills/spur-check/SKILL.md +14 -13
  56. package/plugins/sp/skills/spur-cli/SKILL.md +1 -1
  57. package/plugins/sp/skills/spur-cli/references/agent.md +12 -0
  58. package/plugins/sp/skills/spur-cli/references/builder.md +27 -6
  59. package/plugins/sp/skills/spur-cli/references/features.md +6 -1
  60. package/plugins/sp/skills/spur-cli/references/history.md +3 -0
  61. package/plugins/sp/skills/spur-cli/references/self.md +5 -3
  62. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +3 -0
  63. package/plugins/sp/skills/spur-cli/references/tasks.md +13 -3
  64. package/plugins/sp/skills/spur-cli/references/workflows.md +36 -1
  65. package/plugins/sp/skills/spur-dev/SKILL.md +1 -1
  66. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +1 -1
  67. package/plugins/sp/skills/spur-dev/references/dev-operations.md +12 -12
  68. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +10 -11
  69. package/plugins/sp/skills/spur-dev/references/execution-batch.md +16 -17
  70. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +1 -1
  71. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +8 -11
  72. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +6 -8
  73. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +1 -1
  74. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +33 -40
  75. package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +24 -24
  76. package/plugins/sp/skills/spur-doctor/SKILL.md +5 -4
  77. package/spur.js +41901 -30598
  78. package/config/plugin-scripts.json +0 -116
  79. package/config/script-placement-baseline.json +0 -33
  80. package/plugins/sp/hooks/careful-guard.ts +0 -74
  81. package/plugins/sp/hooks/pi/guard-extension.ts +0 -304
  82. package/plugins/sp/lib/dogfood-pipeline.ts +0 -265
  83. package/plugins/sp/lib/dogfood-report.ts +0 -278
  84. package/plugins/sp/lib/history-anatomy.generated.d.mts +0 -112
  85. package/plugins/sp/lib/history-anatomy.generated.mjs +0 -691
  86. package/plugins/sp/lib/idea-handoff.generated.d.mts +0 -17
  87. package/plugins/sp/lib/idea-handoff.generated.mjs +0 -1446
  88. package/plugins/sp/lib/inline-run-app.ts +0 -68
  89. package/plugins/sp/lib/inline-run.generated.d.mts +0 -37
  90. package/plugins/sp/lib/inline-run.generated.mjs +0 -3103
  91. package/plugins/sp/lib/plan-projection.generated.d.mts +0 -197
  92. package/plugins/sp/lib/plan-projection.generated.mjs +0 -982
  93. package/plugins/sp/lib/quality-gate.generated.d.mts +0 -139
  94. package/plugins/sp/lib/quality-gate.generated.mjs +0 -30510
  95. package/plugins/sp/lib/residual-scan.generated.d.mts +0 -93
  96. package/plugins/sp/lib/residual-scan.generated.mjs +0 -383
  97. package/plugins/sp/lib/run-summary-core.ts +0 -225
  98. package/plugins/sp/lib/spur-bin.ts +0 -36
  99. package/plugins/sp/lib/step-profile.generated.d.mts +0 -71
  100. package/plugins/sp/lib/step-profile.generated.mjs +0 -174
  101. package/plugins/sp/lib/transcript.ts +0 -112
  102. package/plugins/sp/scripts/batch-plan.mjs +0 -1096
  103. package/plugins/sp/scripts/batch-plan.ts +0 -145
  104. package/plugins/sp/scripts/batch-preflight.mjs +0 -423
  105. package/plugins/sp/scripts/batch-preflight.ts +0 -270
  106. package/plugins/sp/scripts/commit-guard.mjs +0 -226
  107. package/plugins/sp/scripts/commit-guard.ts +0 -248
  108. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -626
  109. package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -855
  110. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -211
  111. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -110
  112. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -282
  113. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -98
  114. package/plugins/sp/scripts/feature-verification-steps.mjs +0 -82
  115. package/plugins/sp/scripts/feature-verification-steps.ts +0 -164
  116. package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -931
  117. package/plugins/sp/scripts/history-anatomy-cache.ts +0 -247
  118. package/plugins/sp/scripts/idea-handoff.mjs +0 -32
  119. package/plugins/sp/scripts/idea-handoff.ts +0 -47
  120. package/plugins/sp/scripts/inline-run-dispatch.mjs +0 -131
  121. package/plugins/sp/scripts/inline-run-dispatch.ts +0 -115
  122. package/plugins/sp/scripts/inline-run-setup.mjs +0 -746
  123. package/plugins/sp/scripts/inline-run-setup.ts +0 -198
  124. package/plugins/sp/scripts/persist-out-check.mjs +0 -217
  125. package/plugins/sp/scripts/persist-out-check.ts +0 -249
  126. package/plugins/sp/scripts/pr-reviewing.mjs +0 -712
  127. package/plugins/sp/scripts/pr-reviewing.ts +0 -952
  128. package/plugins/sp/scripts/quality-gate.mjs +0 -30491
  129. package/plugins/sp/scripts/quality-gate.ts +0 -80
  130. package/plugins/sp/scripts/residual-scan.mjs +0 -634
  131. package/plugins/sp/scripts/residual-scan.ts +0 -248
  132. package/plugins/sp/scripts/run-summary.mjs +0 -758
  133. package/plugins/sp/scripts/run-summary.ts +0 -107
  134. package/plugins/sp/scripts/script-root.mjs +0 -129
  135. package/plugins/sp/scripts/script-root.ts +0 -199
  136. package/plugins/sp/scripts/session-timeline.mjs +0 -359
  137. package/plugins/sp/scripts/session-timeline.ts +0 -190
  138. package/plugins/sp/scripts/task-diffstat.mjs +0 -181
  139. package/plugins/sp/scripts/task-diffstat.ts +0 -219
  140. package/plugins/sp/scripts/workflow-step-profile.mjs +0 -338
  141. package/plugins/sp/scripts/workflow-step-profile.ts +0 -165
  142. package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
  143. package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -261
  144. package/plugins/sp/scripts/wrapup-steps.mjs +0 -627
  145. package/plugins/sp/scripts/wrapup-steps.ts +0 -891
@@ -1,116 +0,0 @@
1
- {
2
- "schema_version": 1,
3
- "description": "Registry of plugin sp scripts and their entrypoint contracts (standard with .mjs twin vs repo-only).",
4
- "entries": [
5
- {
6
- "rel": "batch-preflight.ts",
7
- "contract": "standard",
8
- "twin": "batch-preflight.mjs"
9
- },
10
- {
11
- "rel": "batch-plan.ts",
12
- "contract": "standard",
13
- "twin": "batch-plan.mjs"
14
- },
15
- {
16
- "rel": "commit-guard.ts",
17
- "contract": "standard",
18
- "twin": "commit-guard.mjs"
19
- },
20
- {
21
- "rel": "daily-summary/daily-summary.ts",
22
- "contract": "standard",
23
- "twin": "daily-summary/daily-summary.mjs"
24
- },
25
- {
26
- "rel": "dogfood-testing/detect-pipeline-driving.ts",
27
- "contract": "standard",
28
- "twin": "dogfood-testing/detect-pipeline-driving.mjs"
29
- },
30
- {
31
- "rel": "dogfood-testing/validate-report.ts",
32
- "contract": "standard",
33
- "twin": "dogfood-testing/validate-report.mjs"
34
- },
35
- {
36
- "rel": "idea-handoff.ts",
37
- "contract": "standard",
38
- "twin": "idea-handoff.mjs"
39
- },
40
- {
41
- "rel": "wrapup-drift-probe.ts",
42
- "contract": "standard",
43
- "twin": "wrapup-drift-probe.mjs"
44
- },
45
- {
46
- "rel": "wrapup-steps.ts",
47
- "contract": "standard",
48
- "twin": "wrapup-steps.mjs"
49
- },
50
- {
51
- "rel": "history-anatomy-cache.ts",
52
- "contract": "standard",
53
- "twin": "history-anatomy-cache.mjs"
54
- },
55
- {
56
- "rel": "inline-run-setup.ts",
57
- "contract": "standard",
58
- "twin": "inline-run-setup.mjs"
59
- },
60
- {
61
- "rel": "inline-run-dispatch.ts",
62
- "contract": "standard",
63
- "twin": "inline-run-dispatch.mjs"
64
- },
65
- {
66
- "rel": "feature-verification-steps.ts",
67
- "contract": "standard",
68
- "twin": "feature-verification-steps.mjs"
69
- },
70
- {
71
- "rel": "pr-reviewing.ts",
72
- "contract": "standard",
73
- "twin": "pr-reviewing.mjs"
74
- },
75
- {
76
- "rel": "quality-gate.ts",
77
- "contract": "standard",
78
- "twin": "quality-gate.mjs"
79
- },
80
- {
81
- "rel": "persist-out-check.ts",
82
- "contract": "standard",
83
- "twin": "persist-out-check.mjs"
84
- },
85
- {
86
- "rel": "residual-scan.ts",
87
- "contract": "standard",
88
- "twin": "residual-scan.mjs"
89
- },
90
- {
91
- "rel": "run-summary.ts",
92
- "contract": "standard",
93
- "twin": "run-summary.mjs"
94
- },
95
- {
96
- "rel": "script-root.ts",
97
- "contract": "standard",
98
- "twin": "script-root.mjs"
99
- },
100
- {
101
- "rel": "session-timeline.ts",
102
- "contract": "standard",
103
- "twin": "session-timeline.mjs"
104
- },
105
- {
106
- "rel": "task-diffstat.ts",
107
- "contract": "standard",
108
- "twin": "task-diffstat.mjs"
109
- },
110
- {
111
- "rel": "workflow-step-profile.ts",
112
- "contract": "standard",
113
- "twin": "workflow-step-profile.mjs"
114
- }
115
- ]
116
- }
@@ -1,33 +0,0 @@
1
- {
2
- "schemaVersion": 1,
3
- "description": "ADR-130 placement baseline (task 1000, seeded from plan \u00a72 inventory). Entries suppress sp-script-placement findings; kinds[] rows are migration-ledger entries for plan \u00a72 rows with no current findings. Baseline can only shrink: a listed kind the file no longer produces is a stale-baseline finding. Tasks 1001-1007 delete their entries in the same commit as the move.",
4
- "entries": {
5
- "plugins/sp/scripts/pr-reviewing.ts": {
6
- "kinds": ["budget"],
7
- "reason": "Keep, sp-owned git/gh loop; cited exemption (plan \u00a72)"
8
- },
9
- "plugins/sp/scripts/wrapup-steps.ts": {
10
- "kinds": ["budget", "corpus-parse"],
11
- "reason": "Keep (budget exemption), W2 (plan \u00a72). corpus-parse: the 1147 R1 doc-sync write-scope guard excludes the task/feature corpus by path prefix -- a scope exclusion, not corpus parsing (same class as wrapup-drift-probe.ts).",
12
- "exempt": true
13
- },
14
- "plugins/sp/scripts/batch-preflight.ts": {
15
- "kinds": ["budget"],
16
- "reason": "Keep (budget exemption; reviewed W4) (plan \u00a72)",
17
- "exempt": true
18
- },
19
- "plugins/sp/scripts/wrapup-drift-probe.ts": {
20
- "kinds": ["budget", "corpus-parse"],
21
- "reason": "Keep (glue over capture file; budget exemption) (plan \u00a72)",
22
- "exempt": true
23
- },
24
- "plugins/sp/scripts/daily-summary/daily-summary.ts": {
25
- "kinds": ["budget"],
26
- "reason": "Keep (daily-summary/, plan \u00a72); read-only history-health reads via the app service (task 1178)"
27
- },
28
- "plugins/sp/hooks/pi/guard-extension.ts": {
29
- "kinds": ["budget"],
30
- "reason": "Keep (ADR-129 normalizer) (plan \u00a72)"
31
- }
32
- }
33
- }
@@ -1,74 +0,0 @@
1
- #!/usr/bin/env bun
2
-
3
- /**
4
- * careful-guard — PreToolUse guard for destructive shell commands (task 0215, R3).
5
- *
6
- * Warn (permission `ask`) before a `Bash` tool call runs a destructive command — `rm -rf`
7
- * (any flag spelling, including the POSIX uppercase `-R`), `DROP TABLE`/`DROP DATABASE`/
8
- * `TRUNCATE`, `git push --force`/`-f`/`+refspec`, `git reset --hard`, `git checkout .`/
9
- * `git restore .`, `kubectl delete`, `docker system prune`. The operator can confirm to
10
- * proceed. Pure pattern-match + decision, no domain logic (mirrors `task-write-guard`).
11
- *
12
- * **Safe exceptions:** `rm -rf` of well-known build/dependency caches (`node_modules`, `dist`,
13
- * `.next`, `coverage`, `build`, `.turbo`, `.cache`) passes without a warning — deleting a rebuildable
14
- * cache is routine, not dangerous.
15
- *
16
- * **Fail-open contract:** every error path — unparseable payload, non-Bash tool, empty command —
17
- * emits an `allow` decision. A broken guard must never wedge an agent tool call.
18
- *
19
- * **Escape hatch:** `SPUR_CAREFUL=off` short-circuits to allow.
20
- *
21
- * Classification itself lives in `destructive-policy.ts` — one policy, every platform
22
- * adapter. This file owns the Claude Code payload/decision shape only.
23
- */
24
-
25
- import { classifyCommand } from '../lib/destructive-policy';
26
- import { getEnvVar } from '../lib/env';
27
-
28
- interface ToolPayload {
29
- tool_name?: string;
30
- tool_input?: { command?: string };
31
- }
32
-
33
- type Decision = 'allow' | 'ask';
34
-
35
- function preToolUseDecision(decision: Decision, reason?: string): never {
36
- const hookSpecificOutput: Record<string, unknown> = {
37
- hookEventName: 'PreToolUse',
38
- permissionDecision: decision,
39
- };
40
- if (reason !== undefined) hookSpecificOutput.permissionDecisionReason = reason;
41
- const out: Record<string, unknown> = { hookSpecificOutput };
42
- if (reason !== undefined) out.systemMessage = reason;
43
- process.stdout.write(JSON.stringify(out));
44
- process.exit(0);
45
- }
46
-
47
- async function main(): Promise<void> {
48
- if (getEnvVar('SPUR_CAREFUL') === 'off') preToolUseDecision('allow');
49
-
50
- const stdinText = await Bun.stdin.text();
51
- let payload: ToolPayload;
52
- try {
53
- payload = JSON.parse(stdinText) as ToolPayload;
54
- } catch {
55
- preToolUseDecision('allow'); // unparseable payload — fail open
56
- }
57
-
58
- if (payload.tool_name !== 'Bash') preToolUseDecision('allow');
59
-
60
- const command = payload.tool_input?.command ?? '';
61
- if (command.trim() === '') preToolUseDecision('allow');
62
-
63
- const hit = classifyCommand(command);
64
- if (hit !== null) {
65
- preToolUseDecision(
66
- 'ask',
67
- `This command looks like ${hit} — a destructive operation. Confirm you intend to run it. ` +
68
- 'Set SPUR_CAREFUL=off to disable this guard.',
69
- );
70
- }
71
- preToolUseDecision('allow');
72
- }
73
-
74
- if (import.meta.main) void main();
@@ -1,304 +0,0 @@
1
- /**
2
- * Pi extension for Spur (sp) plugin hooks.
3
- *
4
- * Replaces @vahor/pi-hooks with native Pi event handlers — no spinner,
5
- * no pi.exec overhead, no "Operation aborted" on Esc.
6
- *
7
- * Implements:
8
- * - task-write-guard (tool_call → block Write/Edit to Spur task files)
9
- * - careful-guard (tool_call → warn on destructive Bash commands)
10
- * - context-post-tool (tool_result → shared recordToolUseEvent)
11
- * - context-session-start (session_start → shared recordSessionStart)
12
- * - context-session-stop (session_shutdown → shared recordSessionEnd)
13
- * - agent-lifecycle (agent_start/agent_settled → shared reportLifecycleHook, G73 R4)
14
- *
15
- * The ledger and session cores are shared with the Claude hooks (task 0969): this
16
- * extension only normalizes Pi event shapes and adapts them to those cores.
17
- *
18
- * Installation:
19
- * Add to ~/.pi/agent/settings.json packages:
20
- * "npm:spur" (when published), or
21
- * Copy to ~/.pi/agent/extensions/sp-guard/ and reference via:
22
- * "pi": { "extensions": ["path/to/guard-extension.ts"] }
23
- */
24
-
25
- import { spawnSync } from 'node:child_process';
26
- import { existsSync, realpathSync } from 'node:fs';
27
- import { homedir } from 'node:os';
28
- import { isAbsolute, join, resolve } from 'node:path';
29
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
30
- import type { SessionOwner } from '../../lib/context-owner';
31
- import { recordToolUseEvent, type ToolPayload } from '../../lib/context-post-tool';
32
- import { recordSessionStart } from '../../lib/context-session-start';
33
- import { recordSessionEnd } from '../../lib/context-session-stop';
34
- import { classifyCommand } from '../../lib/destructive-policy';
35
- import { getEnvVar, getEnvVars } from '../../lib/env';
36
- import { couldBeTaskFile } from '../../lib/task-file-policy';
37
- import { reportLifecycleHook } from '../agent-lifecycle';
38
-
39
- // ─── Constants ───────────────────────────────────────────────────────────
40
-
41
- // Resolved per call, not cached at module load: Pi's cwd is fixed for the
42
- // process lifetime, and lazy resolution keeps the extension testable (tests
43
- // chdir into a temp project before driving handlers).
44
- const spurContextDir = (): string => join(process.cwd(), '.spur', 'context');
45
-
46
- // ─── Helpers ─────────────────────────────────────────────────────────────
47
-
48
- /**
49
- * Pi session owner identity (task 1159): the extension's own session-manager id, stable for the
50
- * life of the Pi session and distinct from any nested child session. Read here — not from a PID or
51
- * `SPUR_RUN_ID` — so a nested child's `session_shutdown` cannot claim the parent's pointer.
52
- */
53
- function piSessionOwner(ctx: ExtensionContext): SessionOwner | null {
54
- const sessionId = ctx.sessionManager.getSessionId().trim();
55
- return sessionId.length > 0 ? { host: 'pi', sessionId } : null;
56
- }
57
-
58
- type TaskOwnership = 'owned' | 'unowned' | 'unknown';
59
-
60
- /**
61
- * Extract the target path from a write/edit tool input. Pi uses `path`;
62
- * Claude Code uses `file_path`. Returns an absolute path when present.
63
- */
64
- function resolveInputPath(input: Record<string, unknown> | undefined): string {
65
- let raw = '';
66
- if (input) {
67
- if (typeof input.path === 'string') raw = input.path;
68
- else if (typeof input.file_path === 'string') raw = input.file_path;
69
- }
70
- if (!raw) return '';
71
- return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
72
- }
73
-
74
- /**
75
- * Candidate `spur` executable locations (symlinks). Pi launched from a GUI
76
- * may not have `~/.bun/bin` or node/bun on PATH, so we try each in order
77
- * and finally run spur.js via the current Bun executable directly.
78
- */
79
- const SPUR_BIN_CANDIDATES: string[] = [
80
- join(homedir(), '.bun', 'bin', 'spur'),
81
- '/opt/homebrew/bin/spur',
82
- '/usr/local/bin/spur',
83
- ];
84
-
85
- /** Resolve a candidate spur symlink to its real spur.js path (for execPath fallback). */
86
- function resolveSpurJsPath(candidate: string): string | undefined {
87
- try {
88
- return realpathSync(candidate);
89
- } catch {
90
- return existsSync(candidate) ? candidate : undefined;
91
- }
92
- }
93
-
94
- function resolveSpurTaskOwnership(filePath: string): TaskOwnership {
95
- const run = (cmd: string, args: string[]) =>
96
- spawnSync(cmd, args, { cwd: process.cwd(), encoding: 'utf-8', timeout: 8000 });
97
-
98
- // 1. SPUR_BIN env override (may include args) or `spur` on PATH
99
- const envBin = getEnvVar('SPUR_BIN') || 'spur';
100
- const envParts = envBin.split(' ');
101
- let res = run(envParts[0] ?? 'spur', [...envParts.slice(1), 'task', 'resolve', filePath, '--strict', '--json']);
102
- // Only 0 (owned) / 1 (unowned) are valid spur exit codes; 127 (interpreter
103
- // missing) and other codes mean the environment is broken — keep trying.
104
- if (!res.error && (res.status === 0 || res.status === 1)) return res.status === 0 ? 'owned' : 'unowned';
105
-
106
- // 2. Absolute symlink paths (needs node/bun on PATH for the shebang)
107
- for (const candidate of SPUR_BIN_CANDIDATES) {
108
- if (!existsSync(candidate)) continue;
109
- res = run(candidate, ['task', 'resolve', filePath, '--strict', '--json']);
110
- if (!res.error && (res.status === 0 || res.status === 1)) return res.status === 0 ? 'owned' : 'unowned';
111
- }
112
-
113
- // 3. Run spur.js via the current Bun executable (no PATH dependency)
114
- for (const candidate of SPUR_BIN_CANDIDATES) {
115
- const spurJs = resolveSpurJsPath(candidate);
116
- if (!spurJs) continue;
117
- res = run(process.execPath, [spurJs, 'task', 'resolve', filePath, '--strict', '--json']);
118
- if (!res.error && (res.status === 0 || res.status === 1)) return res.status === 0 ? 'owned' : 'unowned';
119
- }
120
-
121
- return 'unknown';
122
- }
123
-
124
- // Destructive-command classification is imported from `../destructive-policy`, the
125
- // single cross-platform policy. This file previously carried its own regex copy; it
126
- // diverged from the Claude matrix on 7 of 10 pinned cases (it allowed
127
- // `rm -rf node_modules /etc/nginx`, `rm -R --force /var/data`, `git push -f` and
128
- // `git push origin +main`, and warned on `git push --force-with-lease`). Do not
129
- // re-introduce a local copy — add cases to `destructive-policy.test.ts` instead.
130
-
131
- // ─── Pi → Claude payload normalization (task 0969 R1) ─────────────────────
132
-
133
- /** Pi tool name → the Claude tool name the shared core records. */
134
- const PI_TO_CLAUDE: Record<string, string> = {
135
- read: 'Read',
136
- write: 'Write',
137
- edit: 'Edit',
138
- bash: 'Bash',
139
- grep: 'Grep',
140
- find: 'Glob',
141
- };
142
-
143
- /** Join the text parts of a Pi tool_result `content` payload. */
144
- function piContentText(content: unknown): string {
145
- if (typeof content === 'string') return content;
146
- if (!Array.isArray(content)) return '';
147
- const parts: string[] = [];
148
- for (const part of content) {
149
- if (part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string') {
150
- parts.push((part as { text: string }).text);
151
- }
152
- }
153
- return parts.join('\n');
154
- }
155
-
156
- /**
157
- * Pure Pi-event → Claude-{@link ToolPayload} adapter. An unmapped tool returns null so the
158
- * shared core writes no row (R1's row filter). Search tools keep `path` (never `file_path`)
159
- * because the core treats `file_path` as the written file, not a search root.
160
- */
161
- export function normalizePiToolEvent(
162
- toolName: string,
163
- input: Record<string, unknown> | undefined,
164
- content?: unknown,
165
- ): ToolPayload | null {
166
- const mapped = PI_TO_CLAUDE[toolName];
167
- if (mapped === undefined) return null;
168
- const i = input ?? {};
169
- const str = (value: unknown): string => (typeof value === 'string' ? value : '');
170
- const path = str(i.path) || str(i.file_path);
171
- const text = piContentText(content);
172
- const tool_response = text.length > 0 ? { content: text } : undefined;
173
-
174
- switch (mapped) {
175
- case 'Read':
176
- return { tool_name: mapped, tool_input: { file_path: path }, tool_response };
177
- case 'Write':
178
- return { tool_name: mapped, tool_input: { file_path: path, content: str(i.content) }, tool_response };
179
- case 'Edit': {
180
- const edits = Array.isArray(i.edits) ? (i.edits as Array<Record<string, unknown>>) : [];
181
- return {
182
- tool_name: mapped,
183
- tool_input: {
184
- file_path: path,
185
- old_string: edits.map((e) => str(e.oldText)).join(''),
186
- new_string: edits.map((e) => str(e.newText)).join(''),
187
- },
188
- tool_response,
189
- };
190
- }
191
- case 'Grep':
192
- return {
193
- tool_name: mapped,
194
- tool_input: {
195
- pattern: str(i.pattern),
196
- ...(path ? { path } : {}),
197
- ...(str(i.glob) ? { glob: str(i.glob) } : {}),
198
- },
199
- tool_response,
200
- };
201
- case 'Glob':
202
- return {
203
- tool_name: mapped,
204
- tool_input: { pattern: str(i.pattern), ...(path ? { path } : {}) },
205
- tool_response,
206
- };
207
- case 'Bash':
208
- return { tool_name: mapped, tool_input: { command: str(i.command) }, tool_response };
209
- default:
210
- return null;
211
- }
212
- }
213
-
214
- // ─── Extension entry point ───────────────────────────────────────────────
215
-
216
- export default function (pi: ExtensionAPI): void {
217
- // ── task-write-guard + careful-guard ──────────────────────────────
218
- pi.on('tool_call', async (event, ctx) => {
219
- // task-write-guard: block Write/Edit to Spur task files.
220
- // Both guards honor the same `off` escape hatches as the Claude hooks.
221
- if ((event.toolName === 'write' || event.toolName === 'edit') && getEnvVar('SPUR_WRITE_GUARD') !== 'off') {
222
- const input = event.input as Record<string, unknown> | undefined;
223
- // Pi's write/edit tools use `path` (not Claude Code's `file_path`)
224
- const filePath = resolveInputPath(input);
225
- // Skip the `spur task resolve` subprocess for paths that cannot name a
226
- // task file (shared predicate — see task-file-policy.ts).
227
- if (filePath && couldBeTaskFile(filePath) && resolveSpurTaskOwnership(filePath) === 'owned') {
228
- const msg = `Denied: ${filePath} is a Spur task file. Use 'spur task update' instead.`;
229
- ctx.ui.notify(msg, 'error');
230
- return { block: true, reason: msg };
231
- }
232
- }
233
-
234
- // careful-guard: warn on destructive Bash commands
235
- if (event.toolName === 'bash' && getEnvVar('SPUR_CAREFUL') !== 'off') {
236
- const input = event.input as Record<string, unknown> | undefined;
237
- const command = typeof input?.command === 'string' ? input.command : '';
238
- const hit = command ? classifyCommand(command) : null;
239
- if (hit !== null) {
240
- const msg = `Warning: destructive command — ${hit}: ${command.slice(0, 120)}`;
241
- ctx.ui.notify(msg, 'warning');
242
- // Ask for confirmation
243
- const ok = await ctx.ui.confirm('Destructive command', msg);
244
- if (!ok) return { block: true, reason: 'Cancelled by user' };
245
- }
246
- }
247
-
248
- return {};
249
- });
250
-
251
- // ── context-post-tool: shared recordToolUseEvent (task 0969 R1) ──
252
- pi.on('tool_result', async (event, ctx) => {
253
- try {
254
- const payload = normalizePiToolEvent(
255
- event.toolName,
256
- event.input as Record<string, unknown> | undefined,
257
- (event as { content?: unknown }).content,
258
- );
259
- // Host-native session id, as Claude's payload carries (H21 parity).
260
- const sessionId = piSessionOwner(ctx)?.sessionId;
261
- if (payload)
262
- recordToolUseEvent(spurContextDir(), sessionId ? { ...payload, session_id: sessionId } : payload);
263
- } catch {
264
- // fail-open
265
- }
266
- });
267
-
268
- // ── context-session-start: shared recordSessionStart (R2) ────────
269
- pi.on('session_start', async (_event, ctx) => {
270
- try {
271
- recordSessionStart(spurContextDir(), getEnvVars(), undefined, 'pi', piSessionOwner(ctx) ?? undefined);
272
- } catch {
273
- // fail-open
274
- }
275
- });
276
-
277
- // ── context-session-stop: shared recordSessionEnd (R3) ──────────
278
- pi.on('session_shutdown', async (_event, ctx) => {
279
- try {
280
- recordSessionEnd(spurContextDir(), undefined, piSessionOwner(ctx) ?? undefined);
281
- } catch {
282
- // fail-open
283
- }
284
- });
285
-
286
- // ── agent-lifecycle: fleet lifecycle reporting (G73 R4, task 1080) ──
287
- // Pi has no native `blocked` signal (herdr finding P2), so only working/idle are
288
- // reported here; a no-op outside a fleet, like every other hook path.
289
- pi.on('agent_start', async () => {
290
- try {
291
- reportLifecycleHook({ hook_event_name: 'agent_start' });
292
- } catch {
293
- // fail-open
294
- }
295
- });
296
-
297
- pi.on('agent_settled', async () => {
298
- try {
299
- reportLifecycleHook({ hook_event_name: 'agent_settled' });
300
- } catch {
301
- // fail-open
302
- }
303
- });
304
- }