spec-ctx 0.4.1 → 0.6.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 +124 -264
- package/dist/adopt/plan.js +40 -7
- package/dist/adopt/write.js +27 -4
- package/dist/assets.generated.js +1 -1
- package/dist/check/check.js +63 -23
- package/dist/cli/args.js +1 -0
- package/dist/cli/commands.js +45 -8
- package/dist/cli/gate.js +2 -1
- package/dist/cli/help.js +12 -5
- package/dist/cli/workflow.js +8 -3
- package/dist/cli.js +45 -6
- package/dist/graph/bundle.js +145 -18
- package/dist/graph/files.js +2 -1
- package/dist/graph/related.js +1 -1
- package/dist/graph/trust.js +114 -0
- package/dist/index/scan.js +2 -2
- package/dist/init/init.js +21 -11
- package/dist/init/next.js +50 -0
- package/dist/journal/journal.js +65 -0
- package/dist/journal/note.js +57 -0
- package/dist/journal/report.js +115 -0
- package/dist/journal/signals.js +26 -0
- package/dist/loop/fails.js +61 -10
- package/dist/loop/openspec.js +2 -1
- package/dist/main.js +16 -1
- package/dist/tasks/evidence.js +3 -3
- package/dist/util/env.js +10 -0
- package/dist/util/redact.js +57 -0
- package/dist/verify/run.js +2 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/specctx-run/SKILL.md +2 -2
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// REQ-JRN-004 REQ-JRN-005 the bug report. The only reader of the journal. It never runs gh and never uses the network (057/D6).
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse } from "smol-toml";
|
|
5
|
+
import { JOURNAL_DIR, JOURNAL_FILE, ROTATED_FILE } from "./journal.js";
|
|
6
|
+
import { SIGNALS } from "./note.js";
|
|
7
|
+
export const NO_JOURNAL = `no journal found under ${JOURNAL_DIR}\n`;
|
|
8
|
+
function readLines(path) {
|
|
9
|
+
if (!existsSync(path))
|
|
10
|
+
return [];
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
|
13
|
+
if (!raw.trim())
|
|
14
|
+
continue;
|
|
15
|
+
try {
|
|
16
|
+
const v = JSON.parse(raw);
|
|
17
|
+
if (v === null || typeof v !== "object" || v.v !== 1)
|
|
18
|
+
continue;
|
|
19
|
+
if (typeof v.at !== "string" || Number.isNaN(Date.parse(v.at)) || typeof v.cmd !== "string")
|
|
20
|
+
continue;
|
|
21
|
+
if (typeof v.exit !== "number" || typeof v.ms !== "number" || !Array.isArray(v.signals))
|
|
22
|
+
continue;
|
|
23
|
+
const line = { at: v.at, cmd: v.cmd, exit: v.exit, ms: v.ms, signals: v.signals.filter((s) => typeof s === "string") };
|
|
24
|
+
const e = v.err;
|
|
25
|
+
if (e && typeof e === "object") {
|
|
26
|
+
line.err = {
|
|
27
|
+
name: typeof e.name === "string" && /^\w+$/.test(e.name) ? e.name : "?",
|
|
28
|
+
code: typeof e.code === "string" && /^[A-Z][A-Z0-9_]*$/.test(e.code) ? e.code : null,
|
|
29
|
+
frames: Array.isArray(e.frames) ? e.frames.filter((f) => typeof f === "string" && /^[\w./-]+:\d+$/.test(f)) : [],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
out.push(line);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// a line that is not JSON is skipped
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** REQ-JRN-004 section and key names of spec-ctx.toml, with no value. */
|
|
41
|
+
export function configShape(root) {
|
|
42
|
+
const path = join(root, "spec-ctx.toml");
|
|
43
|
+
if (!existsSync(path))
|
|
44
|
+
return ["none (no spec-ctx.toml)"];
|
|
45
|
+
let doc;
|
|
46
|
+
try {
|
|
47
|
+
doc = parse(readFileSync(path, "utf8"));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return ["none (spec-ctx.toml does not parse)"];
|
|
51
|
+
}
|
|
52
|
+
const rows = [];
|
|
53
|
+
const top = Object.keys(doc).filter((k) => !isTable(doc[k]));
|
|
54
|
+
if (top.length)
|
|
55
|
+
rows.push(`(top level): ${top.join(", ")}`);
|
|
56
|
+
const walk = (name, t) => {
|
|
57
|
+
const keys = Object.keys(t).filter((k) => !isTable(t[k]));
|
|
58
|
+
rows.push(`[${name}]: ${keys.length ? keys.join(", ") : "no keys"}`);
|
|
59
|
+
for (const k of Object.keys(t))
|
|
60
|
+
if (isTable(t[k]))
|
|
61
|
+
walk(`${name}.${k}`, t[k]);
|
|
62
|
+
};
|
|
63
|
+
for (const k of Object.keys(doc))
|
|
64
|
+
if (isTable(doc[k]))
|
|
65
|
+
walk(k, doc[k]);
|
|
66
|
+
return rows.length ? rows : ["none (empty file)"];
|
|
67
|
+
}
|
|
68
|
+
function isTable(v) {
|
|
69
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
|
|
70
|
+
}
|
|
71
|
+
/** Nearest rank. */
|
|
72
|
+
export function p95(values) {
|
|
73
|
+
const s = [...values].sort((a, b) => a - b);
|
|
74
|
+
return s[Math.max(0, Math.ceil(0.95 * s.length) - 1)];
|
|
75
|
+
}
|
|
76
|
+
/** REQ-JRN-004 the Markdown report. It is a pure function of the journal files and the config. */
|
|
77
|
+
export function buildReport(root, now, sys) {
|
|
78
|
+
const dir = join(root, JOURNAL_DIR);
|
|
79
|
+
if (!existsSync(join(dir, JOURNAL_FILE)) && !existsSync(join(dir, ROTATED_FILE)))
|
|
80
|
+
return { found: false, text: NO_JOURNAL };
|
|
81
|
+
const lines = [...readLines(join(dir, ROTATED_FILE)), ...readLines(join(dir, JOURNAL_FILE))];
|
|
82
|
+
const md = ["# spec-ctx report", "", `Generated: ${now.toISOString()}`, ""];
|
|
83
|
+
md.push("## Versions", "", `- spec-ctx: ${sys.version}`, `- Node: ${sys.node}`, `- OS: ${sys.platform} ${sys.arch}`, "");
|
|
84
|
+
md.push("## Config shape", "", ...configShape(root).map((r) => `- ${r}`), "");
|
|
85
|
+
const i = lines.findIndex((l) => l.signals.includes("init-ok"));
|
|
86
|
+
const j = i < 0 ? -1 : lines.findIndex((l, k) => k > i && l.signals.includes("context-has-code-or-test"));
|
|
87
|
+
md.push("## North star", "");
|
|
88
|
+
if (j < 0)
|
|
89
|
+
md.push("- Minutes from the first `init-ok` to the first `context-has-code-or-test`: n/a", "- Runs between them: n/a", "");
|
|
90
|
+
else {
|
|
91
|
+
const mins = (Date.parse(lines[j].at) - Date.parse(lines[i].at)) / 60000;
|
|
92
|
+
md.push(`- Minutes from the first \`init-ok\` to the first \`context-has-code-or-test\`: ${mins.toFixed(1)}`, `- Runs between them: ${j - i}`, "");
|
|
93
|
+
}
|
|
94
|
+
md.push(`## Runs`, "", `- Total: ${lines.length}`, "");
|
|
95
|
+
const byCmd = new Map();
|
|
96
|
+
for (const l of lines)
|
|
97
|
+
byCmd.set(l.cmd, [...(byCmd.get(l.cmd) ?? []), l.ms]);
|
|
98
|
+
md.push("## Time", "", "| cmd | runs | p95 ms |", "| --- | --- | --- |");
|
|
99
|
+
for (const c of [...byCmd.keys()].sort())
|
|
100
|
+
md.push(`| ${c.replace(/[^\w ?-]/g, "?")} | ${byCmd.get(c).length} | ${p95(byCmd.get(c))} |`);
|
|
101
|
+
md.push("", "## Signals", "");
|
|
102
|
+
for (const s of SIGNALS)
|
|
103
|
+
md.push(`- ${s}: ${lines.filter((l) => l.signals.includes(s)).length}`);
|
|
104
|
+
md.push("", "## Last internal error", "");
|
|
105
|
+
const last = [...lines].reverse().find((l) => l.err !== undefined);
|
|
106
|
+
if (!last?.err)
|
|
107
|
+
md.push("- none");
|
|
108
|
+
else {
|
|
109
|
+
md.push(`- name: ${last.err.name}`, `- code: ${last.err.code ?? "none"}`, "- frames:", ...last.err.frames.map((f) => ` - ${f}`));
|
|
110
|
+
if (!last.err.frames.length)
|
|
111
|
+
md.push(" - none");
|
|
112
|
+
}
|
|
113
|
+
md.push("", "Review this text, then file it. Run: `gh issue create -R sonqh/spec-ctx --body-file <file>`", "");
|
|
114
|
+
return { found: true, text: md.join("\n") };
|
|
115
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// REQ-JRN-002 the signals of one context run. They read the provenance lines of the printed text (057/D3).
|
|
2
|
+
import { parseProvenance } from "../graph/bundle.js";
|
|
3
|
+
import { canon } from "../index/canonical.js";
|
|
4
|
+
/** Only `context` calls this. No provenance line gives empty-context. A kept code or test block gives context-has-code-or-test. */
|
|
5
|
+
export function contextSignals(entries, b) {
|
|
6
|
+
const ids = canon(entries);
|
|
7
|
+
const out = [];
|
|
8
|
+
let lines = 0;
|
|
9
|
+
let line = false;
|
|
10
|
+
for (const l of b.text.split("\n")) {
|
|
11
|
+
const id = parseProvenance(l);
|
|
12
|
+
if (id === null)
|
|
13
|
+
continue;
|
|
14
|
+
lines++;
|
|
15
|
+
const k = ids.get(id)?.kind;
|
|
16
|
+
if (k === "code" || k === "test")
|
|
17
|
+
line = true;
|
|
18
|
+
}
|
|
19
|
+
if (lines === 0)
|
|
20
|
+
out.push("empty-context");
|
|
21
|
+
if (line)
|
|
22
|
+
out.push("context-has-code-or-test");
|
|
23
|
+
if (!b.complete)
|
|
24
|
+
out.push("truncated-context");
|
|
25
|
+
return out;
|
|
26
|
+
}
|
package/dist/loop/fails.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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";
|
|
@@ -8,6 +8,7 @@ import { runCommand } from "../runcmd.js";
|
|
|
8
8
|
import { dumps } from "../util/json.js";
|
|
9
9
|
import { cmpStr } from "../util/pystr.js";
|
|
10
10
|
import { isoNow } from "../util/time.js";
|
|
11
|
+
const CURRENT = ".ctx/current.json";
|
|
11
12
|
/** Every fail file of a task, parsed, in `at` order. A damaged file is skipped. */
|
|
12
13
|
export function readFails(root, task) {
|
|
13
14
|
const rel = evidenceRel({ id: task });
|
|
@@ -31,19 +32,22 @@ export function readFails(root, task) {
|
|
|
31
32
|
return out.sort((a, b) => cmpStr(a.file.at, b.file.at) || cmpStr(a.rel, b.rel));
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
36
38
|
*/
|
|
37
|
-
export function windowStart(root, task, questions) {
|
|
38
|
-
let start = "";
|
|
39
|
+
export function windowStart(root, task, questions, fallback = false) {
|
|
40
|
+
let start = fallback ? "" : null;
|
|
39
41
|
try {
|
|
40
|
-
const cur = JSON.parse(readFileSync(join(root,
|
|
42
|
+
const cur = JSON.parse(readFileSync(join(root, CURRENT), "utf8"));
|
|
41
43
|
if (cur.task === task && typeof cur.started_at === "string")
|
|
42
44
|
start = cur.started_at;
|
|
43
45
|
}
|
|
44
46
|
catch {
|
|
45
47
|
// no current task
|
|
46
48
|
}
|
|
49
|
+
if (start === null)
|
|
50
|
+
return null;
|
|
47
51
|
for (const q of questions ?? safeQuestions(root)) {
|
|
48
52
|
if (q.task === task && q.status === "resolved" && typeof q.resolved_at === "string" && cmpStr(q.resolved_at, start) > 0)
|
|
49
53
|
start = q.resolved_at;
|
|
@@ -58,9 +62,11 @@ function safeQuestions(root) {
|
|
|
58
62
|
return [];
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
|
-
/**
|
|
62
|
-
export function failsInWindow(root, task, questions) {
|
|
63
|
-
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 [];
|
|
64
70
|
return readFails(root, task).filter((f) => cmpStr(f.file.at, start) >= 0);
|
|
65
71
|
}
|
|
66
72
|
/** The open question that names the task, if any. */
|
|
@@ -74,9 +80,53 @@ export function openQuestionFor(root, task, questions) {
|
|
|
74
80
|
export function isCapQuestion(q, task) {
|
|
75
81
|
return q.status === "open" && q.task === task && q.stage === "cap";
|
|
76
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
|
+
}
|
|
77
125
|
/** Write the next fail file of a task. The attempt counts the window, the file number counts every fail file. */
|
|
78
126
|
export function writeFail(root, task, verify, tail) {
|
|
79
|
-
|
|
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;
|
|
80
130
|
const rel = evidenceRel({ id: task });
|
|
81
131
|
const base = rel.slice(0, -".json".length);
|
|
82
132
|
let n = 1;
|
|
@@ -86,6 +136,7 @@ export function writeFail(root, task, verify, tail) {
|
|
|
86
136
|
const file = { at: isoNow(), attempt, stderr_tail: tail, task, verify };
|
|
87
137
|
mkdirSync(dirname(join(root, target)), { recursive: true });
|
|
88
138
|
writeFileSync(join(root, target), dumps(file) + "\n");
|
|
139
|
+
ensureFailIgnore(root);
|
|
89
140
|
return { rel: target, file };
|
|
90
141
|
}
|
|
91
142
|
/** REQ-LOOP-002 the question text at the cap. It quotes the failing line (033/D2). Strict mode. */
|
package/dist/loop/openspec.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// REQ-LOOP-001 the one OpenSpec call of next: status --change <c> --json, two keys read (024/D3, ADR-006 item 2).
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { childEnv } from "../util/env.js";
|
|
3
4
|
const TIMEOUT_MS = 60_000;
|
|
4
5
|
/** REQ-LOOP-001 the command that gate and next run for OpenSpec. The env var wins over [gate] openspec (018/D2). */
|
|
5
6
|
export function openspecCommand(configured) {
|
|
@@ -19,7 +20,7 @@ function errorMessage(stdout) {
|
|
|
19
20
|
}
|
|
20
21
|
/** Run `<openspec> status --change <folder> --json` in the root. A failure of any kind is a reason, never a throw. */
|
|
21
22
|
export function openspecStatus(root, openspec, folder) {
|
|
22
|
-
const r = spawnSync("/bin/sh", ["-c", `${openspec} status --change ${folder} --json`], { cwd: root, encoding: "utf8", timeout: TIMEOUT_MS });
|
|
23
|
+
const r = spawnSync("/bin/sh", ["-c", `${openspec} status --change ${folder} --json`], { cwd: root, encoding: "utf8", timeout: TIMEOUT_MS, env: childEnv() /* REQ-DIST-016 */ });
|
|
23
24
|
if (r.error && r.error.code === "ETIMEDOUT") {
|
|
24
25
|
return { ok: false, reason: `openspec status timed out after ${TIMEOUT_MS / 1000}s` };
|
|
25
26
|
}
|
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
|
-
|
|
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/tasks/evidence.js
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|
package/dist/util/env.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// REQ-DIST-016 a child that may run npx gets the environment without the two variables of a nested npx.
|
|
2
|
+
/** npm sets the first under `npx --package`. A leaked value makes a nested npx run the wrong command. */
|
|
3
|
+
export const NESTED_NPX_VARS = ["npm_config_package", "npm_config_call"];
|
|
4
|
+
/** A copy of `base` without the nested npx variables. It never writes to `base`. */
|
|
5
|
+
export function childEnv(base = process.env) {
|
|
6
|
+
const copy = { ...base };
|
|
7
|
+
for (const k of NESTED_NPX_VARS)
|
|
8
|
+
delete copy[k];
|
|
9
|
+
return copy;
|
|
10
|
+
}
|
|
@@ -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/verify/run.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// REQ-VER-001 REQ-VER-003 run a Verify command with sh -c in the project root, with a timeout.
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { childEnv } from "../util/env.js";
|
|
3
4
|
export const TAIL_LINES = 20;
|
|
4
5
|
export function tail(text, n = TAIL_LINES) {
|
|
5
6
|
const lines = text.replace(/\n$/, "").split("\n");
|
|
@@ -9,7 +10,7 @@ export function tail(text, n = TAIL_LINES) {
|
|
|
9
10
|
export function runVerify(command, cwd, timeoutS, env) {
|
|
10
11
|
return new Promise((resolve, reject) => {
|
|
11
12
|
// a new process group, so the timeout stops the children of sh too
|
|
12
|
-
const child = spawn("sh", ["-c", command], { cwd, detached: true,
|
|
13
|
+
const child = spawn("sh", ["-c", command], { cwd, detached: true, env: childEnv(env), /* REQ-DIST-016 */ stdio: ["ignore", "pipe", "pipe"] });
|
|
13
14
|
const out = [];
|
|
14
15
|
const err = [];
|
|
15
16
|
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.6.0";
|
package/package.json
CHANGED
|
@@ -16,9 +16,9 @@ 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 human approves the question. You never resolve a plan question yourself.
|
|
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>`. `id new` reserves nothing. A second call before `specctx build` returns the same ID. 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
|
-
- `start`: run `specctx task start <t>`. Read the bundle it prints. Continue.
|
|
21
|
+
- `start`: run `specctx task start <t>`. Read the bundle it prints. Each card ends with trust labels. Act on a line marked `verified` or `fresh`. Reread a line marked `stale since`, `unverified`, or `no evidence` with `specctx bundle <ID>` before you build on it. 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.
|