@gobing-ai/spur 0.3.48 → 0.3.49
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 +52 -4
- package/config/workflows/pr-review.yaml +338 -0
- package/package.json +8 -8
- package/plugins/sp/README.md +9 -6
- package/plugins/sp/commands/{dev-featurechange.md → dev-feature-change.md} +7 -10
- package/plugins/sp/commands/dev-find-issue.md +24 -19
- package/plugins/sp/commands/dev-find-next.md +3 -3
- package/plugins/sp/commands/dev-gtd.md +11 -12
- package/plugins/sp/commands/dev-history-load.md +63 -0
- package/plugins/sp/commands/dev-pr-review.md +39 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +25 -12
- package/plugins/sp/scripts/history-load.ts +400 -0
- package/plugins/sp/scripts/pr-reviewing.ts +867 -0
- package/plugins/sp/scripts/validate-commands.ts +33 -2
- package/plugins/sp/skills/code-implementation/SKILL.md +9 -1
- package/plugins/sp/skills/code-verification/SKILL.md +27 -28
- package/plugins/sp/skills/issue-finding/SKILL.md +6 -5
- package/plugins/sp/skills/issue-finding/references/session-formats.md +4 -2
- package/plugins/sp/skills/next-feature/SKILL.md +6 -6
- package/plugins/sp/skills/next-feature/references/handoff-routing.md +5 -5
- package/plugins/sp/skills/next-feature/references/signal-derivation.md +7 -2
- package/plugins/sp/skills/pr-reviewing/SKILL.md +285 -0
- package/plugins/sp/skills/spur-cli/references/features/hierarchy-mece.md +5 -5
- package/plugins/sp/skills/spur-cli/references/features.md +1 -1
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +14 -4
- package/schemas/spur-config.schema.json +20 -0
- package/spur.js +682 -222
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* dev-history-load — on-demand cumulative history import + narrowed analyze (task 0567).
|
|
4
|
+
*
|
|
5
|
+
* Deterministic CLI sequence backing `/sp:dev-history-load`. Runs `spur history import`
|
|
6
|
+
* first, then `spur history analyze` only after import exits 0 or 2 (exit 2 is a
|
|
7
|
+
* mixed/degraded fan-out: proceed with a loud per-source warning — 0569). Narrowing flags
|
|
8
|
+
* (`--session`, `--task`, `--since`, `--until`) are forwarded to `analyze` only — `import`
|
|
9
|
+
* rejects them. `--source` reaches both. Owns no import logic, no state, and no cadence:
|
|
10
|
+
* cumulative behavior comes from the shipped checkpoint resume, and the periodic pipeline
|
|
11
|
+
* stays on `spur history daily`.
|
|
12
|
+
*
|
|
13
|
+
* Frozen flag set (dev-history-load.md argument-hint): `--source <name>`, `--session <id>`,
|
|
14
|
+
* `--task <wbs>`, `--since <iso>`, `--until <iso>`, `--report`, `--dry-run`, `--json`.
|
|
15
|
+
* Unknown flags are a hard error (exit 2) — never silently forwarded.
|
|
16
|
+
*
|
|
17
|
+
* Every `spur history` step uses `--json`; human output is derived from parsed JSON, never
|
|
18
|
+
* from child-process prose.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
// ─── Frozen flag surface ────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** Flags that consume the next argv token as their value. */
|
|
29
|
+
const VALUE_FLAGS: Record<string, true> = {
|
|
30
|
+
'--source': true,
|
|
31
|
+
'--session': true,
|
|
32
|
+
'--task': true,
|
|
33
|
+
'--since': true,
|
|
34
|
+
'--until': true,
|
|
35
|
+
};
|
|
36
|
+
/** Flags that are boolean switches. */
|
|
37
|
+
const BOOL_FLAGS: Record<string, true> = {
|
|
38
|
+
'--report': true,
|
|
39
|
+
'--dry-run': true,
|
|
40
|
+
'--json': true,
|
|
41
|
+
};
|
|
42
|
+
const ALL_FLAGS: Record<string, true> = { ...VALUE_FLAGS, ...BOOL_FLAGS };
|
|
43
|
+
|
|
44
|
+
interface ParsedArgs {
|
|
45
|
+
source?: string;
|
|
46
|
+
session?: string;
|
|
47
|
+
task?: string;
|
|
48
|
+
since?: string;
|
|
49
|
+
until?: string;
|
|
50
|
+
report: boolean;
|
|
51
|
+
dryRun: boolean;
|
|
52
|
+
json: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ProcResult {
|
|
56
|
+
status: number;
|
|
57
|
+
stdout: string;
|
|
58
|
+
stderr: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Arg parsing ─────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function usage(): never {
|
|
64
|
+
console.error(
|
|
65
|
+
'Usage: history-load.ts [--source <name>] [--session <id>] [--task <wbs>] ' +
|
|
66
|
+
'[--since <iso>] [--until <iso>] [--report] [--dry-run] [--json]',
|
|
67
|
+
);
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Flag literal → ParsedArgs field. `--dry-run` is the one flag whose field name differs. */
|
|
72
|
+
const FLAG_KEY: Record<string, keyof ParsedArgs> = {
|
|
73
|
+
'--source': 'source',
|
|
74
|
+
'--session': 'session',
|
|
75
|
+
'--task': 'task',
|
|
76
|
+
'--since': 'since',
|
|
77
|
+
'--until': 'until',
|
|
78
|
+
'--report': 'report',
|
|
79
|
+
'--dry-run': 'dryRun',
|
|
80
|
+
'--json': 'json',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Parse argv against the frozen flag set; unknown flags exit 2. */
|
|
84
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
85
|
+
const out: ParsedArgs = { report: false, dryRun: false, json: false };
|
|
86
|
+
for (let i = 0; i < argv.length; i++) {
|
|
87
|
+
const arg = argv[i];
|
|
88
|
+
if (!arg.startsWith('--') || ALL_FLAGS[arg] !== true) usage();
|
|
89
|
+
if (BOOL_FLAGS[arg] === true) {
|
|
90
|
+
out[FLAG_KEY[arg]] = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const value = argv[++i];
|
|
94
|
+
if (value === undefined || value.startsWith('--')) usage();
|
|
95
|
+
out[FLAG_KEY[arg]] = value;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── spur resolution + invocation ────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the spur CLI monorepo-safely: SPUR_BIN env > monorepo-local CLI entry > PATH.
|
|
104
|
+
* Mirrors task-size-precheck.ts defaultSpurBin so ad-hoc and test invocations resolve the
|
|
105
|
+
* same way (never a silently stale PATH install).
|
|
106
|
+
*/
|
|
107
|
+
function defaultSpurBin(): string {
|
|
108
|
+
if (process.env.SPUR_BIN) return process.env.SPUR_BIN;
|
|
109
|
+
const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
|
|
110
|
+
if (existsSync(local)) return `bun ${local}`;
|
|
111
|
+
return 'spur';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Run spur with a possibly multi-token bin (`<runtime> <mainModule>`), splitting like runSpur. */
|
|
115
|
+
function runSpur(spurBin: string, args: string[]): ProcResult {
|
|
116
|
+
const [file = 'spur', ...lead] = spurBin.split(/\s+/).filter(Boolean);
|
|
117
|
+
const result = spawnSync(file, [...lead, ...args], { encoding: 'utf-8' });
|
|
118
|
+
return {
|
|
119
|
+
status: result.status ?? 1,
|
|
120
|
+
stdout: result.stdout ?? '',
|
|
121
|
+
stderr: result.stderr ?? '',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Artifact path resolution ────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve the analyze artifact path from the `latest.json` pointer the analyze step
|
|
129
|
+
* maintains (task 0464 R2 symlink under `.spur/reports/history/`). Returns null when the
|
|
130
|
+
* pointer is absent or dangling.
|
|
131
|
+
*/
|
|
132
|
+
function latestArtifactPath(cwd: string): string | null {
|
|
133
|
+
const pointer = join(cwd, '.spur', 'reports', 'history', 'latest.json');
|
|
134
|
+
if (!existsSync(pointer)) return null;
|
|
135
|
+
try {
|
|
136
|
+
return realpathSync(pointer);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Result shaping ──────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
interface ImportJson {
|
|
145
|
+
entries?: Array<{
|
|
146
|
+
source: string;
|
|
147
|
+
status: string;
|
|
148
|
+
messages?: number;
|
|
149
|
+
parseErrors?: number;
|
|
150
|
+
validationErrors?: number;
|
|
151
|
+
}>;
|
|
152
|
+
exitCode?: number;
|
|
153
|
+
warnings?: Array<{ code: string; source: string; detail?: string }>;
|
|
154
|
+
provenance?: unknown;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Parse `spur history import --json` output; returns null when unparseable. */
|
|
158
|
+
function parseImportJson(stdout: string): ImportJson | null {
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(stdout) as ImportJson;
|
|
161
|
+
return parsed && Array.isArray(parsed.entries) ? parsed : null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Build the analyze argv with narrowing routed to analyze only. */
|
|
168
|
+
function buildAnalyzeArgs(args: ParsedArgs): string[] {
|
|
169
|
+
const out = ['history', 'analyze', '--json'];
|
|
170
|
+
if (args.source) out.push('--source', args.source);
|
|
171
|
+
if (args.session) out.push('--session', args.session);
|
|
172
|
+
if (args.task) out.push('--task', args.task);
|
|
173
|
+
if (args.since) out.push('--since', args.since);
|
|
174
|
+
if (args.until) out.push('--until', args.until);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Emit a single JSON object on stdout; used for every `--json` exit path. */
|
|
179
|
+
function emitJson(obj: unknown): void {
|
|
180
|
+
process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Per-source degradation summary carried into output payloads (task 0569). */
|
|
184
|
+
interface DegradedWarning {
|
|
185
|
+
source: string;
|
|
186
|
+
status: string;
|
|
187
|
+
parseErrors: number;
|
|
188
|
+
validationErrors: number;
|
|
189
|
+
detail: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build per-source degradation warnings from a fan-out import JSON: one entry per
|
|
194
|
+
* degraded/failed source with its parse/validation error counts and the import step's
|
|
195
|
+
* warning detail (0569 R1). Empty on a clean fan-out.
|
|
196
|
+
*/
|
|
197
|
+
function buildDegradedWarnings(imp: ImportJson | null): DegradedWarning[] {
|
|
198
|
+
const detailFor = (source: string): string =>
|
|
199
|
+
imp?.warnings?.find((w) => w.source === source)?.detail ?? 'no warning detail reported by import';
|
|
200
|
+
return (imp?.entries ?? [])
|
|
201
|
+
.filter((e) => e.status === 'degraded' || e.status === 'failed')
|
|
202
|
+
.map((e) => ({
|
|
203
|
+
source: e.source,
|
|
204
|
+
status: e.status,
|
|
205
|
+
parseErrors: typeof e.parseErrors === 'number' ? e.parseErrors : 0,
|
|
206
|
+
validationErrors: typeof e.validationErrors === 'number' ? e.validationErrors : 0,
|
|
207
|
+
detail: detailFor(e.source),
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Attach the degradation warnings to a payload only when the fan-out was degraded (0569 R1). */
|
|
212
|
+
function withWarnings(payload: Record<string, unknown>, degraded: DegradedWarning[]): Record<string, unknown> {
|
|
213
|
+
return degraded.length > 0 ? { ...payload, warnings: degraded } : payload;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Main sequence ───────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
function main(): void {
|
|
219
|
+
const args = parseArgs(process.argv.slice(2));
|
|
220
|
+
const spurBin = defaultSpurBin();
|
|
221
|
+
const cwd = process.cwd();
|
|
222
|
+
|
|
223
|
+
// 1. Import argv — narrowing flags NEVER reach import (it rejects them).
|
|
224
|
+
const importArgs = ['history', 'import', '--json'];
|
|
225
|
+
if (args.source) importArgs.push('--source', args.source);
|
|
226
|
+
if (args.dryRun) importArgs.push('--dry-run');
|
|
227
|
+
|
|
228
|
+
const importResult = runSpur(spurBin, importArgs);
|
|
229
|
+
const imp = parseImportJson(importResult.stdout);
|
|
230
|
+
|
|
231
|
+
// 2. Fatal import failures (R9, 0569): any non-zero exit EXCEPT the mixed/degraded
|
|
232
|
+
// code 2 aborts — surface the failing source + error, skip analyze, propagate the
|
|
233
|
+
// import step's exit code.
|
|
234
|
+
if (importResult.status !== 0 && importResult.status !== 2) {
|
|
235
|
+
const failed = (imp?.entries ?? [])
|
|
236
|
+
.filter((e) => e.status !== 'ok' && e.status !== 'empty')
|
|
237
|
+
.map((e) => e.source);
|
|
238
|
+
const warning = imp?.warnings?.find((w) => w.code === 'source-failed' || w.code === 'source-degraded');
|
|
239
|
+
const detail = warning?.detail || importResult.stderr.trim() || 'import exited non-zero';
|
|
240
|
+
const message = failed.length > 0 ? `import failed for source(s): ${failed.join(', ')} — ${detail}` : detail;
|
|
241
|
+
if (args.json) {
|
|
242
|
+
emitJson({
|
|
243
|
+
import: imp ?? { entries: [], exitCode: importResult.status },
|
|
244
|
+
artifact: null,
|
|
245
|
+
reported: false,
|
|
246
|
+
status: 'error',
|
|
247
|
+
message,
|
|
248
|
+
});
|
|
249
|
+
} else {
|
|
250
|
+
console.error(message);
|
|
251
|
+
}
|
|
252
|
+
process.exit(importResult.status);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 2b. Degraded fan-out tolerance (0569 R1): exit 2 (mixed — at least one source
|
|
256
|
+
// imported, some skipped rows) proceeds to analyze with a loud per-source warning.
|
|
257
|
+
const degraded = importResult.status === 2 ? buildDegradedWarnings(imp) : [];
|
|
258
|
+
if (degraded.length > 0 && !args.json) {
|
|
259
|
+
console.error('WARNING: import fan-out degraded — proceeding with the healthy sources:');
|
|
260
|
+
for (const w of degraded) {
|
|
261
|
+
console.error(
|
|
262
|
+
` ${w.source}: status=${w.status} parseErrors=${w.parseErrors} ` +
|
|
263
|
+
`validationErrors=${w.validationErrors} — ${w.detail}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 3. Dry-run short-circuit (R4): report what would have run, write nothing.
|
|
269
|
+
if (args.dryRun) {
|
|
270
|
+
const analyzeArgs = buildAnalyzeArgs(args);
|
|
271
|
+
const sequence = [`spur history import --json${args.source ? ` --source ${args.source}` : ''} --dry-run`];
|
|
272
|
+
sequence.push(`spur ${analyzeArgs.join(' ')}`);
|
|
273
|
+
if (args.report) sequence.push('spur history report --mode forensics <artifact-path>');
|
|
274
|
+
if (args.json) {
|
|
275
|
+
emitJson(
|
|
276
|
+
withWarnings(
|
|
277
|
+
{
|
|
278
|
+
import: imp ?? { entries: [], exitCode: importResult.status },
|
|
279
|
+
artifact: null,
|
|
280
|
+
reported: false,
|
|
281
|
+
status: 'dry-run',
|
|
282
|
+
wouldRun: sequence,
|
|
283
|
+
},
|
|
284
|
+
degraded,
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
} else {
|
|
288
|
+
console.log('[dry-run] would run:');
|
|
289
|
+
for (const line of sequence) console.log(` ${line}`);
|
|
290
|
+
}
|
|
291
|
+
process.exit(0);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 4. Analyze — only after import exited 0. Narrowing flags forwarded here only.
|
|
295
|
+
const analyzeResult = runSpur(spurBin, buildAnalyzeArgs(args));
|
|
296
|
+
if (analyzeResult.status !== 0) {
|
|
297
|
+
const message = analyzeResult.stderr.trim() || `history analyze exited non-zero (${analyzeResult.status})`;
|
|
298
|
+
if (args.json) {
|
|
299
|
+
emitJson({
|
|
300
|
+
import: imp ?? { entries: [], exitCode: 0 },
|
|
301
|
+
artifact: null,
|
|
302
|
+
reported: false,
|
|
303
|
+
status: 'error',
|
|
304
|
+
message,
|
|
305
|
+
});
|
|
306
|
+
} else {
|
|
307
|
+
console.error(message);
|
|
308
|
+
}
|
|
309
|
+
process.exit(analyzeResult.status);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let artifact: { totals?: { messages?: number } } | null = null;
|
|
313
|
+
try {
|
|
314
|
+
artifact = JSON.parse(analyzeResult.stdout) as { totals?: { messages?: number } };
|
|
315
|
+
} catch {
|
|
316
|
+
// fall through — artifact resolution below will surface the missing pointer
|
|
317
|
+
}
|
|
318
|
+
const artifactPath = latestArtifactPath(cwd);
|
|
319
|
+
|
|
320
|
+
// 5. Empty-window guard (R10): zero matched messages is NOT a successful analysis.
|
|
321
|
+
const messages = artifact?.totals?.messages;
|
|
322
|
+
if (typeof messages === 'number' && messages === 0) {
|
|
323
|
+
const message = 'history analyze: window matched zero messages — nothing to report';
|
|
324
|
+
if (args.json) {
|
|
325
|
+
emitJson({
|
|
326
|
+
import: imp ?? { entries: [], exitCode: 0 },
|
|
327
|
+
artifact: artifactPath,
|
|
328
|
+
reported: false,
|
|
329
|
+
status: 'empty-window',
|
|
330
|
+
message,
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
console.error(message);
|
|
334
|
+
}
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (artifactPath === null) {
|
|
339
|
+
const message =
|
|
340
|
+
'history analyze completed but no artifact pointer (.spur/reports/history/latest.json) was found';
|
|
341
|
+
if (args.json) {
|
|
342
|
+
emitJson({
|
|
343
|
+
import: imp ?? { entries: [], exitCode: 0 },
|
|
344
|
+
artifact: null,
|
|
345
|
+
reported: false,
|
|
346
|
+
status: 'error',
|
|
347
|
+
message,
|
|
348
|
+
});
|
|
349
|
+
} else {
|
|
350
|
+
console.error(message);
|
|
351
|
+
}
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 6. Optional forensics render (R5) against the artifact just written.
|
|
356
|
+
let reported = false;
|
|
357
|
+
let reportText = '';
|
|
358
|
+
if (args.report) {
|
|
359
|
+
const reportResult = runSpur(spurBin, ['history', 'report', '--mode', 'forensics', artifactPath]);
|
|
360
|
+
reported = reportResult.status === 0;
|
|
361
|
+
reportText = reportResult.stdout;
|
|
362
|
+
if (reportResult.status !== 0) {
|
|
363
|
+
const message = reportResult.stderr.trim() || `history report exited non-zero (${reportResult.status})`;
|
|
364
|
+
if (args.json) {
|
|
365
|
+
emitJson({
|
|
366
|
+
import: imp ?? { entries: [], exitCode: 0 },
|
|
367
|
+
artifact: artifactPath,
|
|
368
|
+
reported: false,
|
|
369
|
+
status: 'error',
|
|
370
|
+
message,
|
|
371
|
+
});
|
|
372
|
+
} else {
|
|
373
|
+
console.error(message);
|
|
374
|
+
}
|
|
375
|
+
process.exit(reportResult.status);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 7. Output: one JSON object (no interleaved banner) or a short human summary.
|
|
380
|
+
const count = (imp?.entries ?? []).reduce((sum, e) => sum + (typeof e.messages === 'number' ? e.messages : 0), 0);
|
|
381
|
+
if (args.json) {
|
|
382
|
+
const payload = withWarnings(
|
|
383
|
+
{
|
|
384
|
+
import: imp ?? { entries: [], exitCode: 0 },
|
|
385
|
+
artifact: artifactPath,
|
|
386
|
+
reported,
|
|
387
|
+
status: 'ok',
|
|
388
|
+
},
|
|
389
|
+
degraded,
|
|
390
|
+
);
|
|
391
|
+
if (args.report) payload.report = reportText;
|
|
392
|
+
emitJson(payload);
|
|
393
|
+
} else {
|
|
394
|
+
console.log(`history import: ${count} records`);
|
|
395
|
+
console.log(`artifact: ${artifactPath}`);
|
|
396
|
+
if (args.report) process.stdout.write(reportText);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
main();
|