@gobing-ai/spur 0.3.41 → 0.3.43

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 (185) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/README.md +15 -1
  3. package/config/templates/AGENTS.md +5 -3
  4. package/config/workflows/task-pipeline.yaml +113 -12
  5. package/package.json +15 -12
  6. package/plugins/README.md +655 -0
  7. package/plugins/sp/README.md +621 -0
  8. package/plugins/sp/agents/expert-spur.md +109 -0
  9. package/plugins/sp/agents/super-coder.md +126 -0
  10. package/plugins/sp/agents/super-planner.md +283 -0
  11. package/plugins/sp/agents/super-reviewer.md +154 -0
  12. package/plugins/sp/commands/dev-arch.md +30 -0
  13. package/plugins/sp/commands/dev-brainstorm.md +40 -0
  14. package/plugins/sp/commands/dev-changelog.md +28 -0
  15. package/plugins/sp/commands/dev-daily.md +29 -0
  16. package/plugins/sp/commands/dev-debug.md +29 -0
  17. package/plugins/sp/commands/dev-dogfood.md +33 -0
  18. package/plugins/sp/commands/dev-featurechange.md +130 -0
  19. package/plugins/sp/commands/dev-find-conflict.md +51 -0
  20. package/plugins/sp/commands/dev-find-issue.md +76 -0
  21. package/plugins/sp/commands/dev-find-next.md +60 -0
  22. package/plugins/sp/commands/dev-fixall.md +33 -0
  23. package/plugins/sp/commands/dev-gitmsg.md +28 -0
  24. package/plugins/sp/commands/dev-gtd.md +97 -0
  25. package/plugins/sp/commands/dev-handover.md +26 -0
  26. package/plugins/sp/commands/dev-idea.md +50 -0
  27. package/plugins/sp/commands/dev-next.md +31 -0
  28. package/plugins/sp/commands/dev-parallel.md +30 -0
  29. package/plugins/sp/commands/dev-plan.md +46 -0
  30. package/plugins/sp/commands/dev-refine.md +48 -0
  31. package/plugins/sp/commands/dev-refineall.md +65 -0
  32. package/plugins/sp/commands/dev-refresh.md +32 -0
  33. package/plugins/sp/commands/dev-reverse.md +32 -0
  34. package/plugins/sp/commands/dev-review.md +31 -0
  35. package/plugins/sp/commands/dev-run.md +63 -0
  36. package/plugins/sp/commands/dev-runall.md +83 -0
  37. package/plugins/sp/commands/dev-simplify.md +39 -0
  38. package/plugins/sp/commands/dev-unit.md +29 -0
  39. package/plugins/sp/commands/dev-verify.md +48 -0
  40. package/plugins/sp/commands/dev-verifyall.md +72 -0
  41. package/plugins/sp/commands/dev-wrap.md +48 -0
  42. package/plugins/sp/commands/dev-wrapall.md +51 -0
  43. package/plugins/sp/commands/rule-add.md +18 -0
  44. package/plugins/sp/commands/rule-refine.md +18 -0
  45. package/plugins/sp/commands/rule-scan.md +18 -0
  46. package/plugins/sp/commands/spur-init.md +22 -0
  47. package/plugins/sp/commands/workflow-add.md +18 -0
  48. package/plugins/sp/commands/workflow-refine.md +18 -0
  49. package/plugins/sp/hooks/agent-hint.ts +60 -0
  50. package/plugins/sp/hooks/careful-guard.ts +147 -0
  51. package/plugins/sp/hooks/context-post-tool.ts +325 -0
  52. package/plugins/sp/hooks/context-session-start.ts +182 -0
  53. package/plugins/sp/hooks/context-session-stop.ts +95 -0
  54. package/plugins/sp/hooks/hooks.json +51 -0
  55. package/plugins/sp/hooks/pi/guard-extension.ts +341 -0
  56. package/plugins/sp/hooks/task-write-guard.ts +84 -0
  57. package/plugins/sp/plugin.json +8 -0
  58. package/plugins/sp/scripts/batch-preflight.ts +204 -0
  59. package/plugins/sp/scripts/daily-summary/daily-summary.ts +606 -0
  60. package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
  61. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +363 -0
  62. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +168 -0
  63. package/plugins/sp/scripts/feature-sync-bounded.ts +451 -0
  64. package/plugins/sp/scripts/stage-registry-adapter.ts +1341 -0
  65. package/plugins/sp/scripts/task-size-precheck.ts +202 -0
  66. package/plugins/sp/scripts/validate-commands.ts +651 -0
  67. package/plugins/sp/scripts/validate-flag-contracts.ts +875 -0
  68. package/plugins/sp/skills/brainstorm/SKILL.md +361 -0
  69. package/plugins/sp/skills/brainstorm/agents/openai.yaml +14 -0
  70. package/plugins/sp/skills/brainstorm/examples/ideation-example.md +209 -0
  71. package/plugins/sp/skills/brainstorm/metadata.openclaw +7 -0
  72. package/plugins/sp/skills/brainstorm/references/grilling-interview.md +68 -0
  73. package/plugins/sp/skills/brainstorm/references/workflows.md +255 -0
  74. package/plugins/sp/skills/branch-workflow/SKILL.md +109 -0
  75. package/plugins/sp/skills/branch-workflow/references/branch-lifecycle.md +69 -0
  76. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +100 -0
  77. package/plugins/sp/skills/code-implementation/SKILL.md +203 -0
  78. package/plugins/sp/skills/code-implementation/references/debugging.md +64 -0
  79. package/plugins/sp/skills/code-implementation/references/implementation-patterns.md +113 -0
  80. package/plugins/sp/skills/code-improvement/SKILL.md +231 -0
  81. package/plugins/sp/skills/code-improvement/references/deepening-signals.md +205 -0
  82. package/plugins/sp/skills/code-review/SKILL.md +112 -0
  83. package/plugins/sp/skills/code-review/references/receiving-code-review.md +70 -0
  84. package/plugins/sp/skills/code-review/references/review-lenses.md +117 -0
  85. package/plugins/sp/skills/code-review/references/self-review-checklist.md +53 -0
  86. package/plugins/sp/skills/code-review/references/verification-before-completion.md +68 -0
  87. package/plugins/sp/skills/code-simplification/SKILL.md +225 -0
  88. package/plugins/sp/skills/code-testing/SKILL.md +108 -0
  89. package/plugins/sp/skills/code-testing/references/stacks/bun-ts.md +165 -0
  90. package/plugins/sp/skills/code-testing/references/stacks/go.md +88 -0
  91. package/plugins/sp/skills/code-testing/references/stacks/python.md +117 -0
  92. package/plugins/sp/skills/code-testing/references/test-loop-breaker.md +37 -0
  93. package/plugins/sp/skills/code-testing/references/test-output-discipline.md +52 -0
  94. package/plugins/sp/skills/code-testing/references/unit-testing.md +212 -0
  95. package/plugins/sp/skills/code-verification/SKILL.md +535 -0
  96. package/plugins/sp/skills/code-verification/references/code-improvement.md +39 -0
  97. package/plugins/sp/skills/code-verification/references/secu-review.md +94 -0
  98. package/plugins/sp/skills/code-verification/references/verdict-schema.md +141 -0
  99. package/plugins/sp/skills/conflict-finding/SKILL.md +254 -0
  100. package/plugins/sp/skills/conflict-finding/references/authority-resolution.md +258 -0
  101. package/plugins/sp/skills/conflict-finding/references/comparison-protocol.md +277 -0
  102. package/plugins/sp/skills/conflict-finding/references/finding-contract.md +346 -0
  103. package/plugins/sp/skills/conflict-finding/references/remediation-routing.md +276 -0
  104. package/plugins/sp/skills/daily-summary/SKILL.md +171 -0
  105. package/plugins/sp/skills/daily-summary/agents/openai.yaml +13 -0
  106. package/plugins/sp/skills/doc-evolve/SKILL.md +175 -0
  107. package/plugins/sp/skills/doc-evolve/references/operations.md +93 -0
  108. package/plugins/sp/skills/dogfood-testing/SKILL.md +608 -0
  109. package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +211 -0
  110. package/plugins/sp/skills/dogfood-testing/references/report-template.md +351 -0
  111. package/plugins/sp/skills/doubt-driven-development/SKILL.md +136 -0
  112. package/plugins/sp/skills/functional-review/SKILL.md +364 -0
  113. package/plugins/sp/skills/functional-review/references/verdict-schema.md +133 -0
  114. package/plugins/sp/skills/indexed-context/SKILL.md +164 -0
  115. package/plugins/sp/skills/issue-finding/SKILL.md +442 -0
  116. package/plugins/sp/skills/issue-finding/agents/openai.yaml +15 -0
  117. package/plugins/sp/skills/issue-finding/examples/expected-findings.json +27 -0
  118. package/plugins/sp/skills/issue-finding/examples/session-test-loop.jsonl +23 -0
  119. package/plugins/sp/skills/issue-finding/references/session-formats.md +151 -0
  120. package/plugins/sp/skills/next-feature/SKILL.md +125 -0
  121. package/plugins/sp/skills/next-feature/references/handoff-routing.md +92 -0
  122. package/plugins/sp/skills/next-feature/references/proposal-contract.md +49 -0
  123. package/plugins/sp/skills/next-feature/references/ranking-rubric.md +52 -0
  124. package/plugins/sp/skills/next-feature/references/signal-derivation.md +67 -0
  125. package/plugins/sp/skills/next-router/SKILL.md +181 -0
  126. package/plugins/sp/skills/next-router/references/messages.md +90 -0
  127. package/plugins/sp/skills/next-router/references/routing-table.md +216 -0
  128. package/plugins/sp/skills/parallel-execution/SKILL.md +148 -0
  129. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +109 -0
  130. package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +101 -0
  131. package/plugins/sp/skills/parallel-execution/references/result-synthesis.md +94 -0
  132. package/plugins/sp/skills/reverse-engineering/SKILL.md +375 -0
  133. package/plugins/sp/skills/reverse-engineering/agents/openai.yaml +18 -0
  134. package/plugins/sp/skills/source-driven-development/SKILL.md +124 -0
  135. package/plugins/sp/skills/spec-decomposition/SKILL.md +117 -0
  136. package/plugins/sp/skills/spec-decomposition/references/decomposition.md +539 -0
  137. package/plugins/sp/skills/spur-cli/SKILL.md +133 -0
  138. package/plugins/sp/skills/spur-cli/references/agent.md +179 -0
  139. package/plugins/sp/skills/spur-cli/references/features/acceptance-criteria.md +82 -0
  140. package/plugins/sp/skills/spur-cli/references/features/hierarchy-mece.md +177 -0
  141. package/plugins/sp/skills/spur-cli/references/features/roadmap-priority.md +84 -0
  142. package/plugins/sp/skills/spur-cli/references/features/verbs.md +122 -0
  143. package/plugins/sp/skills/spur-cli/references/features.md +234 -0
  144. package/plugins/sp/skills/spur-cli/references/init.md +119 -0
  145. package/plugins/sp/skills/spur-cli/references/message.md +98 -0
  146. package/plugins/sp/skills/spur-cli/references/rules/authoring-rules.md +212 -0
  147. package/plugins/sp/skills/spur-cli/references/rules/fine-tuning.md +148 -0
  148. package/plugins/sp/skills/spur-cli/references/rules/operations.md +228 -0
  149. package/plugins/sp/skills/spur-cli/references/rules/validation-and-extension.md +129 -0
  150. package/plugins/sp/skills/spur-cli/references/rules.md +209 -0
  151. package/plugins/sp/skills/spur-cli/references/serve.md +58 -0
  152. package/plugins/sp/skills/spur-cli/references/tasks/l3-guard-cheatsheet.md +98 -0
  153. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +95 -0
  154. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +361 -0
  155. package/plugins/sp/skills/spur-cli/references/tasks.md +280 -0
  156. package/plugins/sp/skills/spur-cli/references/team.md +133 -0
  157. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +226 -0
  158. package/plugins/sp/skills/spur-cli/references/workflows/operations.md +211 -0
  159. package/plugins/sp/skills/spur-cli/references/workflows/validation-and-extension.md +129 -0
  160. package/plugins/sp/skills/spur-cli/references/workflows.md +355 -0
  161. package/plugins/sp/skills/spur-dev/SKILL.md +224 -0
  162. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +196 -0
  163. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +680 -0
  164. package/plugins/sp/skills/spur-dev/references/decision-brief.md +80 -0
  165. package/plugins/sp/skills/spur-dev/references/dev-operations.md +546 -0
  166. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +164 -0
  167. package/plugins/sp/skills/spur-dev/references/execution-batch.md +790 -0
  168. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +352 -0
  169. package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +191 -0
  170. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +417 -0
  171. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +171 -0
  172. package/plugins/sp/skills/spur-dev/references/glossary.md +95 -0
  173. package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +80 -0
  174. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +120 -0
  175. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +302 -0
  176. package/plugins/sp/skills/spur-dev/references/product-planning.md +206 -0
  177. package/plugins/sp/skills/spur-dev/references/section-batching.md +24 -0
  178. package/plugins/sp/skills/sys-architecture/SKILL.md +155 -0
  179. package/plugins/sp/skills/sys-architecture/references/decision-method.md +137 -0
  180. package/plugins/sp/skills/sys-architecture/references/upkeep-survey.md +75 -0
  181. package/plugins/sp/skills/sys-debugging/SKILL.md +228 -0
  182. package/plugins/sp/skills/sys-debugging/references/debugging-protocol.md +62 -0
  183. package/plugins/sp/skills/test-driven-development/SKILL.md +248 -0
  184. package/plugins/sp/skills/wayfinder/SKILL.md +292 -0
  185. package/spur.js +6035 -4963
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * context-session-stop — SessionEnd hook for indexed-context (session teardown).
4
+ *
5
+ * Scans `token-ledger.jsonl` for the current session's events, computes rollup totals, appends
6
+ * a `session_end` event, and cleans up `.session.json`. Registered on SessionEnd (not Stop) so it
7
+ * runs once at true session teardown: on Claude Code the rollup counts the whole session (Stop fired
8
+ * per-turn and tore down after turn 1), and on Pi/omp it maps to `session_shutdown` — off the
9
+ * `agent_end`/Stop event where blocking gates (e.g. cc/anti-hallucination) live and would collide.
10
+ *
11
+ * **Fail-open contract:** every error path — missing `.spur/context/`, missing/unparseable
12
+ * `.session.json`, write failure — exits 0 with no output.
13
+ *
14
+ * Self-contained by design (task 0232). Installed hook configs use the portable
15
+ * `superskill hook run sp context-session-stop` entrypoint.
16
+ */
17
+
18
+ import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ interface LedgerEvent {
22
+ session: string;
23
+ type: string;
24
+ tokens?: number;
25
+ }
26
+
27
+ /** Scan the token-ledger JSONL for this session's events and compute rollup totals. */
28
+ function computeSessionTotals(
29
+ ledgerPath: string,
30
+ sessionId: string,
31
+ ): { reads: number; writes: number; tokens: number } {
32
+ if (!existsSync(ledgerPath)) return { reads: 0, writes: 0, tokens: 0 };
33
+ let reads = 0;
34
+ let writes = 0;
35
+ let tokens = 0;
36
+ for (const line of readFileSync(ledgerPath, 'utf-8').split('\n')) {
37
+ if (!line.trim()) continue;
38
+ try {
39
+ const evt = JSON.parse(line) as LedgerEvent;
40
+ if (evt.session !== sessionId) continue;
41
+ if (evt.type === 'read') reads++;
42
+ else if (evt.type === 'write') writes++;
43
+ if (evt.tokens) tokens += evt.tokens;
44
+ } catch {
45
+ // skip unparseable lines
46
+ }
47
+ }
48
+ return { reads, writes, tokens };
49
+ }
50
+
51
+ function exitOk(): never {
52
+ process.exit(0);
53
+ }
54
+
55
+ async function main(): Promise<void> {
56
+ const dir = join(process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), '.spur', 'context');
57
+
58
+ const sessionFile = join(dir, '.session.json');
59
+ if (!existsSync(sessionFile)) exitOk();
60
+
61
+ let sessionId = '';
62
+ try {
63
+ const session = JSON.parse(readFileSync(sessionFile, 'utf-8')) as { session?: string };
64
+ sessionId = session.session ?? '';
65
+ } catch {
66
+ exitOk();
67
+ }
68
+
69
+ if (!sessionId) exitOk();
70
+
71
+ const totals = computeSessionTotals(join(dir, 'token-ledger.jsonl'), sessionId);
72
+
73
+ const event = {
74
+ ts: new Date().toISOString(),
75
+ session: sessionId,
76
+ type: 'session_end' as const,
77
+ totals,
78
+ };
79
+
80
+ try {
81
+ appendFileSync(join(dir, 'token-ledger.jsonl'), `${JSON.stringify(event)}\n`);
82
+ } catch {
83
+ exitOk();
84
+ }
85
+
86
+ try {
87
+ rmSync(sessionFile, { force: true });
88
+ } catch {
89
+ // cleanup is best-effort
90
+ }
91
+
92
+ exitOk();
93
+ }
94
+
95
+ void main().catch(exitOk);
@@ -0,0 +1,51 @@
1
+ {
2
+ "description": "Spur (sp) plugin hooks — task corpus write protection + indexed-context token-ledger tracking.",
3
+ "hooks": {
4
+ "PreToolUse": [
5
+ {
6
+ "matcher": "Write|Edit",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "superskill hook run sp task-write-guard",
11
+ "timeout": 10
12
+ }
13
+ ]
14
+ }
15
+ ],
16
+ "PostToolUse": [
17
+ {
18
+ "matcher": "Bash|Grep|Glob|Read|Write|Edit",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "superskill hook run sp context-post-tool",
23
+ "timeout": 10
24
+ }
25
+ ]
26
+ }
27
+ ],
28
+ "SessionStart": [
29
+ {
30
+ "hooks": [
31
+ {
32
+ "type": "command",
33
+ "command": "superskill hook run sp context-session-start",
34
+ "timeout": 15
35
+ }
36
+ ]
37
+ }
38
+ ],
39
+ "SessionEnd": [
40
+ {
41
+ "hooks": [
42
+ {
43
+ "type": "command",
44
+ "command": "superskill hook run sp context-session-stop",
45
+ "timeout": 15
46
+ }
47
+ ]
48
+ }
49
+ ]
50
+ }
51
+ }
@@ -0,0 +1,341 @@
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 → append to token-ledger.jsonl)
11
+ * - context-session-start (session_start → init .session.json)
12
+ * - context-session-stop (session_shutdown → rollup + cleanup)
13
+ *
14
+ * Installation:
15
+ * Add to ~/.pi/agent/settings.json packages:
16
+ * "npm:spur" (when published), or
17
+ * Copy to ~/.pi/agent/extensions/sp-guard/ and reference via:
18
+ * "pi": { "extensions": ["path/to/guard-extension.ts"] }
19
+ */
20
+
21
+ import { spawnSync } from 'node:child_process';
22
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { isAbsolute, join, resolve } from 'node:path';
25
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
26
+ import { resolveAgentHint as resolveAgentHintShared, resolveModelHint as resolveModelHintShared } from '../agent-hint';
27
+
28
+ // ─── Constants ───────────────────────────────────────────────────────────
29
+
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');
33
+ const REDACTION_CAP = 4096;
34
+ const SUMMARY_MAX_CHARS = 200;
35
+
36
+ // ─── Helpers ─────────────────────────────────────────────────────────────
37
+
38
+ type TaskOwnership = 'owned' | 'unowned' | 'unknown';
39
+
40
+ /**
41
+ * Extract the target path from a write/edit tool input. Pi uses `path`;
42
+ * Claude Code uses `file_path`. Returns an absolute path when present.
43
+ */
44
+ function resolveInputPath(input: Record<string, unknown> | undefined): string {
45
+ let raw = '';
46
+ if (input) {
47
+ if (typeof input.path === 'string') raw = input.path;
48
+ else if (typeof input.file_path === 'string') raw = input.file_path;
49
+ }
50
+ if (!raw) return '';
51
+ return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
52
+ }
53
+
54
+ /**
55
+ * Candidate `spur` executable locations (symlinks). Pi launched from a GUI
56
+ * may not have `~/.bun/bin` or node/bun on PATH, so we try each in order
57
+ * and finally run spur.js via the current Bun executable directly.
58
+ */
59
+ const SPUR_BIN_CANDIDATES: string[] = [
60
+ join(homedir(), '.bun', 'bin', 'spur'),
61
+ '/opt/homebrew/bin/spur',
62
+ '/usr/local/bin/spur',
63
+ ];
64
+
65
+ /** Resolve a candidate spur symlink to its real spur.js path (for execPath fallback). */
66
+ function resolveSpurJsPath(candidate: string): string | undefined {
67
+ try {
68
+ return realpathSync(candidate);
69
+ } catch {
70
+ return existsSync(candidate) ? candidate : undefined;
71
+ }
72
+ }
73
+
74
+ function resolveSpurTaskOwnership(filePath: string): TaskOwnership {
75
+ const run = (cmd: string, args: string[]) =>
76
+ spawnSync(cmd, args, { cwd: process.cwd(), encoding: 'utf-8', timeout: 8000 });
77
+
78
+ // 1. SPUR_BIN env override (may include args) or `spur` on PATH
79
+ const envBin = process.env.SPUR_BIN || 'spur';
80
+ const envParts = envBin.split(' ');
81
+ let res = run(envParts[0] ?? 'spur', [...envParts.slice(1), 'task', 'resolve', filePath, '--strict', '--json']);
82
+ // Only 0 (owned) / 1 (unowned) are valid spur exit codes; 127 (interpreter
83
+ // missing) and other codes mean the environment is broken — keep trying.
84
+ if (!res.error && (res.status === 0 || res.status === 1)) return res.status === 0 ? 'owned' : 'unowned';
85
+
86
+ // 2. Absolute symlink paths (needs node/bun on PATH for the shebang)
87
+ for (const candidate of SPUR_BIN_CANDIDATES) {
88
+ if (!existsSync(candidate)) continue;
89
+ res = run(candidate, ['task', 'resolve', filePath, '--strict', '--json']);
90
+ if (!res.error && (res.status === 0 || res.status === 1)) return res.status === 0 ? 'owned' : 'unowned';
91
+ }
92
+
93
+ // 3. Run spur.js via the current Bun executable (no PATH dependency)
94
+ for (const candidate of SPUR_BIN_CANDIDATES) {
95
+ const spurJs = resolveSpurJsPath(candidate);
96
+ if (!spurJs) continue;
97
+ res = run(process.execPath, [spurJs, 'task', 'resolve', filePath, '--strict', '--json']);
98
+ if (!res.error && (res.status === 0 || res.status === 1)) return res.status === 0 ? 'owned' : 'unowned';
99
+ }
100
+
101
+ return 'unknown';
102
+ }
103
+
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
+ }
131
+
132
+ // ─── Token ledger helpers (mirrors context-post-tool.ts) ──────────────────
133
+
134
+ interface ToolEvent {
135
+ session_id?: string;
136
+ tool_name?: string;
137
+ tool_input?: Record<string, unknown>;
138
+ }
139
+
140
+ function estimateTokenCount(text: string): number {
141
+ if (!text) return 0;
142
+ // Rough estimate: ~4 chars per token for English text
143
+ return Math.ceil(text.length / 4);
144
+ }
145
+
146
+ function summarizeToolEvent(event: ToolEvent): string {
147
+ const input = event.tool_input ?? {};
148
+ const candidates = [input.file_path, input.command, input.pattern, input.glob_pattern, input.glob, input.path];
149
+ for (const c of candidates) {
150
+ if (typeof c === 'string' && c.trim()) {
151
+ const s = c.trim();
152
+ return s.length > SUMMARY_MAX_CHARS ? `${s.slice(0, SUMMARY_MAX_CHARS - 3)}...` : s;
153
+ }
154
+ }
155
+ return `(${event.tool_name ?? 'unknown'})`;
156
+ }
157
+
158
+ function redactText(text: string): string {
159
+ // Strip obvious secret patterns
160
+ let result = text.slice(0, REDACTION_CAP);
161
+ result = result.replace(
162
+ /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----/g,
163
+ '[REDACTED: PRIVATE KEY]',
164
+ );
165
+ result = result.replace(/ghp_[A-Za-z0-9]{36}/g, '[REDACTED: GITHUB_TOKEN]');
166
+ result = result.replace(/gho_[A-Za-z0-9]{36}/g, '[REDACTED: GITHUB_TOKEN]');
167
+ result = result.replace(/sk-[A-Za-z0-9]{20,}/g, '[REDACTED: API_KEY]');
168
+ result = result.replace(/api[_-]key['"]?\s*[:=]\s*['"][A-Za-z0-9_-]{16,}['"]/gi, '[REDACTED: API_KEY]');
169
+ result = result.replace(/AKIA[0-9A-Z]{16}/g, '[REDACTED: AWS_KEY]');
170
+ return result;
171
+ }
172
+
173
+ function appendToLedger(event: ToolEvent, command: string | undefined): void {
174
+ try {
175
+ if (!existsSync(SPUR_CONTEXT_DIR)) return;
176
+ const sessionId = readSessionId();
177
+ if (!sessionId) return;
178
+
179
+ const summary = summarizeToolEvent(event);
180
+ const tokens = command ? estimateTokenCount(redactText(command)) : 0;
181
+
182
+ const ledgerEntry = JSON.stringify({
183
+ session: sessionId,
184
+ type: event.tool_name === 'Read' ? 'read' : 'write',
185
+ tool: event.tool_name,
186
+ path: event.tool_input?.file_path ?? null,
187
+ summary,
188
+ tokens,
189
+ timestamp: new Date().toISOString(),
190
+ });
191
+
192
+ appendFileSync(LEDGER_FILE, `${ledgerEntry}\n`);
193
+ } catch {
194
+ // fail-open: skip ledger writes on error
195
+ }
196
+ }
197
+
198
+ function readSessionId(): string | undefined {
199
+ try {
200
+ if (!existsSync(SESSION_FILE)) return undefined;
201
+ const data = JSON.parse(readFileSync(SESSION_FILE, 'utf-8')) as { session_id?: string };
202
+ return data.session_id;
203
+ } catch {
204
+ return undefined;
205
+ }
206
+ }
207
+
208
+ // ─── Session helpers (agent/model hints from agent-hint.ts; mirrors context-session-stop.ts) ─
209
+
210
+ function generateSessionId(): string {
211
+ const timestamp = Date.now().toString(36);
212
+ const random = Math.random().toString(36).slice(2, 10);
213
+ return `${timestamp}-${random}`;
214
+ }
215
+
216
+ function initSession(): void {
217
+ try {
218
+ mkdirSync(SPUR_CONTEXT_DIR, { recursive: true });
219
+ const sessionId = generateSessionId();
220
+ const session = {
221
+ session_id: sessionId,
222
+ agent: resolveAgentHintShared(process.env, 'pi'),
223
+ model: resolveModelHintShared(process.env),
224
+ started_at: new Date().toISOString(),
225
+ };
226
+ writeFileSync(SESSION_FILE, `${JSON.stringify(session, null, 2)}\n`);
227
+
228
+ // Append session_start event to ledger
229
+ const startEntry = JSON.stringify({
230
+ session: sessionId,
231
+ type: 'session_start',
232
+ agent: session.agent,
233
+ model: session.model,
234
+ timestamp: session.started_at,
235
+ });
236
+ appendFileSync(LEDGER_FILE, `${startEntry}\n`);
237
+ } catch {
238
+ // fail-open
239
+ }
240
+ }
241
+
242
+ function cleanupSession(): void {
243
+ try {
244
+ if (!existsSync(SESSION_FILE)) return;
245
+ const session = JSON.parse(readFileSync(SESSION_FILE, 'utf-8')) as { session_id?: string };
246
+ const sessionId = session.session_id;
247
+
248
+ // Compute rollup totals from ledger
249
+ let reads = 0;
250
+ let writes = 0;
251
+ let tokens = 0;
252
+ if (sessionId && existsSync(LEDGER_FILE)) {
253
+ for (const line of readFileSync(LEDGER_FILE, 'utf-8').split('\n')) {
254
+ if (!line.trim()) continue;
255
+ try {
256
+ const evt = JSON.parse(line) as { session?: string; type?: string; tokens?: number };
257
+ if (evt.session !== sessionId) continue;
258
+ if (evt.type === 'read') reads++;
259
+ else if (evt.type === 'write') writes++;
260
+ if (evt.tokens) tokens += evt.tokens;
261
+ } catch {
262
+ // skip unparseable lines
263
+ }
264
+ }
265
+ }
266
+
267
+ // Append session_end event
268
+ const endEntry = JSON.stringify({
269
+ session: sessionId,
270
+ type: 'session_end',
271
+ reads,
272
+ writes,
273
+ tokens,
274
+ timestamp: new Date().toISOString(),
275
+ });
276
+ appendFileSync(LEDGER_FILE, `${endEntry}\n`);
277
+
278
+ // Cleanup session file
279
+ rmSync(SESSION_FILE, { force: true });
280
+ } catch {
281
+ // fail-open
282
+ }
283
+ }
284
+
285
+ // ─── Extension entry point ───────────────────────────────────────────────
286
+
287
+ export default function (pi: ExtensionAPI): void {
288
+ // ── task-write-guard + careful-guard ──────────────────────────────
289
+ pi.on('tool_call', async (event, ctx) => {
290
+ // task-write-guard: block Write/Edit to Spur task files
291
+ if (event.toolName === 'write' || event.toolName === 'edit') {
292
+ const input = event.input as Record<string, unknown> | undefined;
293
+ // Pi's write/edit tools use `path` (not Claude Code's `file_path`)
294
+ const filePath = resolveInputPath(input);
295
+ if (filePath && resolveSpurTaskOwnership(filePath) === 'owned') {
296
+ const msg = `Denied: ${filePath} is a Spur task file. Use 'spur task update' instead.`;
297
+ ctx.ui.notify(msg, 'error');
298
+ return { block: true, reason: msg };
299
+ }
300
+ }
301
+
302
+ // careful-guard: warn on destructive Bash commands
303
+ if (event.toolName === 'bash') {
304
+ const input = event.input as Record<string, unknown> | undefined;
305
+ 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)}`;
308
+ ctx.ui.notify(msg, 'warning');
309
+ // Ask for confirmation
310
+ const ok = await ctx.ui.confirm('Destructive command', msg);
311
+ if (!ok) return { block: true, reason: 'Cancelled by user' };
312
+ }
313
+ }
314
+
315
+ return {};
316
+ });
317
+
318
+ // ── context-post-tool: append to token-ledger.jsonl ───────────────
319
+ pi.on('tool_result', async (event) => {
320
+ const input = event.input as Record<string, unknown> | undefined;
321
+ const command = typeof input?.command === 'string' ? input.command : undefined;
322
+ appendToLedger(
323
+ {
324
+ session_id: readSessionId(),
325
+ tool_name: event.toolName,
326
+ tool_input: input as Record<string, unknown>,
327
+ },
328
+ command,
329
+ );
330
+ });
331
+
332
+ // ── context-session-start: init session tracking ─────────────────
333
+ pi.on('session_start', async () => {
334
+ initSession();
335
+ });
336
+
337
+ // ── context-session-stop: rollup + cleanup ───────────────────────
338
+ pi.on('session_shutdown', async () => {
339
+ cleanupSession();
340
+ });
341
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * task-write-guard — PreToolUse guard for the Spur task corpus.
4
+ *
5
+ * Deny a raw `Write` or `Edit` whose target path is owned by a Spur task. Mutate task files through
6
+ * the `spur task` CLI (e.g. `spur task update <wbs> --section <name> --from-file <file>`), never by
7
+ * hand. Pure delegation: ownership is decided by `spur task resolve --strict --json`'s exit code
8
+ * alone (exit 0 → owned, non-zero → unowned).
9
+ *
10
+ * **Fail-open contract** (inherited from the pre-N3 shim and from superskill's runner): every error
11
+ * path — unparseable payload, non-Write/Edit tool, empty path, missing `spur` binary, spawn
12
+ * failure, timeout, `spur task resolve` non-zero exit, non-JSON stdout — emits an `allow`
13
+ * decision. A broken guard must never wedge an agent tool call.
14
+ *
15
+ * **Escape hatch:** `SPUR_WRITE_GUARD=off` short-circuits to allow.
16
+ *
17
+ * Self-contained by design (Wave E, task 0181, R3). Installed hook configs still use the portable
18
+ * `superskill hook run sp task-write-guard` entrypoint from task 0151, while this source script keeps
19
+ * the real guard decisions versioned and covered by this repo's test gate.
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+
24
+ interface ToolPayload {
25
+ tool_name?: string;
26
+ tool_input?: { file_path?: string };
27
+ }
28
+
29
+ type TaskOwnership = 'owned' | 'unowned' | 'unknown';
30
+
31
+ function preToolUseDecision(decision: 'allow' | 'deny', reason?: string): never {
32
+ const out: Record<string, unknown> = {
33
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision },
34
+ };
35
+ if (reason !== undefined) out.systemMessage = reason;
36
+ process.stdout.write(JSON.stringify(out));
37
+ process.exit(0);
38
+ }
39
+
40
+ /** Resolve whether a path is owned by a Spur task via `spur task resolve --strict --json`. */
41
+ function resolveSpurTaskOwnership(filePath: string, cwd: string): TaskOwnership {
42
+ const spurBin = process.env.SPUR_BIN || 'spur';
43
+ const parts = spurBin.split(' ');
44
+ const cmd = parts[0] ?? 'spur';
45
+ const args = [...parts.slice(1), 'task', 'resolve', filePath, '--strict', '--json'];
46
+ const res = spawnSync(cmd, args, {
47
+ cwd,
48
+ encoding: 'utf-8',
49
+ timeout: 8000,
50
+ });
51
+ if (res.error || typeof res.status !== 'number') return 'unknown';
52
+ return res.status === 0 ? 'owned' : 'unowned';
53
+ }
54
+
55
+ async function main(): Promise<void> {
56
+ if (process.env.SPUR_WRITE_GUARD === 'off') preToolUseDecision('allow');
57
+
58
+ const stdinText = await Bun.stdin.text();
59
+ let payload: ToolPayload;
60
+ try {
61
+ payload = JSON.parse(stdinText) as ToolPayload;
62
+ } catch {
63
+ preToolUseDecision('allow'); // unparseable payload — fail open
64
+ }
65
+
66
+ const toolName = payload.tool_name ?? '';
67
+ if (toolName !== 'Write' && toolName !== 'Edit') preToolUseDecision('allow');
68
+
69
+ const filePath = payload.tool_input?.file_path ?? '';
70
+ if (filePath === '') preToolUseDecision('allow');
71
+
72
+ const ownership = resolveSpurTaskOwnership(filePath, process.env.CLAUDE_PROJECT_DIR ?? process.cwd());
73
+ if (ownership === 'owned') {
74
+ preToolUseDecision(
75
+ 'deny',
76
+ `${filePath} is a task file owned by the spur corpus. Edit it through the spur CLI ` +
77
+ '(e.g. `spur task update <wbs> --section <name> --from-file <file>`), not a raw ' +
78
+ 'Write/Edit. Set SPUR_WRITE_GUARD=off to bypass.',
79
+ );
80
+ }
81
+ preToolUseDecision('allow');
82
+ }
83
+
84
+ void main();
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "sp",
3
+ "version": "0.3.43",
4
+ "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
5
+ "extensions": {
6
+ "pi": ["./hooks/pi/guard-extension.ts"]
7
+ }
8
+ }