@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/pipeline-budgets.json +2 -2
- package/config/rules/boundary/config-loading-ownership.yaml +1 -0
- package/config/rules/boundary/sp-plugin-standalone.yaml +1 -1
- package/config/rules/boundary/sp-script-placement.yaml +1 -1
- package/config/rules/docs/sourced-api-claims.yaml +45 -7
- package/config/rules/strict/runtime-boundaries.yaml +40 -1
- package/config/rules/structure/protected-files.yaml +1 -1
- package/config/rules/surface/check-cli-surface.yaml +33 -0
- package/config/rules/typescript/no-debugger.yaml +3 -0
- package/config/workflow-candidates.json +2 -2
- package/config/workflows/feature-verification.yaml +6 -11
- package/config/workflows/history-anatomy.yaml +14 -28
- package/config/workflows/idea-pipeline.yaml +6 -19
- package/config/workflows/pr-review.yaml +9 -16
- package/config/workflows/task-pipeline.yaml +16 -23
- package/config/workflows/wrapup-pipeline.yaml +21 -63
- package/package.json +3 -3
- package/plugins/sp/README.md +70 -37
- package/plugins/sp/agents/super-planner.md +5 -5
- package/plugins/sp/agents/super-reviewer.md +2 -2
- package/plugins/sp/commands/dev-changelog.md +1 -1
- package/plugins/sp/commands/dev-daily.md +1 -1
- package/plugins/sp/commands/dev-pr-review.md +1 -1
- package/plugins/sp/commands/dev-review-session.md +1 -1
- package/plugins/sp/commands/dev-runall.md +4 -4
- package/plugins/sp/commands/dev-verify.md +4 -6
- package/plugins/sp/commands/dev-verifyall.md +4 -5
- package/plugins/sp/hooks/hooks.json +11 -1
- package/plugins/sp/lib/context-post-tool.ts +1 -1
- package/plugins/sp/lib/context-session-start.ts +1 -1
- package/plugins/sp/lib/context-session-stop.ts +1 -1
- package/plugins/sp/lib/destructive-policy.ts +65 -1
- package/plugins/sp/plugin.json +2 -5
- package/plugins/sp/{hooks → scripts/hooks}/agent-lifecycle.ts +1 -1
- package/plugins/sp/scripts/hooks/careful-guard.ts +94 -0
- package/plugins/sp/{hooks → scripts/hooks}/context-post-tool.ts +2 -2
- package/plugins/sp/{hooks → scripts/hooks}/context-session-start.ts +1 -1
- package/plugins/sp/{hooks → scripts/hooks}/context-session-stop.ts +1 -1
- package/plugins/sp/{hooks → scripts/hooks}/fleet-guest-stop.ts +6 -4
- package/plugins/sp/{hooks → scripts/hooks}/task-write-guard.ts +38 -21
- package/plugins/sp/skills/code-implementation/SKILL.md +1 -1
- package/plugins/sp/skills/code-verification/SKILL.md +8 -8
- package/plugins/sp/skills/code-verification/references/secu-review.md +4 -3
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +9 -1
- package/plugins/sp/skills/daily-summary/SKILL.md +4 -5
- package/plugins/sp/skills/dogfood-testing/SKILL.md +9 -9
- package/plugins/sp/skills/dogfood-testing/references/report-template.md +1 -1
- package/plugins/sp/skills/history-anatomy/references/modes.md +2 -2
- package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
- package/plugins/sp/skills/parallel-execution/SKILL.md +1 -1
- package/plugins/sp/skills/pr-reviewing/SKILL.md +14 -15
- package/plugins/sp/skills/session-review/SKILL.md +3 -3
- package/plugins/sp/skills/source-driven-development/SKILL.md +7 -4
- package/plugins/sp/skills/spur-check/SKILL.md +14 -13
- package/plugins/sp/skills/spur-cli/SKILL.md +1 -1
- package/plugins/sp/skills/spur-cli/references/agent.md +12 -0
- package/plugins/sp/skills/spur-cli/references/builder.md +27 -6
- package/plugins/sp/skills/spur-cli/references/features.md +6 -1
- package/plugins/sp/skills/spur-cli/references/history.md +3 -0
- package/plugins/sp/skills/spur-cli/references/self.md +5 -3
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +3 -0
- package/plugins/sp/skills/spur-cli/references/tasks.md +13 -3
- package/plugins/sp/skills/spur-cli/references/workflows.md +36 -1
- package/plugins/sp/skills/spur-dev/SKILL.md +1 -1
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +1 -1
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +12 -12
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +10 -11
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +16 -17
- package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +1 -1
- package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +8 -11
- package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +6 -8
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +1 -1
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +33 -40
- package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +24 -24
- package/plugins/sp/skills/spur-doctor/SKILL.md +5 -4
- package/spur.js +41901 -30598
- package/config/plugin-scripts.json +0 -116
- package/config/script-placement-baseline.json +0 -33
- package/plugins/sp/hooks/careful-guard.ts +0 -74
- package/plugins/sp/hooks/pi/guard-extension.ts +0 -304
- package/plugins/sp/lib/dogfood-pipeline.ts +0 -265
- package/plugins/sp/lib/dogfood-report.ts +0 -278
- package/plugins/sp/lib/history-anatomy.generated.d.mts +0 -112
- package/plugins/sp/lib/history-anatomy.generated.mjs +0 -691
- package/plugins/sp/lib/idea-handoff.generated.d.mts +0 -17
- package/plugins/sp/lib/idea-handoff.generated.mjs +0 -1446
- package/plugins/sp/lib/inline-run-app.ts +0 -68
- package/plugins/sp/lib/inline-run.generated.d.mts +0 -37
- package/plugins/sp/lib/inline-run.generated.mjs +0 -3103
- package/plugins/sp/lib/plan-projection.generated.d.mts +0 -197
- package/plugins/sp/lib/plan-projection.generated.mjs +0 -982
- package/plugins/sp/lib/quality-gate.generated.d.mts +0 -139
- package/plugins/sp/lib/quality-gate.generated.mjs +0 -30510
- package/plugins/sp/lib/residual-scan.generated.d.mts +0 -93
- package/plugins/sp/lib/residual-scan.generated.mjs +0 -383
- package/plugins/sp/lib/run-summary-core.ts +0 -225
- package/plugins/sp/lib/spur-bin.ts +0 -36
- package/plugins/sp/lib/step-profile.generated.d.mts +0 -71
- package/plugins/sp/lib/step-profile.generated.mjs +0 -174
- package/plugins/sp/lib/transcript.ts +0 -112
- package/plugins/sp/scripts/batch-plan.mjs +0 -1096
- package/plugins/sp/scripts/batch-plan.ts +0 -145
- package/plugins/sp/scripts/batch-preflight.mjs +0 -423
- package/plugins/sp/scripts/batch-preflight.ts +0 -270
- package/plugins/sp/scripts/commit-guard.mjs +0 -226
- package/plugins/sp/scripts/commit-guard.ts +0 -248
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -626
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -855
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -211
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -110
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -282
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -98
- package/plugins/sp/scripts/feature-verification-steps.mjs +0 -82
- package/plugins/sp/scripts/feature-verification-steps.ts +0 -164
- package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -931
- package/plugins/sp/scripts/history-anatomy-cache.ts +0 -247
- package/plugins/sp/scripts/idea-handoff.mjs +0 -32
- package/plugins/sp/scripts/idea-handoff.ts +0 -47
- package/plugins/sp/scripts/inline-run-dispatch.mjs +0 -131
- package/plugins/sp/scripts/inline-run-dispatch.ts +0 -115
- package/plugins/sp/scripts/inline-run-setup.mjs +0 -746
- package/plugins/sp/scripts/inline-run-setup.ts +0 -198
- package/plugins/sp/scripts/persist-out-check.mjs +0 -217
- package/plugins/sp/scripts/persist-out-check.ts +0 -249
- package/plugins/sp/scripts/pr-reviewing.mjs +0 -712
- package/plugins/sp/scripts/pr-reviewing.ts +0 -952
- package/plugins/sp/scripts/quality-gate.mjs +0 -30491
- package/plugins/sp/scripts/quality-gate.ts +0 -80
- package/plugins/sp/scripts/residual-scan.mjs +0 -634
- package/plugins/sp/scripts/residual-scan.ts +0 -248
- package/plugins/sp/scripts/run-summary.mjs +0 -758
- package/plugins/sp/scripts/run-summary.ts +0 -107
- package/plugins/sp/scripts/script-root.mjs +0 -129
- package/plugins/sp/scripts/script-root.ts +0 -199
- package/plugins/sp/scripts/session-timeline.mjs +0 -359
- package/plugins/sp/scripts/session-timeline.ts +0 -190
- package/plugins/sp/scripts/task-diffstat.mjs +0 -181
- package/plugins/sp/scripts/task-diffstat.ts +0 -219
- package/plugins/sp/scripts/workflow-step-profile.mjs +0 -338
- package/plugins/sp/scripts/workflow-step-profile.ts +0 -165
- package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
- package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -261
- package/plugins/sp/scripts/wrapup-steps.mjs +0 -627
- 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
|
-
}
|