spec-ctx 0.1.1 → 0.1.2

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 CHANGED
@@ -181,7 +181,8 @@ specctx gate --strict
181
181
  - `adopt` derives a capability code from each folder name, for example `UA` for `user-auth`. It writes the codes into `spec-ctx.toml`. Rename a code there before the IDs spread.
182
182
  - `adopt` runs `check` before and after the write. A finding that the write added undoes every file. A second run prints `nothing to adopt`.
183
183
  - `task backfill` writes manual evidence for every ticked task that has none.
184
- - `baseline write` records the debt of the day in `.ctx/baseline.json`: untested requirements and scenarios, tasks without evidence, and legacy tasks without a citation or a Verify code span. `check` hides that debt and prints `baselined: <n>`. New debt still shows.
184
+ - `baseline write` records the debt of the day in `.ctx/baseline.json`. The debt has five kinds. Untested requirements and scenarios. Tasks without evidence. Legacy tasks without a citation or a Verify code span. Delta headers that match no main requirement. Hard lint findings per file and rule on the gate globs. `check` hides that debt and prints `baselined: <n>`. `gate` fails the lint step only when a count rises. New debt still shows.
185
+ - `[gate] markdown` in `spec-ctx.toml` names the markdown globs that `gate` lints. Narrow it when a folder is not yours to fix.
185
186
  - Legacy tasks stay in the baseline until an agent adds `[REQ-...]` citations and a `Verify:` code span. `specctx baseline prune` then drops them. No command adds a single entry.
186
187
 
187
188
  Both paths have an end to end test: `tests/paths/fresh.sh` and `tests/paths/existing.sh`.
@@ -269,18 +270,18 @@ The action runs `specctx gate`: `check --strict`, both markdown linters, `opensp
269
270
 
270
271
  ## Status
271
272
 
272
- - Published: `spec-ctx@0.1.1` on npm, with tags `v0.1.1` and `v0` and binaries on the GitHub release.
273
- - Archived: 001 to 013. No change is open. See `CHANGELOG.md` for each release.
273
+ - Published: `spec-ctx@0.1.2` on npm, with tags `v0.1.2` and `v0` and binaries on the GitHub release.
274
+ - Archived: 001 to 017. No change is open. See `CHANGELOG.md` for each release.
274
275
  - A version bump on `main` publishes the next release. See `docs/CONTRIBUTING.md`.
275
276
  - Upstream: the ID scheme is proposed as a comment on [OpenSpec#1967](https://github.com/Fission-AI/OpenSpec/issues/1967#issuecomment-6414314730). The schema bundle is submitted to the community table in [OpenSpec#2157](https://github.com/Fission-AI/OpenSpec/issues/2157). No pull request until a maintainer asks.
276
277
 
277
278
  ## Measured
278
279
 
279
- `specctx stats --write` measures this repository after each change. The newest file is `docs/metrics/2026-10-26.md`. Its total row:
280
+ `specctx stats --write` measures this repository after each change. The newest file is `docs/metrics/2026-10-27.md`. Its total row:
280
281
 
281
282
  | change | context_ratio | evidence_ratio | stale_catches | cycle_time_median_s | debt |
282
283
  | --- | --- | --- | --- | --- | --- |
283
- | total | 0.0469 (n=41) | 0.85 (88/104) | 36 (of 62 reopens) | 60 (n=41) | 0 (0 baselined + 0 new) |
284
+ | total | 0.0435 (n=52) | 0.87 (106/122) | 36 (of 68 reopens) | 70 (n=52) | 0 (0 baselined + 0 new) |
284
285
 
285
286
  - `context_ratio`: the size of one task bundle divided by the size of all spec text. Lower is better.
286
287
  - `evidence_ratio`: done tasks with a passed Verify command, divided by all done tasks. The rest have a manual note. Higher is better.
@@ -1,10 +1,12 @@
1
1
  // REQ-ADOPT-005 REQ-CHK-002 the baseline ratchet in .ctx/baseline.json: sorted "rule id" pairs.
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { lintLine, parseLintLine } from "../lint/counts.js";
4
5
  import { cmpStr } from "../util/pystr.js";
5
6
  export const BASELINE_PATH = ".ctx/baseline.json";
6
- export const BASELINE_RULES = ["uncovered", "scenario-untested", "done-without-evidence", "manual-evidence", "task-without-req", "task-without-verify"];
7
- const key = (rule, id) => `${rule} ${id}`;
7
+ export const BASELINE_RULES = ["uncovered", "scenario-untested", "done-without-evidence", "manual-evidence", "task-without-req", "task-without-verify", "missing-id"];
8
+ /** REQ-ADOPT-005 a missing-id finding has no ID. Its key carries the file and the header title. */
9
+ const key = (f) => (f.rule === "missing-id" ? `${f.rule} ${f.file} ${f.id}` : `${f.rule} ${f.id}`);
8
10
  /** A warning line reads "manual-evidence <task>". */
9
11
  const warningKey = (w) => w;
10
12
  export function readBaseline(root) {
@@ -24,24 +26,48 @@ function save(root, entries) {
24
26
  }
25
27
  /** The current debt that the baseline may hold: strict findings and warnings of the listed rules. */
26
28
  export function debt(findings, warnings) {
27
- const out = findings.filter((f) => BASELINE_RULES.includes(f.rule)).map((f) => key(f.rule, f.id));
29
+ const out = findings.filter((f) => BASELINE_RULES.includes(f.rule)).map((f) => key(f));
28
30
  for (const w of warnings)
29
31
  if (BASELINE_RULES.includes(w.split(" ")[0]))
30
32
  out.push(warningKey(w));
31
33
  return out;
32
34
  }
33
- export function writeBaseline(root, findings, warnings) {
34
- return save(root, debt(findings, warnings));
35
+ /** REQ-ADOPT-007 one `lint <file> <rule> <count>` line per key with hard lint findings. */
36
+ export function lintDebt(counts) {
37
+ return [...counts].filter(([, n]) => n > 0).map(([k, n]) => lintLine(k, n));
38
+ }
39
+ export function writeBaseline(root, findings, warnings, lint = new Map()) {
40
+ return save(root, [...debt(findings, warnings), ...lintDebt(lint)]);
35
41
  }
36
42
  /** Drop entries that no longer fire. Returns the number dropped. */
37
- export function pruneBaseline(root, findings, warnings) {
43
+ export function pruneBaseline(root, findings, warnings, lint = new Map()) {
38
44
  const cur = readBaseline(root);
39
45
  if (!cur)
40
46
  return 0;
41
47
  const live = new Set(debt(findings, warnings));
42
- const kept = [...cur].filter((e) => live.has(e));
48
+ const kept = [];
49
+ let pruned = 0;
50
+ for (const e of cur) {
51
+ const l = parseLintLine(e);
52
+ if (l) {
53
+ // REQ-ADOPT-007 a lint line drops at zero and lowers when the count fell
54
+ const now = lint.get(l.key) ?? 0;
55
+ if (now === 0)
56
+ pruned++;
57
+ else if (now < l.count) {
58
+ pruned++;
59
+ kept.push(lintLine(l.key, now));
60
+ }
61
+ else
62
+ kept.push(e);
63
+ }
64
+ else if (live.has(e))
65
+ kept.push(e);
66
+ else
67
+ pruned++;
68
+ }
43
69
  save(root, kept);
44
- return cur.size - kept.length;
70
+ return pruned;
45
71
  }
46
72
  /** REQ-CHK-002 a finding or warning in the baseline leaves the output and counts as baselined. */
47
73
  export function applyBaseline(root, findings, warnings) {
@@ -50,7 +76,7 @@ export function applyBaseline(root, findings, warnings) {
50
76
  return { findings, warnings, baselined: 0 };
51
77
  let n = 0;
52
78
  const f2 = findings.filter((f) => {
53
- const hit = base.has(key(f.rule, f.id));
79
+ const hit = base.has(key(f));
54
80
  if (hit)
55
81
  n++;
56
82
  return !hit;
@@ -90,9 +90,9 @@ export async function run(p, io) {
90
90
  print(plan);
91
91
  else if (!plan.edits.length)
92
92
  io.out(plan.unmatched.length ? planLines(plan).join("\n") + "\nnothing to adopt\n" : "nothing to adopt\n");
93
- // REQ-CLI-007 the dry run ends with the next step. The exit code stays 1.
93
+ // REQ-CLI-007 the dry run ends with the next step. REQ-ADOPT-001 the exit code stays 1 and the last line says why (017/D5).
94
94
  else
95
- io.out(planLines(plan).join("\n") + `\n${plan.edits.length} headers need an ID. Next: specctx adopt\n`);
95
+ io.out(planLines(plan).join("\n") + `\nexit 1: ${plan.edits.length} headers need an ID. Next: specctx adopt\n`);
96
96
  return plan.edits.length ? 1 : 0;
97
97
  }
98
98
  if (cmd === "baseline write" || cmd === "baseline prune") {
@@ -106,15 +106,18 @@ export async function run(p, io) {
106
106
  const data = buildIndex(root, cfg);
107
107
  const findings = check(root, cfg, data, true);
108
108
  const warnings = manualWarnings(root, data.entries, listEvidence(root));
109
+ // REQ-ADOPT-007 the lint debt of the gate globs joins the baseline
110
+ const { lintCounts } = await import("../lint/counts.js");
111
+ const lint = lintCounts(root, cfg.gate.markdown.join(" ")).counts;
109
112
  if (cmd === "baseline write") {
110
- const list = writeBaseline(root, findings, warnings);
113
+ const list = writeBaseline(root, findings, warnings, lint);
111
114
  if (json)
112
115
  print({ baseline: BASELINE_PATH, entries: list.length });
113
116
  else
114
117
  io.out(`wrote ${BASELINE_PATH} with ${list.length} entr${list.length === 1 ? "y" : "ies"}\n`);
115
118
  }
116
119
  else {
117
- const n = pruneBaseline(root, findings, warnings);
120
+ const n = pruneBaseline(root, findings, warnings, lint);
118
121
  if (json)
119
122
  print({ pruned: n });
120
123
  else
@@ -125,11 +128,12 @@ export async function run(p, io) {
125
128
  if (cmd === "gate") {
126
129
  if (p.positional.length)
127
130
  throw new UsageExit("gate takes no arguments");
128
- const { runGate, DEFAULT_MARKDOWN } = await import("./gate.js");
131
+ const { runGate } = await import("./gate.js");
129
132
  const strict = !!f.strict;
130
133
  return runGate(root, {
131
134
  strict,
132
- markdown: f.markdown ?? DEFAULT_MARKDOWN,
135
+ markdown: f.markdown ?? cfg.gate.markdown.join(" "),
136
+ json,
133
137
  check: async () => {
134
138
  const { buildIndex } = await import("../index/build.js");
135
139
  const { check } = await import("../check/check.js");
@@ -145,7 +149,8 @@ export async function run(p, io) {
145
149
  if (p.positional.length < n)
146
150
  throw new UsageExit(`${cmd} needs ${what}`);
147
151
  };
148
- if (cmd === "query")
152
+ // REQ-QRY-001 no term lists a whole change. Without --change a term is required (017/D6).
153
+ if (cmd === "query" && !f.change)
149
154
  need(1, "at least one term");
150
155
  if (cmd === "related") {
151
156
  need(1, "one ID");
@@ -267,12 +272,14 @@ async function init(p, io) {
267
272
  if (p.flags.json)
268
273
  io.out(JSON.stringify(r, null, 2) + "\n");
269
274
  else if (r.nothing)
270
- io.out("nothing to do\n" + paint("dim", "next: specctx status\n", io.tty));
275
+ io.out("nothing to do\n" + r.notes.map((n) => `${n}\n`).join("") + paint("dim", "next: specctx status\n", io.tty));
271
276
  else {
272
277
  io.out(r.written.map((w) => `wrote ${w}\n`).join(""));
273
278
  io.out(r.kept.map((k) => `kept ${k}\n`).join(""));
274
- // REQ-CLI-007 init ends with the next step
275
- io.out(paint("dim", "next: specctx status\n", io.tty));
279
+ io.out(r.notes.map((n) => `${n}\n`).join(""));
280
+ // REQ-CLI-007 init ends with the next step. REQ-DIST-002 a kept changed file points to --force (014/D2).
281
+ const again = ["specctx init", all ? "--all" : [p.flags.schema ? "--schema" : "", p.flags.skills ? "--skills" : ""].filter(Boolean).join(" "), "--force"].filter(Boolean).join(" ");
282
+ io.out(paint("dim", `next: ${r.changed.length ? again : "specctx status"}\n`, io.tty));
276
283
  }
277
284
  return 0;
278
285
  }
package/dist/cli/gate.js CHANGED
@@ -40,6 +40,8 @@ export function markdownFiles(root, globs) {
40
40
  }
41
41
  export const GATE_LOG = ".ctx/gate-log.jsonl";
42
42
  /** REQ-STAT-005 the gate steps in order. The log names the step that failed. */
43
+ /** REQ-CLI-004 the lint detail stops here unless --json asks for every line. */
44
+ export const LINT_DETAIL_CAP = 50;
43
45
  export const GATE_STEPS = ["check", "lint tells", "lint ste", "validate", "spec-length"];
44
46
  /** REQ-STAT-005 one JSON line per gate run, after the last step or at the first failure. */
45
47
  export function appendGateLog(root, line) {
@@ -66,33 +68,24 @@ async function gateSteps(root, o, io, ran) {
66
68
  }
67
69
  io.out(`check: ok${o.strict ? " (strict)" : ""}\n`);
68
70
  // gate 2: REQ-LINT-003 both linters of this package on the markdown globs. No python3.
69
- const { lintSte } = await import("../lint/ste.js");
70
- const { lintTells } = await import("../lint/tells.js");
71
- const { steReport } = await import("../lint/cli.js");
72
- const files = markdownFiles(root, o.markdown);
73
- const bad = [];
74
- let tellsFailed = false;
71
+ // REQ-ADOPT-007 the step fails only on a count above its baseline line.
72
+ const { lintCounts, lintRises } = await import("../lint/counts.js");
73
+ const { readBaseline } = await import("../baseline/baseline.js");
75
74
  ran.push("lint tells", "lint ste");
76
- const noRotation = new Set(["synonym-rotation"]);
77
- for (const f of files) {
78
- const text = readFileSync(join(root, f), "utf8").replace(/\r\n?/g, "\n");
79
- const tells = lintTells(text);
80
- const hard = tells.filter((x) => x.level === "hard");
81
- if (hard.length)
82
- tellsFailed = true;
83
- if (hard.length)
84
- bad.push(`${f}\n` + tells.map((x) => `${f}:${x.line}: [${x.level}] ${x.rule}: ${x.match}\n`).join("") + `${hard.length} hard finding(s), baseline 0\n`);
85
- const r = lintSte(text, f);
86
- const ste = r.findings.filter((x) => !noRotation.has(x.rule));
87
- const steHard = ste.filter((x) => x.level === "advisory-free").length;
88
- if (steHard)
89
- bad.push(`${f}\n` + steReport(ste, r.words, false, steHard, 0));
75
+ const { counts, files } = lintCounts(root, o.markdown);
76
+ const base = readBaseline(root);
77
+ const rises = lintRises(counts, base);
78
+ const baselined = [...base ?? []].filter((l) => l.startsWith("lint ")).length;
79
+ if (rises.length) {
80
+ const newFindings = rises.reduce((a, r) => a + r.current - r.baseline, 0);
81
+ const inFiles = new Set(rises.map((r) => r.key.split(" ")[1])).size;
82
+ const lines = rises.map((r) => `${r.key.slice(5).replace(" ", " ")} ${r.current} (baseline ${r.baseline})`);
83
+ const shown = o.json ? lines : lines.slice(0, LINT_DETAIL_CAP);
84
+ const more = lines.length - shown.length;
85
+ io.out(`linters: fail, ${newFindings} new finding(s) in ${inFiles} file(s)\n` + shown.map((l) => l + "\n").join("") + (more ? `... ${more} more line(s)\n` : ""));
86
+ return rises.some((r) => r.key.includes(" tells:")) ? "lint tells" : "lint ste";
90
87
  }
91
- if (bad.length) {
92
- io.out("linters: fail\n" + bad.join(""));
93
- return tellsFailed ? "lint tells" : "lint ste";
94
- }
95
- io.out(`linters: ok (${files.length} file(s))\n`);
88
+ io.out(`linters: ok (${files} file(s)${baselined ? `, ${baselined} baselined` : ""})\n`);
96
89
  ran.push("validate");
97
90
  // gate 3: openspec validate. SPECCTX_OPENSPEC replaces the command, for tests and offline use.
98
91
  const cmd = process.env.SPECCTX_OPENSPEC ?? "npx -y @fission-ai/openspec@latest";
package/dist/cli/help.js CHANGED
@@ -23,9 +23,9 @@ export const TABLE = [
23
23
  // Read
24
24
  { name: "status", group: "Read", args: "", summary: "Show the active changes, the current task, and the open questions.", flags: [], exit: PLAIN, example: "specctx status" },
25
25
  {
26
- name: "query", group: "Read", args: "<term...>", summary: "Find the entries that contain all terms.",
26
+ name: "query", group: "Read", args: "[term...]", summary: "Find the entries that contain all terms. With --change and no term, list every entry of that change.",
27
27
  flags: [
28
- ["--kind <kind>", "Show only entries of this kind."],
28
+ ["--kind <kind>", "Show only entries of this kind. Any case works."],
29
29
  ["--change <name>", "Show only entries of this change. The key or the full folder name works."],
30
30
  ["--limit <n>", "Show n lines at most. The default is 20. Use 0 for no limit."],
31
31
  ["--all", "Show all copies of an ID."],
@@ -56,7 +56,7 @@ export const TABLE = [
56
56
  summary: "Run `check`, the markdown linters, `openspec validate`, and the length check. Stop at the first failure.",
57
57
  flags: [
58
58
  ["--strict", "Run `check` and `openspec validate` in strict mode."],
59
- ["--markdown <globs>", "Lint these markdown globs. Separate them with spaces."],
59
+ ["--markdown <globs>", "Lint these markdown globs instead of [gate] markdown. Separate them with spaces."],
60
60
  ],
61
61
  exit: [["0", "Every step passed."], ["1", "A step failed."], USAGE, INTERNAL], example: "specctx gate --strict",
62
62
  },
@@ -122,7 +122,7 @@ export const TABLE = [
122
122
  ["--schema", "Also install the spec-driven-ids schema and add its rules to openspec/config.yaml."],
123
123
  ["--skills", "Also install the three skills in .claude/skills and .agents/skills."],
124
124
  ["--all", "Do all three."],
125
- ["--force", "Install over files that exist. An existing spec-ctx.toml stays."],
125
+ ["--force", "Replace the files that have other content. spec-ctx.toml stays."],
126
126
  ],
127
127
  exit: PLAIN, example: "specctx init --all",
128
128
  },
@@ -133,7 +133,7 @@ export const TABLE = [
133
133
  example: "specctx adopt --dry-run",
134
134
  },
135
135
  {
136
- name: "task backfill", group: "Setup", args: "--manual <note>", summary: "Write manual evidence for every ticked task that has none.",
136
+ name: "task backfill", group: "Setup", args: "--manual <note>", summary: "Write manual evidence for every ticked task of an active change that has none.",
137
137
  flags: [["--manual <note>", "The note for the evidence. The flag is required."], ["--change <name>", "Look only in this change."]],
138
138
  exit: PLAIN, example: "specctx task backfill --manual \"done before spec-ctx\"",
139
139
  },
@@ -92,10 +92,12 @@ export async function runWorkflow(c) {
92
92
  const { cpLen } = await import("../util/pystr.js");
93
93
  // REQ-STAT-002 the bundle size goes next to the current task, so stats can compute the context ratio
94
94
  writeFileSync(join(c.root, CURRENT_PATH), dumps({ bundle_chars: cpLen(b.text), started_at: isoNow(), task: t.id }) + "\n");
95
+ // REQ-TASK-002 a task that cites no REQ gets one hint line, so the agent knows what to add
96
+ const hint = t.reqs.length ? null : `task ${t.id} cites no REQ. Run specctx query --change ${t.change} --kind req to list its requirements, then add [REQ-<CAP>-NNN] to the task line.`;
95
97
  if (c.json)
96
- print(c, { bundle: b, task: t.id });
98
+ print(c, { bundle: b, hint, task: t.id });
97
99
  else
98
- c.io.out(b.text + footer(cpLen(b.text), (await import("../stats/stats.js")).specCharsOf(entries)));
100
+ c.io.out(b.text + (hint ? hint + "\n" : "") + footer(cpLen(b.text), (await import("../stats/stats.js")).specCharsOf(entries)));
99
101
  return 0;
100
102
  }
101
103
  case "task verify":
@@ -180,7 +182,8 @@ export async function runWorkflow(c) {
180
182
  return 0;
181
183
  }
182
184
  case "task backfill": {
183
- // REQ-TASK-006 manual evidence for every ticked task that has none, through the task done --manual path
185
+ // REQ-TASK-006 manual evidence for every ticked task of an active change that has none, through the task done --manual path.
186
+ // Archived changes stay untouched (017/D1).
184
187
  noMore(c, 0);
185
188
  const note = f.manual?.trim();
186
189
  if (!note)
@@ -191,7 +194,7 @@ export async function runWorkflow(c) {
191
194
  const want = f.change;
192
195
  const counts = new Map();
193
196
  for (const e of tasksOf(entries)) {
194
- if (e.status !== "done")
197
+ if (e.status !== "done" || e.file.includes("/archive/"))
195
198
  continue;
196
199
  if (want && !matchesChange(e.change, want))
197
200
  continue;
package/dist/config.js CHANGED
@@ -16,6 +16,7 @@ export function defaults() {
16
16
  ids: { caps: [], task_pattern: "\\d+\\.\\d+", paths: {} },
17
17
  bundle: { budget: 8000, depth: 1 },
18
18
  verify: { timeout_s: 600 },
19
+ gate: { markdown: ["openspec/**/*.md", "decisions/*.md", "docs/**/*.md", "*.md"] },
19
20
  };
20
21
  }
21
22
  export class ConfigError extends Error {
@@ -81,6 +82,10 @@ export function loadConfig(root) {
81
82
  throw new ConfigError(`${CONFIG_FILE}: verify.timeout_s must be a number above 0.`);
82
83
  cfg.verify.timeout_s = v;
83
84
  }
85
+ // REQ-DIST-002 the globs that gate lints
86
+ const gate = sec("gate");
87
+ if ("markdown" in gate)
88
+ cfg.gate.markdown = strList(gate.markdown, "gate.markdown");
84
89
  return cfg;
85
90
  }
86
91
  /** The file that `specctx init` writes. Each key has a comment above it. */
@@ -116,4 +121,8 @@ depth = 1
116
121
  [verify]
117
122
  # Seconds before task done stops a Verify command.
118
123
  timeout_s = 600
124
+
125
+ [gate]
126
+ # Markdown globs that gate lints. The baseline holds the lint debt of these files.
127
+ markdown = ["openspec/**/*.md", "decisions/*.md", "docs/**/*.md", "*.md"]
119
128
  `;
@@ -1,12 +1,15 @@
1
1
  // REQ-QRY-001 token AND over id, title, text. Score 3 for id, 2 for title, 1 for text, per term.
2
+ // With no term and a change filter, every entry of the change matches with score 0 (017/D6).
2
3
  import { poolOf } from "../index/canonical.js";
3
4
  import { matchesChange } from "../index/scan.js";
4
5
  import { cmpTuple, pySplit } from "../util/pystr.js";
5
6
  export function query(entries, args, o = {}) {
6
7
  const terms = args.flatMap((a) => pySplit(a.toLowerCase()));
8
+ // REQ-QRY-001 the index stores kinds in lower case. The user may type the kind as the ID shows it.
9
+ const kind = o.kind?.toLowerCase();
7
10
  const hits = [];
8
11
  for (const e of poolOf(entries, !!o.all)) {
9
- if ((o.kind && e.kind !== o.kind) || (o.change && !matchesChange(e.change, o.change)))
12
+ if ((kind && e.kind !== kind) || (o.change && !matchesChange(e.change, o.change)))
10
13
  continue;
11
14
  // REQ-IDX-005 --change takes a key or a full folder name
12
15
  const idl = e.id.toLowerCase();
@@ -222,7 +222,9 @@ export function scanMarkdown(sc, rpath, entries, problems) {
222
222
  const rid = m[1];
223
223
  const title = pyStrip(m[2]);
224
224
  if (!rid) {
225
- problems.push([rpath, i, "missing-id", title]);
225
+ // REQ-CHK-001 an archived header without an ID is history. adopt never writes it.
226
+ if (status !== "archived")
227
+ problems.push([rpath, i, "missing-id", title]);
226
228
  curReq = null;
227
229
  curEntry = null;
228
230
  continue;
@@ -246,7 +248,8 @@ export function scanMarkdown(sc, rpath, entries, problems) {
246
248
  const sid = m[1];
247
249
  const title = pyStrip(m[2]);
248
250
  if (!sid) {
249
- problems.push([rpath, i, "missing-id", title]);
251
+ if (status !== "archived")
252
+ problems.push([rpath, i, "missing-id", title]);
250
253
  continue;
251
254
  }
252
255
  const st = curEntry ? curEntry.status : status;
package/dist/init/init.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // REQ-DIST-002 REQ-SCH-003 REQ-SKL-002 specctx init: config file, schema bundle, skills.
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { ASSETS } from "../assets.generated.js";
5
5
  import { UsageExit } from "../cli/errors.js";
@@ -12,9 +12,22 @@ function asset(rel) {
12
12
  throw new Error(`asset ${rel} is missing from the package`);
13
13
  return Buffer.from(b64, "base64");
14
14
  }
15
+ /**
16
+ * REQ-DIST-002 the config text for one project (017/D4). `tests/**` becomes `test/**` when only `test/`
17
+ * exists at the root. `src/**` becomes `lib/**` when only `lib/` exists. The comments stay.
18
+ */
19
+ export function configText(root) {
20
+ const isDir = (d) => existsSync(join(root, d)) && statSync(join(root, d)).isDirectory();
21
+ let text = INIT_TEXT;
22
+ if (!isDir("tests") && isDir("test"))
23
+ text = text.replace('\ntests = ["tests/**"]\n', '\ntests = ["test/**"]\n');
24
+ if (!isDir("src") && isDir("lib"))
25
+ text = text.replace('\ncode = ["src/**"]\n', '\ncode = ["lib/**"]\n');
26
+ return text;
27
+ }
15
28
  /** Target path relative to the project root for every file that init installs, with its bytes. */
16
- export function plan(o) {
17
- const out = [[CONFIG_FILE, Buffer.from(INIT_TEXT)]];
29
+ export function plan(o, root) {
30
+ const out = [[CONFIG_FILE, Buffer.from(configText(root))]];
18
31
  for (const rel of Object.keys(ASSETS).sort()) {
19
32
  if (o.schema && rel.startsWith(`schemas/${SCHEMA}/`) && !rel.endsWith("/config-fragment.yaml")) {
20
33
  out.push([`openspec/${rel}`, asset(rel)]);
@@ -122,30 +135,70 @@ export function mergeConfig(current, fragment) {
122
135
  }
123
136
  return { text, added, kept };
124
137
  }
138
+ /** REQ-DIST-002 the two lines init owns in .gitignore (017/D2). */
139
+ export const GITIGNORE_LINES = [".ctx/index.json", "adopt-report.md"];
140
+ const GITIGNORE = ".gitignore";
141
+ /** The lines of a .gitignore, trimmed, with a leading slash dropped, so `/x` and `x` compare equal. */
142
+ function ignoreLines(text) {
143
+ return new Set(text.split(/\r?\n/).map((l) => l.trim().replace(/^\//, "")));
144
+ }
145
+ /**
146
+ * REQ-DIST-002 add the absent lines under a `# spec-ctx` comment. Create the file when it is missing.
147
+ * Returns the number of lines added, 0 when both are present.
148
+ */
149
+ export function writeGitignore(root) {
150
+ const p = join(root, GITIGNORE);
151
+ const cur = existsSync(p) ? readFileSync(p, "utf8") : null;
152
+ const have = cur === null ? new Set() : ignoreLines(cur);
153
+ const add = GITIGNORE_LINES.filter((l) => !have.has(l));
154
+ if (!add.length)
155
+ return 0;
156
+ const block = `# spec-ctx\n${add.join("\n")}\n`;
157
+ let text;
158
+ if (cur === null || cur === "")
159
+ text = block;
160
+ else
161
+ text = cur.replace(/\n*$/, "\n") + "\n" + block;
162
+ writeFileSync(p, text);
163
+ return add.length;
164
+ }
165
+ /** REQ-DIST-002 true when .gitignore has a line that ignores the whole .claude directory (017/D3). */
166
+ export function ignoresClaude(root) {
167
+ const p = join(root, GITIGNORE);
168
+ if (!existsSync(p))
169
+ return false;
170
+ const lines = ignoreLines(readFileSync(p, "utf8"));
171
+ return lines.has(".claude") || lines.has(".claude/");
172
+ }
173
+ export const CLAUDE_NOTE = "note: .gitignore ignores .claude/. The skills under .claude/skills/ stay out of git. The copies under .agents/skills/ are tracked.";
125
174
  export function runInit(root, o) {
126
- const res = { written: [], kept: [], notes: [] };
127
- let files = plan(o);
175
+ const res = { written: [], kept: [], changed: [], notes: [] };
176
+ let files = plan(o, root);
128
177
  // REQ-CLI-007 every file present with the same bytes and no config key to add: nothing to do
129
- const same = files.every(([rel, data]) => existsSync(join(root, rel)) && readFileSync(join(root, rel)).equals(data));
130
- if (same && !o.force) {
178
+ // an existing spec-ctx.toml counts as present, because the user owns it (014/D4)
179
+ const same = files.every(([rel, data]) => existsSync(join(root, rel)) && (rel === CONFIG_FILE || readFileSync(join(root, rel)).equals(data)));
180
+ const ignoreDone = existsSync(join(root, GITIGNORE)) && GITIGNORE_LINES.every((l) => ignoreLines(readFileSync(join(root, GITIGNORE), "utf8")).has(l));
181
+ if (same && ignoreDone && !o.force) {
131
182
  const cfgPath = join(root, "openspec/config.yaml");
132
183
  const cur = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : null;
133
184
  if (!o.schema || !mergeConfig(cur, asset(`schemas/${SCHEMA}/config-fragment.yaml`).toString("utf8")).added.length) {
134
185
  res.nothing = true;
186
+ if (o.skills && ignoresClaude(root))
187
+ res.notes.push(CLAUDE_NOTE);
135
188
  return res;
136
189
  }
137
190
  }
191
+ // REQ-DIST-002 init never replaces spec-ctx.toml, and never fails because a file exists (014/D1)
138
192
  if (existsSync(join(root, CONFIG_FILE))) {
139
- if (!o.force)
140
- throw new UsageExit(`${CONFIG_FILE} exists. Use --force to install the other files over it`);
141
- // --force never replaces a config that the user wrote
142
193
  files = files.filter(([rel]) => rel !== CONFIG_FILE);
143
194
  res.kept.push(CONFIG_FILE);
144
195
  }
145
196
  if (!o.force) {
146
- const clash = files.filter(([rel, data]) => existsSync(join(root, rel)) && !readFileSync(join(root, rel)).equals(data));
147
- if (clash.length)
148
- throw new UsageExit(`these files exist with other content: ${clash.map(([r]) => r).join(", ")}. Use --force to replace them`);
197
+ const differs = (rel, data) => !readFileSync(join(root, rel)).equals(data);
198
+ res.changed = files.filter(([rel, data]) => existsSync(join(root, rel)) && differs(rel, data)).map(([rel]) => rel);
199
+ for (const rel of res.changed)
200
+ res.kept.push(`${rel} (other content)`);
201
+ files = files.filter(([rel]) => !existsSync(join(root, rel)));
149
202
  }
150
203
  for (const [rel, data] of files) {
151
204
  const p = join(root, rel);
@@ -165,5 +218,14 @@ export function runInit(root, o) {
165
218
  for (const k of m.kept)
166
219
  res.kept.push(`openspec/config.yaml ${k}`);
167
220
  }
221
+ // REQ-DIST-002 the two .gitignore lines (017/D2)
222
+ const added = writeGitignore(root);
223
+ if (added)
224
+ res.written.push(`${GITIGNORE} (${added} line${added === 1 ? "" : "s"})`);
225
+ else
226
+ res.kept.push(GITIGNORE);
227
+ // REQ-DIST-002 skills under an ignored .claude/ never reach git (017/D3)
228
+ if (o.skills && ignoresClaude(root))
229
+ res.notes.push(CLAUDE_NOTE);
168
230
  return res;
169
231
  }
@@ -0,0 +1,60 @@
1
+ // REQ-ADOPT-007 hard lint findings per file and rule on the gate globs. One count per key, so the baseline survives line shifts.
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { markdownFiles } from "../cli/gate.js";
5
+ import { lintSte } from "./ste.js";
6
+ import { lintTells } from "./tells.js";
7
+ /** Rules that the gate never counts. The repo lints by hand with the same disable. */
8
+ const SKIP = new Set(["synonym-rotation"]);
9
+ export const LINT_PREFIX = "lint ";
10
+ /** Key of one lint count: `lint <file> <linter>:<rule>`. */
11
+ export const lintKey = (file, linter, rule) => `${LINT_PREFIX}${file} ${linter}:${rule}`;
12
+ /** Hard findings per `lint <file> <linter>:<rule>` for the markdown globs. Files with no hard finding give no key. */
13
+ export function lintCounts(root, globs) {
14
+ const counts = new Map();
15
+ const files = markdownFiles(root, globs);
16
+ for (const f of files) {
17
+ const text = readFileSync(join(root, f), "utf8").replace(/\r\n?/g, "\n");
18
+ for (const x of lintTells(text)) {
19
+ if (x.level !== "hard")
20
+ continue;
21
+ const k = lintKey(f, "tells", x.rule);
22
+ counts.set(k, (counts.get(k) ?? 0) + 1);
23
+ }
24
+ for (const x of lintSte(text, f).findings) {
25
+ if (x.level !== "advisory-free" || SKIP.has(x.rule))
26
+ continue;
27
+ const k = lintKey(f, "ste", x.rule);
28
+ counts.set(k, (counts.get(k) ?? 0) + 1);
29
+ }
30
+ }
31
+ return { counts, files: files.length };
32
+ }
33
+ /** Parse a baseline line `lint <file> <rule> <count>`. Returns null for any other line. */
34
+ export function parseLintLine(line) {
35
+ if (!line.startsWith(LINT_PREFIX))
36
+ return null;
37
+ const i = line.lastIndexOf(" ");
38
+ const n = Number(line.slice(i + 1));
39
+ if (i < 0 || !Number.isInteger(n) || n < 0)
40
+ return null;
41
+ return { key: line.slice(0, i), count: n };
42
+ }
43
+ export const lintLine = (key, count) => `${key} ${count}`;
44
+ /** REQ-CLI-004 the keys whose count rose above the baseline, worst first. A key with no line has a baseline of 0. */
45
+ export function lintRises(counts, baseline) {
46
+ const base = new Map();
47
+ for (const line of baseline ?? []) {
48
+ const p = parseLintLine(line);
49
+ if (p)
50
+ base.set(p.key, p.count);
51
+ }
52
+ const out = [];
53
+ for (const [key, current] of counts) {
54
+ const b = base.get(key) ?? 0;
55
+ if (current > b)
56
+ out.push({ key, current, baseline: b });
57
+ }
58
+ out.sort((a, b) => b.current - b.baseline - (a.current - a.baseline) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
59
+ return out;
60
+ }
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.1.1";
2
+ export const VERSION = "0.1.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-ctx",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Deterministic context engine and task workflow for OpenSpec projects.",
5
5
  "type": "module",
6
6
  "license": "MIT",