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