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.
@@ -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
- const first = tail.split("\n")[0]?.trim() || "no output";
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 specctx handoff resolve ${n} "<answer>" to continue.`;
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:` or `FAIL` after
94
- * leading spaces, else the last non-empty line, else `no output`. The match is case sensitive.
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
- /** The last line of the gate log, or null. */
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
- const last = lines.at(-1);
56
- if (!last)
57
- return null;
58
- try {
59
- const o = JSON.parse(last);
60
- return { at: typeof o.at === "string" ? o.at : "", ok: o.ok === true };
61
- }
62
- catch {
63
- return null;
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
- const d = { action: "stop", attempt: 0, change: null, command: null, human: false, max_attempts: cfg.loop.max_attempts, reason: null, state: "unknown", task: null };
95
- const done = (o) => ({ ...d, ...o });
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
- return done({ action: "ask", attempt, command: `specctx handoff resolve ${blocking.n} "<answer>"`, human: true, reason: blocking.text, state: "question_open", task });
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: `specctx query --change ${folder} --kind req`, reason: `${first.rule} ${first.id} at ${first.file}:${first.line}`, state: "citation_missing", task });
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
- const ticked = tasks.find((t) => t.status === "done" && !hasEvidence(root, t));
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: `specctx task done ${ticked.id}`, state: "done_without_evidence", task: ticked.id });
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: `specctx handoff ask "${q}"`, human: true, reason, state: "attempts_exhausted", task: current.id });
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: `specctx task done ${current.id}`, reason, state: "task_failed", task: current.id });
151
- return done({ action: "work", command: `specctx task done ${current.id}`, state: "task_started", task: current.id });
152
- }
153
- // REQ-LOOP-008 step 6b: the earliest done task whose evidence went stale gets the repair run, after the current task (031/D4, 031/D5)
154
- for (const t of tasks) {
155
- if (t.status !== "done")
156
- continue;
157
- const path = staleOf(root, t.id);
158
- if (path === null)
159
- continue;
160
- return done({ action: "repair", command: `specctx task reopen ${t.id} "stale evidence: ${path}" && specctx task done ${t.id}`, reason: `stale-evidence ${t.id} ${path}`, state: "evidence_stale", task: t.id });
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: `specctx task start ${open.id}`, state: "task_open", task: open.id });
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: "specctx gate --strict", state: "gate_due" });
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
- const approved = qs.filter((q) => q.status === "resolved" && q.task === folder && q.stage === "archive" && typeof q.resolved_at === "string" && cmpStr(q.resolved_at, newest) > 0).at(-1);
178
- if (approved)
179
- return done({ action: "archive", command: archive, human: false, reason: `archive approved in question ${approved.n}`, state: "gate_passed" });
180
- return done({ action: "archive", command: `specctx handoff ask --change ${folder} "archive ${folder}?"`, human: true, state: "gate_passed" });
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
- const show = (v) => (v === null ? "none" : String(v));
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
@@ -0,0 +1,5 @@
1
+ // REQ-CLI-010 the one function that builds a command for a person or agent to run (043/D1).
2
+ /** The prefix, a space, and the arguments. Every printed command goes through here. */
3
+ export function runCommand(run, args) {
4
+ return `${run} ${args}`;
5
+ }
package/dist/util/time.js CHANGED
@@ -6,6 +6,7 @@ export function now() {
6
6
  export function isoNow() {
7
7
  return now().toISOString();
8
8
  }
9
+ /** REQ-STAT-005 the UTC date, so CI and a local run name a metrics file alike (033/D5). */
9
10
  export function today() {
10
11
  return isoNow().slice(0, 10);
11
12
  }
@@ -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
- export function runVerify(command, cwd, timeoutS) {
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.3.0";
2
+ export const VERSION = "0.4.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-ctx",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Deterministic context engine and task workflow for OpenSpec projects.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 handoff ask --change <c> "plan ready for <c>. Approve to start the tasks?"`. This is the plan stop. `next` returns `ask` until the user resolves the question.
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 it 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 user resolves the question. 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. The user answers with `specctx handoff resolve <n> "<answer>"`.
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.