spec-ctx 0.4.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 +88 -16
- package/dist/adopt/write.js +6 -2
- package/dist/assets.generated.js +1 -1
- package/dist/check/hints.js +10 -9
- package/dist/cli/args.js +2 -1
- package/dist/cli/commands.js +66 -20
- package/dist/cli/help.js +17 -5
- package/dist/cli/workflow.js +24 -10
- package/dist/config.js +8 -0
- package/dist/graph/bundle.js +140 -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 +6 -4
- package/dist/handoff/questions.js +9 -1
- package/dist/index/build.js +33 -4
- package/dist/index/clock.js +55 -0
- package/dist/index/scan.js +36 -9
- package/dist/index/structure.js +97 -0
- package/dist/init/init.js +15 -4
- package/dist/loop/fails.js +10 -2
- package/dist/loop/next.js +38 -17
- package/dist/runcmd.js +5 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/specctx-run/SKILL.md +3 -3
package/dist/loop/next.js
CHANGED
|
@@ -10,6 +10,8 @@ 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"];
|
|
@@ -94,6 +96,8 @@ function readCurrent(root) {
|
|
|
94
96
|
/** REQ-LOOP-001 the decision of 024/D2. A pure function of the files. It writes nothing. */
|
|
95
97
|
export function decideNext(root, cfg, data, want) {
|
|
96
98
|
const openspec = openspecCommand(cfg.gate.openspec);
|
|
99
|
+
// REQ-CLI-010 every printed command goes through the run prefix (043/D1)
|
|
100
|
+
const rc = (args) => runCommand(cfg.cli.run, args);
|
|
97
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: [] };
|
|
98
102
|
const done = (o) => {
|
|
99
103
|
const r = { ...d, ...o };
|
|
@@ -118,7 +122,9 @@ export function decideNext(root, cfg, data, want) {
|
|
|
118
122
|
if (blocking) {
|
|
119
123
|
const task = blocking.task.includes("/") ? blocking.task : null;
|
|
120
124
|
const attempt = task ? failsInWindow(root, task, qs).length : 0;
|
|
121
|
-
|
|
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 });
|
|
122
128
|
}
|
|
123
129
|
// step 3: planning state from OpenSpec
|
|
124
130
|
const st = openspecStatus(root, openspec, folder);
|
|
@@ -135,20 +141,26 @@ export function decideNext(root, cfg, data, want) {
|
|
|
135
141
|
if (found.length) {
|
|
136
142
|
const first = found[0];
|
|
137
143
|
const task = found.find((f) => TASK_RULES.has(f.rule))?.id ?? null;
|
|
138
|
-
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 });
|
|
139
145
|
}
|
|
140
146
|
// step 5: a hand tick without evidence gets the repair run
|
|
141
147
|
const tasks = tasksOf(entries).filter(ofChange).sort((a, b) => cmpTuple([a.file, a.line], [b.file, b.line]));
|
|
142
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).
|
|
143
149
|
const cur0 = readCurrent(root);
|
|
144
150
|
const started = tasks.some((t) => t.status === "done") || (cur0?.task !== undefined && cur0.task.split("/")[0] === key);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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" });
|
|
148
156
|
}
|
|
149
|
-
|
|
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));
|
|
150
162
|
if (ticked)
|
|
151
|
-
return done({ action: "repair", command: `
|
|
163
|
+
return done({ action: "repair", command: rc(`task done ${ticked.id}`), state: "done_without_evidence", task: ticked.id });
|
|
152
164
|
// REQ-LOOP-008 the done tasks whose evidence went stale, earliest first (031/D4, 031/D5)
|
|
153
165
|
const stale = [];
|
|
154
166
|
for (const t of tasks) {
|
|
@@ -171,18 +183,18 @@ export function decideNext(root, cfg, data, want) {
|
|
|
171
183
|
const reason = withWaiting(last ? `Verify failed (exit ${last.file.verify.exit ?? "timeout"}): ${failLine(last.file.stderr_tail)}` : null);
|
|
172
184
|
if (fails.length >= cfg.loop.max_attempts && !openQuestionFor(root, current.id, qs)) {
|
|
173
185
|
const q = capQuestion(current.id, fails.length, last?.file.stderr_tail ?? "");
|
|
174
|
-
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 });
|
|
175
187
|
}
|
|
176
188
|
if (fails.length)
|
|
177
|
-
return done({ action: "fix", attempt: fails.length, command: `
|
|
178
|
-
return done({ action: "work", command: `
|
|
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 });
|
|
179
191
|
}
|
|
180
192
|
// REQ-LOOP-008 step 6b: the stale tasks repair after the current task (031/D4, 031/D5)
|
|
181
193
|
if (stale.length) {
|
|
182
194
|
// REQ-LOOP-008 one turn repairs every stale task (033/D1)
|
|
183
195
|
return done({
|
|
184
196
|
action: "repair",
|
|
185
|
-
command: stale.map((s) => `
|
|
197
|
+
command: stale.map((s) => `${rc(`task reopen ${s.id} "stale evidence: ${s.path}"`)} && ${rc(`task done ${s.id}`)}`).join(" && "),
|
|
186
198
|
reason: stale.map((s) => `stale-evidence ${s.id} ${s.path}`).join(", "),
|
|
187
199
|
state: "evidence_stale",
|
|
188
200
|
task: stale[0].id,
|
|
@@ -192,11 +204,11 @@ export function decideNext(root, cfg, data, want) {
|
|
|
192
204
|
// step 7: the next open task
|
|
193
205
|
const open = nextTask(root, entries, folder);
|
|
194
206
|
if (open)
|
|
195
|
-
return done({ action: "start", command: `
|
|
207
|
+
return done({ action: "start", command: rc(`task start ${open.id}`), state: "task_open", task: open.id });
|
|
196
208
|
// step 8: the gate must pass after the newest evidence
|
|
197
209
|
const gate = lastGate(root);
|
|
198
210
|
if (!gate || !gate.ok || cmpStr(gate.at, newestEvidenceAt(root, key)) < 0) {
|
|
199
|
-
return done({ action: "gate", command: "
|
|
211
|
+
return done({ action: "gate", command: rc("gate --strict"), state: "gate_due" });
|
|
200
212
|
}
|
|
201
213
|
// step 9: archive, with the human stop from [loop] archive
|
|
202
214
|
const archive = `${openspec} archive ${folder} -y`;
|
|
@@ -204,10 +216,19 @@ export function decideNext(root, cfg, data, want) {
|
|
|
204
216
|
return done({ action: "archive", command: archive, human: false, state: "gate_passed" });
|
|
205
217
|
// REQ-LOOP-007 a resolved archive question after the newest evidence is the approval. The stage field decides, not the text (031/D2).
|
|
206
218
|
const newest = newestEvidenceAt(root, key);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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);
|
|
211
232
|
}
|
|
212
233
|
/** REQ-LOOP-001 REQ-CLI-007 the human form: action first, one line per key, next last (024/D1). Strict mode. */
|
|
213
234
|
export function renderNext(d) {
|
package/dist/runcmd.js
ADDED
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.4.
|
|
2
|
+
export const VERSION = "0.4.1";
|
package/package.json
CHANGED
|
@@ -16,15 +16,15 @@ 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 next --json` and run the command it prints. This is the plan stop. `next` returns `ask` with `human: true` until the
|
|
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
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
|
|
27
|
-
- `ask`: print `reason`. It is the open question. Stop. The
|
|
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
|