spec-ctx 0.4.0 → 0.5.0

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.
@@ -1,12 +1,14 @@
1
- // REQ-LOOP-002 fail files under .ctx/evidence/<change>/<task>.fail-<n>.json and the attempt window (024/D4, 024/D5).
1
+ // REQ-LOOP-002 REQ-LOOP-017 REQ-LOOP-018 fail files under .ctx/evidence/<change>/<task>.fail-<n>.json and the attempt window (024/D4, 024/D5).
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  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";
11
+ const CURRENT = ".ctx/current.json";
10
12
  /** Every fail file of a task, parsed, in `at` order. A damaged file is skipped. */
11
13
  export function readFails(root, task) {
12
14
  const rel = evidenceRel({ id: task });
@@ -30,19 +32,22 @@ export function readFails(root, task) {
30
32
  return out.sort((a, b) => cmpStr(a.file.at, b.file.at) || cmpStr(a.rel, b.rel));
31
33
  }
32
34
  /**
33
- * The start of the attempt window of a task: `current.json.started_at` when the file names the task, else the
34
- * start of time. A resolved question for the task moves the start to its `resolved_at`, so a resolve resets the count.
35
+ * REQ-LOOP-018 the start of the attempt window of a task: `current.json.started_at` when the file names the task, else
36
+ * null, and no fail file counts (056/D4). A resolved question for the task moves the start to its `resolved_at`, so a
37
+ * resolve resets the count. `fallback` gives the start of time instead of null, for a current.json that could not be written.
35
38
  */
36
- export function windowStart(root, task, questions) {
37
- let start = "";
39
+ export function windowStart(root, task, questions, fallback = false) {
40
+ let start = fallback ? "" : null;
38
41
  try {
39
- const cur = JSON.parse(readFileSync(join(root, ".ctx/current.json"), "utf8"));
42
+ const cur = JSON.parse(readFileSync(join(root, CURRENT), "utf8"));
40
43
  if (cur.task === task && typeof cur.started_at === "string")
41
44
  start = cur.started_at;
42
45
  }
43
46
  catch {
44
47
  // no current task
45
48
  }
49
+ if (start === null)
50
+ return null;
46
51
  for (const q of questions ?? safeQuestions(root)) {
47
52
  if (q.task === task && q.status === "resolved" && typeof q.resolved_at === "string" && cmpStr(q.resolved_at, start) > 0)
48
53
  start = q.resolved_at;
@@ -57,18 +62,71 @@ function safeQuestions(root) {
57
62
  return [];
58
63
  }
59
64
  }
60
- /** The fail files of a task inside its window, in `at` order. */
61
- export function failsInWindow(root, task, questions) {
62
- const start = windowStart(root, task, questions);
65
+ /** REQ-LOOP-018 the fail files of a task inside its window, in `at` order. None without a window. */
66
+ export function failsInWindow(root, task, questions, fallback = false) {
67
+ const start = windowStart(root, task, questions, fallback);
68
+ if (start === null)
69
+ return [];
63
70
  return readFails(root, task).filter((f) => cmpStr(f.file.at, start) >= 0);
64
71
  }
65
72
  /** The open question that names the task, if any. */
66
73
  export function openQuestionFor(root, task, questions) {
67
74
  return (questions ?? safeQuestions(root)).find((q) => q.task === task && q.status === "open");
68
75
  }
76
+ /**
77
+ * 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).
78
+ * The text plays no part (044/D3).
79
+ */
80
+ export function isCapQuestion(q, task) {
81
+ return q.status === "open" && q.task === task && q.stage === "cap";
82
+ }
83
+ /**
84
+ * REQ-LOOP-018 a failed run with no current.json for the task opens its window first (056/D4). The file has only
85
+ * `started_at` and `task`: no `verify`, so a later Verify edit is not refused, and no `bundle_chars`, so stats keeps
86
+ * cycle time for runs that began with task start. Returns false when the write fails.
87
+ */
88
+ export function openWindow(root, task) {
89
+ try {
90
+ const cur = JSON.parse(readFileSync(join(root, CURRENT), "utf8"));
91
+ if (cur.task === task && typeof cur.started_at === "string")
92
+ return true;
93
+ }
94
+ catch {
95
+ // absent or damaged: write a new one
96
+ }
97
+ try {
98
+ mkdirSync(join(root, ".ctx"), { recursive: true });
99
+ writeFileSync(join(root, CURRENT), dumps({ started_at: isoNow(), task }) + "\n");
100
+ return true;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ /** REQ-LOOP-017 the line that keeps fail files out of git, in `.ctx/evidence/.gitignore` (056/D1). */
107
+ export const FAIL_IGNORE = "*.fail-*.json";
108
+ /**
109
+ * REQ-LOOP-017 make sure `.ctx/evidence/.gitignore` has the fail file line. Create the file or append the line. Other
110
+ * lines keep their bytes and order. A write error changes nothing (056/D1).
111
+ */
112
+ export function ensureFailIgnore(root) {
113
+ const p = join(root, ".ctx/evidence/.gitignore");
114
+ try {
115
+ const cur = existsSync(p) ? readFileSync(p, "utf8") : "";
116
+ if (cur.split(/\r?\n/).some((l) => l.trim() === FAIL_IGNORE))
117
+ return;
118
+ mkdirSync(dirname(p), { recursive: true });
119
+ writeFileSync(p, (cur === "" || cur.endsWith("\n") ? cur : cur + "\n") + FAIL_IGNORE + "\n");
120
+ }
121
+ catch {
122
+ // a read-only tree keeps its fail files tracked
123
+ }
124
+ }
69
125
  /** Write the next fail file of a task. The attempt counts the window, the file number counts every fail file. */
70
126
  export function writeFail(root, task, verify, tail) {
71
- const attempt = failsInWindow(root, task).length + 1;
127
+ // REQ-LOOP-018 when current.json cannot be written, count as before (056/D4)
128
+ const opened = openWindow(root, task);
129
+ const attempt = failsInWindow(root, task, undefined, !opened).length + 1;
72
130
  const rel = evidenceRel({ id: task });
73
131
  const base = rel.slice(0, -".json".length);
74
132
  let n = 1;
@@ -78,6 +136,7 @@ export function writeFail(root, task, verify, tail) {
78
136
  const file = { at: isoNow(), attempt, stderr_tail: tail, task, verify };
79
137
  mkdirSync(dirname(join(root, target)), { recursive: true });
80
138
  writeFileSync(join(root, target), dumps(file) + "\n");
139
+ ensureFailIgnore(root);
81
140
  return { rel: target, file };
82
141
  }
83
142
  /** REQ-LOOP-002 the question text at the cap. It quotes the failing line (033/D2). Strict mode. */
@@ -85,8 +144,8 @@ export function capQuestion(task, attempts, tail) {
85
144
  return `task ${task} failed Verify ${attempts} times. Last: ${failLine(tail)}. What should change?`;
86
145
  }
87
146
  /** REQ-LOOP-002 the last line of a failed `task done` at the cap. Strict mode. */
88
- export function blockedLine(n) {
89
- return `blocked: question ${n} written. Run specctx handoff resolve ${n} "<answer>" to continue.`;
147
+ export function blockedLine(n, run = "specctx") {
148
+ return `blocked: question ${n} written. Run ${runCommand(run, `handoff resolve ${n} "<answer>"`)} to continue.`;
90
149
  }
91
150
  /**
92
151
  * REQ-LOOP-006 the reason line of a Verify tail (029/D3): the last line that starts with `fail:`, `FAIL`, or `(fail)`
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
- 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 });
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: `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 });
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
- if (!started && !qs.some((q) => q.stage === "plan" && q.task === folder)) {
146
- const text = `plan ready for ${folder}. Approve to start the tasks?`;
147
- return done({ action: "ask", command: `specctx handoff ask --change ${folder} --plan "${text}"`, human: true, reason: text, state: "plan_stop" });
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
- const ticked = tasks.find((t) => t.status === "done" && !hasEvidence(root, t));
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: `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 });
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: `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 });
175
187
  }
176
188
  if (fails.length)
177
- return done({ action: "fix", attempt: fails.length, command: `specctx task done ${current.id}`, reason, state: "task_failed", task: current.id });
178
- return done({ action: "work", command: `specctx task done ${current.id}`, reason, state: "task_started", task: current.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 });
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) => `specctx task reopen ${s.id} "stale evidence: ${s.path}" && specctx task done ${s.id}`).join(" && "),
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: `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 });
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: "specctx gate --strict", state: "gate_due" });
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
- 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);
208
- if (approved)
209
- return done({ action: "archive", command: archive, human: false, reason: `archive approved in question ${approved.n}`, state: "gate_passed" });
210
- 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);
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/main.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // REQ-CLI-001 REQ-CLI-002 REQ-CLI-003 command dispatch. Each command loads its modules inside its action.
2
+ import { resolve } from "node:path";
2
3
  import { COMMANDS, GROUPS, parseArgs, UsageError } from "./cli/args.js";
3
4
  import { VERSION } from "./version.js";
5
+ import { errInfo, note } from "./journal/note.js";
6
+ import { runCommand } from "./runcmd.js";
4
7
  export async function main(argv, io) {
5
8
  // REQ-LINT-001 the linters keep the argument rules of the Python scripts, so they skip this parser
6
9
  // REQ-CLI-006 `specctx help <cmd>` prints the page of one command
@@ -29,6 +32,7 @@ export async function main(argv, io) {
29
32
  }
30
33
  catch (e) {
31
34
  if (e instanceof UsageError) {
35
+ note(io, { cmd: "?" });
32
36
  io.err(`specctx: ${e.message}\n`);
33
37
  return 2;
34
38
  }
@@ -49,18 +53,26 @@ export async function main(argv, io) {
49
53
  return 0;
50
54
  }
51
55
  if (GROUPS.includes(parsed.command)) {
56
+ note(io, { cmd: "?" });
52
57
  const subs = COMMANDS.filter((c) => c.startsWith(parsed.command + " ")).map((c) => c.split(" ")[1]);
53
58
  io.err(`specctx: ${parsed.command} needs a subcommand: ${subs.join(", ")}\n`);
54
59
  return 2;
55
60
  }
56
61
  if (!COMMANDS.includes(parsed.command)) {
62
+ note(io, { cmd: "?" });
57
63
  const { commandList } = await import("./cli/help.js");
58
64
  io.err(`specctx: unknown command: ${parsed.command}\n${commandList()}`);
59
65
  return 2;
60
66
  }
67
+ note(io, { cmd: parsed.command });
61
68
  try {
62
69
  const { run } = await import("./cli/commands.js");
63
- return await run(parsed, io);
70
+ const code = await run(parsed, io);
71
+ // REQ-JRN-002 init has no OpenSpec root check. Its root is the one it wrote to.
72
+ if (code === 0 && parsed.command === "init") {
73
+ note(io, { root: resolve(io.cwd, parsed.flags.root ?? "."), signal: "init-ok" });
74
+ }
75
+ return code;
64
76
  }
65
77
  catch (e) {
66
78
  const { UsageExit } = await import("./cli/errors.js");
@@ -69,6 +81,9 @@ export async function main(argv, io) {
69
81
  return 2;
70
82
  }
71
83
  io.err(`specctx: internal error: ${e.stack ?? String(e)}\n`);
84
+ // REQ-JRN-006 one Strict line that points to the report command and the issues page
85
+ io.err(`Run ${runCommand(io.journal?.run ?? "specctx", "report")} and file the output at https://github.com/sonqh/spec-ctx/issues.\n`);
86
+ note(io, { err: errInfo(e) });
72
87
  return 3;
73
88
  }
74
89
  }
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
+ }
@@ -39,9 +39,9 @@ export function writeEvidence(root, t, entries, verify, kind, note, cwd) {
39
39
  // REQ-STAT-002 copy the bundle size and the start time that task start wrote for this task
40
40
  try {
41
41
  const cur = JSON.parse(readFileSync(join(root, ".ctx/current.json"), "utf8"));
42
- if (cur.task === t.id) {
43
- if (typeof cur.bundle_chars === "number")
44
- ev.bundle_chars = cur.bundle_chars;
42
+ // REQ-LOOP-018 started_at only with bundle_chars, so a window that a failed run opened gives no cycle time (056/D4)
43
+ if (cur.task === t.id && typeof cur.bundle_chars === "number") {
44
+ ev.bundle_chars = cur.bundle_chars;
45
45
  if (typeof cur.started_at === "string")
46
46
  ev.started_at = cur.started_at;
47
47
  }
@@ -0,0 +1,57 @@
1
+ // REQ-TASK-008 machine paths out of evidence (056/D5). Windows paths are out of scope: Verify runs through sh.
2
+ import { realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ const WORD = /[A-Za-z0-9._-]/;
5
+ function trimSlash(p) {
6
+ return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
7
+ }
8
+ function realOf(root) {
9
+ try {
10
+ return realpathSync(root);
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ /** The home directory: HOME at call time when set, else the OS answer. Bun caches homedir(), so HOME goes first. */
17
+ function homeNow() {
18
+ return process.env.HOME || homedir();
19
+ }
20
+ /**
21
+ * REQ-TASK-008 replace the project root and its real path with `.`, and the home directory with `~`. The longest path
22
+ * goes first, and the root wins a tie. A match needs a start of text or a non word character before it and a non word
23
+ * character or the end after it. A root or home of `/` or the empty string is skipped. `real` defaults to the real path
24
+ * of `root`, so a test can pin the macOS `/tmp` case.
25
+ */
26
+ export function redactPaths(text, root, home = homeNow(), real = realOf(root)) {
27
+ const pairs = [];
28
+ const add = (from, to) => {
29
+ if (from === null)
30
+ return;
31
+ const p = trimSlash(from);
32
+ if (p === "" || p === "/" || pairs.some(([f]) => f === p))
33
+ return;
34
+ pairs.push([p, to]);
35
+ };
36
+ add(root, ".");
37
+ add(real, ".");
38
+ add(home, "~");
39
+ if (!pairs.length || text === "")
40
+ return text;
41
+ // the longest path first, the root before the home on a tie (a stable sort keeps the push order)
42
+ pairs.sort((a, b) => b[0].length - a[0].length);
43
+ let out = "";
44
+ let i = 0;
45
+ while (i < text.length) {
46
+ const hit = pairs.find(([from]) => text.startsWith(from, i) && (i === 0 || !WORD.test(text[i - 1])) && (i + from.length === text.length || !WORD.test(text[i + from.length])));
47
+ if (hit) {
48
+ out += hit[1];
49
+ i += hit[0].length;
50
+ }
51
+ else {
52
+ out += text[i];
53
+ i++;
54
+ }
55
+ }
56
+ return out;
57
+ }
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.0";
2
+ export const VERSION = "0.5.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-ctx",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Deterministic context engine and task workflow for OpenSpec projects.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 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
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 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>"`.
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