@gobing-ai/spur 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/spur-cli/config/rules/structure/test-location.yaml +1 -0
- package/spur-cli/config/tasks/section-matrix.yaml +9 -7
- package/spur-cli/config/tasks/templates/review.md +5 -0
- package/spur-cli/config/workflows/feature-dev.yaml +8 -4
- package/spur-cli/config/workflows/task-pipeline.yaml +12 -6
- package/spur.js +204 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/spur",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "Spur CLI — local-first harness for mainstream coding agents: constraint checking, workflow orchestration, agent health, and history analytics. Bun-native; exposes the `spur` command.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"spur",
|
|
@@ -108,9 +108,11 @@ variants:
|
|
|
108
108
|
# Plan and Review are optional at backlog/todo so the template renders cleanly;
|
|
109
109
|
# Review becomes required at wip+.
|
|
110
110
|
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
111
|
+
# Requirements is optional from todo onward (the authoring stage where a refined
|
|
112
|
+
# review-findings task gains scoped requirements); Solution and Testing are optional
|
|
113
|
+
# at wip+ — none required, but tolerated so that a review-findings task that evolves
|
|
114
|
+
# into implementation work (the common dogfood follow-up path) can carry scoped
|
|
115
|
+
# requirements, a change-map, and coverage data without matrix violations.
|
|
114
116
|
# The required list guards the real deliverables; optional is permissive on purpose.
|
|
115
117
|
review:
|
|
116
118
|
backlog:
|
|
@@ -119,17 +121,17 @@ variants:
|
|
|
119
121
|
forbidden: [Testing]
|
|
120
122
|
todo:
|
|
121
123
|
required: [Background, Plan]
|
|
122
|
-
optional: [Q&A, Review]
|
|
124
|
+
optional: [Q&A, Requirements, Review]
|
|
123
125
|
forbidden: [Testing]
|
|
124
126
|
wip:
|
|
125
127
|
required: [Background, Review]
|
|
126
|
-
optional: [Plan, Q&A, Solution, Testing]
|
|
128
|
+
optional: [Plan, Q&A, Requirements, Solution, Testing]
|
|
127
129
|
testing:
|
|
128
130
|
required: [Background, Review]
|
|
129
|
-
optional: [Plan, Solution, Testing]
|
|
131
|
+
optional: [Plan, Requirements, Solution, Testing]
|
|
130
132
|
done:
|
|
131
133
|
required: [Background, Review]
|
|
132
|
-
optional: [Plan, Q&A, Solution, Testing]
|
|
134
|
+
optional: [Plan, Q&A, Requirements, Solution, Testing]
|
|
133
135
|
gate: true
|
|
134
136
|
blocked:
|
|
135
137
|
required: [Background]
|
|
@@ -43,6 +43,11 @@ in the reviewed PR/commit/diff). Fix in priority order (P1 → P2 → …); re-r
|
|
|
43
43
|
Post-implementation reflection — filled **after** the first fix round: what went wrong, what
|
|
44
44
|
remains to fix before closing, and any **back-issues** (new findings surfaced by the fix).
|
|
45
45
|
|
|
46
|
+
| Severity | File | Finding | Recommendation |
|
|
47
|
+
| -------- | ---- | ------- | -------------- |
|
|
48
|
+
| P1 | | | |
|
|
49
|
+
| P2 | | | |
|
|
50
|
+
|
|
46
51
|
### References
|
|
47
52
|
|
|
48
53
|
### History
|
|
@@ -63,7 +63,7 @@ states:
|
|
|
63
63
|
description: >
|
|
64
64
|
Drive EVERY pending task under the feature to done. The agent enumerates the work with
|
|
65
65
|
`spur task list --feature ${featureId} --status todo --json`, then runs each task through
|
|
66
|
-
task-pipeline.yaml (
|
|
66
|
+
task-pipeline.yaml (precheck → implement → test → review → approve → verify → record → done). It
|
|
67
67
|
stops and reports on the first non-PASS verdict — a failing task fails the feature.
|
|
68
68
|
onEnter:
|
|
69
69
|
- kind: agent.run
|
|
@@ -71,9 +71,13 @@ states:
|
|
|
71
71
|
agent: ${vars.agent}
|
|
72
72
|
input: >
|
|
73
73
|
Run every pending task under feature ${vars.featureId} to done. Enumerate with
|
|
74
|
-
`${vars.spurBin} task list --feature ${vars.featureId} --status todo --json
|
|
75
|
-
for each
|
|
76
|
-
|
|
74
|
+
`${vars.spurBin} task list --feature ${vars.featureId} --status todo --json`; then
|
|
75
|
+
for each WBS run the standard single-task pipeline VERBATIM:
|
|
76
|
+
`${vars.spurBin} workflow run config/workflows/task-pipeline.yaml --vars
|
|
77
|
+
'{"wbs":"<wbs>","profile":"${vars.profile}","agent":"${vars.agent}"}' --async --json`,
|
|
78
|
+
polling `${vars.spurBin} workflow trace <run-id>` until terminal and inspecting
|
|
79
|
+
`.spur/run/<wbs>-verdict.json`. Stop and report on the first non-PASS verdict
|
|
80
|
+
(a failing task fails the feature).
|
|
77
81
|
|
|
78
82
|
- id: feature-verify
|
|
79
83
|
description: >
|
|
@@ -56,20 +56,26 @@ states:
|
|
|
56
56
|
|
|
57
57
|
- id: implement
|
|
58
58
|
description: >
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
Run agent-driven implementation via /sp:dev-run --mode implement, THEN move the
|
|
60
|
+
task to `wip` via the normal verb. The lifecycle transition fires AFTER the
|
|
61
|
+
implement agent.run succeeds — onEnter runs in declaration order and the default
|
|
62
|
+
'fail' policy halts the sequence (routing the run to `failed`) if the agent.run
|
|
63
|
+
fails or times out, so a halted implement never leaves the task at `wip` with an
|
|
64
|
+
empty Solution (the F3 half-state). The `--no-lifecycle` flag is correct here: the
|
|
65
|
+
pipeline owns this transition; the FSM guard would otherwise re-run `spur task
|
|
66
|
+
check`, which is precheck's job.
|
|
61
67
|
NOTE: this step must NOT call /sp:dev-run in full mode (the default) —
|
|
62
68
|
that command DRIVES this pipeline, so calling it here recurses.
|
|
63
69
|
--mode implement is the single-step implement entry.
|
|
64
70
|
onEnter:
|
|
65
|
-
- kind: shell
|
|
66
|
-
options:
|
|
67
|
-
command: "${vars.spurBin} task update ${vars.wbs} wip --no-lifecycle"
|
|
68
71
|
- kind: agent.run
|
|
69
72
|
options:
|
|
70
73
|
agent: ${vars.agent}
|
|
71
74
|
input: /sp:dev-run --mode implement ${vars.wbs} --auto
|
|
72
75
|
timeoutMs: ${vars.stepTimeoutMs}
|
|
76
|
+
- kind: shell
|
|
77
|
+
options:
|
|
78
|
+
command: "${vars.spurBin} task update ${vars.wbs} wip --no-lifecycle"
|
|
73
79
|
|
|
74
80
|
- id: test
|
|
75
81
|
description: Test execution + coverage via /sp:dev-unit.
|
|
@@ -81,7 +87,7 @@ states:
|
|
|
81
87
|
timeoutMs: ${vars.stepTimeoutMs}
|
|
82
88
|
|
|
83
89
|
- id: review
|
|
84
|
-
description:
|
|
90
|
+
description: SECUA-framework code review via /sp:dev-review (Security, Efficiency, Correctness, Usability, Architecture).
|
|
85
91
|
onEnter:
|
|
86
92
|
- kind: agent.run
|
|
87
93
|
options:
|
package/spur.js
CHANGED
|
@@ -50876,6 +50876,13 @@ var init_run_dao = __esm(() => {
|
|
|
50876
50876
|
metadata_json = json_set(COALESCE(NULLIF(metadata_json, ''), '{}'), '$.staleReason', ?2)
|
|
50877
50877
|
WHERE id = ?3 AND status IN ('running', 'pending')`, nowIso, reason, runId);
|
|
50878
50878
|
}
|
|
50879
|
+
async setPid(runId, pid) {
|
|
50880
|
+
await this.adapter.run("UPDATE runs SET pid = ? WHERE id = ?", pid, runId);
|
|
50881
|
+
}
|
|
50882
|
+
async getPid(runId) {
|
|
50883
|
+
const row = await this.adapter.queryFirst("SELECT pid FROM runs WHERE id = ?", runId);
|
|
50884
|
+
return row?.pid ?? null;
|
|
50885
|
+
}
|
|
50879
50886
|
};
|
|
50880
50887
|
});
|
|
50881
50888
|
|
|
@@ -55315,7 +55322,9 @@ CREATE TABLE IF NOT EXISTS queue_jobs (
|
|
|
55315
55322
|
);
|
|
55316
55323
|
|
|
55317
55324
|
CREATE INDEX IF NOT EXISTS queue_jobs_ready_idx ON queue_jobs (status, next_retry_at, created_at);
|
|
55318
|
-
`, CLI_SCHEMA_SQL,
|
|
55325
|
+
`, CLI_SCHEMA_SQL, RUN_PID_COLUMN_SCHEMA_SQL = `
|
|
55326
|
+
ALTER TABLE runs ADD COLUMN pid INTEGER;
|
|
55327
|
+
`, CLI_MIGRATIONS, CLI_MIGRATION_FILE_MARKER = "_spur_cli_";
|
|
55319
55328
|
var init_migrations = __esm(() => {
|
|
55320
55329
|
init_dist7();
|
|
55321
55330
|
init_dist8();
|
|
@@ -55341,7 +55350,8 @@ ${QUEUE_JOBS_SCHEMA_SQL}
|
|
|
55341
55350
|
{ id: "0001_spur_cli_team_inbox", sql: INBOX_MESSAGES_SCHEMA_SQL },
|
|
55342
55351
|
{ id: "0002_spur_cli_rule_history", sql: RULE_ENGINE_SCHEMA_SQL },
|
|
55343
55352
|
{ id: "0003_spur_cli_planning", sql: PLANNING_SCHEMA_SQL },
|
|
55344
|
-
{ id: "0004_spur_cli_queue_jobs", sql: QUEUE_JOBS_SCHEMA_SQL }
|
|
55353
|
+
{ id: "0004_spur_cli_queue_jobs", sql: QUEUE_JOBS_SCHEMA_SQL },
|
|
55354
|
+
{ id: "0005_spur_cli_run_pid", sql: RUN_PID_COLUMN_SCHEMA_SQL }
|
|
55345
55355
|
];
|
|
55346
55356
|
});
|
|
55347
55357
|
|
|
@@ -57807,7 +57817,7 @@ class PlanningWriteService {
|
|
|
57807
57817
|
const raw = await this.fs.readFile(ref.filePath);
|
|
57808
57818
|
doc2 = MarkdownDocument.parse(raw, domain2);
|
|
57809
57819
|
}
|
|
57810
|
-
const currentStatus = doc2.frontmatterData?.status ?? "";
|
|
57820
|
+
const currentStatus = normalizeStatusForDomain(doc2.frontmatterData?.status ?? "", domain2);
|
|
57811
57821
|
const phantomsBefore = new Set(phantomSections(doc2, domain2));
|
|
57812
57822
|
applyMutation(doc2, mutation);
|
|
57813
57823
|
assertNoNewPhantomSections(doc2, domain2, phantomsBefore);
|
|
@@ -57823,7 +57833,7 @@ class PlanningWriteService {
|
|
|
57823
57833
|
throw new Error(`Frontmatter validation failed for feature ${ref.id}: ${result.error.issues.map((i2) => i2.message).join("; ")}`);
|
|
57824
57834
|
}
|
|
57825
57835
|
}
|
|
57826
|
-
const newStatus = doc2.frontmatterData?.status ?? "";
|
|
57836
|
+
const newStatus = normalizeStatusForDomain(doc2.frontmatterData?.status ?? "", domain2);
|
|
57827
57837
|
const statusChanged = mutation.kind === "transition" && newStatus !== currentStatus;
|
|
57828
57838
|
let fromStatus;
|
|
57829
57839
|
let toStatus;
|
|
@@ -57916,6 +57926,16 @@ function resolveEventName(kind, domain2, statusChanged) {
|
|
|
57916
57926
|
}
|
|
57917
57927
|
return domain2 === "task" ? "task.updated" : "feature.updated";
|
|
57918
57928
|
}
|
|
57929
|
+
function normalizeStatusForDomain(status, domain2) {
|
|
57930
|
+
if (!status)
|
|
57931
|
+
return status;
|
|
57932
|
+
const normalize = domain2 === "task" ? normalizeTaskStatus : normalizeFeatureStatus;
|
|
57933
|
+
try {
|
|
57934
|
+
return normalize(status);
|
|
57935
|
+
} catch {
|
|
57936
|
+
return status;
|
|
57937
|
+
}
|
|
57938
|
+
}
|
|
57919
57939
|
var init_planning_write_service = __esm(() => {
|
|
57920
57940
|
init_src2();
|
|
57921
57941
|
});
|
|
@@ -58461,6 +58481,51 @@ function isPlaceholderBody(body) {
|
|
|
58461
58481
|
const stripped = body.replace(/<!--[\s\S]*?-->/g, "").replace(/^\s*>\s*TBD\s*$/gim, "").trim();
|
|
58462
58482
|
return stripped.length === 0;
|
|
58463
58483
|
}
|
|
58484
|
+
function hasPopulatedPriorityTable(body) {
|
|
58485
|
+
for (const line of body.split(`
|
|
58486
|
+
`)) {
|
|
58487
|
+
const cells = line.split("|");
|
|
58488
|
+
if (cells.length < 3)
|
|
58489
|
+
continue;
|
|
58490
|
+
const severityIdx = cells.findIndex((c3) => /^\s*P[1-4]\s*$/.test(c3));
|
|
58491
|
+
if (severityIdx === -1)
|
|
58492
|
+
continue;
|
|
58493
|
+
const hasContent = cells.some((c3, i2) => i2 !== severityIdx && c3.trim().length > 0);
|
|
58494
|
+
if (hasContent)
|
|
58495
|
+
return true;
|
|
58496
|
+
}
|
|
58497
|
+
return false;
|
|
58498
|
+
}
|
|
58499
|
+
function isReviewScaffold(body) {
|
|
58500
|
+
if (isPlaceholderBody(body))
|
|
58501
|
+
return true;
|
|
58502
|
+
if (hasPopulatedPriorityTable(body))
|
|
58503
|
+
return false;
|
|
58504
|
+
let sawEmptyPRow = false;
|
|
58505
|
+
for (const line of body.split(`
|
|
58506
|
+
`)) {
|
|
58507
|
+
if (!line.includes("|"))
|
|
58508
|
+
continue;
|
|
58509
|
+
const cells = line.split("|").map((c3) => c3.trim());
|
|
58510
|
+
const isSeparator = cells.every((c3) => c3 === "" || /^:?-+:?$/.test(c3));
|
|
58511
|
+
const isEmptyPRow = cells.every((c3) => c3 === "" || /^P[1-4]$/.test(c3)) && cells.some((c3) => /^P[1-4]$/.test(c3));
|
|
58512
|
+
const isHeader = cells.some((c3) => /severity|file|finding|recommendation/i.test(c3));
|
|
58513
|
+
if (isEmptyPRow)
|
|
58514
|
+
sawEmptyPRow = true;
|
|
58515
|
+
else if (!isSeparator && !isHeader)
|
|
58516
|
+
return false;
|
|
58517
|
+
}
|
|
58518
|
+
return sawEmptyPRow;
|
|
58519
|
+
}
|
|
58520
|
+
function isProseOnlyReview(body) {
|
|
58521
|
+
if (isPlaceholderBody(body))
|
|
58522
|
+
return true;
|
|
58523
|
+
if (hasPopulatedPriorityTable(body))
|
|
58524
|
+
return false;
|
|
58525
|
+
const stripped = body.replace(/<!--[\s\S]*?-->/g, "");
|
|
58526
|
+
return !stripped.split(`
|
|
58527
|
+
`).some((l) => l.includes("|"));
|
|
58528
|
+
}
|
|
58464
58529
|
var TaskCheckService;
|
|
58465
58530
|
var init_task_check = __esm(() => {
|
|
58466
58531
|
init_src2();
|
|
@@ -58531,10 +58596,11 @@ var init_task_check = __esm(() => {
|
|
|
58531
58596
|
}
|
|
58532
58597
|
}
|
|
58533
58598
|
const revBody = doc2.getSection("Review");
|
|
58534
|
-
const
|
|
58535
|
-
|
|
58536
|
-
|
|
58537
|
-
|
|
58599
|
+
const revRequired = (entry?.required ?? []).includes("Review");
|
|
58600
|
+
const revAllowed = revRequired || (entry?.optional ?? []).includes("Review");
|
|
58601
|
+
const revScaffoldTolerated = revRequired ? isPlaceholderBody(revBody ?? "") : isReviewScaffold(revBody ?? "") || isProseOnlyReview(revBody ?? "");
|
|
58602
|
+
if (revBody !== null && !revScaffoldTolerated && revAllowed) {
|
|
58603
|
+
if (!hasPopulatedPriorityTable(revBody)) {
|
|
58538
58604
|
findings.push({
|
|
58539
58605
|
layer: "L3",
|
|
58540
58606
|
severity: "error",
|
|
@@ -58557,7 +58623,7 @@ var init_task_check = __esm(() => {
|
|
|
58557
58623
|
}
|
|
58558
58624
|
const planBody = doc2.getSection("Plan");
|
|
58559
58625
|
if (planBody !== null && !isPlaceholderBody(planBody)) {
|
|
58560
|
-
const isList = /^\s*[-*]\s|^\s*\d+\.\s
|
|
58626
|
+
const isList = /^\s*[-*]\s|^\s*\d+\.\s/m.test(planBody);
|
|
58561
58627
|
const isTable2 = /\|/.test(planBody);
|
|
58562
58628
|
if (!isList && !isTable2) {
|
|
58563
58629
|
findings.push({
|
|
@@ -58599,7 +58665,7 @@ var init_task_check = __esm(() => {
|
|
|
58599
58665
|
layer: "L4",
|
|
58600
58666
|
severity: "warning",
|
|
58601
58667
|
section: "",
|
|
58602
|
-
message: "Missing feature_id \u2014 every task should reference a feature (one direction, DD-07)"
|
|
58668
|
+
message: "Missing feature_id \u2014 every task should reference a feature (one direction, DD-07). To link: `spur task update <wbs> --feature <id>`, or use the sp:spur-dev feature-link helper."
|
|
58603
58669
|
});
|
|
58604
58670
|
}
|
|
58605
58671
|
if (parentWbs && parentWbs.length > 0) {
|
|
@@ -60514,6 +60580,44 @@ var now = () => new Date().toISOString();
|
|
|
60514
60580
|
import { access as access2, readdir as readdir3, readFile as readFile2, realpath, stat as stat2 } from "fs/promises";
|
|
60515
60581
|
import { homedir as homedir3 } from "os";
|
|
60516
60582
|
import { join as join11, resolve as resolve3 } from "path";
|
|
60583
|
+
function signalSubprocess(pid) {
|
|
60584
|
+
try {
|
|
60585
|
+
process.kill(-pid, "SIGTERM");
|
|
60586
|
+
return true;
|
|
60587
|
+
} catch {}
|
|
60588
|
+
try {
|
|
60589
|
+
process.kill(pid, "SIGTERM");
|
|
60590
|
+
return true;
|
|
60591
|
+
} catch {
|
|
60592
|
+
return false;
|
|
60593
|
+
}
|
|
60594
|
+
}
|
|
60595
|
+
function withSelfPidRecording(inner, db2) {
|
|
60596
|
+
const stamp = async (runId) => {
|
|
60597
|
+
try {
|
|
60598
|
+
await new RunDao(db2).setPid(runId, process.pid);
|
|
60599
|
+
} catch {}
|
|
60600
|
+
};
|
|
60601
|
+
return new Proxy(inner, {
|
|
60602
|
+
get(target, prop, receiver) {
|
|
60603
|
+
if (prop === "createRun") {
|
|
60604
|
+
return async (record2) => {
|
|
60605
|
+
await target.createRun(record2);
|
|
60606
|
+
await stamp(record2.id);
|
|
60607
|
+
};
|
|
60608
|
+
}
|
|
60609
|
+
if (prop === "createOrAttachRun") {
|
|
60610
|
+
return async (record2) => {
|
|
60611
|
+
const result = await target.createOrAttachRun(record2);
|
|
60612
|
+
await stamp(result.id);
|
|
60613
|
+
return result;
|
|
60614
|
+
};
|
|
60615
|
+
}
|
|
60616
|
+
const value = Reflect.get(target, prop, receiver);
|
|
60617
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
60618
|
+
}
|
|
60619
|
+
});
|
|
60620
|
+
}
|
|
60517
60621
|
|
|
60518
60622
|
class WorkflowAppService {
|
|
60519
60623
|
ctx;
|
|
@@ -60562,7 +60666,7 @@ class WorkflowAppService {
|
|
|
60562
60666
|
}
|
|
60563
60667
|
}
|
|
60564
60668
|
async run(file2, opts = {}) {
|
|
60565
|
-
const svc = await this.createEngineService();
|
|
60669
|
+
const svc = await this.createEngineService({ recordSelfPid: opts.recordSelfPid === true });
|
|
60566
60670
|
const runId = opts.runId ?? crypto.randomUUID();
|
|
60567
60671
|
const isDry = opts.dryRun === true;
|
|
60568
60672
|
const result = await svc.runFile(file2, {
|
|
@@ -60628,6 +60732,21 @@ class WorkflowAppService {
|
|
|
60628
60732
|
cleaned: stale.map((r) => ({ runId: r.id, startedAt: r.started_at }))
|
|
60629
60733
|
};
|
|
60630
60734
|
}
|
|
60735
|
+
async cancel(runId) {
|
|
60736
|
+
const db2 = await this.ctx.getDb();
|
|
60737
|
+
const dao2 = new RunDao(db2);
|
|
60738
|
+
const before = await dao2.traceRowById(runId);
|
|
60739
|
+
if (!before) {
|
|
60740
|
+
return { runId, finalized: false, status: "not_found", killed: false };
|
|
60741
|
+
}
|
|
60742
|
+
const isNonTerminal = before.status === "running" || before.status === "pending";
|
|
60743
|
+
const pid = isNonTerminal ? await dao2.getPid(runId) : null;
|
|
60744
|
+
const killed = pid != null ? signalSubprocess(pid) : false;
|
|
60745
|
+
await dao2.finalizeStale(runId, "cancelled by operator (spur workflow cancel)");
|
|
60746
|
+
const after = await dao2.traceRowById(runId);
|
|
60747
|
+
const finalized = after?.status === "failed" && before.status !== "failed";
|
|
60748
|
+
return { runId, finalized, status: after?.status ?? "not_found", killed };
|
|
60749
|
+
}
|
|
60631
60750
|
async continuePaused(runId) {
|
|
60632
60751
|
const svc = await this.createEngineService();
|
|
60633
60752
|
const run = await svc.listPausedRuns();
|
|
@@ -60751,7 +60870,7 @@ class WorkflowAppService {
|
|
|
60751
60870
|
}
|
|
60752
60871
|
return { run, events: events2 };
|
|
60753
60872
|
}
|
|
60754
|
-
async createEngineService() {
|
|
60873
|
+
async createEngineService(opts = {}) {
|
|
60755
60874
|
const host = createDefaultWorkflowEngineHost();
|
|
60756
60875
|
registerSpurBuiltins(host, {
|
|
60757
60876
|
agentService: this.ctx.agentService(),
|
|
@@ -60760,7 +60879,11 @@ class WorkflowAppService {
|
|
|
60760
60879
|
httpRequester: this.ctx.httpRequester?.(),
|
|
60761
60880
|
hostAllowlist: this.ctx.hostAllowlist?.()
|
|
60762
60881
|
});
|
|
60763
|
-
const
|
|
60882
|
+
const db2 = await this.ctx.getDb();
|
|
60883
|
+
let persistence2 = new DbWorkflowPersistenceAdapter(db2);
|
|
60884
|
+
if (opts.recordSelfPid === true) {
|
|
60885
|
+
persistence2 = withSelfPidRecording(persistence2, db2);
|
|
60886
|
+
}
|
|
60764
60887
|
const bus = this.ctx.observabilityBus?.();
|
|
60765
60888
|
const adapter = bus ? new ObservableWorkflowAdapter(persistence2, bus) : persistence2;
|
|
60766
60889
|
return new WorkflowService(host, adapter);
|
|
@@ -68478,12 +68601,21 @@ function resolveSpurBin() {
|
|
|
68478
68601
|
}
|
|
68479
68602
|
|
|
68480
68603
|
// src/workflow/make-lifecycle-adapter.ts
|
|
68604
|
+
function resolveWorkflowPath(context3, profile) {
|
|
68605
|
+
const bundledRoot = bundledConfigRoot();
|
|
68606
|
+
if (bundledRoot !== null) {
|
|
68607
|
+
const bundledPath = join12(bundledRoot, "workflows", `${profile.workflowName}.yaml`);
|
|
68608
|
+
if (existsSync4(bundledPath))
|
|
68609
|
+
return bundledPath;
|
|
68610
|
+
}
|
|
68611
|
+
const projectPath = join12(context3.cwd, ".spur", "workflows", `${profile.workflowName}.yaml`);
|
|
68612
|
+
if (existsSync4(projectPath))
|
|
68613
|
+
return projectPath;
|
|
68614
|
+
return null;
|
|
68615
|
+
}
|
|
68481
68616
|
function makeLifecycleAdapter(context3, profile) {
|
|
68482
|
-
const
|
|
68483
|
-
if (
|
|
68484
|
-
return;
|
|
68485
|
-
const workflowPath = join12(root, "workflows", `${profile.workflowName}.yaml`);
|
|
68486
|
-
if (!existsSync4(workflowPath))
|
|
68617
|
+
const workflowPath = resolveWorkflowPath(context3, profile);
|
|
68618
|
+
if (workflowPath === null)
|
|
68487
68619
|
return;
|
|
68488
68620
|
const spurBin = resolveSpurBin();
|
|
68489
68621
|
return new LifecycleAdapter({
|
|
@@ -68732,7 +68864,7 @@ import { join as join13, resolve as resolve4 } from "path";
|
|
|
68732
68864
|
var CLI_CONFIG = {
|
|
68733
68865
|
binaryName: "spur",
|
|
68734
68866
|
binaryLabel: "spur",
|
|
68735
|
-
binaryVersion: "0.2.
|
|
68867
|
+
binaryVersion: "0.2.9",
|
|
68736
68868
|
configDir: ".spur",
|
|
68737
68869
|
configFile: ".spur/config.yaml",
|
|
68738
68870
|
databaseFile: ".spur/spur.db"
|
|
@@ -77985,6 +78117,18 @@ ${result.content}`);
|
|
|
77985
78117
|
context3.output.write(`Set ${key}=${value2} on task ${result.ref.id}`);
|
|
77986
78118
|
}
|
|
77987
78119
|
} else if (status !== undefined) {
|
|
78120
|
+
if (options.lifecycle !== false && (status === "done" || status === "testing")) {
|
|
78121
|
+
const adapter = makeLifecycleAdapter(context3, TASK_LIFECYCLE_PROFILE);
|
|
78122
|
+
if (adapter === undefined) {
|
|
78123
|
+
context3.output.error(`warning: lifecycle adapter unavailable \u2014 running \`spur task check\` inline as the ${status} gate. ` + "Restore the bundled task-lifecycle workflow to re-enable the real guard.");
|
|
78124
|
+
const ok = await runDoneGateCheck(context3, wbs, options.folder);
|
|
78125
|
+
if (!ok) {
|
|
78126
|
+
context3.output.error(`Lifecycle transition blocked: \`spur task check ${wbs}\` failed. Fix the findings before transitioning to ${status}.`);
|
|
78127
|
+
context3.setExitCode(1);
|
|
78128
|
+
return;
|
|
78129
|
+
}
|
|
78130
|
+
}
|
|
78131
|
+
}
|
|
77988
78132
|
const result = await svc.updateStatus(wbs, status);
|
|
77989
78133
|
if (options.json) {
|
|
77990
78134
|
context3.output.write(toJson2(result));
|
|
@@ -78280,6 +78424,18 @@ function loadTemplateBodies(projectRoot, variant) {
|
|
|
78280
78424
|
async function makeCheckService(context3) {
|
|
78281
78425
|
return new TaskCheckService(context3.fs, await loadSectionMatrix(context3.cwd));
|
|
78282
78426
|
}
|
|
78427
|
+
async function runDoneGateCheck(context3, wbs, folderOverride) {
|
|
78428
|
+
const foldersConfig = (await resolvePlanningFolders(context3.fs)).foldersConfig;
|
|
78429
|
+
const tasksDir = folderOverride ?? context3.fs.resolve(foldersConfig.active_folder);
|
|
78430
|
+
const entries = await context3.fs.readDir(tasksDir);
|
|
78431
|
+
const fileName = entries.find((n2) => n2.startsWith(`${wbs}_`) && n2.endsWith(".md"));
|
|
78432
|
+
if (!fileName) {
|
|
78433
|
+
return false;
|
|
78434
|
+
}
|
|
78435
|
+
const svc = new TaskCheckService(context3.fs, await loadSectionMatrix(context3.cwd));
|
|
78436
|
+
const result = await svc.check(`${tasksDir}/${fileName}`, wbs, { strict: false });
|
|
78437
|
+
return result.pass;
|
|
78438
|
+
}
|
|
78283
78439
|
async function loadSectionMatrix(projectRoot) {
|
|
78284
78440
|
const localPath = join18(projectRoot, ".spur", "tasks", "section-matrix.yaml");
|
|
78285
78441
|
if (existsSync5(localPath)) {
|
|
@@ -78363,6 +78519,7 @@ init_src3();
|
|
|
78363
78519
|
init_loader();
|
|
78364
78520
|
init_dist7();
|
|
78365
78521
|
init_dist5();
|
|
78522
|
+
import { spawn as spawn2 } from "child_process";
|
|
78366
78523
|
import { resolve as resolve5 } from "path";
|
|
78367
78524
|
function parseVars(raw2) {
|
|
78368
78525
|
if (raw2 === undefined) {
|
|
@@ -78424,7 +78581,7 @@ ${result.errors.map((m) => ` - ${m}`).join(`
|
|
|
78424
78581
|
const spurParts = resolveSpurBin().split(" ");
|
|
78425
78582
|
const spurBin = spurParts[0] ?? process.execPath;
|
|
78426
78583
|
const spurArgs = spurParts.slice(1);
|
|
78427
|
-
const cmd = [
|
|
78584
|
+
const cmd = [...spurArgs, "workflow", "run", file2, "--run-id", runId];
|
|
78428
78585
|
if (options.vars) {
|
|
78429
78586
|
cmd.push("--vars", options.vars);
|
|
78430
78587
|
}
|
|
@@ -78432,11 +78589,12 @@ ${result.errors.map((m) => ` - ${m}`).join(`
|
|
|
78432
78589
|
cmd.push("--dry-run");
|
|
78433
78590
|
}
|
|
78434
78591
|
try {
|
|
78435
|
-
|
|
78436
|
-
|
|
78437
|
-
|
|
78438
|
-
env: process.env
|
|
78439
|
-
})
|
|
78592
|
+
const child = spawn2(spurBin, cmd, {
|
|
78593
|
+
stdio: "ignore",
|
|
78594
|
+
detached: true,
|
|
78595
|
+
env: { ...process.env, SPUR_ASYNC_WORKER: "1" }
|
|
78596
|
+
});
|
|
78597
|
+
child.unref();
|
|
78440
78598
|
} catch {
|
|
78441
78599
|
const result2 = await makeSvc(options.json).run(file2, {
|
|
78442
78600
|
runId,
|
|
@@ -78476,7 +78634,8 @@ Monitor with: spur workflow trace ${runId}`);
|
|
|
78476
78634
|
const result = await makeSvc(options.json, bus).run(file2, {
|
|
78477
78635
|
runId: options.runId || undefined,
|
|
78478
78636
|
vars,
|
|
78479
|
-
dryRun: options.dryRun || undefined
|
|
78637
|
+
dryRun: options.dryRun || undefined,
|
|
78638
|
+
recordSelfPid: process.env.SPUR_ASYNC_WORKER === "1"
|
|
78480
78639
|
});
|
|
78481
78640
|
context3.output.write(options.json ? toJson2(result) : `workflow ${result.status}: ${result.workflowName} -> ${result.finalState}`);
|
|
78482
78641
|
context3.setExitCode(result.status === "done" ? 0 : 1);
|
|
@@ -78516,7 +78675,7 @@ Monitor with: spur workflow trace ${runId}`);
|
|
|
78516
78675
|
context3.setExitCode(1);
|
|
78517
78676
|
}
|
|
78518
78677
|
});
|
|
78519
|
-
workflow.command("clean").description("Finalize orphaned runs stuck in running/pending past a staleness threshold (mark as failed).").option("--older-than <minutes>", "Staleness threshold in minutes", "30").option("--force", "Clean ALL non-terminal runs regardless of age (overrides --older-than)").option("--dry-run", "List what would be cleaned without writing").option("--json", "Output machine-readable JSON where supported").action(async (options) => {
|
|
78678
|
+
workflow.command("clean").description("Finalize orphaned runs stuck in running/pending past a staleness threshold (mark as failed). " + "To cancel a single live run by id, use `spur workflow cancel <run-id>` instead.").option("--older-than <minutes>", "Staleness threshold in minutes", "30").option("--force", "Clean ALL non-terminal runs regardless of age (overrides --older-than)").option("--dry-run", "List what would be cleaned without writing").option("--json", "Output machine-readable JSON where supported").action(async (options) => {
|
|
78520
78679
|
const force = options.force === true;
|
|
78521
78680
|
const minutes = force ? 0 : Number.parseInt(options.olderThan ?? "30", 10);
|
|
78522
78681
|
if (!Number.isFinite(minutes) || minutes < 0) {
|
|
@@ -78540,6 +78699,24 @@ Monitor with: spur workflow trace ${runId}`);
|
|
|
78540
78699
|
}
|
|
78541
78700
|
}
|
|
78542
78701
|
});
|
|
78702
|
+
workflow.command("cancel").description("Cancel a single non-terminal run by id (mark as failed). The bulk/stale variant is `spur workflow clean`.").argument("<run-id>", "Run id to cancel").option("--json", "Output machine-readable JSON where supported").action(async (runId, options) => {
|
|
78703
|
+
const result = await makeSvc(options.json).cancel(runId);
|
|
78704
|
+
if (options.json) {
|
|
78705
|
+
context3.output.write(toJson2(result));
|
|
78706
|
+
return;
|
|
78707
|
+
}
|
|
78708
|
+
if (result.status === "not_found") {
|
|
78709
|
+
context3.output.error(`Run ${runId} not found.`);
|
|
78710
|
+
context3.setExitCode(1);
|
|
78711
|
+
return;
|
|
78712
|
+
}
|
|
78713
|
+
if (result.finalized) {
|
|
78714
|
+
const killNote = result.killed ? " + signalled worker process group" : "";
|
|
78715
|
+
context3.output.write(`Cancelled run ${runId} (marked failed${killNote}).`);
|
|
78716
|
+
} else {
|
|
78717
|
+
context3.output.write(`Run ${runId} already terminal (${result.status}) \u2014 no change.`);
|
|
78718
|
+
}
|
|
78719
|
+
});
|
|
78543
78720
|
workflow.command("list").description("List available workflow YAML files.").option("--json", "Output machine-readable JSON where supported").action(async (options) => {
|
|
78544
78721
|
const paths = await resolveWorkflowPaths(context3.cwd);
|
|
78545
78722
|
const result = await makeSvc().list(paths);
|