spec-ctx 0.2.0 → 0.4.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.
- package/README.md +38 -5
- package/action.yml +8 -0
- package/dist/adopt/write.js +3 -2
- package/dist/assets.generated.js +1 -1
- package/dist/baseline/baseline.js +6 -3
- package/dist/check/check.js +53 -2
- package/dist/check/evidence.js +26 -14
- package/dist/check/hints.js +10 -3
- package/dist/cli/args.js +3 -3
- package/dist/cli/commands.js +56 -18
- package/dist/cli/config.js +14 -0
- package/dist/cli/gate.js +133 -4
- package/dist/cli/help.js +9 -2
- package/dist/cli/status.js +6 -15
- package/dist/cli/workflow.js +4 -1
- package/dist/config.js +1 -1
- package/dist/gate/rerun.js +169 -0
- package/dist/graph/bundle.js +5 -0
- package/dist/handoff/handoff.js +24 -4
- package/dist/handoff/questions.js +3 -1
- package/dist/ids/ids.js +4 -0
- package/dist/index/scan.js +6 -2
- package/dist/init/init.js +100 -19
- package/dist/loop/fails.js +13 -3
- package/dist/loop/next.js +71 -20
- package/dist/stats/stats.js +6 -3
- package/dist/util/time.js +1 -0
- package/dist/verify/parse.js +6 -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 +10 -5
package/dist/init/init.js
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { ASSETS } from "../assets.generated.js";
|
|
6
6
|
import { UsageExit } from "../cli/errors.js";
|
|
7
7
|
import { CONFIG_FILE, DEFAULT_OPENSPEC, INIT_TEXT } from "../config.js";
|
|
8
|
+
import { loadConfigOrExit } from "../cli/config.js";
|
|
8
9
|
export const SCHEMA = "spec-driven-ids";
|
|
9
10
|
/** REQ-SCH-003 where the templates land in a project. Upstream reads no project override for the stock templates, so this is a reference path (025/D4). */
|
|
10
11
|
export const TEMPLATES_DIR = `openspec/templates/${SCHEMA}`;
|
|
@@ -51,13 +52,34 @@ export function configText(root, openspec = DEFAULT_OPENSPEC) {
|
|
|
51
52
|
text = text.replace(`\nopenspec = "${DEFAULT_OPENSPEC}"\n`, `\nopenspec = ${JSON.stringify(openspec)}\n`);
|
|
52
53
|
return text;
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* REQ-SCH-006 wrap the ID after a requirement or scenario header prefix in square brackets (032/D1).
|
|
57
|
+
* An ID in brackets already stays. Task citations, Refs lines, and tags have no header prefix, so they stay bare.
|
|
58
|
+
*/
|
|
59
|
+
export function bracketForm(text) {
|
|
60
|
+
return text.replace(/(### Requirement: |#### Scenario: )((?:REQ|SCN)-[^\s\]"'`]+)/g, "$1[$2]");
|
|
61
|
+
}
|
|
62
|
+
/** REQ-SCH-006 the bytes of one template in the form that `brackets` names (032/D1). */
|
|
63
|
+
export function templateBytes(rel, brackets) {
|
|
64
|
+
const b = asset(rel);
|
|
65
|
+
return brackets ? Buffer.from(bracketForm(b.toString("utf8"))) : b;
|
|
66
|
+
}
|
|
67
|
+
/** REQ-SCH-006 the config fragment in the form that `brackets` names (032/D1). */
|
|
68
|
+
export function fragmentText(brackets) {
|
|
69
|
+
const t = asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8");
|
|
70
|
+
return brackets ? bracketForm(t) : t;
|
|
71
|
+
}
|
|
72
|
+
/** REQ-SCH-006 the value of [ids] brackets, false when spec-ctx.toml is absent (032/D2). */
|
|
73
|
+
export function bracketsOf(root) {
|
|
74
|
+
return existsSync(join(root, CONFIG_FILE)) ? loadConfigOrExit(root).ids.brackets : false;
|
|
75
|
+
}
|
|
54
76
|
/** Target path relative to the project root for every file that init installs, with its bytes. */
|
|
55
|
-
export function plan(o, root, openspec = DEFAULT_OPENSPEC) {
|
|
77
|
+
export function plan(o, root, openspec = DEFAULT_OPENSPEC, brackets = false) {
|
|
56
78
|
const out = [[CONFIG_FILE, Buffer.from(configText(root, openspec))]];
|
|
57
79
|
for (const rel of Object.keys(ASSETS).sort()) {
|
|
58
80
|
// REQ-SCH-003 only the four templates land in the project. The fragment merges into config.yaml. No schema folder (025/D3)
|
|
59
81
|
if (o.schema && rel.startsWith(TEMPLATES_SRC)) {
|
|
60
|
-
out.push([`${TEMPLATES_DIR}/${rel.slice(TEMPLATES_SRC.length)}`,
|
|
82
|
+
out.push([`${TEMPLATES_DIR}/${rel.slice(TEMPLATES_SRC.length)}`, templateBytes(rel, brackets)]);
|
|
61
83
|
}
|
|
62
84
|
if (o.skills && rel.startsWith("skills/")) {
|
|
63
85
|
for (const d of SKILL_DIRS)
|
|
@@ -268,20 +290,26 @@ export const LEGACY_SCHEMA_NOTE = `note: schema ${SCHEMA} is no longer installed
|
|
|
268
290
|
* REQ-SCH-003 add each top-level key of the fragment that the project config lacks.
|
|
269
291
|
* A key that exists gets the fragment items it lacks (022/D4). The schema key stays as the project wrote it (025/D5).
|
|
270
292
|
*/
|
|
271
|
-
export function mergeConfig(current, fragment) {
|
|
293
|
+
export function mergeConfig(current, fragment, other) {
|
|
272
294
|
let text = current ?? "";
|
|
273
295
|
const added = [];
|
|
274
296
|
const kept = [];
|
|
275
297
|
const merged = [];
|
|
276
298
|
const have = topKeys(text);
|
|
277
|
-
|
|
299
|
+
const blocks = topBlocks(fragment);
|
|
300
|
+
// REQ-SCH-007 an item that holds the other bracket form gets the current form in place (032/D4)
|
|
301
|
+
const swapped = other === undefined ? { text, size: 0, get: (_k) => undefined } : swapItems(text, blocks, topBlocks(other));
|
|
302
|
+
if (swapped.size)
|
|
303
|
+
text = swapped.text;
|
|
304
|
+
for (const [key, block] of blocks) {
|
|
278
305
|
if (have.has(key)) {
|
|
279
306
|
const lines = text.split("\n");
|
|
280
307
|
const n = mergeItems(lines, key, block);
|
|
281
|
-
|
|
308
|
+
const r = swapped.get(key) ?? 0;
|
|
309
|
+
if (n)
|
|
282
310
|
text = lines.join("\n");
|
|
283
|
-
|
|
284
|
-
|
|
311
|
+
if (n || r)
|
|
312
|
+
merged.push(r ? { key, added: n, replaced: r } : { key, added: n });
|
|
285
313
|
else
|
|
286
314
|
kept.push(key);
|
|
287
315
|
continue;
|
|
@@ -296,6 +324,46 @@ export function mergeConfig(current, fragment) {
|
|
|
296
324
|
}
|
|
297
325
|
return { text, added, kept, merged };
|
|
298
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* REQ-SCH-007 replace each project item whose value equals an item of the other form with the line of the current form (032/D4).
|
|
329
|
+
* The two fragments have the same lists in the same order, so items pair by position. The indent of the project line stays.
|
|
330
|
+
* Returns the new text and the count per top key.
|
|
331
|
+
*/
|
|
332
|
+
function swapItems(text, cur, other) {
|
|
333
|
+
const swap = new Map();
|
|
334
|
+
for (const [key, block] of cur) {
|
|
335
|
+
const a = fragmentLists(block);
|
|
336
|
+
const b = fragmentLists(other.get(key) ?? "");
|
|
337
|
+
a.forEach((list, i) => list.items.forEach((item, j) => {
|
|
338
|
+
const o = b[i]?.items[j];
|
|
339
|
+
if (o !== undefined && itemValue(o) !== itemValue(item))
|
|
340
|
+
swap.set(`${key}\0${itemValue(o)}`, item.trim());
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
const counts = new Map();
|
|
344
|
+
if (!swap.size)
|
|
345
|
+
return { text, size: 0, get: (k) => counts.get(k) };
|
|
346
|
+
let top = null;
|
|
347
|
+
const lines = text.split("\n").map((line) => {
|
|
348
|
+
const m = TOP_KEY.exec(line);
|
|
349
|
+
if (m)
|
|
350
|
+
top = m[1];
|
|
351
|
+
if (!top || !/^[ \t]+- /.test(line))
|
|
352
|
+
return line;
|
|
353
|
+
const to = swap.get(`${top}\0${itemValue(line)}`);
|
|
354
|
+
if (to === undefined)
|
|
355
|
+
return line;
|
|
356
|
+
counts.set(top, (counts.get(top) ?? 0) + 1);
|
|
357
|
+
return " ".repeat(indentOf(line)) + to;
|
|
358
|
+
});
|
|
359
|
+
return { text: lines.join("\n"), size: counts.size, get: (k) => counts.get(k) };
|
|
360
|
+
}
|
|
361
|
+
/** REQ-SCH-007 the text of the `merged` count: added, replaced, or both (032/D4). */
|
|
362
|
+
export function mergedCount(x) {
|
|
363
|
+
if (!x.replaced)
|
|
364
|
+
return `${x.added} added`;
|
|
365
|
+
return x.added ? `${x.added} added, ${x.replaced} replaced` : `${x.replaced} replaced`;
|
|
366
|
+
}
|
|
299
367
|
/** REQ-DIST-002 the four lines init owns in .gitignore (017/D2, 022/D5). current.json and gate-log.jsonl are per session. */
|
|
300
368
|
export const GITIGNORE_LINES = [".ctx/index.json", "adopt-report.md", ".ctx/current.json", ".ctx/gate-log.jsonl"];
|
|
301
369
|
const GITIGNORE = ".gitignore";
|
|
@@ -344,15 +412,21 @@ export function runInit(root, o) {
|
|
|
344
412
|
if (d.note)
|
|
345
413
|
res.notes.push(d.note);
|
|
346
414
|
}
|
|
347
|
-
|
|
415
|
+
// REQ-SCH-006 the form of the templates and the rules follows [ids] brackets (032/D2)
|
|
416
|
+
const brackets = bracketsOf(root);
|
|
417
|
+
const fragment = fragmentText(brackets);
|
|
418
|
+
const otherFragment = fragmentText(!brackets);
|
|
419
|
+
let files = plan(o, root, openspec, brackets);
|
|
348
420
|
// REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
|
|
349
421
|
// an existing spec-ctx.toml counts as present, because the user owns it (014/D4)
|
|
350
422
|
const same = files.every(([rel, data]) => existsSync(join(root, rel)) && (rel === CONFIG_FILE || readFileSync(join(root, rel)).equals(data)));
|
|
351
|
-
|
|
423
|
+
// REQ-DIST-013 a skills refresh in a project with spec-ctx.toml touches only the skill folders (038/D3)
|
|
424
|
+
const skillsOnly = o.skills && !o.schema && existsSync(join(root, CONFIG_FILE));
|
|
425
|
+
const ignoreDone = skillsOnly || existsSync(join(root, GITIGNORE)) && GITIGNORE_LINES.every((l) => ignoreLines(readFileSync(join(root, GITIGNORE), "utf8")).has(l));
|
|
352
426
|
if (same && ignoreDone && !o.force) {
|
|
353
427
|
const cfgPath = join(root, "openspec/config.yaml");
|
|
354
428
|
const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
|
|
355
|
-
const m = o.schema ? mergeConfig(cur,
|
|
429
|
+
const m = o.schema ? mergeConfig(cur, fragment, otherFragment) : null;
|
|
356
430
|
if (!m || (!m.added.length && !m.merged.length)) {
|
|
357
431
|
res.nothing = true;
|
|
358
432
|
if (o.skills && ignoresClaude(root))
|
|
@@ -370,11 +444,16 @@ export function runInit(root, o) {
|
|
|
370
444
|
res.kept.push(CONFIG_FILE);
|
|
371
445
|
}
|
|
372
446
|
if (!o.force) {
|
|
373
|
-
|
|
447
|
+
// REQ-SCH-007 a template that holds the other bracket form is unchanged, so it gets the current form (032/D3)
|
|
448
|
+
const other = new Map(plan(o, root, openspec, !brackets).filter(([rel]) => rel.startsWith(`${TEMPLATES_DIR}/`)));
|
|
449
|
+
const differs = (rel, data) => {
|
|
450
|
+
const have = readFileSync(join(root, rel));
|
|
451
|
+
return !have.equals(data) && !(other.get(rel)?.equals(have) ?? false);
|
|
452
|
+
};
|
|
374
453
|
res.changed = files.filter(([rel, data]) => existsSync(join(root, rel)) && differs(rel, data)).map(([rel]) => rel);
|
|
375
454
|
for (const rel of res.changed)
|
|
376
455
|
res.kept.push(`${rel} (other content)`);
|
|
377
|
-
files = files.filter(([rel]) => !existsSync(join(root, rel)));
|
|
456
|
+
files = files.filter(([rel, data]) => !existsSync(join(root, rel)) || (!readFileSync(join(root, rel)).equals(data) && !differs(rel, data)));
|
|
378
457
|
}
|
|
379
458
|
for (const [rel, data] of files) {
|
|
380
459
|
const p = join(root, rel);
|
|
@@ -385,7 +464,7 @@ export function runInit(root, o) {
|
|
|
385
464
|
if (o.schema) {
|
|
386
465
|
const cfgPath = join(root, "openspec/config.yaml");
|
|
387
466
|
const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
|
|
388
|
-
const m = mergeConfig(cur,
|
|
467
|
+
const m = mergeConfig(cur, fragment, otherFragment);
|
|
389
468
|
if (m.added.length || m.merged.length) {
|
|
390
469
|
mkdirSync(dirname(cfgPath), { recursive: true });
|
|
391
470
|
writeFileSync(cfgPath, m.text);
|
|
@@ -394,7 +473,7 @@ export function runInit(root, o) {
|
|
|
394
473
|
}
|
|
395
474
|
// REQ-SCH-003 one line per key that gained items (022/D4)
|
|
396
475
|
for (const x of m.merged)
|
|
397
|
-
res.merged.push(`openspec/config.yaml ${x.key} (${x
|
|
476
|
+
res.merged.push(`openspec/config.yaml ${x.key} (${mergedCount(x)})`);
|
|
398
477
|
for (const k of m.kept)
|
|
399
478
|
res.kept.push(`openspec/config.yaml ${k}`);
|
|
400
479
|
// REQ-SCH-003 the schema line is the user's. A legacy value gets a note and stays (025/D5)
|
|
@@ -402,11 +481,13 @@ export function runInit(root, o) {
|
|
|
402
481
|
res.notes.push(LEGACY_SCHEMA_NOTE);
|
|
403
482
|
}
|
|
404
483
|
// REQ-DIST-002 the four .gitignore lines (017/D2, 022/D5)
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
484
|
+
if (!skillsOnly) {
|
|
485
|
+
const added = writeGitignore(root);
|
|
486
|
+
if (added)
|
|
487
|
+
res.written.push(`${GITIGNORE} (${added} line${added === 1 ? "" : "s"})`);
|
|
488
|
+
else
|
|
489
|
+
res.kept.push(GITIGNORE);
|
|
490
|
+
}
|
|
410
491
|
// REQ-DIST-002 skills under an ignored .claude/ never reach git (017/D3)
|
|
411
492
|
if (o.skills && ignoresClaude(root))
|
|
412
493
|
res.notes.push(CLAUDE_NOTE);
|
package/dist/loop/fails.js
CHANGED
|
@@ -80,12 +80,22 @@ export function writeFail(root, task, verify, tail) {
|
|
|
80
80
|
writeFileSync(join(root, target), dumps(file) + "\n");
|
|
81
81
|
return { rel: target, file };
|
|
82
82
|
}
|
|
83
|
-
/** REQ-LOOP-002 the question text at the cap. Strict mode. */
|
|
83
|
+
/** REQ-LOOP-002 the question text at the cap. It quotes the failing line (033/D2). Strict mode. */
|
|
84
84
|
export function capQuestion(task, attempts, tail) {
|
|
85
|
-
|
|
86
|
-
return `task ${task} failed Verify ${attempts} times. Last: ${first}. What should change?`;
|
|
85
|
+
return `task ${task} failed Verify ${attempts} times. Last: ${failLine(tail)}. What should change?`;
|
|
87
86
|
}
|
|
88
87
|
/** REQ-LOOP-002 the last line of a failed `task done` at the cap. Strict mode. */
|
|
89
88
|
export function blockedLine(n) {
|
|
90
89
|
return `blocked: question ${n} written. Run specctx handoff resolve ${n} "<answer>" to continue.`;
|
|
91
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* REQ-LOOP-006 the reason line of a Verify tail (029/D3): the last line that starts with `fail:`, `FAIL`, or `(fail)`
|
|
93
|
+
* after trimming (039/D1), else the last non-empty line, else `no output`. The match is case sensitive.
|
|
94
|
+
*/
|
|
95
|
+
export function failLine(tail) {
|
|
96
|
+
const lines = tail.split("\n").map((l) => l.trimEnd());
|
|
97
|
+
const hit = lines.filter((l) => /^\s*(?:fail:|FAIL|\(fail\))/.test(l)).at(-1);
|
|
98
|
+
if (hit !== undefined)
|
|
99
|
+
return hit.trim();
|
|
100
|
+
return lines.filter((l) => l.trim()).at(-1)?.trim() ?? "no output";
|
|
101
|
+
}
|
package/dist/loop/next.js
CHANGED
|
@@ -3,20 +3,22 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { applyBaseline } from "../baseline/baseline.js";
|
|
5
5
|
import { check } from "../check/check.js";
|
|
6
|
+
import { staleOf } from "../check/evidence.js";
|
|
6
7
|
import { readQuestions } from "../handoff/questions.js";
|
|
7
8
|
import { changeKey, matchesChange } from "../index/scan.js";
|
|
8
9
|
import { listEvidence } from "../tasks/evidence.js";
|
|
9
10
|
import { hasEvidence, nextTask, tasksOf } from "../tasks/tasks.js";
|
|
10
11
|
import { cmpStr, cmpTuple } from "../util/pystr.js";
|
|
11
|
-
import { capQuestion, failsInWindow, openQuestionFor } from "./fails.js";
|
|
12
|
+
import { capQuestion, failLine, failsInWindow, openQuestionFor } from "./fails.js";
|
|
12
13
|
import { openspecCommand, openspecStatus } from "./openspec.js";
|
|
13
14
|
/** The closed action list. A skill stops on any value outside it. */
|
|
14
15
|
export const ACTIONS = ["plan", "cite", "start", "work", "fix", "repair", "gate", "archive", "ask", "stop"];
|
|
15
16
|
/** The keys of the object, sorted. Every state prints all of them. */
|
|
16
|
-
export const KEYS = ["action", "attempt", "change", "command", "human", "max_attempts", "reason", "state", "task"];
|
|
17
|
+
export const KEYS = ["action", "attempt", "change", "command", "human", "max_attempts", "reason", "state", "task", "tasks"];
|
|
17
18
|
/** The rules of check whose finding means a task or a header lacks its citation (024/D2 step 4). */
|
|
18
|
-
|
|
19
|
-
const
|
|
19
|
+
// REQ-LOOP-004 a weak Verify command is a citation gap too (028/D7)
|
|
20
|
+
const CITE_RULES = new Set(["missing-id", "task-without-req", "task-without-verify", "unknown-task-req", "weak-verify"]);
|
|
21
|
+
const TASK_RULES = new Set(["task-without-req", "task-without-verify", "unknown-task-req", "weak-verify"]);
|
|
20
22
|
/** The active change folders under openspec/changes/, sorted. The file system decides, so a change with no tasks.md counts. */
|
|
21
23
|
export function activeFolders(root) {
|
|
22
24
|
const dir = join(root, "openspec", "changes");
|
|
@@ -44,22 +46,25 @@ function questions(root) {
|
|
|
44
46
|
return [];
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
|
-
/**
|
|
49
|
+
/** 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). */
|
|
48
50
|
function lastGate(root) {
|
|
49
51
|
const p = join(root, ".ctx/gate-log.jsonl");
|
|
50
52
|
if (!existsSync(p))
|
|
51
53
|
return null;
|
|
52
54
|
const lines = readFileSync(p, "utf8").split("\n").filter((l) => l.trim());
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
56
|
+
try {
|
|
57
|
+
const o = JSON.parse(lines[i]);
|
|
58
|
+
const rerun = Array.isArray(o.steps) && o.steps.length === 1 && o.steps[0] === "rerun";
|
|
59
|
+
if (rerun)
|
|
60
|
+
continue;
|
|
61
|
+
return { at: typeof o.at === "string" ? o.at : "", ok: o.ok === true && o.strict === true };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
62
66
|
}
|
|
67
|
+
return null;
|
|
63
68
|
}
|
|
64
69
|
/** The `at` of the newest live evidence file of a change key, or "" with none. */
|
|
65
70
|
function newestEvidenceAt(root, key) {
|
|
@@ -89,8 +94,13 @@ function readCurrent(root) {
|
|
|
89
94
|
/** REQ-LOOP-001 the decision of 024/D2. A pure function of the files. It writes nothing. */
|
|
90
95
|
export function decideNext(root, cfg, data, want) {
|
|
91
96
|
const openspec = openspecCommand(cfg.gate.openspec);
|
|
92
|
-
const d = { action: "stop", attempt: 0, change: null, command: null, human: false, max_attempts: cfg.loop.max_attempts, reason: null, state: "unknown", task: null };
|
|
93
|
-
const done = (o) =>
|
|
97
|
+
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
|
+
const done = (o) => {
|
|
99
|
+
const r = { ...d, ...o };
|
|
100
|
+
if (!o.tasks)
|
|
101
|
+
r.tasks = r.task === null ? [] : [r.task];
|
|
102
|
+
return r;
|
|
103
|
+
};
|
|
94
104
|
// step 0 and step 1: the change under consideration, or the plan stop with no change
|
|
95
105
|
const folders = activeFolders(root);
|
|
96
106
|
const folder = want === undefined ? folders[0] : folders.find((f) => matchesChange(f, want));
|
|
@@ -129,23 +139,55 @@ export function decideNext(root, cfg, data, want) {
|
|
|
129
139
|
}
|
|
130
140
|
// step 5: a hand tick without evidence gets the repair run
|
|
131
141
|
const tasks = tasksOf(entries).filter(ofChange).sort((a, b) => cmpTuple([a.file, a.line], [b.file, b.line]));
|
|
142
|
+
// 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
|
+
const cur0 = readCurrent(root);
|
|
144
|
+
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" });
|
|
148
|
+
}
|
|
132
149
|
const ticked = tasks.find((t) => t.status === "done" && !hasEvidence(root, t));
|
|
133
150
|
if (ticked)
|
|
134
151
|
return done({ action: "repair", command: `specctx task done ${ticked.id}`, state: "done_without_evidence", task: ticked.id });
|
|
152
|
+
// REQ-LOOP-008 the done tasks whose evidence went stale, earliest first (031/D4, 031/D5)
|
|
153
|
+
const stale = [];
|
|
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
|
+
stale.push({ id: t.id, path });
|
|
160
|
+
}
|
|
161
|
+
// REQ-LOOP-010 a current task keeps its action. The reason names the stale tasks that wait (034/D4).
|
|
162
|
+
const waiting = stale.length ? `stale waiting: ${stale.map((x) => x.id).join(", ")}` : null;
|
|
163
|
+
const withWaiting = (r) => (waiting === null ? r : r === null ? waiting : `${r}. ${waiting}`);
|
|
135
164
|
// step 6: the current task of this change
|
|
136
165
|
const cur = readCurrent(root);
|
|
137
166
|
const current = cur?.task && cur.task.split("/")[0] === key ? tasks.find((t) => t.id === cur.task && t.status === "open") : undefined;
|
|
138
167
|
if (current) {
|
|
139
168
|
const fails = failsInWindow(root, current.id, qs);
|
|
140
169
|
const last = fails.at(-1);
|
|
141
|
-
|
|
170
|
+
// REQ-LOOP-006 the reason names the last fail line of the tail (029/D3)
|
|
171
|
+
const reason = withWaiting(last ? `Verify failed (exit ${last.file.verify.exit ?? "timeout"}): ${failLine(last.file.stderr_tail)}` : null);
|
|
142
172
|
if (fails.length >= cfg.loop.max_attempts && !openQuestionFor(root, current.id, qs)) {
|
|
143
173
|
const q = capQuestion(current.id, fails.length, last?.file.stderr_tail ?? "");
|
|
144
174
|
return done({ action: "ask", attempt: fails.length, command: `specctx handoff ask "${q}"`, human: true, reason, state: "attempts_exhausted", task: current.id });
|
|
145
175
|
}
|
|
146
176
|
if (fails.length)
|
|
147
177
|
return done({ action: "fix", attempt: fails.length, command: `specctx task done ${current.id}`, reason, state: "task_failed", task: current.id });
|
|
148
|
-
return done({ action: "work", command: `specctx task done ${current.id}`, state: "task_started", task: current.id });
|
|
178
|
+
return done({ action: "work", command: `specctx task done ${current.id}`, reason, state: "task_started", task: current.id });
|
|
179
|
+
}
|
|
180
|
+
// REQ-LOOP-008 step 6b: the stale tasks repair after the current task (031/D4, 031/D5)
|
|
181
|
+
if (stale.length) {
|
|
182
|
+
// REQ-LOOP-008 one turn repairs every stale task (033/D1)
|
|
183
|
+
return done({
|
|
184
|
+
action: "repair",
|
|
185
|
+
command: stale.map((s) => `specctx task reopen ${s.id} "stale evidence: ${s.path}" && specctx task done ${s.id}`).join(" && "),
|
|
186
|
+
reason: stale.map((s) => `stale-evidence ${s.id} ${s.path}`).join(", "),
|
|
187
|
+
state: "evidence_stale",
|
|
188
|
+
task: stale[0].id,
|
|
189
|
+
tasks: stale.map((s) => s.id),
|
|
190
|
+
});
|
|
149
191
|
}
|
|
150
192
|
// step 7: the next open task
|
|
151
193
|
const open = nextTask(root, entries, folder);
|
|
@@ -157,11 +199,20 @@ export function decideNext(root, cfg, data, want) {
|
|
|
157
199
|
return done({ action: "gate", command: "specctx gate --strict", state: "gate_due" });
|
|
158
200
|
}
|
|
159
201
|
// step 9: archive, with the human stop from [loop] archive
|
|
160
|
-
|
|
202
|
+
const archive = `${openspec} archive ${folder} -y`;
|
|
203
|
+
if (cfg.loop.archive !== "ask")
|
|
204
|
+
return done({ action: "archive", command: archive, human: false, state: "gate_passed" });
|
|
205
|
+
// REQ-LOOP-007 a resolved archive question after the newest evidence is the approval. The stage field decides, not the text (031/D2).
|
|
206
|
+
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" });
|
|
161
211
|
}
|
|
162
212
|
/** REQ-LOOP-001 REQ-CLI-007 the human form: action first, one line per key, next last (024/D1). Strict mode. */
|
|
163
213
|
export function renderNext(d) {
|
|
164
|
-
|
|
214
|
+
// REQ-LOOP-012 an empty list prints none, a list joins with a comma and a space
|
|
215
|
+
const show = (v) => (v === null ? "none" : Array.isArray(v) ? (v.length ? v.join(", ") : "none") : String(v));
|
|
165
216
|
const lines = [`action: ${d.action}`];
|
|
166
217
|
for (const k of KEYS) {
|
|
167
218
|
if (k === "action" || k === "command")
|
package/dist/stats/stats.js
CHANGED
|
@@ -5,7 +5,10 @@ import { readBaseline } from "../baseline/baseline.js";
|
|
|
5
5
|
import { changeKey, matchesChange } from "../index/scan.js";
|
|
6
6
|
import { isFailFile } from "../tasks/evidence.js";
|
|
7
7
|
import { cmpStr, cpLen } from "../util/pystr.js";
|
|
8
|
-
|
|
8
|
+
// REQ-STAT-004 the two Verify rules count as debt too, the same rules the baseline can hold (029/D5)
|
|
9
|
+
export const DEBT_RULES = ["uncovered", "scenario-untested", "done-without-evidence", "manual-evidence", "task-without-verify", "weak-verify"];
|
|
10
|
+
/** The debt rules whose ID is a task. A change row counts them. */
|
|
11
|
+
const TASK_DEBT_RULES = ["done-without-evidence", "manual-evidence", "task-without-verify", "weak-verify"];
|
|
9
12
|
const NA = (n = 0) => ({ value: null, n, text: "n/a" });
|
|
10
13
|
function readEvidence(root) {
|
|
11
14
|
const dir = join(root, ".ctx/evidence");
|
|
@@ -120,11 +123,11 @@ export function computeStats(root, entries, strictFindings, warnings, only) {
|
|
|
120
123
|
];
|
|
121
124
|
const fresh = [...new Set(current)].filter((k) => !base.has(k));
|
|
122
125
|
const debtAll = { value: base.size + fresh.length, n: base.size + fresh.length, text: `${base.size + fresh.length} (${base.size} baselined + ${fresh.length} new)` };
|
|
123
|
-
// a change row counts the task scoped debt:
|
|
126
|
+
// a change row counts the task scoped debt: no evidence, manual evidence, no Verify, and a weak Verify (029/D5)
|
|
124
127
|
const taskDebt = (change) => {
|
|
125
128
|
const keys = [...base, ...fresh].filter((k) => {
|
|
126
129
|
const [rule, id] = k.split(" ");
|
|
127
|
-
return (rule
|
|
130
|
+
return TASK_DEBT_RULES.includes(rule ?? "") && nameOf(id ?? "") === change;
|
|
128
131
|
});
|
|
129
132
|
return { value: keys.length, n: keys.length, text: String(keys.length) };
|
|
130
133
|
};
|
package/dist/util/time.js
CHANGED
package/dist/verify/parse.js
CHANGED
|
@@ -56,3 +56,9 @@ export function parseVerify(taskLine) {
|
|
|
56
56
|
}
|
|
57
57
|
return { kind: "command", command: spans.join(" && ") };
|
|
58
58
|
}
|
|
59
|
+
// REQ-CHK-005 a Verify command that always exits 0: `true`, `:`, `exit 0`, or one `echo` with no `;`, `&`, `|` (028/D1)
|
|
60
|
+
const WEAK_VERIFY = /^(?:true|:|exit\s+0|echo(?:\s[^;&|\n]*)?)$/;
|
|
61
|
+
/** REQ-CHK-005 true when the command, trimmed, is one of the weak forms. */
|
|
62
|
+
export function isWeakVerify(command) {
|
|
63
|
+
return WEAK_VERIFY.test(command.trim());
|
|
64
|
+
}
|
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.0";
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ description: Run the spec-ctx loop in an OpenSpec project. Use on "run", "contin
|
|
|
5
5
|
|
|
6
6
|
# specctx-run
|
|
7
7
|
|
|
8
|
-
Run `specctx` as `npx spec-ctx` when it is not installed. `openspec` is the command in `[gate] openspec` of `spec-ctx.toml`.
|
|
8
|
+
Run `specctx` as `npx spec-ctx` when it is not installed. `openspec` is the command in `[gate] openspec` of `spec-ctx.toml`. When that key is absent, `openspec` is `npx -y @fission-ai/openspec@latest`.
|
|
9
9
|
|
|
10
10
|
## The loop
|
|
11
11
|
|
|
@@ -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`:
|
|
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.
|
|
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
|
-
- `repair`: run
|
|
25
|
-
- `gate`: run `specctx gate --strict`. Fix every finding. Run
|
|
26
|
-
- `archive`: when `human` is true,
|
|
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. 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
27
|
- `ask`: print `reason`. It is the open question. Stop. The user answers with `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.
|