spec-ctx 0.3.0 → 0.4.1
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/README.md +119 -16
- package/action.yml +8 -0
- package/dist/adopt/write.js +9 -4
- package/dist/assets.generated.js +1 -1
- package/dist/check/check.js +23 -0
- package/dist/check/hints.js +16 -11
- package/dist/cli/args.js +5 -4
- package/dist/cli/commands.js +89 -29
- package/dist/cli/config.js +14 -0
- package/dist/cli/gate.js +133 -4
- package/dist/cli/help.js +25 -7
- package/dist/cli/workflow.js +28 -11
- package/dist/config.js +9 -1
- package/dist/gate/rerun.js +169 -0
- package/dist/graph/bundle.js +145 -26
- package/dist/graph/files.js +31 -0
- package/dist/graph/input.js +37 -0
- package/dist/graph/links.js +96 -0
- package/dist/graph/path.js +54 -0
- package/dist/graph/query.js +3 -0
- package/dist/graph/related.js +58 -11
- package/dist/handoff/handoff.js +11 -7
- package/dist/handoff/questions.js +9 -1
- package/dist/ids/ids.js +4 -0
- package/dist/index/build.js +33 -4
- package/dist/index/clock.js +55 -0
- package/dist/index/scan.js +42 -11
- package/dist/index/structure.js +97 -0
- package/dist/init/init.js +28 -12
- package/dist/loop/fails.js +15 -8
- package/dist/loop/next.js +89 -37
- package/dist/runcmd.js +5 -0
- package/dist/util/time.js +1 -0
- package/dist/verify/run.js +3 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/specctx-run/SKILL.md +9 -4
package/dist/loop/fails.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { readQuestions } from "../handoff/questions.js";
|
|
5
5
|
import { isFailFile } from "../tasks/evidence.js";
|
|
6
6
|
import { evidenceRel } from "../tasks/tasks.js";
|
|
7
|
+
import { runCommand } from "../runcmd.js";
|
|
7
8
|
import { dumps } from "../util/json.js";
|
|
8
9
|
import { cmpStr } from "../util/pystr.js";
|
|
9
10
|
import { isoNow } from "../util/time.js";
|
|
@@ -66,6 +67,13 @@ export function failsInWindow(root, task, questions) {
|
|
|
66
67
|
export function openQuestionFor(root, task, questions) {
|
|
67
68
|
return (questions ?? safeQuestions(root)).find((q) => q.task === task && q.status === "open");
|
|
68
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* REQ-LOOP-014 an open attempt-cap question of the task: stage cap only. A question with no stage is a person's (044/D3).
|
|
72
|
+
* The text plays no part (044/D3).
|
|
73
|
+
*/
|
|
74
|
+
export function isCapQuestion(q, task) {
|
|
75
|
+
return q.status === "open" && q.task === task && q.stage === "cap";
|
|
76
|
+
}
|
|
69
77
|
/** Write the next fail file of a task. The attempt counts the window, the file number counts every fail file. */
|
|
70
78
|
export function writeFail(root, task, verify, tail) {
|
|
71
79
|
const attempt = failsInWindow(root, task).length + 1;
|
|
@@ -80,22 +88,21 @@ export function writeFail(root, task, verify, tail) {
|
|
|
80
88
|
writeFileSync(join(root, target), dumps(file) + "\n");
|
|
81
89
|
return { rel: target, file };
|
|
82
90
|
}
|
|
83
|
-
/** REQ-LOOP-002 the question text at the cap. Strict mode. */
|
|
91
|
+
/** REQ-LOOP-002 the question text at the cap. It quotes the failing line (033/D2). Strict mode. */
|
|
84
92
|
export function capQuestion(task, attempts, tail) {
|
|
85
|
-
|
|
86
|
-
return `task ${task} failed Verify ${attempts} times. Last: ${first}. What should change?`;
|
|
93
|
+
return `task ${task} failed Verify ${attempts} times. Last: ${failLine(tail)}. What should change?`;
|
|
87
94
|
}
|
|
88
95
|
/** REQ-LOOP-002 the last line of a failed `task done` at the cap. Strict mode. */
|
|
89
|
-
export function blockedLine(n) {
|
|
90
|
-
return `blocked: question ${n} written. Run
|
|
96
|
+
export function blockedLine(n, run = "specctx") {
|
|
97
|
+
return `blocked: question ${n} written. Run ${runCommand(run, `handoff resolve ${n} "<answer>"`)} to continue.`;
|
|
91
98
|
}
|
|
92
99
|
/**
|
|
93
|
-
* REQ-LOOP-006 the reason line of a Verify tail (029/D3): the last line that starts with `fail
|
|
94
|
-
*
|
|
100
|
+
* REQ-LOOP-006 the reason line of a Verify tail (029/D3): the last line that starts with `fail:`, `FAIL`, or `(fail)`
|
|
101
|
+
* after trimming (039/D1), else the last non-empty line, else `no output`. The match is case sensitive.
|
|
95
102
|
*/
|
|
96
103
|
export function failLine(tail) {
|
|
97
104
|
const lines = tail.split("\n").map((l) => l.trimEnd());
|
|
98
|
-
const hit = lines.filter((l) => /^\s*(?:fail:|FAIL)/.test(l)).at(-1);
|
|
105
|
+
const hit = lines.filter((l) => /^\s*(?:fail:|FAIL|\(fail\))/.test(l)).at(-1);
|
|
99
106
|
if (hit !== undefined)
|
|
100
107
|
return hit.trim();
|
|
101
108
|
return lines.filter((l) => l.trim()).at(-1)?.trim() ?? "no output";
|
package/dist/loop/next.js
CHANGED
|
@@ -10,11 +10,13 @@ import { listEvidence } from "../tasks/evidence.js";
|
|
|
10
10
|
import { hasEvidence, nextTask, tasksOf } from "../tasks/tasks.js";
|
|
11
11
|
import { cmpStr, cmpTuple } from "../util/pystr.js";
|
|
12
12
|
import { capQuestion, failLine, failsInWindow, openQuestionFor } from "./fails.js";
|
|
13
|
+
import { runCommand } from "../runcmd.js";
|
|
14
|
+
import { parseVerify } from "../verify/parse.js";
|
|
13
15
|
import { openspecCommand, openspecStatus } from "./openspec.js";
|
|
14
16
|
/** The closed action list. A skill stops on any value outside it. */
|
|
15
17
|
export const ACTIONS = ["plan", "cite", "start", "work", "fix", "repair", "gate", "archive", "ask", "stop"];
|
|
16
18
|
/** The keys of the object, sorted. Every state prints all of them. */
|
|
17
|
-
export const KEYS = ["action", "attempt", "change", "command", "human", "max_attempts", "reason", "state", "task"];
|
|
19
|
+
export const KEYS = ["action", "attempt", "change", "command", "human", "max_attempts", "reason", "state", "task", "tasks"];
|
|
18
20
|
/** The rules of check whose finding means a task or a header lacks its citation (024/D2 step 4). */
|
|
19
21
|
// REQ-LOOP-004 a weak Verify command is a citation gap too (028/D7)
|
|
20
22
|
const CITE_RULES = new Set(["missing-id", "task-without-req", "task-without-verify", "unknown-task-req", "weak-verify"]);
|
|
@@ -46,22 +48,25 @@ function questions(root) {
|
|
|
46
48
|
return [];
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
|
-
/**
|
|
51
|
+
/** REQ-LOOP-011 the last line of the gate log that is not a rerun line, or null. A line with steps exactly ["rerun"] never counts (041/D1). Only `ok` with `strict: true` passes (042/D2). */
|
|
50
52
|
function lastGate(root) {
|
|
51
53
|
const p = join(root, ".ctx/gate-log.jsonl");
|
|
52
54
|
if (!existsSync(p))
|
|
53
55
|
return null;
|
|
54
56
|
const lines = readFileSync(p, "utf8").split("\n").filter((l) => l.trim());
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
58
|
+
try {
|
|
59
|
+
const o = JSON.parse(lines[i]);
|
|
60
|
+
const rerun = Array.isArray(o.steps) && o.steps.length === 1 && o.steps[0] === "rerun";
|
|
61
|
+
if (rerun)
|
|
62
|
+
continue;
|
|
63
|
+
return { at: typeof o.at === "string" ? o.at : "", ok: o.ok === true && o.strict === true };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
64
68
|
}
|
|
69
|
+
return null;
|
|
65
70
|
}
|
|
66
71
|
/** The `at` of the newest live evidence file of a change key, or "" with none. */
|
|
67
72
|
function newestEvidenceAt(root, key) {
|
|
@@ -91,8 +96,15 @@ function readCurrent(root) {
|
|
|
91
96
|
/** REQ-LOOP-001 the decision of 024/D2. A pure function of the files. It writes nothing. */
|
|
92
97
|
export function decideNext(root, cfg, data, want) {
|
|
93
98
|
const openspec = openspecCommand(cfg.gate.openspec);
|
|
94
|
-
|
|
95
|
-
const
|
|
99
|
+
// REQ-CLI-010 every printed command goes through the run prefix (043/D1)
|
|
100
|
+
const rc = (args) => runCommand(cfg.cli.run, args);
|
|
101
|
+
const d = { action: "stop", attempt: 0, change: null, command: null, human: false, max_attempts: cfg.loop.max_attempts, reason: null, state: "unknown", task: null, tasks: [] };
|
|
102
|
+
const done = (o) => {
|
|
103
|
+
const r = { ...d, ...o };
|
|
104
|
+
if (!o.tasks)
|
|
105
|
+
r.tasks = r.task === null ? [] : [r.task];
|
|
106
|
+
return r;
|
|
107
|
+
};
|
|
96
108
|
// step 0 and step 1: the change under consideration, or the plan stop with no change
|
|
97
109
|
const folders = activeFolders(root);
|
|
98
110
|
const folder = want === undefined ? folders[0] : folders.find((f) => matchesChange(f, want));
|
|
@@ -110,7 +122,9 @@ export function decideNext(root, cfg, data, want) {
|
|
|
110
122
|
if (blocking) {
|
|
111
123
|
const task = blocking.task.includes("/") ? blocking.task : null;
|
|
112
124
|
const attempt = task ? failsInWindow(root, task, qs).length : 0;
|
|
113
|
-
|
|
125
|
+
// REQ-LOOP-015 a plan or archive question shows the verdict form (048/D6)
|
|
126
|
+
const form = blocking.stage === "plan" || blocking.stage === "archive" ? "<--approve|--reject> " : "";
|
|
127
|
+
return done({ action: "ask", attempt, command: rc(`handoff resolve ${blocking.n} ${form}"<answer>"`), human: true, reason: blocking.text, state: "question_open", task });
|
|
114
128
|
}
|
|
115
129
|
// step 3: planning state from OpenSpec
|
|
116
130
|
const st = openspecStatus(root, openspec, folder);
|
|
@@ -127,13 +141,38 @@ export function decideNext(root, cfg, data, want) {
|
|
|
127
141
|
if (found.length) {
|
|
128
142
|
const first = found[0];
|
|
129
143
|
const task = found.find((f) => TASK_RULES.has(f.rule))?.id ?? null;
|
|
130
|
-
return done({ action: "cite", command: `
|
|
144
|
+
return done({ action: "cite", command: rc(`query --change ${folder} --kind req`), reason: `${first.rule} ${first.id} at ${first.file}:${first.line}`, state: "citation_missing", task });
|
|
131
145
|
}
|
|
132
146
|
// step 5: a hand tick without evidence gets the repair run
|
|
133
147
|
const tasks = tasksOf(entries).filter(ofChange).sort((a, b) => cmpTuple([a.file, a.line], [b.file, b.line]));
|
|
134
|
-
|
|
148
|
+
// REQ-LOOP-009 step 4b: the plan stop. It needs no [x] task, no current task, and no question with stage plan, open or resolved. Only --plan sets that stage (034/D1, 041/D2).
|
|
149
|
+
const cur0 = readCurrent(root);
|
|
150
|
+
const started = tasks.some((t) => t.status === "done") || (cur0?.task !== undefined && cur0.task.split("/")[0] === key);
|
|
151
|
+
// REQ-LOOP-015 REQ-LOOP-016 the newest plan question decides, the one with the highest n. A rejection holds the stop before the started guard. Only an approval ends it. No verdict is no approval (048/D4).
|
|
152
|
+
const newestPlan = newestOf(qs.filter((q) => q.stage === "plan" && q.task === folder && q.status === "resolved"));
|
|
153
|
+
const planText = `plan ready for ${folder}. Approve to start the tasks?`;
|
|
154
|
+
if (newestPlan?.verdict === "rejected") {
|
|
155
|
+
return done({ action: "ask", command: rc(`handoff ask --change ${folder} --plan "${planText}"`), human: true, reason: `plan rejected in question ${newestPlan.n}: ${newestPlan.answer ?? ""}`, state: "plan_stop" });
|
|
156
|
+
}
|
|
157
|
+
if (!started && newestPlan?.verdict !== "approved") {
|
|
158
|
+
return done({ action: "ask", command: rc(`handoff ask --change ${folder} --plan "${planText}"`), human: true, reason: planText, state: "plan_stop" });
|
|
159
|
+
}
|
|
160
|
+
// REQ-LOOP-013 repair runs Verify, so a ticked task with no Verify command never gets it (044/D2)
|
|
161
|
+
const ticked = tasks.find((t) => t.status === "done" && parseVerify(t.title).command !== null && !hasEvidence(root, t));
|
|
135
162
|
if (ticked)
|
|
136
|
-
return done({ action: "repair", command: `
|
|
163
|
+
return done({ action: "repair", command: rc(`task done ${ticked.id}`), state: "done_without_evidence", task: ticked.id });
|
|
164
|
+
// REQ-LOOP-008 the done tasks whose evidence went stale, earliest first (031/D4, 031/D5)
|
|
165
|
+
const stale = [];
|
|
166
|
+
for (const t of tasks) {
|
|
167
|
+
if (t.status !== "done")
|
|
168
|
+
continue;
|
|
169
|
+
const path = staleOf(root, t.id);
|
|
170
|
+
if (path !== null)
|
|
171
|
+
stale.push({ id: t.id, path });
|
|
172
|
+
}
|
|
173
|
+
// REQ-LOOP-010 a current task keeps its action. The reason names the stale tasks that wait (034/D4).
|
|
174
|
+
const waiting = stale.length ? `stale waiting: ${stale.map((x) => x.id).join(", ")}` : null;
|
|
175
|
+
const withWaiting = (r) => (waiting === null ? r : r === null ? waiting : `${r}. ${waiting}`);
|
|
137
176
|
// step 6: the current task of this change
|
|
138
177
|
const cur = readCurrent(root);
|
|
139
178
|
const current = cur?.task && cur.task.split("/")[0] === key ? tasks.find((t) => t.id === cur.task && t.status === "open") : undefined;
|
|
@@ -141,32 +180,35 @@ export function decideNext(root, cfg, data, want) {
|
|
|
141
180
|
const fails = failsInWindow(root, current.id, qs);
|
|
142
181
|
const last = fails.at(-1);
|
|
143
182
|
// REQ-LOOP-006 the reason names the last fail line of the tail (029/D3)
|
|
144
|
-
const reason = last ? `Verify failed (exit ${last.file.verify.exit ?? "timeout"}): ${failLine(last.file.stderr_tail)}` : null;
|
|
183
|
+
const reason = withWaiting(last ? `Verify failed (exit ${last.file.verify.exit ?? "timeout"}): ${failLine(last.file.stderr_tail)}` : null);
|
|
145
184
|
if (fails.length >= cfg.loop.max_attempts && !openQuestionFor(root, current.id, qs)) {
|
|
146
185
|
const q = capQuestion(current.id, fails.length, last?.file.stderr_tail ?? "");
|
|
147
|
-
return done({ action: "ask", attempt: fails.length, command: `
|
|
186
|
+
return done({ action: "ask", attempt: fails.length, command: rc(`handoff ask "${q}"`), human: true, reason, state: "attempts_exhausted", task: current.id });
|
|
148
187
|
}
|
|
149
188
|
if (fails.length)
|
|
150
|
-
return done({ action: "fix", attempt: fails.length, command: `
|
|
151
|
-
return done({ action: "work", command: `
|
|
152
|
-
}
|
|
153
|
-
// REQ-LOOP-008 step 6b: the
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
189
|
+
return done({ action: "fix", attempt: fails.length, command: rc(`task done ${current.id}`), reason, state: "task_failed", task: current.id });
|
|
190
|
+
return done({ action: "work", command: rc(`task done ${current.id}`), reason, state: "task_started", task: current.id });
|
|
191
|
+
}
|
|
192
|
+
// REQ-LOOP-008 step 6b: the stale tasks repair after the current task (031/D4, 031/D5)
|
|
193
|
+
if (stale.length) {
|
|
194
|
+
// REQ-LOOP-008 one turn repairs every stale task (033/D1)
|
|
195
|
+
return done({
|
|
196
|
+
action: "repair",
|
|
197
|
+
command: stale.map((s) => `${rc(`task reopen ${s.id} "stale evidence: ${s.path}"`)} && ${rc(`task done ${s.id}`)}`).join(" && "),
|
|
198
|
+
reason: stale.map((s) => `stale-evidence ${s.id} ${s.path}`).join(", "),
|
|
199
|
+
state: "evidence_stale",
|
|
200
|
+
task: stale[0].id,
|
|
201
|
+
tasks: stale.map((s) => s.id),
|
|
202
|
+
});
|
|
161
203
|
}
|
|
162
204
|
// step 7: the next open task
|
|
163
205
|
const open = nextTask(root, entries, folder);
|
|
164
206
|
if (open)
|
|
165
|
-
return done({ action: "start", command: `
|
|
207
|
+
return done({ action: "start", command: rc(`task start ${open.id}`), state: "task_open", task: open.id });
|
|
166
208
|
// step 8: the gate must pass after the newest evidence
|
|
167
209
|
const gate = lastGate(root);
|
|
168
210
|
if (!gate || !gate.ok || cmpStr(gate.at, newestEvidenceAt(root, key)) < 0) {
|
|
169
|
-
return done({ action: "gate", command: "
|
|
211
|
+
return done({ action: "gate", command: rc("gate --strict"), state: "gate_due" });
|
|
170
212
|
}
|
|
171
213
|
// step 9: archive, with the human stop from [loop] archive
|
|
172
214
|
const archive = `${openspec} archive ${folder} -y`;
|
|
@@ -174,14 +216,24 @@ export function decideNext(root, cfg, data, want) {
|
|
|
174
216
|
return done({ action: "archive", command: archive, human: false, state: "gate_passed" });
|
|
175
217
|
// REQ-LOOP-007 a resolved archive question after the newest evidence is the approval. The stage field decides, not the text (031/D2).
|
|
176
218
|
const newest = newestEvidenceAt(root, key);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
219
|
+
// REQ-LOOP-015 REQ-LOOP-016 the newest archive question after the newest evidence decides. Only an approval counts (048/D5).
|
|
220
|
+
const decided = newestOf(qs.filter((q) => q.status === "resolved" && q.task === folder && q.stage === "archive" && typeof q.resolved_at === "string" && cmpStr(q.resolved_at, newest) > 0));
|
|
221
|
+
if (decided?.verdict === "approved")
|
|
222
|
+
return done({ action: "archive", command: archive, human: false, reason: `archive approved in question ${decided.n}`, state: "gate_passed" });
|
|
223
|
+
const askArchive = rc(`handoff ask --change ${folder} "archive ${folder}?"`);
|
|
224
|
+
if (decided?.verdict === "rejected") {
|
|
225
|
+
return done({ action: "archive", command: askArchive, human: true, reason: `archive rejected in question ${decided.n}: ${decided.answer ?? ""}`, state: "gate_passed" });
|
|
226
|
+
}
|
|
227
|
+
return done({ action: "archive", command: askArchive, human: true, state: "gate_passed" });
|
|
228
|
+
}
|
|
229
|
+
/** The question with the highest n, or undefined (048/D4). */
|
|
230
|
+
function newestOf(qs) {
|
|
231
|
+
return qs.reduce((a, q) => (a === undefined || q.n > a.n ? q : a), undefined);
|
|
181
232
|
}
|
|
182
233
|
/** REQ-LOOP-001 REQ-CLI-007 the human form: action first, one line per key, next last (024/D1). Strict mode. */
|
|
183
234
|
export function renderNext(d) {
|
|
184
|
-
|
|
235
|
+
// REQ-LOOP-012 an empty list prints none, a list joins with a comma and a space
|
|
236
|
+
const show = (v) => (v === null ? "none" : Array.isArray(v) ? (v.length ? v.join(", ") : "none") : String(v));
|
|
185
237
|
const lines = [`action: ${d.action}`];
|
|
186
238
|
for (const k of KEYS) {
|
|
187
239
|
if (k === "action" || k === "command")
|
package/dist/runcmd.js
ADDED
package/dist/util/time.js
CHANGED
package/dist/verify/run.js
CHANGED
|
@@ -5,10 +5,11 @@ export function tail(text, n = TAIL_LINES) {
|
|
|
5
5
|
const lines = text.replace(/\n$/, "").split("\n");
|
|
6
6
|
return text === "" ? "" : lines.slice(-n).join("\n");
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
/** REQ-VER-007 `env` replaces the environment of the child. The rerun passes the current env plus CI and SPECCTX_RERUN. */
|
|
9
|
+
export function runVerify(command, cwd, timeoutS, env) {
|
|
9
10
|
return new Promise((resolve, reject) => {
|
|
10
11
|
// a new process group, so the timeout stops the children of sh too
|
|
11
|
-
const child = spawn("sh", ["-c", command], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
12
|
+
const child = spawn("sh", ["-c", command], { cwd, detached: true, ...(env ? { env } : {}), stdio: ["ignore", "pipe", "pipe"] });
|
|
12
13
|
const out = [];
|
|
13
14
|
const err = [];
|
|
14
15
|
let output = "";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// REQ-CLI-001 version string. tests/cli/version.test.ts keeps it equal to package.json.
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.4.1";
|
package/package.json
CHANGED
|
@@ -16,22 +16,27 @@ Run `specctx` as `npx spec-ctx` when it is not installed. `openspec` is the comm
|
|
|
16
16
|
|
|
17
17
|
## Actions
|
|
18
18
|
|
|
19
|
-
- `plan`: take the change name from the request of the user in kebab case. That name is `<c>`. Follow the upstream `openspec-propose` skill for `<c>`. Read existing requirements with `specctx bundle <id>` or `specctx query`, never `openspec show`. Take every ID from `specctx id new REQ <CAP>` and `specctx id new SCN <CAP> --for <REQ>`. When the change needs several IDs of one kind, run `specctx id new --count <n>` once. Give every task a `[REQ-...]` citation and a `Verify:` code span. Then run `specctx check --strict` and `specctx gate --strict`. The gate runs the OpenSpec validate and the spec-length check. Fix every finding. Then run `specctx
|
|
19
|
+
- `plan`: take the change name from the request of the user in kebab case. That name is `<c>`. Follow the upstream `openspec-propose` skill for `<c>`. Read existing requirements with `specctx bundle <id>` or `specctx query`, never `openspec show`. Take every ID from `specctx id new REQ <CAP>` and `specctx id new SCN <CAP> --for <REQ>`. When the change needs several IDs of one kind, run `specctx id new --count <n>` once. Give every task a `[REQ-...]` citation and a `Verify:` code span. Then run `specctx check --strict` and `specctx gate --strict`. The gate runs the OpenSpec validate and the spec-length check. Fix every finding. Then run `specctx next --json` and run the command it prints. This is the plan stop. `next` returns `ask` with `human: true` until the human approves the question. You never resolve a plan question yourself.
|
|
20
20
|
- `cite`: run `specctx query --change <c> --kind req`. Add the `[REQ-...]` citation and a `Verify:` code span to the task line named in `task`. Run `specctx check`. Continue.
|
|
21
21
|
- `start`: run `specctx task start <t>`. Read the bundle it prints. Continue.
|
|
22
22
|
- `work`: implement the task. Tag code with `// REQ-...`. Tag tests with `// Covers: REQ-...`. Then run `specctx task done <t>`.
|
|
23
23
|
- `fix`: read `reason`. It holds the tail of the failed Verify output. Fix the cause. Run `specctx task done <t>` again.
|
|
24
24
|
- `repair`: run the command in the `command` field. It runs Verify again for a ticked task whose evidence is absent or stale.
|
|
25
|
-
- `gate`: run `specctx gate --strict`. Fix every finding. Run
|
|
26
|
-
- `archive`: when `human` is true, run `specctx handoff ask --change <c> "archive <c>?"` and stop. This is the archive stop. `next` returns `ask` until the
|
|
27
|
-
- `ask`: print `reason`. It is the open question. Stop. The
|
|
25
|
+
- `gate`: run `specctx gate --strict`. Fix every finding. For a `stale-evidence` finding, go back to step 1. `next` returns `repair` and its command repairs every stale task. Run the gate again until it exits 0.
|
|
26
|
+
- `archive`: when `human` is true, run `specctx handoff ask --change <c> "archive <c>?"` and stop. This is the archive stop. `next` returns `ask` until the human approves the question. You never resolve an archive question yourself. After a rejection, `next` returns `archive` with `human: true` again. When `human` is false, the user approved or `[loop] archive` is `auto`. Then run each step under `operations.archive.guidance` in `openspec/config.yaml`. Then run the command in the `command` field. Then run `openspec validate --all --strict`, `specctx check --strict`, and `specctx handoff write`.
|
|
27
|
+
- `ask`: print `reason`. It is the open question. Stop. You never resolve a plan or archive question yourself. The human does, with `specctx handoff resolve <n> --approve "<answer>"` or `--reject "<answer>"`. A rejection keeps the stop. Any other question takes `specctx handoff resolve <n> "<answer>"`.
|
|
28
28
|
- `stop`: print `reason`. Stop.
|
|
29
29
|
|
|
30
30
|
## Rules
|
|
31
31
|
|
|
32
|
+
- This skill overrides two steps of the upstream `openspec-propose` skill. It reads specs with `specctx bundle` or `specctx query`, not `openspec show`. It never runs the closing `/opsx:apply`.
|
|
32
33
|
- Never tick a checkbox by hand. `specctx task done` is the only way a task closes.
|
|
33
34
|
- Never use `--manual` on a task with a Verify command.
|
|
34
35
|
- Never call `/opsx:apply`. This skill replaces it.
|
|
36
|
+
- To write a MODIFIED block, run `specctx bundle <REQ> --block`. It prints the block as it sits in the spec file. A MODIFIED block keeps every scenario title unchanged.
|
|
37
|
+
- `gate` counts the Refs line in the 500 characters of a requirement.
|
|
38
|
+
- The CHANGELOG entry goes under `## Unreleased`, never under a released version.
|
|
39
|
+
- After an edit under `skills/`, run `bun run build && node dist/cli.js init --skills --force` to refresh the copies.
|
|
35
40
|
- Never read `openspec/specs/` or `openspec/changes/*/specs/` directly. Use the bundle from `task start` or `specctx bundle <id>`.
|
|
36
41
|
- Stop on any `action` not in the list above. Print the object and ask the user.
|
|
37
42
|
- Keep no state in your context. A new session starts the same loop again with step 1.
|