@gobing-ai/spur 0.3.76 → 0.3.78
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/plugin-scripts.json +4 -0
- package/config/rules/strict/runtime-boundaries.yaml +1 -0
- package/config/workflows/task-pipeline.yaml +19 -6
- package/package.json +9 -9
- package/plugins/sp/commands/dev-refineall.md +1 -0
- package/plugins/sp/commands/dev-run.md +1 -0
- package/plugins/sp/commands/dev-runall.md +1 -0
- package/plugins/sp/commands/dev-verifyall.md +1 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/batch-preflight.mjs +173 -2
- package/plugins/sp/scripts/batch-preflight.ts +257 -2
- package/plugins/sp/scripts/inline-run-setup.ts +198 -0
- package/plugins/sp/scripts/verify-answer-lint.ts +139 -26
- package/plugins/sp/skills/code-verification/SKILL.md +4 -2
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +11 -2
- package/plugins/sp/skills/dogfood-testing/SKILL.md +41 -36
- package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +34 -10
- package/plugins/sp/skills/dogfood-testing/references/report-template.md +10 -6
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +33 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +17 -0
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +133 -16
- package/spur.js +10328 -9186
|
@@ -25,6 +25,182 @@ export type PreflightResult =
|
|
|
25
25
|
| { action: 'run'; code?: string; reason?: string }
|
|
26
26
|
| { action: 'skip'; code: string; reason: string; unmetDeps?: string[] };
|
|
27
27
|
|
|
28
|
+
// ── Command-aware quick readiness (task 0814 R2) ──────────────────────────────
|
|
29
|
+
// Read-only admission decision for the requested dev operation. Distinguishes
|
|
30
|
+
// runnable / needs-refinement / blocked / skipped / invalid outcomes without
|
|
31
|
+
// LLM dispatch, full tests/lint, live-data probes, feature mutation, or
|
|
32
|
+
// corpus-wide relational checking. Refinement gaps are work to do, not errors.
|
|
33
|
+
|
|
34
|
+
export type ReadinessOperation = 'run' | 'refine' | 'verify';
|
|
35
|
+
|
|
36
|
+
export interface QuickReadinessInput {
|
|
37
|
+
wbs: string;
|
|
38
|
+
status: TaskStatus;
|
|
39
|
+
operation: ReadinessOperation;
|
|
40
|
+
/** Frontmatter dependencies[] WBS list (run only). */
|
|
41
|
+
dependencies?: string[];
|
|
42
|
+
/** Status of each dependency WBS; missing → treated as unmet (run only). */
|
|
43
|
+
depStatuses?: Record<string, string>;
|
|
44
|
+
/** Size of the status-filtered candidate set after the selector resolved; 0 = empty set. */
|
|
45
|
+
filteredCount?: number;
|
|
46
|
+
/** Required planning sections for this variant+status; empty = not applicable. */
|
|
47
|
+
requiredSections?: string[];
|
|
48
|
+
/** Sections that are actually present (non-placeholder) in the task. */
|
|
49
|
+
presentSections?: string[];
|
|
50
|
+
/** L1/L2/L3 content-policy findings keyed by section; empty = clean. */
|
|
51
|
+
sectionFindings?: Record<string, string>;
|
|
52
|
+
/** Verify only: re-verification semantics (--force) — never a dirty-tree bypass. */
|
|
53
|
+
force?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type QuickReadinessResult =
|
|
57
|
+
| { action: 'runnable'; code: string; reason: string }
|
|
58
|
+
| { action: 'needs-refinement'; code: string; reason: string; gaps: string[] }
|
|
59
|
+
| { action: 'blocked'; code: string; reason: string; unmetDeps?: string[] }
|
|
60
|
+
| { action: 'skipped'; code: string; reason: string }
|
|
61
|
+
| { action: 'invalid'; code: string; reason: string };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Evaluate quick readiness for a requested dev operation (0814 R2). Read-only:
|
|
65
|
+
* no model, no full tests/lint, no live-data probe, no feature mutation, no
|
|
66
|
+
* corpus-wide relational check. An empty status-filtered set is `skipped`
|
|
67
|
+
* (mirrors the zero-task rule), never an error. Refinement gaps under `refine`
|
|
68
|
+
* are work to do, so they do not block; under `run` they are `needs-refinement`.
|
|
69
|
+
*/
|
|
70
|
+
export function quickReadiness(input: QuickReadinessInput): QuickReadinessResult {
|
|
71
|
+
const status = (input.status ?? '').toLowerCase();
|
|
72
|
+
const operation = input.operation;
|
|
73
|
+
|
|
74
|
+
if (operation !== 'run' && operation !== 'refine' && operation !== 'verify') {
|
|
75
|
+
return {
|
|
76
|
+
action: 'invalid',
|
|
77
|
+
code: 'IV',
|
|
78
|
+
reason: `quick-readiness: unknown operation '${operation}' (${input.wbs})`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (input.filteredCount !== undefined) {
|
|
83
|
+
if (input.filteredCount < 0) {
|
|
84
|
+
return {
|
|
85
|
+
action: 'invalid',
|
|
86
|
+
code: 'IV',
|
|
87
|
+
reason: `quick-readiness: invalid negative filtered count '${input.filteredCount}' (${input.wbs})`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (input.filteredCount === 0) {
|
|
91
|
+
return {
|
|
92
|
+
action: 'skipped',
|
|
93
|
+
code: 'EMPTY',
|
|
94
|
+
reason: `quick-readiness: empty status-filtered set — nothing to ${operation} (${input.wbs})`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (status === 'cancelled' || status === 'done') {
|
|
100
|
+
// verify --force re-verification (R2 AC): an already-verified terminal task
|
|
101
|
+
// is re-checked, not skipped — but force never bypasses a dirty tree or the
|
|
102
|
+
// owning gates; it only re-admits a terminal task for re-verification.
|
|
103
|
+
if (operation === 'verify' && input.force === true) {
|
|
104
|
+
return {
|
|
105
|
+
action: 'runnable',
|
|
106
|
+
code: 'FORCE',
|
|
107
|
+
reason: `quick-readiness: verify --force re-verification of ${status} task ${input.wbs}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
action: 'skipped',
|
|
112
|
+
code: status === 'done' ? 'DONE' : 'CANCELLED',
|
|
113
|
+
reason: `quick-readiness: already ${status} — no ${operation} hop (${input.wbs})`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (status === 'blocked') {
|
|
118
|
+
return {
|
|
119
|
+
action: 'blocked',
|
|
120
|
+
code: 'BLK',
|
|
121
|
+
reason: `quick-readiness: blocked — human/handover first (${input.wbs})`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Unmet out-of-set dependency is a block for the operation that needs it.
|
|
126
|
+
if (operation === 'run' && input.dependencies && input.dependencies.length > 0) {
|
|
127
|
+
const unmet = input.dependencies.filter((d) => (input.depStatuses?.[d] ?? 'missing').toLowerCase() !== 'done');
|
|
128
|
+
if (unmet.length > 0) {
|
|
129
|
+
return {
|
|
130
|
+
action: 'blocked',
|
|
131
|
+
code: 'DEP',
|
|
132
|
+
reason: `quick-readiness: unmet deps — ${unmet.join(', ')} (${input.wbs})`,
|
|
133
|
+
unmetDeps: unmet,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const required = input.requiredSections ?? [];
|
|
139
|
+
const present = input.presentSections ?? [];
|
|
140
|
+
// A required section is a gap when it is absent from the present-set OR carries a
|
|
141
|
+
// content-policy finding (the caller-supplied `sectionFindings` from the matrix /
|
|
142
|
+
// `TaskCheckService.checkContentPolicy`). This lets the function detect a gap itself
|
|
143
|
+
// rather than depending on the caller to pre-enumerate every missing section.
|
|
144
|
+
const gaps = required.filter((s) => {
|
|
145
|
+
const finding = input.sectionFindings?.[s];
|
|
146
|
+
return !present.includes(s) || (finding !== undefined && finding !== '');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// refine: missing/incomplete planning sections are the work, not a failure.
|
|
150
|
+
if (operation === 'refine') {
|
|
151
|
+
if (status !== 'backlog' && status !== 'todo') {
|
|
152
|
+
return {
|
|
153
|
+
action: 'skipped',
|
|
154
|
+
code: 'NONPLAN',
|
|
155
|
+
reason: `quick-readiness: refine targets backlog/todo only, not '${status}' (${input.wbs})`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
action: 'runnable',
|
|
160
|
+
code: 'OK',
|
|
161
|
+
reason: `quick-readiness: refine ready for ${input.wbs} (${gaps.length} planning gap(s) to fill)`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (operation === 'verify') {
|
|
166
|
+
if (status !== 'testing' && status !== 'wip') {
|
|
167
|
+
return {
|
|
168
|
+
action: 'invalid',
|
|
169
|
+
code: 'NOVERIFY',
|
|
170
|
+
reason: `quick-readiness: verify needs testing/wip, not '${status}' (${input.wbs})`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
action: 'runnable',
|
|
175
|
+
code: 'OK',
|
|
176
|
+
reason: `quick-readiness: verify ready for ${input.wbs}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// run: implementation admission. Eligible statuses are todo/wip/testing;
|
|
181
|
+
// a backlog task needs the chain's auto-promotion first (step 0).
|
|
182
|
+
if (status !== 'todo' && status !== 'wip' && status !== 'testing') {
|
|
183
|
+
return {
|
|
184
|
+
action: 'invalid',
|
|
185
|
+
code: 'NORUN',
|
|
186
|
+
reason: `quick-readiness: run needs todo/wip/testing, not '${status}' (${input.wbs})`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (gaps.length > 0) {
|
|
190
|
+
return {
|
|
191
|
+
action: 'needs-refinement',
|
|
192
|
+
code: 'REFINE',
|
|
193
|
+
reason: `quick-readiness: implementation sections incomplete (${input.wbs})`,
|
|
194
|
+
gaps,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
action: 'runnable',
|
|
199
|
+
code: 'OK',
|
|
200
|
+
reason: `quick-readiness: run ready for ${input.wbs}`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
28
204
|
/**
|
|
29
205
|
* Evaluate whether the batch should launch task-pipeline.yaml for this WBS.
|
|
30
206
|
* STOP codes align with routing-table TABLE A row ids (A2, A7, A8, A9).
|
|
@@ -113,6 +289,16 @@ export interface PreflightCliArgs {
|
|
|
113
289
|
recovery: boolean;
|
|
114
290
|
help: boolean;
|
|
115
291
|
json: boolean;
|
|
292
|
+
/** Quick-readiness operation (run|refine|verify); when set, run quickReadiness. */
|
|
293
|
+
operation: ReadinessOperation | null;
|
|
294
|
+
/** Verify-only re-verification (R2 AC). */
|
|
295
|
+
force: boolean;
|
|
296
|
+
/** Status-filtered candidate set size; 0 = empty set. */
|
|
297
|
+
filteredCount: number | null;
|
|
298
|
+
/** Required planning sections (matrix-selected). */
|
|
299
|
+
requiredSections: string[];
|
|
300
|
+
/** Sections actually present in the task. */
|
|
301
|
+
presentSections: string[];
|
|
116
302
|
}
|
|
117
303
|
|
|
118
304
|
export function parsePreflightCliArgs(argv: string[]): PreflightCliArgs {
|
|
@@ -123,13 +309,43 @@ export function parsePreflightCliArgs(argv: string[]): PreflightCliArgs {
|
|
|
123
309
|
let recovery = false;
|
|
124
310
|
let help = false;
|
|
125
311
|
let json = false;
|
|
312
|
+
let operation: ReadinessOperation | null = null;
|
|
313
|
+
let force = false;
|
|
314
|
+
let filteredCount: number | null = null;
|
|
315
|
+
let requiredSections: string[] = [];
|
|
316
|
+
let presentSections: string[] = [];
|
|
126
317
|
|
|
127
318
|
for (let i = 0; i < argv.length; i++) {
|
|
128
319
|
const a = argv[i];
|
|
129
320
|
if (a === '--help' || a === '-h') help = true;
|
|
130
321
|
else if (a === '--json') json = true;
|
|
131
322
|
else if (a === '--recovery') recovery = true;
|
|
132
|
-
else if (a === '--
|
|
323
|
+
else if (a === '--force') force = true;
|
|
324
|
+
else if (a === '--operation') {
|
|
325
|
+
const v = argv[++i] ?? '';
|
|
326
|
+
operation = v === 'run' || v === 'refine' || v === 'verify' ? v : null;
|
|
327
|
+
} else if (a === '--filtered-count') {
|
|
328
|
+
const v = Number(argv[++i]);
|
|
329
|
+
filteredCount = Number.isFinite(v) ? v : null;
|
|
330
|
+
} else if (a === '--required-sections') {
|
|
331
|
+
const raw = argv[++i] ?? '';
|
|
332
|
+
requiredSections =
|
|
333
|
+
raw.length === 0
|
|
334
|
+
? []
|
|
335
|
+
: raw
|
|
336
|
+
.split(',')
|
|
337
|
+
.map((s) => s.trim())
|
|
338
|
+
.filter(Boolean);
|
|
339
|
+
} else if (a === '--present-sections') {
|
|
340
|
+
const raw = argv[++i] ?? '';
|
|
341
|
+
presentSections =
|
|
342
|
+
raw.length === 0
|
|
343
|
+
? []
|
|
344
|
+
: raw
|
|
345
|
+
.split(',')
|
|
346
|
+
.map((s) => s.trim())
|
|
347
|
+
.filter(Boolean);
|
|
348
|
+
} else if (a === '--wbs') wbs = argv[++i] ?? wbs;
|
|
133
349
|
else if (a === '--status') status = argv[++i] ?? null;
|
|
134
350
|
else if (a === '--deps') {
|
|
135
351
|
const raw = argv[++i] ?? '';
|
|
@@ -149,12 +365,27 @@ export function parsePreflightCliArgs(argv: string[]): PreflightCliArgs {
|
|
|
149
365
|
}
|
|
150
366
|
}
|
|
151
367
|
}
|
|
152
|
-
return {
|
|
368
|
+
return {
|
|
369
|
+
status,
|
|
370
|
+
deps,
|
|
371
|
+
depStatuses,
|
|
372
|
+
wbs,
|
|
373
|
+
recovery,
|
|
374
|
+
help,
|
|
375
|
+
json,
|
|
376
|
+
operation,
|
|
377
|
+
force,
|
|
378
|
+
filteredCount,
|
|
379
|
+
requiredSections,
|
|
380
|
+
presentSections,
|
|
381
|
+
};
|
|
153
382
|
}
|
|
154
383
|
|
|
155
384
|
export const PREFLIGHT_CLI_USAGE = `Usage:
|
|
156
385
|
bun plugins/sp/scripts/batch-preflight.ts --wbs <wbs> --status <status> \\
|
|
157
386
|
[--deps 0275,0276] [--dep-status 0275:done,0276:todo] [--recovery] [--json]
|
|
387
|
+
bun plugins/sp/scripts/batch-preflight.ts --operation <run|refine|verify> --wbs <wbs> --status <status> \
|
|
388
|
+
[--filtered-count <n>] [--required-sections A,B] [--present-sections A,B] [--force] [--json]
|
|
158
389
|
|
|
159
390
|
Exit: 0 = run (or recovery hint printed); 2 = skip; 1 = usage.`;
|
|
160
391
|
|
|
@@ -173,6 +404,30 @@ export function runPreflightCli(argv: string[]): { exitCode: number; stdout: str
|
|
|
173
404
|
return { exitCode: 0, stdout: body, stderr: '' };
|
|
174
405
|
}
|
|
175
406
|
|
|
407
|
+
// Quick command-aware readiness (0814 R2) — read-only admission decision.
|
|
408
|
+
if (args.operation !== null) {
|
|
409
|
+
const result = quickReadiness({
|
|
410
|
+
wbs: args.wbs,
|
|
411
|
+
status: args.status,
|
|
412
|
+
operation: args.operation,
|
|
413
|
+
dependencies: args.deps,
|
|
414
|
+
depStatuses: args.depStatuses,
|
|
415
|
+
...(args.filteredCount !== null ? { filteredCount: args.filteredCount } : {}),
|
|
416
|
+
...(args.requiredSections.length > 0 ? { requiredSections: args.requiredSections } : {}),
|
|
417
|
+
...(args.presentSections.length > 0 ? { presentSections: args.presentSections } : {}),
|
|
418
|
+
...(args.force ? { force: true } : {}),
|
|
419
|
+
});
|
|
420
|
+
const runnable = result.action === 'runnable' || result.action === 'needs-refinement';
|
|
421
|
+
if (args.json) {
|
|
422
|
+
return { exitCode: runnable ? 0 : 2, stdout: `${JSON.stringify(result, null, 2)}\n`, stderr: '' };
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
exitCode: runnable ? 0 : 2,
|
|
426
|
+
stdout: `${result.action}${result.code ? ` ${result.code}` : ''}: ${result.reason}\n`,
|
|
427
|
+
stderr: '',
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
176
431
|
const result = preflightTask({
|
|
177
432
|
wbs: args.wbs,
|
|
178
433
|
status: args.status,
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* inline-run-setup — authoritative inline full-pipeline run identity (task 0804 R1).
|
|
4
|
+
*
|
|
5
|
+
* Unlike the subprocess path (`spur workflow run`), the interactive inline driver
|
|
6
|
+
* allocated a run id but never persisted an authoritative `runs` row, so bound
|
|
7
|
+
* `run.artifact` registration (0785 R3) correctly refused every inline record. This
|
|
8
|
+
* script is the thin delegate the driver now runs at Run setup: it resolves the spur
|
|
9
|
+
* repo checkout from the SPUR_BIN chain, imports the real app service
|
|
10
|
+
* (`createOrAttachInlineRun` / `openInlineRunProjectDb` from packages/app), and lets it
|
|
11
|
+
* resolve the SAME project-or-bundled definition the engine would launch, compute the
|
|
12
|
+
* canonical definition digest with the exported hash machinery, and create-or-attach the
|
|
13
|
+
* run row through the existing engine persistence adapter.
|
|
14
|
+
*
|
|
15
|
+
* The script itself contains NO direct SQL, NO second hasher and NO persistence policy —
|
|
16
|
+
* every rule lives in packages/app (0804 D1). On a bundle-only install there is no repo
|
|
17
|
+
* checkout to import the app service from, so the setup fails closed with actionable
|
|
18
|
+
* remediation guidance (point SPUR_BIN at a repo checkout); it never falls back to an
|
|
19
|
+
* unbound run (0804 R1 failure policy).
|
|
20
|
+
*
|
|
21
|
+
* Outcome JSON is written to `.spur/run/<run-id>-inline-setup.json` so the driver can
|
|
22
|
+
* seed the inline var overlay (`__runId`, `__definitionDigest`) that proof capture and
|
|
23
|
+
* bound registration verify against. Exit 0 = authoritative identity ready (created or
|
|
24
|
+
* idempotently attached); exit 1 = fail closed, the driver must stop.
|
|
25
|
+
*
|
|
26
|
+
* Repo-only script (ADR-065): it imports the app workspace source, so it runs under bun
|
|
27
|
+
* against a monorepo checkout only — the same posture as task-size-precheck.ts and
|
|
28
|
+
* task-evidence-precheck.ts.
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* bun plugins/sp/scripts/inline-run-setup.ts --run-id <id> --file <definition> [--spur-bin <path>]
|
|
32
|
+
*
|
|
33
|
+
* Env: SPUR_BIN
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { dirname, join, resolve } from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
/** Outcome document written to `.spur/run/<run-id>-inline-setup.json`. */
|
|
41
|
+
interface SetupOutcome {
|
|
42
|
+
readonly ok: boolean;
|
|
43
|
+
readonly runId: string;
|
|
44
|
+
readonly attached?: boolean;
|
|
45
|
+
readonly definitionDigest?: string;
|
|
46
|
+
readonly workflowName?: string;
|
|
47
|
+
readonly workflowVersion?: string | null;
|
|
48
|
+
readonly resolvedPath?: string;
|
|
49
|
+
readonly layer?: string;
|
|
50
|
+
readonly workdir?: string;
|
|
51
|
+
readonly status?: string;
|
|
52
|
+
readonly error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function usage(): never {
|
|
56
|
+
console.error(
|
|
57
|
+
'Usage: bun plugins/sp/scripts/inline-run-setup.ts --run-id <id> --file <definition> [--spur-bin <path>]',
|
|
58
|
+
);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The run id becomes a filename under `.spur/run/` (`<run-id>-inline-setup.json`), so it must be a
|
|
64
|
+
* single safe filename component before anything is written — the same guard class the
|
|
65
|
+
* task-pipeline.yaml route-reason action applies to `$__runId` (task 0804 R8). The allowlist
|
|
66
|
+
* refuses path separators, dot traversal (leading `.`), unresolved interpolation (`$`/`{`/`}`) and
|
|
67
|
+
* every other shell/unspecified metachar; valid UUID/timestamp-slug ids pass.
|
|
68
|
+
*/
|
|
69
|
+
const SAFE_RUN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
70
|
+
|
|
71
|
+
function refuseUnsafeRunId(runId: string): never {
|
|
72
|
+
// Refuse BEFORE any outcome write: an unsafe id must never reach
|
|
73
|
+
// `.spur/run/<run-id>-inline-setup.json` (no traversal, no unintended file).
|
|
74
|
+
console.error(`inline-run-setup: refusing unsafe run id: ${runId}`);
|
|
75
|
+
console.error(
|
|
76
|
+
' The run id must be a single safe filename component (alphanumeric/._-, no leading dot, ' +
|
|
77
|
+
'no path separators, interpolation or traversal; same class as the task-pipeline ' +
|
|
78
|
+
'route-reason guard, task 0804 R8). Allocate a fresh run id (uuid or timestamp slug) and retry.',
|
|
79
|
+
);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the spur repo checkout the same way the other prechecks resolve the CLI
|
|
85
|
+
* (--spur-bin > SPUR_BIN > monorepo-local CLI entry > PATH `spur`), then derive the repo
|
|
86
|
+
* root from the resolved main module. `bun <repo>/apps/cli/src/index.ts` → repo root is
|
|
87
|
+
* three levels up. A bundle-only install (`spur` on PATH, a bundled `spur.js`, or a spur
|
|
88
|
+
* binary without the app workspace) has no app entry to import — the caller fails that
|
|
89
|
+
* closed with remediation guidance.
|
|
90
|
+
*/
|
|
91
|
+
function resolveAppEntry(
|
|
92
|
+
spurBin: string,
|
|
93
|
+
): { entry: string; repoRoot: string } | { entry: null; repoRoot: null; chain: string } {
|
|
94
|
+
let candidates: string[] = [];
|
|
95
|
+
if (spurBin !== '') {
|
|
96
|
+
candidates = [spurBin];
|
|
97
|
+
} else {
|
|
98
|
+
// scripts/ -> plugins/sp/ -> <repo>/apps/cli/src/index.ts (fileURLToPath — raw
|
|
99
|
+
// pathname breaks on %-encoded paths, e.g. spaces in the checkout directory).
|
|
100
|
+
candidates = [fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url))];
|
|
101
|
+
}
|
|
102
|
+
for (const candidate of candidates) {
|
|
103
|
+
const tokens = candidate.split(/\s+/).filter(Boolean);
|
|
104
|
+
// The main module is the last path-like token (tolerates `bun <path>` /
|
|
105
|
+
// `bun run <path>` lead tokens). Only a TypeScript source entry proves a repo
|
|
106
|
+
// checkout; a bundled `spur.js` or a bare `spur` binary does not.
|
|
107
|
+
const mainModule = [...tokens].reverse().find((t) => t.endsWith('.ts'));
|
|
108
|
+
if (mainModule === undefined || !existsSync(mainModule)) continue;
|
|
109
|
+
// <repo>/apps/cli/src/index.ts → repo root; then require the app package source.
|
|
110
|
+
const srcDir = dirname(mainModule);
|
|
111
|
+
const repoRoot = resolve(srcDir, '..', '..', '..');
|
|
112
|
+
const appEntry = join(repoRoot, 'packages', 'app', 'src', 'index.ts');
|
|
113
|
+
if (existsSync(appEntry)) return { entry: appEntry, repoRoot };
|
|
114
|
+
return { entry: null, repoRoot: null, chain: `${candidate} (no ${appEntry})` };
|
|
115
|
+
}
|
|
116
|
+
return { entry: null, repoRoot: null, chain: spurBin === '' ? 'PATH spur (bundle-only install)' : spurBin };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function writeOutcome(runId: string, outcome: SetupOutcome): void {
|
|
120
|
+
const runDir = join(process.cwd(), '.spur', 'run');
|
|
121
|
+
if (!existsSync(runDir)) mkdirSync(runDir, { recursive: true });
|
|
122
|
+
writeFileSync(join(runDir, `${runId}-inline-setup.json`), `${JSON.stringify(outcome, null, 4)}\n`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function main(): Promise<void> {
|
|
126
|
+
let runId = '';
|
|
127
|
+
let file = '';
|
|
128
|
+
let spurBin = process.env.SPUR_BIN ?? '';
|
|
129
|
+
const argv = process.argv.slice(2);
|
|
130
|
+
for (let i = 0; i < argv.length; i++) {
|
|
131
|
+
if (argv[i] === '--run-id') runId = argv[++i] ?? '';
|
|
132
|
+
else if (argv[i] === '--file') file = argv[++i] ?? '';
|
|
133
|
+
else if (argv[i] === '--spur-bin') spurBin = argv[++i] ?? spurBin;
|
|
134
|
+
}
|
|
135
|
+
if (runId.trim() === '' || file.trim() === '') usage();
|
|
136
|
+
if (!SAFE_RUN_ID_RE.test(runId)) refuseUnsafeRunId(runId);
|
|
137
|
+
|
|
138
|
+
const { entry, repoRoot, chain } = resolveAppEntry(spurBin);
|
|
139
|
+
if (entry === null || repoRoot === null) {
|
|
140
|
+
const outcome: SetupOutcome = {
|
|
141
|
+
ok: false,
|
|
142
|
+
runId,
|
|
143
|
+
error:
|
|
144
|
+
`inline run setup failed closed: no monorepo checkout of spur is reachable via ${chain}. ` +
|
|
145
|
+
'The authoritative run identity must be persisted by the app service ' +
|
|
146
|
+
'(packages/app/src/services/inline-run-setup.ts); a bundle-only install cannot do this. ' +
|
|
147
|
+
'Remediation: point SPUR_BIN at a repo checkout, e.g. ' +
|
|
148
|
+
'SPUR_BIN="bun /path/to/spur/apps/cli/src/index.ts". The pipeline must not run unbound.',
|
|
149
|
+
};
|
|
150
|
+
writeOutcome(runId, outcome);
|
|
151
|
+
console.error(`inline-run-setup: FAIL for run ${runId}`);
|
|
152
|
+
console.error(` ${outcome.error}`);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Dynamic import by absolute path: the app source graph resolves its own workspace
|
|
157
|
+
// dependencies from the repo checkout, never from this plugin script's location.
|
|
158
|
+
const app = (await import(entry)) as {
|
|
159
|
+
createOrAttachInlineRun: (input: {
|
|
160
|
+
workdir: string;
|
|
161
|
+
getDb: () => Promise<unknown>;
|
|
162
|
+
file: string;
|
|
163
|
+
runId: string;
|
|
164
|
+
}) => Promise<SetupOutcome & { ok: boolean }>;
|
|
165
|
+
openInlineRunProjectDb: (workdir: string) => Promise<{ adapter: unknown; close: () => void }>;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const workdir = process.cwd();
|
|
169
|
+
const projectDb = await app.openInlineRunProjectDb(workdir);
|
|
170
|
+
let exitCode = 0;
|
|
171
|
+
try {
|
|
172
|
+
const result = await app.createOrAttachInlineRun({
|
|
173
|
+
workdir,
|
|
174
|
+
getDb: async () => projectDb.adapter,
|
|
175
|
+
file,
|
|
176
|
+
runId,
|
|
177
|
+
});
|
|
178
|
+
writeOutcome(runId, result);
|
|
179
|
+
if (!result.ok) {
|
|
180
|
+
console.error(`inline-run-setup: FAIL for run ${runId}`);
|
|
181
|
+
console.error(` ${result.error}`);
|
|
182
|
+
exitCode = 1;
|
|
183
|
+
} else {
|
|
184
|
+
console.error(
|
|
185
|
+
`inline-run-setup: ${result.attached ? 'attached' : 'created'} run ${runId} ` +
|
|
186
|
+
`(${result.workflowName}, layer ${result.layer}, digest ${result.definitionDigest}, status ${result.status})`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
projectDb.close();
|
|
191
|
+
}
|
|
192
|
+
process.exit(exitCode);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
main().catch((e: unknown) => {
|
|
196
|
+
console.error(`inline-run-setup: FAIL — ${e instanceof Error ? e.message : String(e)}`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
});
|