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
package/dist/graph/bundle.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// REQ-QRY-003 canonical text with one provenance line per block, in a fixed order, within a budget.
|
|
2
|
-
import {
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
3
4
|
import { canon, poolOf } from "../index/canonical.js";
|
|
4
5
|
import { blockRange, readText } from "../index/scan.js";
|
|
6
|
+
import { Trust, provenanceClass, taggedReq } from "./trust.js";
|
|
5
7
|
import { cmpStr, cmpTuple, cpLen, pyStrip, splitLines } from "../util/pystr.js";
|
|
6
8
|
import { KIND_RANK, mainVersion, walkFound } from "./related.js";
|
|
7
9
|
export class Lines {
|
|
@@ -42,8 +44,13 @@ function rstrip(s) {
|
|
|
42
44
|
const t = pyStrip("x" + s);
|
|
43
45
|
return t.slice(1);
|
|
44
46
|
}
|
|
45
|
-
export function provenance(e, reason) {
|
|
46
|
-
return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}\n`;
|
|
47
|
+
export function provenance(e, reason, trust = "") {
|
|
48
|
+
return `-- ${e.id} | ${e.file}:${e.line} | ${e.status} | ${reason}${trust}\n`;
|
|
49
|
+
}
|
|
50
|
+
/** REQ-JRN-002 the ID of a provenance line, or null. It anchors on the start of `provenance()` (057/D3). */
|
|
51
|
+
export function parseProvenance(line) {
|
|
52
|
+
const m = /^-- (\S+) \| /.exec(line);
|
|
53
|
+
return m ? m[1] : null;
|
|
47
54
|
}
|
|
48
55
|
/** REQ-QRY-004 the canonical blocks of requirements, joined by one blank line, with no provenance line. */
|
|
49
56
|
export function reqBlocks(root, reqs) {
|
|
@@ -132,12 +139,15 @@ export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
|
|
|
132
139
|
if (m)
|
|
133
140
|
blocks.push([m, `main version of ${i}`, 0]);
|
|
134
141
|
}
|
|
142
|
+
// REQ-QRY-018 the count of blocks that the loop above pushed. They print first and always stay.
|
|
143
|
+
const pinned = blocks.length;
|
|
135
144
|
const found = new Map();
|
|
136
145
|
for (const i of wanted) {
|
|
137
146
|
// decisions that reference a requested ID are included at every depth, as depth 1.
|
|
138
147
|
// REQ-QRY-016 They replace a deeper hit, which the walk for an earlier requested ID can leave (048/D1).
|
|
139
148
|
for (const e of entries) {
|
|
140
|
-
|
|
149
|
+
// REQ-QRY-014 a requested decision prints once, as requested (050/D10)
|
|
150
|
+
if (e.kind !== "decision" || !e.refs.includes(i) || wanted.includes(e.id))
|
|
141
151
|
continue;
|
|
142
152
|
const cur = found.get(e.id);
|
|
143
153
|
if (!cur || cur.depth > 1)
|
|
@@ -161,7 +171,7 @@ export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
|
|
|
161
171
|
}
|
|
162
172
|
for (const c of r.cited) {
|
|
163
173
|
for (const e of entries) {
|
|
164
|
-
if (e.kind !== "decision" || !e.refs.includes(c))
|
|
174
|
+
if (e.kind !== "decision" || !e.refs.includes(c) || wanted.includes(e.id))
|
|
165
175
|
continue;
|
|
166
176
|
const cur = found.get(e.id);
|
|
167
177
|
if (!cur || cur.depth > 1)
|
|
@@ -186,28 +196,145 @@ export function bundle(root, entries0, wanted, budget, depth, opts = {}) {
|
|
|
186
196
|
blocks.push([m, `main version of ${r2}`, cls]);
|
|
187
197
|
}
|
|
188
198
|
const files = new Lines(root);
|
|
189
|
-
|
|
199
|
+
// REQ-QRY-020 one state per block: 0 none, 1 card, 2 full. The fill sets it and the output reads it in print order.
|
|
200
|
+
// REQ-QRY-023 REQ-QRY-024 REQ-QRY-025 the trust columns of each card (062/D1 to D5)
|
|
201
|
+
const trust = new Trust(root, entries);
|
|
202
|
+
const labels = (e, reason) => {
|
|
203
|
+
let prov = provenanceClass(reason);
|
|
204
|
+
if (prov === "tagged" && (e.kind === "code" || e.kind === "test")) {
|
|
205
|
+
const r = taggedReq(reason);
|
|
206
|
+
if (r && trust.lineVerified(e.file, r))
|
|
207
|
+
prov = "verified";
|
|
208
|
+
}
|
|
209
|
+
let ev = null;
|
|
210
|
+
if (e.kind === "task")
|
|
211
|
+
ev = trust.taskState(e.id);
|
|
212
|
+
else if (e.kind === "req")
|
|
213
|
+
ev = trust.reqState(e.id);
|
|
214
|
+
return { provenance: prov, evidence: ev };
|
|
215
|
+
};
|
|
216
|
+
const blk = blocks.map(([e, reason, cls]) => {
|
|
190
217
|
const body = blockText(e, files);
|
|
191
|
-
|
|
192
|
-
|
|
218
|
+
const l = labels(e, reason);
|
|
219
|
+
const suffix = ` | ${l.provenance}` + (l.evidence ? ` | ${l.evidence}` : "");
|
|
220
|
+
return { id: e.id, head: provenance(e, reason, suffix), body: body ? body + "\n" : "", cls, st: 0,
|
|
221
|
+
info: { id: e.id, file: e.file, line: e.line, status: e.status, reason, ...l } };
|
|
193
222
|
});
|
|
194
|
-
//
|
|
195
|
-
const
|
|
223
|
+
// REQ-QRY-021 the files that a requested task names, as class 1 cards after the code and test lines (060/D4)
|
|
224
|
+
const named = [];
|
|
225
|
+
if (depth >= 1) {
|
|
226
|
+
const taken = new Set(blocks.filter(([e]) => e.kind === "code" || e.kind === "test").map(([e]) => e.file));
|
|
227
|
+
for (const t of wanted) {
|
|
228
|
+
const te = ids.get(t);
|
|
229
|
+
if (te?.kind !== "task")
|
|
230
|
+
continue;
|
|
231
|
+
for (const path of te.paths ?? []) {
|
|
232
|
+
if (taken.has(path) || !isFile(root, path))
|
|
233
|
+
continue;
|
|
234
|
+
taken.add(path);
|
|
235
|
+
named.push({ id: path, head: `-- ${path} | ${path} | active | named by ${t} | declared\n`, body: "", cls: 1, st: 0,
|
|
236
|
+
info: { id: path, file: path, line: 0, status: "active", reason: `named by ${t}`, provenance: "declared", evidence: null } });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// the pinned blocks can hold a change, whose rank is above test, so the search starts after them
|
|
241
|
+
let at = blocks.findIndex(([e], k) => k >= pinned && (KIND_RANK[e.kind] ?? 9) > KIND_RANK.test);
|
|
242
|
+
if (at < 0)
|
|
243
|
+
at = blk.length;
|
|
244
|
+
blk.splice(Math.max(at, pinned), 0, ...named);
|
|
245
|
+
// REQ-QRY-018 the pinned blocks always stay, with their bodies (050/D2, 050/D3)
|
|
246
|
+
for (let k = 0; k < pinned; k++)
|
|
247
|
+
blk[k].st = 2;
|
|
196
248
|
let used = 0;
|
|
197
|
-
for (let
|
|
198
|
-
|
|
199
|
-
|
|
249
|
+
for (let k = 0; k < pinned; k++)
|
|
250
|
+
used += cpLen(render(blk[k]));
|
|
251
|
+
const cost = (b, st) => cpLen(renderAs(b, st));
|
|
252
|
+
// fill in five steps, in print order inside each: cards of class 0, cards of class 1, bodies of class 0, cards of class 2, bodies of class 2
|
|
253
|
+
const cards = (cls) => {
|
|
254
|
+
for (let k = pinned; k < blk.length; k++) {
|
|
255
|
+
const b = blk[k];
|
|
256
|
+
if (b.cls !== cls || b.st)
|
|
200
257
|
continue;
|
|
201
|
-
const
|
|
258
|
+
const st = b.body ? 1 : 2;
|
|
259
|
+
const n = cost(b, st);
|
|
202
260
|
if (used + n > budget)
|
|
203
261
|
continue;
|
|
204
|
-
|
|
262
|
+
b.st = st;
|
|
205
263
|
used += n;
|
|
206
264
|
}
|
|
265
|
+
};
|
|
266
|
+
const bodies = (cls) => {
|
|
267
|
+
for (let k = pinned; k < blk.length; k++) {
|
|
268
|
+
const b = blk[k];
|
|
269
|
+
if (b.cls !== cls || b.st !== 1)
|
|
270
|
+
continue;
|
|
271
|
+
const d = cost(b, 2) - cost(b, 1);
|
|
272
|
+
if (used + d > budget)
|
|
273
|
+
continue;
|
|
274
|
+
b.st = 2;
|
|
275
|
+
used += d;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
cards(0);
|
|
279
|
+
cards(1);
|
|
280
|
+
bodies(0);
|
|
281
|
+
cards(2);
|
|
282
|
+
bodies(2);
|
|
283
|
+
// class 3 fills as a whole block
|
|
284
|
+
for (let k = pinned; k < blk.length; k++) {
|
|
285
|
+
const b = blk[k];
|
|
286
|
+
if (b.cls !== 3)
|
|
287
|
+
continue;
|
|
288
|
+
const n = cost(b, 2);
|
|
289
|
+
if (used + n > budget)
|
|
290
|
+
continue;
|
|
291
|
+
b.st = 2;
|
|
292
|
+
used += n;
|
|
207
293
|
}
|
|
208
|
-
const omitted =
|
|
209
|
-
|
|
294
|
+
const omitted = blk.filter((b) => !b.st).map((b) => b.id);
|
|
295
|
+
const bodyOmitted = blk.filter((b) => b.st === 1).map((b) => b.id);
|
|
296
|
+
// REQ-QRY-022 the note of the first requested task with no requirement that the index holds. It prints after the pinned blocks.
|
|
297
|
+
let note = "";
|
|
298
|
+
if (depth >= 1) {
|
|
299
|
+
const t = wanted.find((w) => {
|
|
300
|
+
const te = ids.get(w);
|
|
301
|
+
return te?.kind === "task" && !te.refs.some((x) => ids.get(x)?.kind === "req");
|
|
302
|
+
});
|
|
303
|
+
if (t)
|
|
304
|
+
note = `note: ${t} cites no requirement that the index holds. The blocks come from its change and the paths it names. Add a [REQ-...] citation.\n`;
|
|
305
|
+
}
|
|
306
|
+
const out = blk.map((b) => render(b));
|
|
307
|
+
let text = out.slice(0, pinned).join("") + note + out.slice(pinned).join("");
|
|
308
|
+
// the budget counts the blocks, so the figure of the over budget line is their length
|
|
309
|
+
used = out.reduce((n, x) => n + cpLen(x), 0);
|
|
210
310
|
if (omitted.length)
|
|
211
311
|
text += `truncated: ${omitted.length} entries omitted: ${omitted.join(", ")}\n`;
|
|
212
|
-
|
|
312
|
+
// REQ-QRY-018 the over budget line goes last, so the 0.4.1 output stays a prefix (050/D4)
|
|
313
|
+
if (used > budget)
|
|
314
|
+
text += `over budget: ${used} of ${budget} characters\n`;
|
|
315
|
+
const printed = blk.filter((b) => b.st).map((b) => b.info);
|
|
316
|
+
return { text, omitted, body_omitted: bodyOmitted, chars: cpLen(text), budget, complete: omitted.length === 0 && bodyOmitted.length === 0, used, blocks: printed };
|
|
317
|
+
}
|
|
318
|
+
const MARKER = " | body omitted";
|
|
319
|
+
function renderAs(b, st) {
|
|
320
|
+
if (st === 0)
|
|
321
|
+
return "";
|
|
322
|
+
if (st === 1 && b.body)
|
|
323
|
+
return b.head.slice(0, -1) + MARKER + "\n";
|
|
324
|
+
return b.head + b.body;
|
|
325
|
+
}
|
|
326
|
+
function render(b) {
|
|
327
|
+
return renderAs(b, b.st);
|
|
328
|
+
}
|
|
329
|
+
/** REQ-QRY-021 true when the path is a file under the root. */
|
|
330
|
+
function isFile(root, path) {
|
|
331
|
+
const n = normalize(path);
|
|
332
|
+
if (isAbsolute(n) || n.startsWith(".."))
|
|
333
|
+
return false;
|
|
334
|
+
try {
|
|
335
|
+
return statSync(join(root, n)).isFile();
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
213
340
|
}
|
package/dist/graph/files.js
CHANGED
|
@@ -24,7 +24,8 @@ export function fileEdges(entries, path) {
|
|
|
24
24
|
for (const r of e.refs)
|
|
25
25
|
push(g, r, `${verb}, tag at ${e.file}:${e.line}`);
|
|
26
26
|
}
|
|
27
|
-
|
|
27
|
+
// REQ-QRY-021 an archived task names paths too, but it is no mention
|
|
28
|
+
const mentions = entries.filter((e) => e.kind === "task" && e.canonical && !e.file.includes("/archive/") && (e.paths ?? []).includes(path));
|
|
28
29
|
for (const e of mentions)
|
|
29
30
|
push(2, e.id, "mentions, in task line");
|
|
30
31
|
return groups.flatMap((g) => g.sort((a, b) => cmpStr(a[0], b[0])));
|
package/dist/graph/related.js
CHANGED
|
@@ -8,7 +8,7 @@ import { structuralEdges, structure } from "./links.js";
|
|
|
8
8
|
export const KIND_RANK = { adr: 0, decision: 1, req: 2, scn: 2, task: 3, code: 4, test: 5, cap: 6, change: 7 };
|
|
9
9
|
export const REASONS = [
|
|
10
10
|
"scenario of", "ref from", "ref to", "decision for", "task cites", "implements", "covers", "mentions",
|
|
11
|
-
"delta in", "design named in", "task names", "capability of", "requirement in", "capability touched by", "main version of",
|
|
11
|
+
"delta in", "design named in", "task names", "capability of", "requirement in", "capability touched by", "main version of", "named by",
|
|
12
12
|
];
|
|
13
13
|
/** The walk behind `related` and `bundle`. The map keeps the insertion order and the depth of each entry (046/D7). */
|
|
14
14
|
export function walkFound(entries, start, depth, all = false) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// REQ-QRY-023 REQ-QRY-024 REQ-QRY-025 the trust labels of a card: the provenance class and the evidence state.
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { staleOf } from "../check/evidence.js";
|
|
5
|
+
import { evidenceRel } from "../tasks/tasks.js";
|
|
6
|
+
import { fileSha256 } from "../util/hash.js";
|
|
7
|
+
const TAGGED = ["implements", "covers", "tag at"];
|
|
8
|
+
const STRUCTURAL = ["delta in", "design named in", "task names", "capability of", "requirement in", "capability touched by", "main version of"];
|
|
9
|
+
/** REQ-QRY-023 the class of a reason, by its prefix (062/D1). An unknown prefix is declared. */
|
|
10
|
+
export function provenanceClass(reason) {
|
|
11
|
+
if (TAGGED.some((p) => reason.startsWith(p)))
|
|
12
|
+
return "tagged";
|
|
13
|
+
if (STRUCTURAL.some((p) => reason.startsWith(p)))
|
|
14
|
+
return "structural";
|
|
15
|
+
return "declared";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The trust state of one bundle run. It reads each evidence file at most once and hashes each file at most once
|
|
19
|
+
* (062/D3). It writes nothing.
|
|
20
|
+
*/
|
|
21
|
+
export class Trust {
|
|
22
|
+
tasks = new Map();
|
|
23
|
+
citing = new Map();
|
|
24
|
+
states = new Map();
|
|
25
|
+
evidence = new Map();
|
|
26
|
+
hashes = new Map();
|
|
27
|
+
root;
|
|
28
|
+
constructor(root, entries) {
|
|
29
|
+
this.root = root;
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
if (e.kind !== "task")
|
|
32
|
+
continue;
|
|
33
|
+
// an active copy wins over an archived one, as check does
|
|
34
|
+
const cur = this.tasks.get(e.id);
|
|
35
|
+
if (!cur || (cur.file.includes("/archive/") && !e.file.includes("/archive/")))
|
|
36
|
+
this.tasks.set(e.id, e);
|
|
37
|
+
}
|
|
38
|
+
for (const t of this.tasks.values()) {
|
|
39
|
+
for (const r of t.refs) {
|
|
40
|
+
const l = this.citing.get(r) ?? [];
|
|
41
|
+
l.push(t.id);
|
|
42
|
+
this.citing.set(r, l);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
readEvidence(task) {
|
|
47
|
+
if (this.evidence.has(task))
|
|
48
|
+
return this.evidence.get(task);
|
|
49
|
+
const p = join(this.root, evidenceRel({ id: task }));
|
|
50
|
+
let ev = null;
|
|
51
|
+
if (existsSync(p)) {
|
|
52
|
+
try {
|
|
53
|
+
const j = JSON.parse(readFileSync(p, "utf8"));
|
|
54
|
+
ev = { files: j.files ?? {} };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
ev = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
this.evidence.set(task, ev);
|
|
61
|
+
return ev;
|
|
62
|
+
}
|
|
63
|
+
hash(path) {
|
|
64
|
+
if (!this.hashes.has(path))
|
|
65
|
+
this.hashes.set(path, fileSha256(join(this.root, path)));
|
|
66
|
+
return this.hashes.get(path);
|
|
67
|
+
}
|
|
68
|
+
/** REQ-QRY-024 fresh, stale since the first stale path, or no evidence (062/D2). Never the word current. */
|
|
69
|
+
taskState(id) {
|
|
70
|
+
if (this.states.has(id))
|
|
71
|
+
return this.states.get(id);
|
|
72
|
+
const t = this.tasks.get(id);
|
|
73
|
+
let s = "no evidence";
|
|
74
|
+
if (t && t.status === "done" && existsSync(join(this.root, evidenceRel({ id })))) {
|
|
75
|
+
// a damaged file is stale as a whole, as check reports it, so the decision rests on the file and not on the parse
|
|
76
|
+
if (t.file.includes("/archive/"))
|
|
77
|
+
s = "fresh";
|
|
78
|
+
else {
|
|
79
|
+
const stale = staleOf(this.root, id);
|
|
80
|
+
s = stale ? `stale since ${stale}` : "fresh";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.states.set(id, s);
|
|
84
|
+
return s;
|
|
85
|
+
}
|
|
86
|
+
/** REQ-QRY-025 verified when one citing task is fresh, with an early stop (062/D3) */
|
|
87
|
+
reqState(id) {
|
|
88
|
+
for (const t of this.citing.get(id) ?? [])
|
|
89
|
+
if (this.taskState(t) === "fresh")
|
|
90
|
+
return "verified";
|
|
91
|
+
return "unverified";
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* REQ-QRY-023 a tagged line is verified when a done task that cites its requirement lists the file in its
|
|
95
|
+
* evidence with the current hash (062/D1a). An archived task counts too, so a changed line never reads verified.
|
|
96
|
+
*/
|
|
97
|
+
lineVerified(file, req) {
|
|
98
|
+
for (const t of this.citing.get(req) ?? []) {
|
|
99
|
+
const task = this.tasks.get(t);
|
|
100
|
+
if (!task || task.status !== "done")
|
|
101
|
+
continue;
|
|
102
|
+
const ev = this.readEvidence(t);
|
|
103
|
+
const want = ev?.files[file];
|
|
104
|
+
if (want && this.hash(file) === want)
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** REQ-QRY-023 the requirement that a tagged reason names, or null */
|
|
111
|
+
export function taggedReq(reason) {
|
|
112
|
+
const m = /^(?:implements|covers) (\S+)/.exec(reason);
|
|
113
|
+
return m ? m[1] : null;
|
|
114
|
+
}
|
package/dist/index/scan.js
CHANGED
|
@@ -309,8 +309,8 @@ export function scanMarkdown(sc, rpath, entries, problems) {
|
|
|
309
309
|
id: tid, kind: "task", file: rpath, line: i, title: pyStrip(body), change,
|
|
310
310
|
status: pyStrip(box).toLowerCase() === "x" ? "done" : "open", refs: reqs,
|
|
311
311
|
});
|
|
312
|
-
// REQ-IDX-009
|
|
313
|
-
const paths =
|
|
312
|
+
// REQ-IDX-009 a task of any change records the paths that its line names in backticks
|
|
313
|
+
const paths = taskPaths(body);
|
|
314
314
|
if (paths.length)
|
|
315
315
|
t.paths = paths;
|
|
316
316
|
entries.push(t);
|
package/dist/init/init.js
CHANGED
|
@@ -6,6 +6,7 @@ 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
8
|
import { loadConfigOrExit } from "../cli/config.js";
|
|
9
|
+
import { childEnv } from "../util/env.js";
|
|
9
10
|
import { VERSION } from "../version.js";
|
|
10
11
|
export const SCHEMA = "spec-driven-ids";
|
|
11
12
|
/** 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). */
|
|
@@ -31,7 +32,7 @@ export function detectOpenspec(timeoutMs = DETECT_TIMEOUT_MS) {
|
|
|
31
32
|
if (env !== undefined && env.trim())
|
|
32
33
|
return { command: env };
|
|
33
34
|
const [cmd, ...args] = DEFAULT_OPENSPEC.split(" ");
|
|
34
|
-
const r = spawnSync(cmd, [...args, "--version"], { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"] });
|
|
35
|
+
const r = spawnSync(cmd, [...args, "--version"], { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"], env: childEnv() /* REQ-DIST-016 */ });
|
|
35
36
|
const m = r.status === 0 && !r.error ? /\d+\.\d+\.\d+/.exec(r.stdout ?? "") : null;
|
|
36
37
|
if (!m)
|
|
37
38
|
return { command: DEFAULT_OPENSPEC, note: OPENSPEC_NOTE };
|
|
@@ -402,17 +403,24 @@ export function writeGitignore(root) {
|
|
|
402
403
|
writeFileSync(p, text);
|
|
403
404
|
return add.length;
|
|
404
405
|
}
|
|
405
|
-
/** REQ-DIST-
|
|
406
|
-
export function
|
|
406
|
+
/** REQ-DIST-018 the agent folders that .gitignore ignores as a whole, with or without a slash. */
|
|
407
|
+
export function ignoredAgentDirs(root) {
|
|
407
408
|
const p = join(root, GITIGNORE);
|
|
408
409
|
if (!existsSync(p))
|
|
409
|
-
return
|
|
410
|
+
return [];
|
|
410
411
|
const lines = ignoreLines(readFileSync(p, "utf8"));
|
|
411
|
-
return
|
|
412
|
+
return [".claude", ".agents"].filter((d) => lines.has(d) || lines.has(`${d}/`));
|
|
413
|
+
}
|
|
414
|
+
/** REQ-DIST-018 one note for the ignored agent folders, or null when none is ignored. */
|
|
415
|
+
export function ignoredNote(root) {
|
|
416
|
+
const dirs = ignoredAgentDirs(root).map((d) => `${d}/`);
|
|
417
|
+
if (dirs.length === 0)
|
|
418
|
+
return null;
|
|
419
|
+
const names = dirs.join(" and ");
|
|
420
|
+
return `note: .gitignore ignores ${names}. The skills under ${names} stay out of git.`;
|
|
412
421
|
}
|
|
413
422
|
/** REQ-SCH-003 where the templates with the ID placeholders are. Printed after the schema step (018/D5, 025/D4). */
|
|
414
423
|
export const TEMPLATES_NOTE = `templates: ${TEMPLATES_DIR}/ (spec.md, tasks.md hold the ID placeholders)`;
|
|
415
|
-
export const CLAUDE_NOTE = "note: .gitignore ignores .claude/. The skills under .claude/skills/ stay out of git. The copies under .agents/skills/ are tracked.";
|
|
416
424
|
export function runInit(root, o) {
|
|
417
425
|
const res = { written: [], kept: [], changed: [], merged: [], notes: [] };
|
|
418
426
|
// REQ-DIST-002 the detection runs only when init writes spec-ctx.toml. An existing file stays (022/D2).
|
|
@@ -440,8 +448,9 @@ export function runInit(root, o) {
|
|
|
440
448
|
const m = o.schema ? mergeConfig(cur, fragment, otherFragment) : null;
|
|
441
449
|
if (!m || (!m.added.length && !m.merged.length)) {
|
|
442
450
|
res.nothing = true;
|
|
443
|
-
|
|
444
|
-
|
|
451
|
+
const ign = ignoredNote(root); // REQ-DIST-018
|
|
452
|
+
if (ign)
|
|
453
|
+
res.notes.push(ign);
|
|
445
454
|
if (o.schema && hasLegacySchema(cur))
|
|
446
455
|
res.notes.push(LEGACY_SCHEMA_NOTE);
|
|
447
456
|
if (o.schema)
|
|
@@ -499,9 +508,10 @@ export function runInit(root, o) {
|
|
|
499
508
|
else
|
|
500
509
|
res.kept.push(GITIGNORE);
|
|
501
510
|
}
|
|
502
|
-
// REQ-DIST-
|
|
503
|
-
|
|
504
|
-
|
|
511
|
+
// REQ-DIST-018 skills under an ignored agent folder never reach git (017/D3)
|
|
512
|
+
const ign = ignoredNote(root);
|
|
513
|
+
if (ign)
|
|
514
|
+
res.notes.push(ign);
|
|
505
515
|
// REQ-SCH-003 the templates line comes last, right before next: (018/D5)
|
|
506
516
|
if (o.schema)
|
|
507
517
|
res.notes.push(TEMPLATES_NOTE);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// REQ-DIST-017 what init says to do next, from the state of the specs.
|
|
2
|
+
import { UsageExit } from "../cli/errors.js";
|
|
3
|
+
import { loadConfigOrExit } from "../cli/config.js";
|
|
4
|
+
import { ConfigError } from "../config.js";
|
|
5
|
+
import { planAdopt } from "../adopt/plan.js";
|
|
6
|
+
import { loadIndex } from "../index/build.js";
|
|
7
|
+
import { nextTask } from "../tasks/tasks.js";
|
|
8
|
+
import { runCommand } from "../runcmd.js";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
/** true when .gitignore keeps .ctx/index.json out of git, or the index is already there */
|
|
12
|
+
function indexIsSafe(root) {
|
|
13
|
+
if (existsSync(join(root, ".ctx/index.json")))
|
|
14
|
+
return true;
|
|
15
|
+
const g = join(root, ".gitignore");
|
|
16
|
+
if (!existsSync(g))
|
|
17
|
+
return false;
|
|
18
|
+
return readFileSync(g, "utf8").split("\n").map((l) => l.trim()).includes(".ctx/index.json");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Read the specs with the adopt planner. The planner writes nothing. When no header needs an ID,
|
|
22
|
+
* loadIndex may write .ctx/index.json, only when .gitignore already lists it or it exists (061/D1). Only a usage or config error gives
|
|
23
|
+
* the status fallback. Any other throw propagates.
|
|
24
|
+
*/
|
|
25
|
+
export function detectNext(root, run) {
|
|
26
|
+
const status = { needs_id: 0, summary: null, next: runCommand(run, "status") };
|
|
27
|
+
try {
|
|
28
|
+
const cfg = loadConfigOrExit(root);
|
|
29
|
+
const plan = planAdopt(root, cfg);
|
|
30
|
+
if (plan.edits.length > 0) {
|
|
31
|
+
const files = new Set(plan.edits.map((e) => e.file)).size;
|
|
32
|
+
const caps = plan.caps.filter((c) => c.derived).length;
|
|
33
|
+
return {
|
|
34
|
+
needs_id: plan.edits.length,
|
|
35
|
+
summary: `specs: ${plan.edits.length} headers in ${files} files need an ID (${caps} capability codes). adopt --dry-run shows each one.`,
|
|
36
|
+
next: runCommand(run, "adopt"),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// a skills refresh must not leave an untracked index behind (REQ-DIST-013)
|
|
40
|
+
if (!indexIsSafe(root))
|
|
41
|
+
return status;
|
|
42
|
+
const task = nextTask(root, loadIndex(root, cfg).data.entries);
|
|
43
|
+
return task ? { ...status, next: runCommand(run, `context ${task.id}`) } : status;
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (e instanceof UsageExit || e instanceof ConfigError)
|
|
47
|
+
return status;
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// REQ-JRN-001 REQ-JRN-003 the journal write. One call, one line, no output, no throw (057/D2, 057/D3).
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { COMMANDS } from "../cli/args.js";
|
|
5
|
+
import { dumps } from "../util/json.js";
|
|
6
|
+
import { SIGNALS } from "./note.js";
|
|
7
|
+
export const JOURNAL_DIR = ".ctx/local";
|
|
8
|
+
export const JOURNAL_FILE = "runs.jsonl";
|
|
9
|
+
export const ROTATED_FILE = "runs.1.jsonl";
|
|
10
|
+
export const MAX_BYTES = 256 * 1024;
|
|
11
|
+
export const MAX_LINE = 4096;
|
|
12
|
+
/** REQ-JRN-001 the line, with sorted keys and no newline inside. It stays under 4 KB. */
|
|
13
|
+
export function journalLine(sink, run) {
|
|
14
|
+
const signals = new Set(sink.signals);
|
|
15
|
+
if (run.exit === 2)
|
|
16
|
+
signals.add("usage-error");
|
|
17
|
+
if (run.exit === 3)
|
|
18
|
+
signals.add("internal-error");
|
|
19
|
+
const rec = {
|
|
20
|
+
at: run.at ?? new Date().toISOString(),
|
|
21
|
+
cmd: sink.cmd !== undefined && COMMANDS.includes(sink.cmd) ? sink.cmd : "?",
|
|
22
|
+
exit: run.exit,
|
|
23
|
+
ms: Math.max(0, Math.round(run.ms)),
|
|
24
|
+
signals: SIGNALS.filter((s) => signals.has(s)),
|
|
25
|
+
v: 1,
|
|
26
|
+
version: run.version,
|
|
27
|
+
};
|
|
28
|
+
if (sink.err && run.exit === 3)
|
|
29
|
+
rec.err = { code: sink.err.code, frames: sink.err.frames, name: sink.err.name };
|
|
30
|
+
let line = JSON.stringify(JSON.parse(dumps(rec)));
|
|
31
|
+
if (line.length > MAX_LINE && sink.err) {
|
|
32
|
+
rec.err = { code: sink.err.code, frames: sink.err.frames.slice(0, 5), name: sink.err.name };
|
|
33
|
+
line = JSON.stringify(JSON.parse(dumps(rec)));
|
|
34
|
+
}
|
|
35
|
+
if (line.length > MAX_LINE) {
|
|
36
|
+
delete rec.err;
|
|
37
|
+
line = JSON.stringify(JSON.parse(dumps(rec)));
|
|
38
|
+
}
|
|
39
|
+
return line;
|
|
40
|
+
}
|
|
41
|
+
/** REQ-JRN-003 append one line. Any failure stays silent. */
|
|
42
|
+
export function appendRun(sink, run) {
|
|
43
|
+
try {
|
|
44
|
+
if (process.env.SPECCTX_JOURNAL === "0")
|
|
45
|
+
return;
|
|
46
|
+
const root = sink.root;
|
|
47
|
+
if (!root)
|
|
48
|
+
return;
|
|
49
|
+
const os = join(root, "openspec");
|
|
50
|
+
if (!existsSync(os) || !statSync(os).isDirectory())
|
|
51
|
+
return;
|
|
52
|
+
const dir = join(root, JOURNAL_DIR);
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
const ignore = join(dir, ".gitignore");
|
|
55
|
+
if (!existsSync(ignore))
|
|
56
|
+
writeFileSync(ignore, "*\n");
|
|
57
|
+
const file = join(dir, JOURNAL_FILE);
|
|
58
|
+
if (existsSync(file) && statSync(file).size > MAX_BYTES)
|
|
59
|
+
renameSync(file, join(dir, ROTATED_FILE));
|
|
60
|
+
appendFileSync(file, journalLine(sink, run) + "\n");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// the journal never changes the exit code or the output
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// REQ-JRN-001 REQ-JRN-002 the sink that main() fills. It has no fs import, so a --version run loads no write code (057/D1).
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/** REQ-JRN-002 the closed set of signals. The line version `v` versions it. */
|
|
5
|
+
export const SIGNALS = ["init-ok", "context-has-code-or-test", "empty-context", "truncated-context", "usage-error", "internal-error"];
|
|
6
|
+
/** REQ-JRN-001 fill the sink. It does nothing when the caller passed no sink. */
|
|
7
|
+
export function note(io, patch) {
|
|
8
|
+
const s = io.journal;
|
|
9
|
+
if (!s)
|
|
10
|
+
return;
|
|
11
|
+
if (patch.root !== undefined)
|
|
12
|
+
s.root = patch.root;
|
|
13
|
+
if (patch.cmd !== undefined)
|
|
14
|
+
s.cmd = patch.cmd;
|
|
15
|
+
if (patch.run !== undefined)
|
|
16
|
+
s.run = patch.run;
|
|
17
|
+
if (patch.signal !== undefined && SIGNALS.includes(patch.signal))
|
|
18
|
+
s.signals.add(patch.signal);
|
|
19
|
+
if (patch.err !== undefined)
|
|
20
|
+
s.err = patch.err;
|
|
21
|
+
}
|
|
22
|
+
/** The package root: two folders above src/journal or dist/journal. */
|
|
23
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
24
|
+
/** REQ-JRN-002 name, code, and package frames of an error. Never the message (057/D4). */
|
|
25
|
+
export function errInfo(e, pkgRoot = PACKAGE_ROOT) {
|
|
26
|
+
if (!(e instanceof Error))
|
|
27
|
+
return { name: "NonError", code: null, frames: [] };
|
|
28
|
+
const raw = e.code;
|
|
29
|
+
const code = typeof raw === "string" && /^[A-Z][A-Z0-9_]*$/.test(raw) ? raw : null;
|
|
30
|
+
const frames = [];
|
|
31
|
+
// stack line 1 holds the message, so it never counts
|
|
32
|
+
for (const line of (e.stack ?? "").split("\n").slice(1)) {
|
|
33
|
+
if (!/^\s+at /.test(line))
|
|
34
|
+
continue;
|
|
35
|
+
const m = /\(?((?:file:\/\/)?[^()\s]+):(\d+):\d+\)?$/.exec(line);
|
|
36
|
+
if (!m)
|
|
37
|
+
continue;
|
|
38
|
+
let file = m[1];
|
|
39
|
+
try {
|
|
40
|
+
if (file.startsWith("file://"))
|
|
41
|
+
file = fileURLToPath(file);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
// REQ-JRN-007 a frame like node:internal/... or <anonymous> is no path in the package
|
|
47
|
+
if (!isAbsolute(file))
|
|
48
|
+
continue;
|
|
49
|
+
const rel = relative(pkgRoot, file);
|
|
50
|
+
if (rel === "" || rel.startsWith("..") || rel.split(/[\\/]/).includes("node_modules") || resolve(rel) === rel)
|
|
51
|
+
continue;
|
|
52
|
+
frames.push(`${rel.split("\\").join("/")}:${m[2]}`);
|
|
53
|
+
if (frames.length === 10)
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
return { name: e.name, code, frames };
|
|
57
|
+
}
|