@spec-engine/spec-engine 0.6.1 → 0.7.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/dist/impl.js CHANGED
@@ -20,7 +20,7 @@ var package_default;
20
20
  var init_package = __esm(() => {
21
21
  package_default = {
22
22
  name: "@spec-engine/spec-engine",
23
- version: "0.6.1",
23
+ version: "0.7.0",
24
24
  description: "Cross-repo spec engine \u2014 durable requirements with permanent IDs, bound to code via @spec tags, checked in CI. Ships the spec CLI, the local coverage webapp (spec serve), and offline docs (spec docs).",
25
25
  license: "MIT",
26
26
  type: "module",
@@ -73,18 +73,8 @@ var init_package = __esm(() => {
73
73
  };
74
74
  });
75
75
 
76
- // ../shared/src/config.ts
77
- import { z } from "zod";
78
- var SpecConfigSchema;
79
- var init_config = __esm(() => {
80
- SpecConfigSchema = z.object({
81
- specs: z.string().regex(/^spec-engine@\d+$/, "must be of the form spec-engine@N where N is an integer"),
82
- ignore: z.array(z.string().min(1, "ignore entries must be non-empty strings")).optional()
83
- });
84
- });
85
-
86
76
  // ../shared/src/diagnostics.ts
87
- var DiagnosticCode, NotASpecPlatformError;
77
+ var DiagnosticCode, NotASpecPlatformError, InvalidDomainFilesError;
88
78
  var init_diagnostics = __esm(() => {
89
79
  DiagnosticCode = {
90
80
  DUP_ID: "DUP_ID",
@@ -111,6 +101,9 @@ var init_diagnostics = __esm(() => {
111
101
  UNPROVEN_REQ: "UNPROVEN_REQ",
112
102
  PROOFS_UNCONFIRMED: "PROOFS_UNCONFIRMED",
113
103
  REQUIREMENT_REMOVED: "REQUIREMENT_REMOVED",
104
+ IMPL_LOST: "IMPL_LOST",
105
+ VERIFY_LOST: "VERIFY_LOST",
106
+ SPEC_FILE_DELETED: "SPEC_FILE_DELETED",
114
107
  UNAPPROVED_STATUS_FLIP: "UNAPPROVED_STATUS_FLIP",
115
108
  PARTIAL_PROPAGATION: "PARTIAL_PROPAGATION",
116
109
  MALFORMED_FILE: "MALFORMED_FILE",
@@ -135,10 +128,20 @@ var init_diagnostics = __esm(() => {
135
128
  Object.setPrototypeOf(this, NotASpecPlatformError.prototype);
136
129
  }
137
130
  };
131
+ InvalidDomainFilesError = class InvalidDomainFilesError extends Error {
132
+ diagnostics;
133
+ constructor(diagnostics) {
134
+ super(diagnostics.map((d) => `${d.source_file ?? ""}: ${d.detail}`).join(`
135
+ `));
136
+ this.name = "InvalidDomainFilesError";
137
+ this.diagnostics = diagnostics;
138
+ Object.setPrototypeOf(this, InvalidDomainFilesError.prototype);
139
+ }
140
+ };
138
141
  });
139
142
 
140
143
  // ../shared/src/domain.ts
141
- import { z as z2 } from "zod";
144
+ import { z } from "zod";
142
145
  function reqIdForIssuePath(input, path) {
143
146
  if (path.length < 2 || path[0] !== "requirements" || typeof path[1] !== "number") {
144
147
  return null;
@@ -221,57 +224,193 @@ function orderDomain(d) {
221
224
  }))
222
225
  };
223
226
  }
227
+ function isDefaultValue(v) {
228
+ return v === undefined || v === null || Array.isArray(v) && v.length === 0;
229
+ }
230
+ function patchFields(raw, before, next) {
231
+ for (const key of new Set([...Object.keys(before), ...Object.keys(next)])) {
232
+ if (key === "requirements")
233
+ continue;
234
+ const v = next[key];
235
+ if (sameValue(before[key], v))
236
+ continue;
237
+ if (v === undefined)
238
+ delete raw[key];
239
+ else if (!isDefaultValue(v) || key in raw)
240
+ raw[key] = v;
241
+ }
242
+ }
243
+ function shapeLike(template, next) {
244
+ if (template === undefined)
245
+ return next;
246
+ const out = {};
247
+ for (const key of Object.keys(template)) {
248
+ if (next[key] !== undefined)
249
+ out[key] = next[key];
250
+ }
251
+ for (const [key, v] of Object.entries(next)) {
252
+ if (!(key in out) && !isDefaultValue(v))
253
+ out[key] = v;
254
+ }
255
+ return out;
256
+ }
257
+ function preserveShape(raw, rawDomain, domain) {
258
+ const before = orderDomain(rawDomain);
259
+ const next = orderDomain(domain);
260
+ patchFields(raw, before, next);
261
+ const rawEntries = raw.requirements;
262
+ const byId = new Map;
263
+ rawDomain.requirements.forEach((r, i) => {
264
+ byId.set(r.id, [...byId.get(r.id) ?? [], i]);
265
+ });
266
+ const template = rawEntries[rawEntries.length - 1];
267
+ raw.requirements = next.requirements.map((entry, i) => {
268
+ const idx = byId.get(domain.requirements[i]?.id ?? "")?.shift();
269
+ if (idx === undefined)
270
+ return shapeLike(template, entry);
271
+ const rawEntry = rawEntries[idx];
272
+ patchFields(rawEntry, before.requirements[idx], entry);
273
+ return rawEntry;
274
+ });
275
+ return raw;
276
+ }
277
+ function verifyRoundTrip(serialized, domain, sourceFile) {
278
+ const reparsed = parseDomainText(serialized, sourceFile);
279
+ if (!reparsed.ok)
280
+ return { ok: false, diagnostics: reparsed.diagnostics };
281
+ if (sameValue(orderDomain(reparsed.data), orderDomain(domain)))
282
+ return { ok: true };
283
+ return {
284
+ ok: false,
285
+ diagnostics: [
286
+ {
287
+ code: DiagnosticCode.INVALID_DOMAIN_FILE,
288
+ source_file: sourceFile,
289
+ line: 0,
290
+ repo: null,
291
+ req_id: null,
292
+ detail: "the shape-preserving rewrite does not re-parse to the validated domain",
293
+ severity: "error"
294
+ }
295
+ ]
296
+ };
297
+ }
298
+ async function readExisting(path, sourceFile) {
299
+ const file = Bun.file(path);
300
+ if (!await file.exists())
301
+ return null;
302
+ let raw;
303
+ try {
304
+ raw = JSON.parse(await file.text());
305
+ } catch {
306
+ return null;
307
+ }
308
+ const validated = validateDomainFile(raw, sourceFile);
309
+ return validated.ok ? { raw, data: validated.data } : null;
310
+ }
224
311
  async function validateAndWrite(path, domain, sourceFile) {
225
- const validated = validateDomainFile(domain, sourceFile ?? path);
312
+ const src = sourceFile ?? path;
313
+ const validated = validateDomainFile(domain, src);
226
314
  if (!validated.ok) {
227
315
  return { ok: false, diagnostics: validated.diagnostics };
228
316
  }
229
- const serialized = `${JSON.stringify(orderDomain(validated.data), null, 2)}
317
+ const existing = await readExisting(path, src);
318
+ if (existing === null) {
319
+ await Bun.write(path, `${JSON.stringify(orderDomain(validated.data), null, 2)}
320
+ `);
321
+ return { ok: true };
322
+ }
323
+ const shaped = preserveShape(existing.raw, existing.data, validated.data);
324
+ const serialized = `${JSON.stringify(shaped, null, 2)}
230
325
  `;
326
+ const verdict = verifyRoundTrip(serialized, validated.data, src);
327
+ if (!verdict.ok)
328
+ return verdict;
231
329
  await Bun.write(path, serialized);
232
330
  return { ok: true };
233
331
  }
234
- var ID_RE, KEY_RE, SpecIssueSchema, SpecCiteSchema, SpecRequirementSchema, SpecDomainSchema;
332
+ var ID_RE, KEY_RE, SpecIssueSchema, SpecCiteSchema, SpecRequirementSchema, SpecDomainSchema, sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
235
333
  var init_domain = __esm(() => {
236
334
  init_diagnostics();
237
335
  ID_RE = /^[A-Z][A-Z0-9]*-\d+$/;
238
336
  KEY_RE = /^[A-Z][A-Z0-9]*$/;
239
- SpecIssueSchema = z2.object({
240
- role: z2.string().min(1),
241
- id: z2.string().min(1)
337
+ SpecIssueSchema = z.object({
338
+ role: z.string().min(1),
339
+ id: z.string().min(1)
340
+ }).strict();
341
+ SpecCiteSchema = z.object({
342
+ term: z.string(),
343
+ pinned: z.number().int()
242
344
  }).strict();
243
- SpecCiteSchema = z2.object({
244
- term: z2.string(),
245
- pinned: z2.number().int()
345
+ SpecRequirementSchema = z.object({
346
+ id: z.string().regex(ID_RE),
347
+ status: z.string().min(1),
348
+ statement: z.string().min(1),
349
+ why: z.string().nullable().optional(),
350
+ supersedes: z.string().nullable().optional(),
351
+ supersededBy: z.string().nullable().optional(),
352
+ relates: z.array(z.string()).optional().default([]),
353
+ livesIn: z.array(z.string()).optional().default([]),
354
+ issues: z.array(SpecIssueSchema).optional().default([]),
355
+ changedAtVersion: z.number().int().optional(),
356
+ supersededAtVersion: z.number().int().optional(),
357
+ deprecatedReason: z.string().optional(),
358
+ term: z.string().optional(),
359
+ aliases: z.array(z.string()).optional().default([]),
360
+ cites: z.array(SpecCiteSchema).optional().default([]),
361
+ section: z.string().nullable().optional()
246
362
  }).strict();
247
- SpecRequirementSchema = z2.object({
248
- id: z2.string().regex(ID_RE),
249
- status: z2.string().min(1),
250
- statement: z2.string().min(1),
251
- why: z2.string().nullable().optional(),
252
- supersedes: z2.string().nullable().optional(),
253
- supersededBy: z2.string().nullable().optional(),
254
- relates: z2.array(z2.string()).optional().default([]),
255
- livesIn: z2.array(z2.string()).optional().default([]),
256
- issues: z2.array(SpecIssueSchema).optional().default([]),
257
- changedAtVersion: z2.number().int().optional(),
258
- supersededAtVersion: z2.number().int().optional(),
259
- deprecatedReason: z2.string().optional(),
260
- term: z2.string().optional(),
261
- aliases: z2.array(z2.string()).optional().default([]),
262
- cites: z2.array(SpecCiteSchema).optional().default([]),
263
- section: z2.string().nullable().optional()
363
+ SpecDomainSchema = z.object({
364
+ key: z.string().regex(KEY_RE),
365
+ owner: z.string().nullable().optional(),
366
+ specVersion: z.number().int().positive().optional(),
367
+ updated: z.string(),
368
+ scope: z.string().nullable().optional(),
369
+ grammar: z.enum(["ears", "freeform"]).optional(),
370
+ grammarSeverity: z.enum(["warning", "error"]).optional(),
371
+ requirements: z.array(SpecRequirementSchema)
264
372
  }).strict();
265
- SpecDomainSchema = z2.object({
266
- key: z2.string().regex(KEY_RE),
267
- owner: z2.string().nullable().optional(),
268
- specVersion: z2.number().int().positive().optional(),
269
- updated: z2.string(),
270
- scope: z2.string().nullable().optional(),
271
- grammar: z2.enum(["ears", "freeform"]).optional(),
272
- grammarSeverity: z2.enum(["warning", "error"]).optional(),
273
- requirements: z2.array(SpecRequirementSchema)
373
+ });
374
+
375
+ // ../shared/src/checkConfig.ts
376
+ import { z as z2 } from "zod";
377
+ function parseCheckConfigText(text, sourceFile) {
378
+ let parsed;
379
+ try {
380
+ parsed = JSON.parse(text);
381
+ } catch (err) {
382
+ const msg = err instanceof Error ? err.message : String(err);
383
+ return { ok: false, detail: `${sourceFile}: not valid JSON: ${msg}` };
384
+ }
385
+ const result = CheckConfigSchema.safeParse(parsed);
386
+ if (result.success)
387
+ return { ok: true, config: result.data };
388
+ const issue = result.error.issues[0];
389
+ const path = issue && issue.path.length > 0 ? issue.path.join(".") : "(root)";
390
+ return { ok: false, detail: `${sourceFile}: ${path}: ${issue?.message ?? "invalid"}` };
391
+ }
392
+ var CODES, matchEntry, SeverityRuleSchema, CheckConfigSchema;
393
+ var init_checkConfig = __esm(() => {
394
+ init_diagnostics();
395
+ init_domain();
396
+ CODES = Object.values(DiagnosticCode);
397
+ matchEntry = z2.string().refine((s) => KEY_RE.test(s) || ID_RE.test(s), "must be a domain key or a requirement id");
398
+ SeverityRuleSchema = z2.object({
399
+ code: z2.enum(CODES),
400
+ severity: z2.enum(["error", "warning", "off"]),
401
+ match: z2.array(matchEntry).optional()
274
402
  }).strict();
403
+ CheckConfigSchema = z2.object({ severity: z2.array(SeverityRuleSchema) }).strict();
404
+ });
405
+
406
+ // ../shared/src/config.ts
407
+ import { z as z3 } from "zod";
408
+ var SpecConfigSchema;
409
+ var init_config = __esm(() => {
410
+ SpecConfigSchema = z3.object({
411
+ specs: z3.string().regex(/^spec-engine@\d+$/, "must be of the form spec-engine@N where N is an integer"),
412
+ ignore: z3.array(z3.string().min(1, "ignore entries must be non-empty strings")).optional()
413
+ });
275
414
  });
276
415
 
277
416
  // ../shared/src/ears.ts
@@ -677,6 +816,7 @@ var init_storage = __esm(() => {
677
816
 
678
817
  // ../shared/src/index.ts
679
818
  var init_src = __esm(() => {
819
+ init_checkConfig();
680
820
  init_config();
681
821
  init_diagnostics();
682
822
  init_domain();
@@ -949,8 +1089,7 @@ import { readdir } from "fs/promises";
949
1089
  import { join as join2 } from "path";
950
1090
  function isPathIgnored(path, extra = []) {
951
1091
  const anchored = `/${path}`;
952
- const tokens = extra.length === 0 ? IGNORE_SUBSTR : [...IGNORE_SUBSTR, ...extra];
953
- return tokens.some((t) => anchored.includes(`/${t.endsWith("/") ? t : `${t}/`}`));
1092
+ return IGNORE_SUBSTR.some((t) => anchored.includes(`/${t}`)) || extra.some((t) => path.startsWith(t.endsWith("/") ? t : `${t}/`));
954
1093
  }
955
1094
  async function walkInto(rootDir, relDir, keep, extraIgnore, out) {
956
1095
  const absDir = relDir ? join2(rootDir, relDir) : rootDir;
@@ -979,15 +1118,19 @@ async function walkFiles(rootDir, keep, extraIgnore) {
979
1118
  out.sort();
980
1119
  return out;
981
1120
  }
1121
+ function isDockerfile(name) {
1122
+ return name === "Dockerfile" || name.startsWith("Dockerfile.") && name.length > 11;
1123
+ }
1124
+ function isTagSource(rel, exts = DEFAULT_EXTS) {
1125
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
1126
+ const dot = name.lastIndexOf(".");
1127
+ return isDockerfile(name) || dot > 0 && exts.includes(name.slice(dot + 1));
1128
+ }
982
1129
  async function findDomainJsonFiles(canonicalDir) {
983
1130
  return walkFiles(canonicalDir, (rel) => rel === "SPEC.json" || rel.endsWith("/SPEC.json"), []);
984
1131
  }
985
1132
  async function findCodeFiles(repoDir, exts = DEFAULT_EXTS, extraIgnore = []) {
986
- const extSet = new Set(exts);
987
- return walkFiles(repoDir, (rel) => {
988
- const dot = rel.lastIndexOf(".");
989
- return dot > 0 && extSet.has(rel.slice(dot + 1));
990
- }, extraIgnore);
1133
+ return walkFiles(repoDir, (rel) => isTagSource(rel, exts), extraIgnore);
991
1134
  }
992
1135
  async function findDocFiles(repoDir, extraIgnore = []) {
993
1136
  return walkFiles(repoDir, (rel) => rel.endsWith(".md") && rel !== "SPEC.md" && !rel.endsWith("/SPEC.md"), extraIgnore);
@@ -1017,7 +1160,11 @@ var init_fs = __esm(() => {
1017
1160
  "lua",
1018
1161
  "luau",
1019
1162
  "go",
1020
- "rs"
1163
+ "rs",
1164
+ "tf",
1165
+ "toml",
1166
+ "sql",
1167
+ "css"
1021
1168
  ];
1022
1169
  });
1023
1170
 
@@ -1548,9 +1695,13 @@ SELECT
1548
1695
  t.file AS source_file,
1549
1696
  t.line AS line,
1550
1697
  t.req_id AS req_id,
1551
- ('Tag references Draft requirement ' || t.req_id ||
1552
- ' \u2014 code is bound to a promise nobody approved yet') AS detail,
1553
- 'warning' AS severity
1698
+ CASE WHEN t.kind = 'verifies'
1699
+ THEN ('Test verifies Draft requirement ' || t.req_id ||
1700
+ ' \u2014 it asserts a promise nobody accepted yet')
1701
+ ELSE ('Tag references Draft requirement ' || t.req_id ||
1702
+ ' \u2014 code is bound to a promise nobody accepted yet')
1703
+ END AS detail,
1704
+ CASE WHEN t.kind = 'verifies' THEN 'error' ELSE 'warning' END AS severity
1554
1705
  FROM tags t
1555
1706
  JOIN requirements r ON r.id = t.req_id
1556
1707
  WHERE r.status = 'Draft'
@@ -1580,11 +1731,10 @@ SELECT
1580
1731
  'error' AS severity
1581
1732
  FROM requirements r
1582
1733
  WHERE r.status = 'Active'
1583
- -- code-coverage obligation \u2014 exclude the reserved TERM domain so a migrated
1584
- -- term never fires ORPHAN_REQ. Literal 'TERM' only, never a real domain key;
1585
- -- mirrors the coverage VIEW exclusion (WHERE r.key != 'TERM') in schema.ts.
1734
+ -- Must stay in step with the coverage view's TERM exclusion in schema.ts.
1586
1735
  -- @spec SCHM-017
1587
1736
  AND r.key != 'TERM'
1737
+ -- A doc paragraph citing a requirement is not an implementation.
1588
1738
  AND NOT EXISTS (
1589
1739
  SELECT 1 FROM tags t WHERE t.req_id = r.id AND t.kind != 'documents'
1590
1740
  )
@@ -1598,11 +1748,10 @@ SELECT
1598
1748
  r.id AS req_id,
1599
1749
  ('Requirement ' || r.id ||
1600
1750
  ' is implemented but has no verifies-kind tag') AS detail,
1601
- 'error' AS severity
1751
+ 'warning' AS severity
1602
1752
  FROM requirements r
1603
1753
  WHERE r.status = 'Active'
1604
- -- never implemented/verified by code, so it must never fire UNVERIFIED_REQ.
1605
- -- Literal 'TERM' only; mirrors Q4 + the coverage VIEW exclusion.
1754
+ -- Must stay in step with Q4 and the coverage view's TERM exclusion.
1606
1755
  AND r.key != 'TERM'
1607
1756
  AND EXISTS (SELECT 1 FROM tags t WHERE t.req_id = r.id AND t.kind = 'implements')
1608
1757
  AND NOT EXISTS (SELECT 1 FROM tags t WHERE t.req_id = r.id AND t.kind = 'verifies')
@@ -2445,7 +2594,7 @@ async function runIndex(opts) {
2445
2594
  unpinned,
2446
2595
  diagnostics: platformDiagnostics
2447
2596
  } = await discoverRepos(platformDir);
2448
- const { specs, structuralDiagnostics } = await readSpecs(canonical);
2597
+ const specs = await readSpecs(canonical);
2449
2598
  const { tagHits, docMentionCandidates } = await scanMembers(members);
2450
2599
  const rows = buildRows({
2451
2600
  canonical,
@@ -2453,7 +2602,6 @@ async function runIndex(opts) {
2453
2602
  members,
2454
2603
  specs,
2455
2604
  tagHits,
2456
- structuralDiagnostics,
2457
2605
  unpinned,
2458
2606
  platformDiagnostics
2459
2607
  });
@@ -2475,29 +2623,22 @@ async function runIndex(opts) {
2475
2623
  async function readSpecs(canonical) {
2476
2624
  const jsonPaths = await findDomainJsonFiles(canonical.path);
2477
2625
  const specs = [];
2478
- const structuralDiagnostics = [];
2626
+ const invalid = [];
2479
2627
  for (const rel of jsonPaths) {
2480
2628
  const absPath = join4(canonical.path, rel);
2481
2629
  const text = await Bun.file(absPath).text();
2482
2630
  const platformRelative = `${canonical.name}/${rel}`;
2483
2631
  const fallbackKey = basename2(dirname3(rel));
2484
2632
  const result = parseDomainJsonFile({ text, sourceFile: platformRelative, fallbackKey });
2485
- if (!result.ok) {
2486
- for (const d of result.diagnostics) {
2487
- structuralDiagnostics.push({
2488
- code: d.code,
2489
- source_file: d.source_file ?? platformRelative,
2490
- line: d.line ?? 0,
2491
- req_id: d.req_id,
2492
- detail: d.detail,
2493
- severity: d.severity
2494
- });
2495
- }
2496
- continue;
2497
- }
2498
- specs.push(result.spec);
2633
+ if (result.ok)
2634
+ specs.push(result.spec);
2635
+ else
2636
+ for (const d of result.diagnostics)
2637
+ invalid.push({ ...d, source_file: d.source_file ?? platformRelative });
2499
2638
  }
2500
- return { specs, structuralDiagnostics };
2639
+ if (invalid.length > 0)
2640
+ throw new InvalidDomainFilesError(invalid);
2641
+ return specs;
2501
2642
  }
2502
2643
  function repoExtraIgnore(member) {
2503
2644
  const repoIgnore = (member.ignore ?? []).map((e) => e.endsWith("/") ? e : `${e}/`);
@@ -2549,16 +2690,7 @@ async function scanMembers(members) {
2549
2690
  return { tagHits, docMentionCandidates };
2550
2691
  }
2551
2692
  function buildRows(input) {
2552
- const {
2553
- canonical,
2554
- platformVersion,
2555
- members,
2556
- specs,
2557
- tagHits,
2558
- structuralDiagnostics,
2559
- unpinned,
2560
- platformDiagnostics
2561
- } = input;
2693
+ const { canonical, platformVersion, members, specs, tagHits, unpinned, platformDiagnostics } = input;
2562
2694
  const sortedRepos = sortBy([
2563
2695
  {
2564
2696
  name: canonical.name,
@@ -2582,8 +2714,6 @@ function buildRows(input) {
2582
2714
  const { sortedTermAliases, sortedTermCitations } = buildTermRows(specs, flatRequirements);
2583
2715
  const sortedProvenance = sortBy(specs.flatMap((s) => s.provenance), (p) => `${p.req_id}\x00${p.role}\x00${p.issue_id}\x00${p.source_file}\x00${pad(p.line)}`);
2584
2716
  const diagnostics2 = validateStructure(specs);
2585
- for (const d of structuralDiagnostics)
2586
- diagnostics2.push(d);
2587
2717
  for (const s of unpinned) {
2588
2718
  diagnostics2.push({
2589
2719
  code: DiagnosticCode.NO_SPEC_CONFIG,
@@ -3017,6 +3147,49 @@ var init_init = __esm(() => {
3017
3147
  KNOWN_KEYS = new Set(SpecConfigSchema.keyof().options);
3018
3148
  });
3019
3149
 
3150
+ // src/check/format.ts
3151
+ function compareRepoNullsLast(a, b) {
3152
+ if (a === b)
3153
+ return 0;
3154
+ if (a === null)
3155
+ return 1;
3156
+ if (b === null)
3157
+ return -1;
3158
+ return a.localeCompare(b);
3159
+ }
3160
+ function compareDiagnostics(a, b) {
3161
+ const codeCmp = a.code.localeCompare(b.code);
3162
+ if (codeCmp !== 0)
3163
+ return codeCmp;
3164
+ const repoCmp = compareRepoNullsLast(a.repo, b.repo);
3165
+ if (repoCmp !== 0)
3166
+ return repoCmp;
3167
+ const fileCmp = (a.source_file ?? "").localeCompare(b.source_file ?? "");
3168
+ if (fileCmp !== 0)
3169
+ return fileCmp;
3170
+ const lineCmp = (a.line ?? 0) - (b.line ?? 0);
3171
+ if (lineCmp !== 0)
3172
+ return lineCmp;
3173
+ return (a.req_id ?? "").localeCompare(b.req_id ?? "");
3174
+ }
3175
+ function sortDiagnostics(rows) {
3176
+ return [...rows].sort(compareDiagnostics);
3177
+ }
3178
+ function renderDiagnostics(rows, mode) {
3179
+ const sorted = sortDiagnostics(rows);
3180
+ if (mode === "json") {
3181
+ return JSON.stringify(sorted);
3182
+ }
3183
+ return sorted.map((d) => {
3184
+ const repo = d.repo ?? "";
3185
+ const file = d.source_file ?? "";
3186
+ const line = d.line ?? "";
3187
+ const reqId = d.req_id ?? "";
3188
+ return `${d.code} ${repo} ${file}:${line} ${reqId} ${d.detail}`;
3189
+ }).join(`
3190
+ `);
3191
+ }
3192
+
3020
3193
  // src/operations/_index.ts
3021
3194
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, readdirSync, statSync as statSync3 } from "fs";
3022
3195
  import { dirname as dirname4, join as join6 } from "path";
@@ -3115,6 +3288,26 @@ function handleNotAPlatform(e) {
3115
3288
  }
3116
3289
  throw e;
3117
3290
  }
3291
+ function handleInvalidDomainFiles(e, json, code = EXIT.FAILURE) {
3292
+ if (!(e instanceof InvalidDomainFilesError))
3293
+ return;
3294
+ if (json)
3295
+ console.log(renderDiagnostics(e.diagnostics, "json"));
3296
+ else
3297
+ console.error(renderDiagnostics(e.diagnostics, "text"));
3298
+ process.exit(code);
3299
+ }
3300
+ function cliFreshTags(platformDir) {
3301
+ const fresh = coldFreshTags(platformDir);
3302
+ return async (reqId) => {
3303
+ try {
3304
+ return await fresh(reqId);
3305
+ } catch (e) {
3306
+ handleInvalidDomainFiles(e, false, EXIT.USAGE);
3307
+ throw e;
3308
+ }
3309
+ };
3310
+ }
3118
3311
  function handleStorageUnavailable(e, dbPath) {
3119
3312
  const info = describeStorageError(e);
3120
3313
  if (info === null)
@@ -3143,7 +3336,7 @@ function exitOnFailure(cmdName, failure) {
3143
3336
  process.exit(EXIT.USAGE);
3144
3337
  }
3145
3338
  async function withReadStorage(opts, fn) {
3146
- const { platformDir, dbPath, fresh } = opts;
3339
+ const { platformDir, dbPath, fresh, json = false } = opts;
3147
3340
  try {
3148
3341
  await withIndex({ platformDir, dbPath, build: fresh ? "fresh" : "missing" }, async (h) => {
3149
3342
  for (const w of h.warnings)
@@ -3152,6 +3345,7 @@ async function withReadStorage(opts, fn) {
3152
3345
  });
3153
3346
  } catch (e) {
3154
3347
  handleStorageUnavailable(e, dbPath);
3348
+ handleInvalidDomainFiles(e, json);
3155
3349
  handleNotAPlatform(e);
3156
3350
  }
3157
3351
  }
@@ -3358,7 +3552,7 @@ function listFlag(rawArgs, name, parsed) {
3358
3552
  }
3359
3553
  return raw.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v !== "");
3360
3554
  }
3361
- var platformDirArg, outArg, jsonArg, freshArg, noPromptArg, livesArg;
3555
+ var platformDirArg, outArg, jsonArg, freshArg, noPromptArg, livesArg, relatesArg;
3362
3556
  var init__args = __esm(() => {
3363
3557
  init_constants();
3364
3558
  platformDirArg = {
@@ -3383,6 +3577,10 @@ var init__args = __esm(() => {
3383
3577
  type: "string",
3384
3578
  description: "Lives in (livesIn) paths: repeat the flag or comma-separate; an empty value clears the list"
3385
3579
  };
3580
+ relatesArg = {
3581
+ type: "string",
3582
+ description: "Related requirement ids (relates): repeat the flag or comma-separate; an empty value clears the list"
3583
+ };
3386
3584
  });
3387
3585
 
3388
3586
  // src/commands/index.ts
@@ -3457,6 +3655,7 @@ var init_commands = __esm(() => {
3457
3655
  console.error(formatNotASpecPlatform(err.platformDir));
3458
3656
  process.exit(EXIT.USAGE);
3459
3657
  }
3658
+ handleInvalidDomainFiles(err, !!args.json);
3460
3659
  console.error("spec index FAILED:", err);
3461
3660
  process.exit(EXIT.FAILURE);
3462
3661
  } finally {
@@ -3466,49 +3665,6 @@ var init_commands = __esm(() => {
3466
3665
  });
3467
3666
  });
3468
3667
 
3469
- // src/check/format.ts
3470
- function compareRepoNullsLast(a, b) {
3471
- if (a === b)
3472
- return 0;
3473
- if (a === null)
3474
- return 1;
3475
- if (b === null)
3476
- return -1;
3477
- return a.localeCompare(b);
3478
- }
3479
- function compareDiagnostics(a, b) {
3480
- const codeCmp = a.code.localeCompare(b.code);
3481
- if (codeCmp !== 0)
3482
- return codeCmp;
3483
- const repoCmp = compareRepoNullsLast(a.repo, b.repo);
3484
- if (repoCmp !== 0)
3485
- return repoCmp;
3486
- const fileCmp = (a.source_file ?? "").localeCompare(b.source_file ?? "");
3487
- if (fileCmp !== 0)
3488
- return fileCmp;
3489
- const lineCmp = (a.line ?? 0) - (b.line ?? 0);
3490
- if (lineCmp !== 0)
3491
- return lineCmp;
3492
- return (a.req_id ?? "").localeCompare(b.req_id ?? "");
3493
- }
3494
- function sortDiagnostics(rows) {
3495
- return [...rows].sort(compareDiagnostics);
3496
- }
3497
- function renderDiagnostics(rows, mode) {
3498
- const sorted = sortDiagnostics(rows);
3499
- if (mode === "json") {
3500
- return JSON.stringify(sorted);
3501
- }
3502
- return sorted.map((d) => {
3503
- const repo = d.repo ?? "";
3504
- const file = d.source_file ?? "";
3505
- const line = d.line ?? "";
3506
- const reqId = d.req_id ?? "";
3507
- return `${d.code} ${repo} ${file}:${line} ${reqId} ${d.detail}`;
3508
- }).join(`
3509
- `);
3510
- }
3511
-
3512
3668
  // src/check/proven.ts
3513
3669
  function normalizeToSegments(path) {
3514
3670
  const out = [];
@@ -3691,6 +3847,14 @@ function gitDiffNameStatus(platformDir, ref) {
3691
3847
  }
3692
3848
  return rows;
3693
3849
  }
3850
+ function newestReleaseTag(platformDir) {
3851
+ const proc = Bun.spawnSync(["git", "tag", "--list", "v*", "--merged", "HEAD", "--sort=-v:refname"], { cwd: platformDir });
3852
+ if (proc.exitCode !== 0)
3853
+ return null;
3854
+ const tag = proc.stdout.toString().split(`
3855
+ `)[0]?.trim() ?? "";
3856
+ return tag !== "" && isSafeRef(tag) ? tag : null;
3857
+ }
3694
3858
 
3695
3859
  // src/check/codeowners.ts
3696
3860
  function parseCodeowners(text) {
@@ -4063,6 +4227,60 @@ var init_removed = __esm(() => {
4063
4227
  init_src();
4064
4228
  });
4065
4229
 
4230
+ // src/check/severity.ts
4231
+ import { existsSync as existsSync7, readFileSync } from "fs";
4232
+ import { join as join8 } from "path";
4233
+ function readCheckConfig(platformDir) {
4234
+ const path = join8(platformDir, CHECK_CONFIG_REL);
4235
+ if (!existsSync7(path))
4236
+ return { ok: true, config: { severity: [] } };
4237
+ return parseCheckConfigText(readFileSync(path, "utf-8"), CHECK_CONFIG_REL);
4238
+ }
4239
+ function specificity(rule, row) {
4240
+ if (rule.code !== row.code)
4241
+ return Specificity.none;
4242
+ if (rule.match === undefined)
4243
+ return Specificity.code;
4244
+ const id = row.req_id;
4245
+ if (id === null)
4246
+ return Specificity.none;
4247
+ if (rule.match.includes(id))
4248
+ return Specificity.id;
4249
+ const key = id.slice(0, id.lastIndexOf("-"));
4250
+ return rule.match.includes(key) ? Specificity.domain : Specificity.none;
4251
+ }
4252
+ function applySeverity(rows, rules) {
4253
+ const out = [];
4254
+ for (const row of rows) {
4255
+ let best;
4256
+ let bestScore = Specificity.none;
4257
+ for (const rule of rules) {
4258
+ const score = specificity(rule, row);
4259
+ if (score !== Specificity.none && score >= bestScore) {
4260
+ best = rule;
4261
+ bestScore = score;
4262
+ }
4263
+ }
4264
+ if (best === undefined)
4265
+ out.push(row);
4266
+ else if (best.severity !== "off")
4267
+ out.push({ ...row, severity: best.severity });
4268
+ }
4269
+ return out;
4270
+ }
4271
+ var CHECK_CONFIG_REL, Specificity;
4272
+ var init_severity = __esm(() => {
4273
+ init_src();
4274
+ init_constants();
4275
+ CHECK_CONFIG_REL = `${CANONICAL_SPECS_DIR}/check.json`;
4276
+ Specificity = {
4277
+ none: -1,
4278
+ code: 0,
4279
+ domain: 1,
4280
+ id: 2
4281
+ };
4282
+ });
4283
+
4066
4284
  // src/check/sqlDiagnostics.ts
4067
4285
  function collectDiagnostics(storage2) {
4068
4286
  const semantic = storage2.listSemanticDiagnostics();
@@ -4501,7 +4719,7 @@ function localToday() {
4501
4719
  }
4502
4720
 
4503
4721
  // src/operations/_envelope.ts
4504
- import { existsSync as existsSync7 } from "fs";
4722
+ import { existsSync as existsSync8 } from "fs";
4505
4723
  function domainKeyOf(id) {
4506
4724
  return id.slice(0, id.indexOf("-"));
4507
4725
  }
@@ -4510,7 +4728,7 @@ function displayStatus(raw) {
4510
4728
  }
4511
4729
  async function readEnvelope(platformDir, key, missingDetail) {
4512
4730
  const { abs: specPath, rel: relFile } = specPaths(platformDir, key);
4513
- if (!existsSync7(specPath)) {
4731
+ if (!existsSync8(specPath)) {
4514
4732
  return fail("not_found", missingDetail ?? `no domain ${key} (expected ${relFile} under ${platformDir})`);
4515
4733
  }
4516
4734
  const parsed = parseDomainText(await Bun.file(specPath).text(), relFile);
@@ -4526,32 +4744,58 @@ async function readEnvelope(platformDir, key, missingDetail) {
4526
4744
  const domain2 = parsed.data;
4527
4745
  return { ok: true, key, specPath, relFile, domain: domain2, requirements: domain2.requirements };
4528
4746
  }
4747
+ async function scanForEntry(platformDir, id, skipKey) {
4748
+ for (const key of listDomainKeys(platformDir)) {
4749
+ if (key === skipKey)
4750
+ continue;
4751
+ const file = await readEnvelope(platformDir, key);
4752
+ if (!file.ok)
4753
+ continue;
4754
+ const req = file.requirements.find((r) => r.id === id);
4755
+ if (req !== undefined)
4756
+ return { ...file, req };
4757
+ }
4758
+ return null;
4759
+ }
4529
4760
  async function locateEntry(platformDir, id, missingDomainDetail) {
4530
- const file = await readEnvelope(platformDir, domainKeyOf(id), missingDomainDetail);
4531
- if (!file.ok)
4761
+ const prefixKey = domainKeyOf(id);
4762
+ const file = await readEnvelope(platformDir, prefixKey, missingDomainDetail);
4763
+ if (!file.ok && file.reason !== "not_found")
4532
4764
  return file;
4533
- const req = file.requirements.find((r) => r.id === id);
4534
- if (req === undefined)
4535
- return fail("not_found", `no entry ${id} in ${file.relFile}`);
4536
- return { ...file, req };
4537
- }
4538
- function successorEntry(id, fields) {
4539
- return {
4765
+ const req = file.ok ? file.requirements.find((r) => r.id === id) : undefined;
4766
+ if (file.ok && req !== undefined)
4767
+ return { ...file, req };
4768
+ const scanned = await scanForEntry(platformDir, id, prefixKey);
4769
+ if (scanned !== null)
4770
+ return scanned;
4771
+ return file.ok ? fail("not_found", `no entry ${id} in ${file.relFile}`) : file;
4772
+ }
4773
+ function splitList(values) {
4774
+ return values.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v !== "");
4775
+ }
4776
+ function successorEntry(id, pred, fields) {
4777
+ const entry = {
4540
4778
  id,
4541
4779
  status: "active",
4542
4780
  statement: fields.statement,
4543
4781
  why: fields.why === "" ? null : fields.why,
4544
4782
  supersedes: null,
4545
4783
  supersededBy: null,
4546
- relates: [],
4784
+ relates: fields.relates,
4547
4785
  livesIn: fields.livesIn,
4548
4786
  issues: fields.issue ? [{ role: "created", id: fields.issue }] : [],
4549
- aliases: [],
4550
- cites: []
4787
+ aliases: [...pred.aliases],
4788
+ cites: pred.cites.map((c) => ({ ...c }))
4551
4789
  };
4790
+ if (pred.term !== undefined)
4791
+ entry.term = pred.term;
4792
+ if (pred.section !== undefined)
4793
+ entry.section = pred.section;
4794
+ return entry;
4552
4795
  }
4553
4796
  var init__envelope = __esm(() => {
4554
4797
  init_src();
4798
+ init_domains();
4555
4799
  init_constants();
4556
4800
  init__result();
4557
4801
  });
@@ -4606,7 +4850,7 @@ var init_grammar2 = __esm(() => {
4606
4850
  });
4607
4851
 
4608
4852
  // src/operations/mint.ts
4609
- import { existsSync as existsSync8 } from "fs";
4853
+ import { existsSync as existsSync9 } from "fs";
4610
4854
  function unresolvableRefWarnings(platformDir, fieldValues) {
4611
4855
  const out = [];
4612
4856
  for (const value of fieldValues) {
@@ -4621,7 +4865,7 @@ function unresolvableRefWarnings(platformDir, fieldValues) {
4621
4865
  async function mint(input) {
4622
4866
  const { platformDir, key } = input;
4623
4867
  const { abs: specPath, rel: relFile } = specPaths(platformDir, key);
4624
- if (!existsSync8(specPath)) {
4868
+ if (!existsSync9(specPath)) {
4625
4869
  return fail("not_found", `no domain ${key} (expected ${relFile} under ${platformDir})`);
4626
4870
  }
4627
4871
  const warnings = unresolvableRefWarnings(platformDir, [
@@ -4649,7 +4893,7 @@ async function mint(input) {
4649
4893
  why: input.why || null,
4650
4894
  supersedes: null,
4651
4895
  supersededBy: null,
4652
- relates: input.relates ?? [],
4896
+ relates: splitList(input.relates ?? []),
4653
4897
  livesIn: input.livesIn,
4654
4898
  issues: input.issue ? [{ role: "created", id: input.issue }] : [],
4655
4899
  aliases: [],
@@ -4808,8 +5052,8 @@ var init_term = __esm(() => {
4808
5052
  });
4809
5053
 
4810
5054
  // src/operations/glossary.ts
4811
- import { existsSync as existsSync9, readFileSync } from "fs";
4812
- import { join as join8 } from "path";
5055
+ import { existsSync as existsSync10, readFileSync as readFileSync2 } from "fs";
5056
+ import { join as join9 } from "path";
4813
5057
  function parseGlossary(md) {
4814
5058
  const terms = [];
4815
5059
  let section = null;
@@ -4861,15 +5105,15 @@ function generateGlossary(terms) {
4861
5105
  }
4862
5106
  function readStoreTerms(platformDir) {
4863
5107
  const { abs: specPath, rel: relFile } = specPaths(platformDir, TERM_KEY);
4864
- if (!existsSync9(specPath))
5108
+ if (!existsSync10(specPath))
4865
5109
  return [];
4866
- const parsed = parseDomainText(readFileSync(specPath, "utf8"), relFile);
5110
+ const parsed = parseDomainText(readFileSync2(specPath, "utf8"), relFile);
4867
5111
  if (!parsed.ok)
4868
5112
  return [];
4869
5113
  return parsed.data.requirements.filter((r) => r.status.toLowerCase() === "active").sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((r) => ({ term: r.term ?? "", statement: r.statement, section: r.section ?? null }));
4870
5114
  }
4871
5115
  function glossaryPath(platformDir) {
4872
- return join8(platformDir, "GLOSSARY.md");
5116
+ return join9(platformDir, "GLOSSARY.md");
4873
5117
  }
4874
5118
  async function migrateGlossary(platformDir) {
4875
5119
  const store = await termStore(platformDir);
@@ -4880,10 +5124,10 @@ async function migrateGlossary(platformDir) {
4880
5124
  return { ok: true, skipped: true, existing: domain2.requirements.length };
4881
5125
  }
4882
5126
  const gPath = glossaryPath(platformDir);
4883
- if (!existsSync9(gPath)) {
5127
+ if (!existsSync10(gPath)) {
4884
5128
  return fail("not_found", `no GLOSSARY.md to migrate (expected ${gPath})`);
4885
5129
  }
4886
- const parsed = parseGlossary(readFileSync(gPath, "utf8"));
5130
+ const parsed = parseGlossary(readFileSync2(gPath, "utf8"));
4887
5131
  domain2.requirements = parsed.map((t, i) => ({
4888
5132
  id: `${TERM_KEY}-${String(i + 1).padStart(3, "0")}`,
4889
5133
  status: "active",
@@ -4920,10 +5164,10 @@ function glossaryDriftDiagnostic(platformDir) {
4920
5164
  if (terms.length === 0)
4921
5165
  return null;
4922
5166
  const gPath = glossaryPath(platformDir);
4923
- if (!existsSync9(gPath))
5167
+ if (!existsSync10(gPath))
4924
5168
  return null;
4925
5169
  const generated = generateGlossary(terms);
4926
- const committed = readFileSync(gPath, "utf8");
5170
+ const committed = readFileSync2(gPath, "utf8");
4927
5171
  if (generated === committed)
4928
5172
  return null;
4929
5173
  return {
@@ -4936,30 +5180,224 @@ function glossaryDriftDiagnostic(platformDir) {
4936
5180
  detail: "GLOSSARY.md does not match the TERM store \u2014 run `spec glossary .` to regenerate the human view"
4937
5181
  };
4938
5182
  }
4939
- function checkGlossary(platformDir) {
4940
- const generated = generateGlossary(readStoreTerms(platformDir));
4941
- const gPath = glossaryPath(platformDir);
4942
- const committed = existsSync9(gPath) ? readFileSync(gPath, "utf8") : "";
4943
- return { ok: true, clean: generated === committed };
5183
+ function checkGlossary(platformDir) {
5184
+ const generated = generateGlossary(readStoreTerms(platformDir));
5185
+ const gPath = glossaryPath(platformDir);
5186
+ const committed = existsSync10(gPath) ? readFileSync2(gPath, "utf8") : "";
5187
+ return { ok: true, clean: generated === committed };
5188
+ }
5189
+ var GLOSSARY_INTRO = `Canonical names for Spec Engine concepts. When prose, code comments, diagnostics, or
5190
+ docs need one of these ideas, use the term below \u2014 not a synonym. Terms are ordered
5191
+ by the data model, outside-in.`;
5192
+ var init_glossary = __esm(() => {
5193
+ init_src();
5194
+ init_constants();
5195
+ init__result();
5196
+ init_term();
5197
+ });
5198
+
5199
+ // src/guard/collect.ts
5200
+ import { join as join10 } from "path";
5201
+ function isIgnored(path) {
5202
+ return isPathIgnored(path);
5203
+ }
5204
+ function isSpecFile(path) {
5205
+ return path.startsWith(`${CANONICAL_SPECS_DIR}/`) && path.endsWith("/SPEC.json") && !isIgnored(path);
5206
+ }
5207
+ function isCodeFile(path) {
5208
+ return !isSpecFile(path) && !isIgnored(path) && isTagSource(path);
5209
+ }
5210
+ function parseReqs(text) {
5211
+ const v = parseDomainText(text, "");
5212
+ return v.ok ? v.data.requirements : [];
5213
+ }
5214
+ function collectBaseReqs(platformDir, ref, specPaths2) {
5215
+ const reqs = [];
5216
+ const path = new Map;
5217
+ for (const p of specPaths2) {
5218
+ const bytes = gitShow(platformDir, ref, p);
5219
+ if (bytes === null)
5220
+ continue;
5221
+ for (const r of parseReqs(bytes)) {
5222
+ reqs.push(r);
5223
+ path.set(r.id, p);
5224
+ }
5225
+ }
5226
+ return { reqs, path };
5227
+ }
5228
+ function collectBaseTags(platformDir, ref, codePaths) {
5229
+ const implSite = new Map;
5230
+ const verifySite = new Map;
5231
+ for (const p of codePaths) {
5232
+ const bytes = gitShow(platformDir, ref, p);
5233
+ if (bytes === null)
5234
+ continue;
5235
+ for (const tag of scanTagsInFile("", p, bytes)) {
5236
+ const target = tag.kind === "verifies" ? verifySite : implSite;
5237
+ if (!target.has(tag.req_id))
5238
+ target.set(tag.req_id, { file: tag.file, line: tag.line });
5239
+ }
5240
+ }
5241
+ return { implSite, verifySite };
5242
+ }
5243
+ async function collectWorktreeReqs(canonicalDir) {
5244
+ const ids = new Set;
5245
+ const activeIds = new Set;
5246
+ const supersedesTargets = new Set;
5247
+ for (const rel of await findDomainJsonFiles(canonicalDir)) {
5248
+ const text = await Bun.file(join10(canonicalDir, rel)).text();
5249
+ for (const r of parseReqs(text)) {
5250
+ ids.add(r.id);
5251
+ if (r.status.toLowerCase() === "active")
5252
+ activeIds.add(r.id);
5253
+ if (r.supersedes != null)
5254
+ supersedesTargets.add(r.supersedes);
5255
+ }
5256
+ }
5257
+ return { ids, activeIds, supersedesTargets };
5258
+ }
5259
+ function tallyWorktreeTags(storage2) {
5260
+ const implCount = new Map;
5261
+ const verifyCount = new Map;
5262
+ for (const t of storage2.listTags()) {
5263
+ const bucket = t.kind === "implements" ? implCount : t.kind === "verifies" ? verifyCount : null;
5264
+ if (bucket !== null)
5265
+ bucket.set(t.req_id, (bucket.get(t.req_id) ?? 0) + 1);
5266
+ }
5267
+ return { implCount, verifyCount };
5268
+ }
5269
+ async function collectFacts(platformDir, ref, storage2) {
5270
+ const changed = gitDiffNameStatus(platformDir, ref);
5271
+ const specPaths2 = changed.filter((e) => isSpecFile(e.path)).map((e) => e.path);
5272
+ const codePaths = changed.filter((e) => isCodeFile(e.path)).map((e) => e.path);
5273
+ const deletedSpecFiles = changed.filter((e) => e.status === "D" && isSpecFile(e.path)).map((e) => e.path);
5274
+ const base = collectBaseReqs(platformDir, ref, specPaths2);
5275
+ const baseTags = collectBaseTags(platformDir, ref, codePaths);
5276
+ const worktree = await collectWorktreeReqs(join10(platformDir, CANONICAL_SPECS_DIR));
5277
+ const counts = tallyWorktreeTags(storage2);
5278
+ return {
5279
+ baseReqs: base.reqs,
5280
+ baseReqPath: base.path,
5281
+ baseImplSite: baseTags.implSite,
5282
+ baseVerifySite: baseTags.verifySite,
5283
+ worktreeReqIds: worktree.ids,
5284
+ worktreeActiveIds: worktree.activeIds,
5285
+ worktreeSupersedesTargets: worktree.supersedesTargets,
5286
+ worktreeImplCount: counts.implCount,
5287
+ worktreeVerifyCount: counts.verifyCount,
5288
+ deletedSpecFiles
5289
+ };
5290
+ }
5291
+ var init_collect = __esm(() => {
5292
+ init_src();
5293
+ init_constants();
5294
+ init_fs();
5295
+ init_tags();
5296
+ });
5297
+
5298
+ // src/guard/losses.ts
5299
+ function isActive(status) {
5300
+ return status.toLowerCase() === "active";
5301
+ }
5302
+ function isSuperseded(base, f) {
5303
+ const forward = base.supersededBy != null && f.worktreeReqIds.has(base.supersededBy);
5304
+ return forward || f.worktreeSupersedesTargets.has(base.id);
5305
+ }
5306
+ function reqDeletedLoss(base, f) {
5307
+ return {
5308
+ kind: "REQUIREMENT_REMOVED",
5309
+ req_id: base.id,
5310
+ file: f.baseReqPath.get(base.id) ?? "",
5311
+ line: 0,
5312
+ detail: `${base.id} was Active at the ref but is absent from the working-tree spec and was not superseded \u2014 a requirement is never deleted`
5313
+ };
5314
+ }
5315
+ function historyRemovedLoss(base, f) {
5316
+ const display = base.status.charAt(0).toUpperCase() + base.status.slice(1).toLowerCase();
5317
+ return {
5318
+ kind: "REQUIREMENT_REMOVED",
5319
+ req_id: base.id,
5320
+ file: f.baseReqPath.get(base.id) ?? "",
5321
+ line: 0,
5322
+ detail: `${base.id} was ${display} at the ref but is absent from the working-tree spec \u2014 a requirement is never deleted, and its history is the record`
5323
+ };
5324
+ }
5325
+ function implLostLoss(id, site) {
5326
+ return {
5327
+ kind: "IMPL_LOST",
5328
+ req_id: id,
5329
+ file: site.file,
5330
+ line: site.line,
5331
+ detail: `${id} lost its last implementing @spec tag (was ${site.file}:${site.line})`
5332
+ };
5333
+ }
5334
+ function verifyLostLoss(id, site) {
5335
+ return {
5336
+ kind: "VERIFY_LOST",
5337
+ req_id: id,
5338
+ file: site.file,
5339
+ line: site.line,
5340
+ detail: `${id} lost its last verifying @spec tag (was ${site.file}:${site.line})`
5341
+ };
5342
+ }
5343
+ function specFileDeletedLoss(path) {
5344
+ return {
5345
+ kind: "SPEC_FILE_DELETED",
5346
+ req_id: null,
5347
+ file: path,
5348
+ line: 0,
5349
+ detail: `canonical spec file ${path} present at the ref is absent from the working tree`
5350
+ };
5351
+ }
5352
+ function reqDeletedFor(base, f) {
5353
+ if (f.worktreeReqIds.has(base.id))
5354
+ return null;
5355
+ if (!isActive(base.status))
5356
+ return historyRemovedLoss(base, f);
5357
+ if (isSuperseded(base, f))
5358
+ return null;
5359
+ return reqDeletedLoss(base, f);
5360
+ }
5361
+ function lastTagGone(id, count, f) {
5362
+ return f.worktreeActiveIds.has(id) && count === 0;
5363
+ }
5364
+ function classifyLosses(f) {
5365
+ const out = [];
5366
+ for (const base of f.baseReqs) {
5367
+ const loss = reqDeletedFor(base, f);
5368
+ if (loss !== null)
5369
+ out.push(loss);
5370
+ }
5371
+ for (const [id, site] of f.baseImplSite) {
5372
+ if (lastTagGone(id, f.worktreeImplCount.get(id) ?? 0, f))
5373
+ out.push(implLostLoss(id, site));
5374
+ }
5375
+ for (const [id, site] of f.baseVerifySite) {
5376
+ if (lastTagGone(id, f.worktreeVerifyCount.get(id) ?? 0, f))
5377
+ out.push(verifyLostLoss(id, site));
5378
+ }
5379
+ for (const path of f.deletedSpecFiles)
5380
+ out.push(specFileDeletedLoss(path));
5381
+ return out;
5382
+ }
5383
+
5384
+ // src/operations/guard.ts
5385
+ async function guard(storage2, platformDir, ref) {
5386
+ const losses = classifyLosses(await collectFacts(platformDir, ref, storage2));
5387
+ return { ok: true, ref, losses };
4944
5388
  }
4945
- var GLOSSARY_INTRO = `Canonical names for Spec Engine concepts. When prose, code comments, diagnostics, or
4946
- docs need one of these ideas, use the term below \u2014 not a synonym. Terms are ordered
4947
- by the data model, outside-in.`;
4948
- var init_glossary = __esm(() => {
4949
- init_src();
4950
- init_constants();
4951
- init__result();
4952
- init_term();
5389
+ var init_guard = __esm(() => {
5390
+ init_collect();
4953
5391
  });
4954
5392
 
4955
5393
  // src/operations/check.ts
4956
- import { existsSync as existsSync10, readFileSync as readFileSync2 } from "fs";
4957
- import { join as join9 } from "path";
5394
+ import { existsSync as existsSync11, readFileSync as readFileSync3 } from "fs";
5395
+ import { join as join11 } from "path";
4958
5396
  function readCodeownersText(platformDir) {
4959
- const path = join9(platformDir, ".github", "CODEOWNERS");
4960
- if (!existsSync10(path))
5397
+ const path = join11(platformDir, ".github", "CODEOWNERS");
5398
+ if (!existsSync11(path))
4961
5399
  return null;
4962
- return readFileSync2(path, "utf-8");
5400
+ return readFileSync3(path, "utf-8");
4963
5401
  }
4964
5402
  function domainReqsFromText(text, platformRel) {
4965
5403
  const validated = parseDomainText(text, platformRel);
@@ -4972,7 +5410,7 @@ async function collectChangeReqs(canonicalDir) {
4972
5410
  const relPathById = new Map;
4973
5411
  for (const rel of await findDomainJsonFiles(canonicalDir)) {
4974
5412
  const platformRel = `${CANONICAL_SPECS_DIR}/${rel}`;
4975
- const text = await Bun.file(join9(canonicalDir, rel)).text();
5413
+ const text = await Bun.file(join11(canonicalDir, rel)).text();
4976
5414
  const parsed = domainReqsFromText(text, platformRel);
4977
5415
  for (const r of parsed.reqs) {
4978
5416
  reqs.push(r);
@@ -4981,7 +5419,7 @@ async function collectChangeReqs(canonicalDir) {
4981
5419
  }
4982
5420
  return { reqs, relPathById };
4983
5421
  }
4984
- function collectBaseReqs(platformDir, ref, diagnostics2) {
5422
+ function collectBaseReqs2(platformDir, ref, diagnostics2) {
4985
5423
  const reqs = [];
4986
5424
  const relPathById = new Map;
4987
5425
  for (const path of gitLsTree(platformDir, ref, CANONICAL_SPECS_DIR)) {
@@ -5013,8 +5451,8 @@ async function ingestResults(storage2, resultsPath, diagnostics2) {
5013
5451
  }
5014
5452
  async function governanceGate(input, ref, diagnostics2, results) {
5015
5453
  const { platformDir } = input;
5016
- const change = await collectChangeReqs(join9(platformDir, CANONICAL_SPECS_DIR));
5017
- const base = collectBaseReqs(platformDir, ref, diagnostics2);
5454
+ const change = await collectChangeReqs(join11(platformDir, CANONICAL_SPECS_DIR));
5455
+ const base = collectBaseReqs2(platformDir, ref, diagnostics2);
5018
5456
  diagnostics2.push(...requirementRemoved(base.reqs, change.reqs, (id) => base.relPathById.get(id) ?? null));
5019
5457
  const codeowners = parseCodeowners(readCodeownersText(platformDir) ?? "");
5020
5458
  const approvedBy = (input.approvedBy ?? "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -5023,12 +5461,33 @@ async function governanceGate(input, ref, diagnostics2, results) {
5023
5461
  diagnostics2.push(...partialPropagation(changedRules(base.reqs, change.reqs), results.verifyingTags, results.resultsParsed, (id) => change.relPathById.get(id) ?? null));
5024
5462
  }
5025
5463
  }
5464
+ async function lossDiagnostics(storage2, platformDir, ref) {
5465
+ const { losses } = await guard(storage2, platformDir, ref);
5466
+ const rows = [];
5467
+ for (const loss of losses) {
5468
+ if (loss.kind === "REQUIREMENT_REMOVED")
5469
+ continue;
5470
+ rows.push({
5471
+ code: loss.kind,
5472
+ severity: LOSS_SEVERITY[loss.kind],
5473
+ repo: null,
5474
+ source_file: loss.file === "" ? null : loss.file,
5475
+ line: loss.line,
5476
+ req_id: loss.req_id,
5477
+ detail: loss.detail
5478
+ });
5479
+ }
5480
+ return rows;
5481
+ }
5026
5482
  async function check(input, storage2) {
5027
5483
  const { platformDir } = input;
5028
5484
  if (input.base && !gitRefResolves(platformDir, input.base)) {
5029
5485
  return fail("usage", `--base ref '${input.base}' does not resolve in ${platformDir} ` + "(not a git repo, or the ref is unfetched/misspelled) \u2014 refusing to run the governance gate fail-open");
5030
5486
  }
5031
5487
  const result = await runIndex({ platformDir, storage: storage2 });
5488
+ const checkConfig2 = readCheckConfig(platformDir);
5489
+ if (!checkConfig2.ok)
5490
+ return fail("usage", checkConfig2.detail);
5032
5491
  const diagnostics2 = collectDiagnostics(storage2);
5033
5492
  if (input.unsourcedChange) {
5034
5493
  const superseded = storage2.listRequirements({ status: "Superseded" });
@@ -5043,20 +5502,24 @@ async function check(input, storage2) {
5043
5502
  const results = await ingestResults(storage2, input.resultsPath, diagnostics2);
5044
5503
  if (input.base) {
5045
5504
  await governanceGate(input, input.base, diagnostics2, results);
5505
+ diagnostics2.push(...await lossDiagnostics(storage2, platformDir, input.base));
5046
5506
  } else if (gitRefResolves(platformDir, "HEAD")) {
5047
- const change = await collectChangeReqs(join9(platformDir, CANONICAL_SPECS_DIR));
5507
+ const change = await collectChangeReqs(join11(platformDir, CANONICAL_SPECS_DIR));
5048
5508
  const baseParseSink = [];
5049
- const base = collectBaseReqs(platformDir, "HEAD", baseParseSink);
5509
+ const base = collectBaseReqs2(platformDir, "HEAD", baseParseSink);
5050
5510
  diagnostics2.push(...requirementRemoved(base.reqs, change.reqs, (id) => base.relPathById.get(id) ?? null));
5511
+ diagnostics2.push(...await lossDiagnostics(storage2, platformDir, "HEAD"));
5051
5512
  }
5513
+ const graded = applySeverity(diagnostics2, checkConfig2.config.severity);
5052
5514
  return {
5053
5515
  ok: true,
5054
- diagnostics: diagnostics2,
5516
+ diagnostics: graded,
5055
5517
  buildId: result.build_id,
5056
- failing: diagnostics2.some((d) => d.severity === "error"),
5518
+ failing: graded.some((d) => d.severity === "error"),
5057
5519
  proofsUnconfirmed: !input.resultsPath
5058
5520
  };
5059
5521
  }
5522
+ var LOSS_SEVERITY;
5060
5523
  var init_check = __esm(() => {
5061
5524
  init_src();
5062
5525
  init_filerefs2();
@@ -5064,6 +5527,7 @@ var init_check = __esm(() => {
5064
5527
  init_propagation_teeth();
5065
5528
  init_proven();
5066
5529
  init_removed();
5530
+ init_severity();
5067
5531
  init_sqlDiagnostics();
5068
5532
  init_statusflip();
5069
5533
  init_supersedes();
@@ -5074,6 +5538,12 @@ var init_check = __esm(() => {
5074
5538
  init_fs();
5075
5539
  init__result();
5076
5540
  init_glossary();
5541
+ init_guard();
5542
+ LOSS_SEVERITY = {
5543
+ IMPL_LOST: "error",
5544
+ SPEC_FILE_DELETED: "error",
5545
+ VERIFY_LOST: "warning"
5546
+ };
5077
5547
  });
5078
5548
 
5079
5549
  // src/commands/check.ts
@@ -5123,17 +5593,17 @@ var init_check2 = __esm(() => {
5123
5593
  out: outArg,
5124
5594
  ci: {
5125
5595
  type: "boolean",
5126
- description: "Force cold rebuild: rm db+wal+shm BEFORE indexing (CHCK-01)"
5596
+ description: "Force cold rebuild: rm db+wal+shm BEFORE indexing"
5127
5597
  },
5128
5598
  json: jsonArg,
5129
5599
  noPrompt: noPromptArg,
5130
5600
  unsourcedChange: {
5131
5601
  type: "boolean",
5132
- description: "Opt-in: emit warning-severity UNSOURCED_CHANGE for Superseded requirements lacking a supersedes-via issue (OFF by default; USRC-02)"
5602
+ description: "Opt-in: emit warning-severity UNSOURCED_CHANGE for Superseded requirements lacking a supersedes-via issue (OFF by default)"
5133
5603
  },
5134
5604
  results: {
5135
5605
  type: "string",
5136
- description: "Ingest a JUnit XML results file; enforce the trusted-red PROVEN gate (GATE-01)"
5606
+ description: "Ingest a JUnit XML results file; enforce the trusted-red PROVEN gate"
5137
5607
  },
5138
5608
  base: {
5139
5609
  type: "string",
@@ -5184,6 +5654,7 @@ var init_check2 = __esm(() => {
5184
5654
  return result;
5185
5655
  });
5186
5656
  } catch (e) {
5657
+ handleInvalidDomainFiles(e, jsonMode);
5187
5658
  exitAfterCrash(e);
5188
5659
  }
5189
5660
  if (outcome === undefined) {
@@ -5207,8 +5678,8 @@ var init_check2 = __esm(() => {
5207
5678
  });
5208
5679
 
5209
5680
  // src/operations/domain.ts
5210
- import { existsSync as existsSync11, mkdirSync as mkdirSync4 } from "fs";
5211
- import { dirname as dirname6, join as join10, resolve as resolve9 } from "path";
5681
+ import { existsSync as existsSync12, mkdirSync as mkdirSync4 } from "fs";
5682
+ import { dirname as dirname6, join as join12, resolve as resolve9 } from "path";
5212
5683
  function definedFields(options) {
5213
5684
  const out = {};
5214
5685
  if (options.scope !== undefined)
@@ -5228,9 +5699,9 @@ async function newDomain(platformDir, key, options = {}) {
5228
5699
  if (!(resolvedDest === resolvedRoot || resolvedDest.startsWith(`${resolvedRoot}/`))) {
5229
5700
  return fail("usage", `refusing to write outside platformDir (${resolvedDest})`);
5230
5701
  }
5231
- if (existsSync11(dest))
5702
+ if (existsSync12(dest))
5232
5703
  return fail("conflict", `refusing to overwrite ${dest}`);
5233
- if (existsSync11(join10(dirname6(dest), "SPEC.md"))) {
5704
+ if (existsSync12(join12(dirname6(dest), "SPEC.md"))) {
5234
5705
  return fail("conflict", `refusing to create ${dest}: domain ${key} already exists as spec-engine/${key}/SPEC.md ` + "\u2014 migrate the SPEC.md first (a fresh empty SPEC.json would shadow it on the next index)");
5235
5706
  }
5236
5707
  mkdirSync4(dirname6(dest), { recursive: true });
@@ -5528,7 +5999,7 @@ var init_map = __esm(() => {
5528
5999
  noPrompt: args.noPrompt
5529
6000
  }
5530
6001
  });
5531
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6002
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
5532
6003
  const { rows } = coverageMatrix(storage2);
5533
6004
  if (!args.json && rows.length === 0) {
5534
6005
  console.error(formatNoRequirementsIndexed(platformDir));
@@ -5641,7 +6112,7 @@ var init_get = __esm(() => {
5641
6112
  platformDir,
5642
6113
  args: { noPrompt: args.noPrompt }
5643
6114
  });
5644
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6115
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
5645
6116
  const result = getRecord(storage2, reqId);
5646
6117
  if (result.row === null) {
5647
6118
  console.error(result.platformEmpty ? formatNoRequirementsIndexed(platformDir) : `spec get: no requirement ${reqId} in the index under ${platformDir} (check the id, or re-run with --fresh after editing specs)`);
@@ -5715,7 +6186,7 @@ var init_list = __esm(() => {
5715
6186
  platformDir,
5716
6187
  args: { noPrompt: args.noPrompt }
5717
6188
  });
5718
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6189
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
5719
6190
  const result = listRecords(storage2, filter);
5720
6191
  if (!args.json && result.rows.length === 0) {
5721
6192
  console.error(result.platformEmpty ? formatNoRequirementsIndexed(platformDir) : `spec list: no requirements match under ${platformDir}`);
@@ -5792,7 +6263,7 @@ var init_propagation2 = __esm(() => {
5792
6263
  platformDir,
5793
6264
  args: { noPrompt: args.noPrompt }
5794
6265
  });
5795
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6266
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
5796
6267
  const result = propagation2(storage2, reqId);
5797
6268
  if (!args.json && result.platformEmpty) {
5798
6269
  console.error(formatNoRequirementsIndexed(platformDir));
@@ -5915,7 +6386,7 @@ var init_query = __esm(() => {
5915
6386
  platformDir,
5916
6387
  args: { noPrompt: args.noPrompt }
5917
6388
  });
5918
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6389
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
5919
6390
  const result = query(storage2, text, limit);
5920
6391
  if (!result.ok) {
5921
6392
  console.error(`spec query: ${result.detail}`);
@@ -6037,7 +6508,7 @@ var init_relations = __esm(() => {
6037
6508
  noPrompt: args.noPrompt
6038
6509
  }
6039
6510
  });
6040
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6511
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
6041
6512
  const { rows } = relations(storage2);
6042
6513
  if (!args.json && rows.length === 0) {
6043
6514
  console.error(formatNoRelations(platformDir));
@@ -6162,7 +6633,7 @@ var init_adapter = () => {};
6162
6633
 
6163
6634
  // ../tracker/src/cache.ts
6164
6635
  import { mkdirSync as mkdirSync5 } from "fs";
6165
- import { dirname as dirname7, join as join11 } from "path";
6636
+ import { dirname as dirname7, join as join13 } from "path";
6166
6637
  function isFresh(entry, now, ttlMs = CACHE_TTL_MS) {
6167
6638
  const t = Date.parse(entry.fetched_at);
6168
6639
  if (Number.isNaN(t))
@@ -6176,7 +6647,7 @@ function isValidEntry(e) {
6176
6647
  return typeof r.title === "string" && typeof r.status === "string" && typeof r.url === "string" && typeof r.fetched_at === "string";
6177
6648
  }
6178
6649
  function sidecarPath(platformDir) {
6179
- return join11(platformDir, ".spec-engine", SIDECAR_FILE);
6650
+ return join13(platformDir, ".spec-engine", SIDECAR_FILE);
6180
6651
  }
6181
6652
  async function readCache(platformDir) {
6182
6653
  try {
@@ -6443,7 +6914,7 @@ var init_provenance = __esm(() => {
6443
6914
  noPrompt: args.noPrompt
6444
6915
  }
6445
6916
  });
6446
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, async (storage2) => {
6917
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, async (storage2) => {
6447
6918
  const { rows } = provenance(storage2, issueId || undefined);
6448
6919
  if (!args.json && rows.length === 0) {
6449
6920
  console.error(issueId ? formatNoProvenanceForIssue(issueId, platformDir) : formatNoProvenance(platformDir));
@@ -6507,6 +6978,7 @@ async function mintFromFlags(platformDir, key, textFlag, args, rawArgs) {
6507
6978
  statement,
6508
6979
  why: (args.why ?? "").trim(),
6509
6980
  livesIn: listFlag(rawArgs, "lives", args.lives) ?? [],
6981
+ relates: listFlag(rawArgs, "relates", args.relates) ?? [],
6510
6982
  issue: (args.issue ?? "").trim() || undefined,
6511
6983
  status: statusOf(args),
6512
6984
  json: Boolean(args.json)
@@ -6585,6 +7057,7 @@ var init_req = __esm(() => {
6585
7057
  },
6586
7058
  why: { type: "string", description: "Why it matters field (only with --text)" },
6587
7059
  lives: livesArg,
7060
+ relates: relatesArg,
6588
7061
  issue: {
6589
7062
  type: "string",
6590
7063
  description: "Originating ticket id, recorded as created-provenance on the entry (only with --text; opaque \u2014 never a requirement id)"
@@ -6617,9 +7090,9 @@ var init_req = __esm(() => {
6617
7090
  await mintFromFlags(platformDir, key, textFlag, args, rawArgs);
6618
7091
  return;
6619
7092
  }
6620
- const hasFieldFlags = typeof args.why === "string" || typeof args.lives === "string" || typeof args.issue === "string";
7093
+ const hasFieldFlags = typeof args.why === "string" || typeof args.lives === "string" || typeof args.relates === "string" || typeof args.issue === "string";
6621
7094
  if (hasFieldFlags) {
6622
- console.error("spec req: --why/--lives/--issue require --text (the Requirement field is mandatory)");
7095
+ console.error("spec req: --why/--lives/--relates/--issue require --text (the Requirement field is mandatory)");
6623
7096
  process.exit(EXIT.USAGE);
6624
7097
  return;
6625
7098
  }
@@ -6755,6 +7228,9 @@ async function confirmTermCommand(opts) {
6755
7228
  exitOnFailure("spec term confirm", result);
6756
7229
  await withIndex({ platformDir, build: "fresh" }, () => {
6757
7230
  return;
7231
+ }).catch((e) => {
7232
+ handleInvalidDomainFiles(e, false, EXIT.USAGE);
7233
+ throw e;
6758
7234
  });
6759
7235
  const { pinned, file } = result;
6760
7236
  if (opts.json) {
@@ -7027,14 +7503,18 @@ async function supersedeTarget(platformDir, id) {
7027
7503
  return located;
7028
7504
  }
7029
7505
  function successorFields(input, req) {
7030
- const predLives = (req.livesIn[0] ?? "").trim();
7031
- const why = (input.why ?? req.why ?? "").trim();
7032
- const livesIn = input.livesIn ?? (predLives === "" ? [] : [predLives]);
7033
- return { why, livesIn };
7506
+ return {
7507
+ why: (input.why ?? req.why ?? "").trim(),
7508
+ livesIn: input.livesIn ?? [...req.livesIn],
7509
+ relates: input.relates === undefined ? [...req.relates] : splitList(input.relates)
7510
+ };
7034
7511
  }
7035
- function successorTermFields(input, req) {
7036
- const term = (input.term ?? req.term)?.trim() || req.term;
7037
- return { term, aliases: input.aliases ?? req.aliases };
7512
+ function applyTermOverrides(successor, input) {
7513
+ const term = input.term?.trim();
7514
+ if (term)
7515
+ successor.term = term;
7516
+ if (input.aliases !== undefined)
7517
+ successor.aliases = input.aliases;
7038
7518
  }
7039
7519
  function flipPredecessor(req, newId, issue) {
7040
7520
  req.status = "superseded";
@@ -7070,18 +7550,19 @@ async function supersede(input, freshTags) {
7070
7550
  }
7071
7551
  warnings.push({ kind: "grammar", text: grammarWarningText("", verdict).slice(2) });
7072
7552
  }
7073
- const { why, livesIn } = successorFields(input, req);
7553
+ const { why, livesIn, relates } = successorFields(input, req);
7074
7554
  warnings.push(...unresolvableRefWarnings(platformDir, [input.statement, why, ...livesIn]));
7075
7555
  const newId = await nextRequirementId(platformDir, key);
7076
7556
  const issue = input.issue?.trim() || undefined;
7557
+ const successor = successorEntry(newId, req, {
7558
+ statement: input.statement,
7559
+ why,
7560
+ livesIn,
7561
+ relates,
7562
+ issue
7563
+ });
7564
+ applyTermOverrides(successor, input);
7077
7565
  flipPredecessor(req, newId, issue);
7078
- const successor = successorEntry(newId, { statement: input.statement, why, livesIn, issue });
7079
- if (key === "TERM") {
7080
- const termFields = successorTermFields(input, req);
7081
- if (termFields.term !== undefined)
7082
- successor.term = termFields.term;
7083
- successor.aliases = termFields.aliases;
7084
- }
7085
7566
  requirements.push(successor);
7086
7567
  const specVersion = applyVersionStage(domain2, req, successor, Boolean(input.noBump));
7087
7568
  domain2.updated = localToday();
@@ -7190,6 +7671,9 @@ function inputFromArgs(args, rawArgs, platformDir, id, statement) {
7190
7671
  const livesIn = listFlag(rawArgs, "lives", args.lives);
7191
7672
  if (livesIn !== undefined)
7192
7673
  input.livesIn = livesIn;
7674
+ const relates = listFlag(rawArgs, "relates", args.relates);
7675
+ if (relates !== undefined)
7676
+ input.relates = relates;
7193
7677
  if (typeof args.term === "string")
7194
7678
  input.term = args.term;
7195
7679
  if (typeof args.aliases === "string")
@@ -7203,7 +7687,6 @@ var supersedeArgs, supersedeCommand;
7203
7687
  var init_supersede2 = __esm(() => {
7204
7688
  init_constants();
7205
7689
  init_discover();
7206
- init__index();
7207
7690
  init_supersede();
7208
7691
  init_grammar3();
7209
7692
  init__args();
@@ -7228,6 +7711,10 @@ var init_supersede2 = __esm(() => {
7228
7711
  type: "string",
7229
7712
  description: "Successor's Lives in: repeat or comma-separate; empty clears (default: copied from the old entry)"
7230
7713
  },
7714
+ relates: {
7715
+ type: "string",
7716
+ description: "Successor's related ids: repeat or comma-separate; empty clears (default: copied from the old entry)"
7717
+ },
7231
7718
  term: {
7232
7719
  type: "string",
7233
7720
  description: "TERM successor's headword (default: copied from the old entry; TERM ids only)"
@@ -7268,7 +7755,7 @@ var init_supersede2 = __esm(() => {
7268
7755
  handleNotAPlatform(e);
7269
7756
  }
7270
7757
  const statement = await resolveSuccessorText(args, platformDir, id);
7271
- const result = await supersede(inputFromArgs(args, rawArgs, platformDir, id, statement), coldFreshTags(platformDir));
7758
+ const result = await supersede(inputFromArgs(args, rawArgs, platformDir, id, statement), cliFreshTags(platformDir));
7272
7759
  if (!result.ok)
7273
7760
  exitOnFailure("spec supersede", result);
7274
7761
  printWarnings("spec supersede", result.warnings);
@@ -7335,7 +7822,6 @@ var deprecateCommand;
7335
7822
  var init_deprecate2 = __esm(() => {
7336
7823
  init_constants();
7337
7824
  init_discover();
7338
- init__index();
7339
7825
  init_deprecate();
7340
7826
  init_grammar3();
7341
7827
  init__args();
@@ -7378,7 +7864,7 @@ var init_deprecate2 = __esm(() => {
7378
7864
  } catch (e) {
7379
7865
  handleNotAPlatform(e);
7380
7866
  }
7381
- const result = await deprecate({ platformDir, id, reason }, coldFreshTags(platformDir));
7867
+ const result = await deprecate({ platformDir, id, reason }, cliFreshTags(platformDir));
7382
7868
  if (!result.ok)
7383
7869
  exitOnFailure("spec deprecate", result);
7384
7870
  const { file, sites } = result;
@@ -7397,30 +7883,28 @@ var init_deprecate2 = __esm(() => {
7397
7883
 
7398
7884
  // src/operations/move.ts
7399
7885
  async function locateMove(platformDir, id, targetKey) {
7400
- const sourceKey = domainKeyOf(id);
7401
- if (sourceKey === targetKey) {
7886
+ const prefixKey = domainKeyOf(id);
7887
+ const located = await locateEntry(platformDir, id, `no domain ${prefixKey} (expected spec-engine/${prefixKey}/SPEC.json)`);
7888
+ if (!located.ok)
7889
+ return located;
7890
+ const { req, ...source } = located;
7891
+ if (source.key === targetKey) {
7402
7892
  return fail("usage", `${id} is already in ${targetKey} \u2014 use spec supersede for an in-domain revision`);
7403
7893
  }
7404
- const source = await readEnvelope(platformDir, sourceKey, `no domain ${sourceKey} (expected spec-engine/${sourceKey}/SPEC.json)`);
7405
- if (!source.ok)
7406
- return source;
7407
7894
  const target = await readEnvelope(platformDir, targetKey, `no target domain ${targetKey} \u2014 run \`spec domain new ${targetKey}\` first`);
7408
7895
  if (!target.ok)
7409
7896
  return target;
7410
- const req = source.requirements.find((r) => r.id === id);
7411
- if (req === undefined)
7412
- return fail("not_found", `no entry ${id} in ${source.relFile}`);
7413
7897
  if (req.status.toLowerCase() !== "active") {
7414
7898
  return fail("conflict", `${id} is ${req.status} \u2014 only Active requirements move (superseded/retired entries stay as history)`);
7415
7899
  }
7416
7900
  return { ok: true, source, target, req };
7417
7901
  }
7418
7902
  function successorFields2(input, req) {
7419
- const srcLives = (req.livesIn[0] ?? "").trim();
7420
7903
  return {
7421
7904
  statement: (input.statement ?? req.statement).trim(),
7422
7905
  why: (input.why ?? req.why ?? "").trim(),
7423
- livesIn: input.livesIn ?? (srcLives === "" ? [] : [srcLives])
7906
+ livesIn: input.livesIn ?? [...req.livesIn],
7907
+ relates: [...req.relates]
7424
7908
  };
7425
7909
  }
7426
7910
  function versionSide(domain2, key, noBump) {
@@ -7469,7 +7953,7 @@ async function move(input, freshTags) {
7469
7953
  const sourceCurrent = source.domain.specVersion ?? 1;
7470
7954
  req.status = "superseded";
7471
7955
  req.supersededBy = newId;
7472
- target.requirements.push(successorEntry(newId, fields));
7956
+ target.requirements.push(successorEntry(newId, req, fields));
7473
7957
  const noBump = Boolean(input.noBump);
7474
7958
  const sourceSpecVersion = versionSide(source.domain, source.key, noBump);
7475
7959
  const targetSpecVersion = versionSide(target.domain, target.key, noBump);
@@ -7530,7 +8014,6 @@ var init_move2 = __esm(() => {
7530
8014
  init_domains();
7531
8015
  init_constants();
7532
8016
  init_discover();
7533
- init__index();
7534
8017
  init_move();
7535
8018
  init_grammar3();
7536
8019
  init__args();
@@ -7592,7 +8075,7 @@ var init_move2 = __esm(() => {
7592
8075
  } catch (e) {
7593
8076
  handleNotAPlatform(e);
7594
8077
  }
7595
- const result = await move(inputFromArgs2(args, rawArgs, platformDir, id, targetKey), coldFreshTags(platformDir));
8078
+ const result = await move(inputFromArgs2(args, rawArgs, platformDir, id, targetKey), cliFreshTags(platformDir));
7596
8079
  if (!result.ok)
7597
8080
  exitOnFailure("spec move", result);
7598
8081
  printWarnings("spec move", result.warnings);
@@ -7626,18 +8109,30 @@ var init_move2 = __esm(() => {
7626
8109
  });
7627
8110
 
7628
8111
  // src/operations/amend.ts
7629
- async function gateRejection(id, req, freshTags) {
8112
+ function releaseHolding(platformDir, relFile, id) {
8113
+ const tag = newestReleaseTag(platformDir);
8114
+ if (tag === null)
8115
+ return null;
8116
+ const text = gitShow(platformDir, tag, relFile);
8117
+ if (text === null)
8118
+ return null;
8119
+ const parsed = parseDomainText(text, relFile);
8120
+ return parsed.ok && parsed.data.requirements.some((r) => r.id === id) ? tag : null;
8121
+ }
8122
+ function gateRejection(platformDir, relFile, req, fields) {
8123
+ const { id } = req;
7630
8124
  const statusLc = req.status.toLowerCase();
7631
8125
  if (statusLc !== "active" && statusLc !== "draft") {
7632
8126
  return fail("conflict", `${id} is ${displayStatus(req.status)} \u2014 only Active/Draft entries amend (a superseded entry is history; supersede its successor instead)`);
7633
8127
  }
7634
8128
  if (statusLc !== "active")
7635
8129
  return null;
7636
- const bound = (await freshTags(id)).filter((t) => t.kind === "implements" || t.kind === "verifies");
7637
- const site = bound[0];
7638
- if (site === undefined)
8130
+ if (!PROMISE_FIELDS.some((f) => fields[f] !== undefined))
7639
8131
  return null;
7640
- return fail("conflict", `${id} is shipped \u2014 ${bound.length} code tag(s) bind it (e.g. ${site.file}:${site.line}). ` + "A bound requirement is immutable; supersede it with a successor instead of amending in place.");
8132
+ const tag = releaseHolding(platformDir, relFile, id);
8133
+ if (tag === null)
8134
+ return null;
8135
+ return fail("conflict", `${id} is released in ${tag} \u2014 its promise is immutable. ` + `Run \`spec supersede ${id}\` to change the statement or why; ` + "pass only --lives or --relates to amend its metadata in place.");
7641
8136
  }
7642
8137
  function applyFields(req, fields) {
7643
8138
  const fieldsChanged = [];
@@ -7657,6 +8152,10 @@ function applyFields(req, fields) {
7657
8152
  fieldsChanged.push("lives");
7658
8153
  refValues.push(...fields.livesIn);
7659
8154
  }
8155
+ if (fields.relates !== undefined) {
8156
+ req.relates = splitList(fields.relates);
8157
+ fieldsChanged.push("relates");
8158
+ }
7660
8159
  if (fields.term !== undefined) {
7661
8160
  req.term = fields.term;
7662
8161
  fieldsChanged.push("term");
@@ -7672,18 +8171,17 @@ function applyFields(req, fields) {
7672
8171
  }
7673
8172
  return { fieldsChanged, refValues };
7674
8173
  }
7675
- async function amend(input, freshTags) {
8174
+ async function amend(input) {
7676
8175
  const { platformDir, id, fields } = input;
7677
8176
  const located = await locateEntry(platformDir, id);
7678
8177
  if (!located.ok)
7679
8178
  return located;
7680
- const { specPath, relFile, domain: domain2, req } = located;
7681
- const rejection = await gateRejection(id, req, freshTags);
8179
+ const { key, specPath, relFile, domain: domain2, req } = located;
8180
+ const rejection = gateRejection(platformDir, relFile, req, fields);
7682
8181
  if (rejection !== null)
7683
8182
  return rejection;
7684
8183
  const warnings = [];
7685
8184
  if (fields.statement !== undefined) {
7686
- const key = domainKeyOf(id);
7687
8185
  const verdict = await judgeStatementGrammar(platformDir, key, fields.statement);
7688
8186
  if (!verdict.ok) {
7689
8187
  if (verdict.severity === "error") {
@@ -7705,12 +8203,14 @@ async function amend(input, freshTags) {
7705
8203
  }
7706
8204
  return { ok: true, id, file: relFile, fieldsChanged: [...fieldsChanged].sort(), warnings };
7707
8205
  }
8206
+ var PROMISE_FIELDS;
7708
8207
  var init_amend = __esm(() => {
7709
8208
  init_src();
7710
8209
  init_grammar2();
7711
8210
  init__envelope();
7712
8211
  init__result();
7713
8212
  init_mint();
8213
+ PROMISE_FIELDS = ["statement", "why", "term", "aliases"];
7714
8214
  });
7715
8215
 
7716
8216
  // src/commands/amend.ts
@@ -7721,9 +8221,9 @@ __export(exports_amend, {
7721
8221
  import { defineCommand as defineCommand18 } from "citty";
7722
8222
  function assertAmendFlags(args) {
7723
8223
  const hasIssue = args.issue !== undefined && args.issue.trim() !== "";
7724
- const hasField = args.text !== undefined || args.why !== undefined || args.lives !== undefined || args.term !== undefined || args.aliases !== undefined;
8224
+ const hasField = args.text !== undefined || args.why !== undefined || args.lives !== undefined || args.relates !== undefined || args.term !== undefined || args.aliases !== undefined;
7725
8225
  if (!hasField && !hasIssue) {
7726
- console.error("spec amend: nothing to amend \u2014 pass at least one of --text / --why / --lives / --term / --aliases");
8226
+ console.error("spec amend: nothing to amend \u2014 pass at least one of --text / --why / --lives / --relates / --term / --aliases");
7727
8227
  process.exit(EXIT.USAGE);
7728
8228
  }
7729
8229
  if (args.text !== undefined && args.text.trim() === "") {
@@ -7750,6 +8250,9 @@ function fieldsFromArgs(args, rawArgs) {
7750
8250
  const livesIn = listFlag(rawArgs, "lives", args.lives);
7751
8251
  if (livesIn !== undefined)
7752
8252
  fields.livesIn = livesIn;
8253
+ const relates = listFlag(rawArgs, "relates", args.relates);
8254
+ if (relates !== undefined)
8255
+ fields.relates = relates;
7753
8256
  if (args.term !== undefined)
7754
8257
  fields.term = args.term.trim();
7755
8258
  if (args.issue !== undefined && args.issue.trim() !== "")
@@ -7762,7 +8265,6 @@ var amendArgs, amendCommand;
7762
8265
  var init_amend2 = __esm(() => {
7763
8266
  init_constants();
7764
8267
  init_discover();
7765
- init__index();
7766
8268
  init_amend();
7767
8269
  init_grammar3();
7768
8270
  init__args();
@@ -7777,6 +8279,7 @@ var init_amend2 = __esm(() => {
7777
8279
  text: { type: "string", description: "New Requirement (statement) field value" },
7778
8280
  why: { type: "string", description: "New Why it matters field value" },
7779
8281
  lives: livesArg,
8282
+ relates: relatesArg,
7780
8283
  issue: {
7781
8284
  type: "string",
7782
8285
  description: "Ticket that motivated this amendment \u2014 recorded as amends-via provenance (opaque, never a requirement id)"
@@ -7791,7 +8294,7 @@ var init_amend2 = __esm(() => {
7791
8294
  amendCommand = defineCommand18({
7792
8295
  meta: {
7793
8296
  name: "amend",
7794
- description: "Revise an unshipped requirement's fields in place (same id, no specVersion bump). Supersede shipped truth instead."
8297
+ description: "Revise a requirement's fields in place (same id, no version bump). A released requirement's statement and why are superseded instead; --lives and --relates still amend."
7795
8298
  },
7796
8299
  args: amendArgs,
7797
8300
  async run({ args, rawArgs }) {
@@ -7808,7 +8311,7 @@ var init_amend2 = __esm(() => {
7808
8311
  handleNotAPlatform(e);
7809
8312
  }
7810
8313
  const fields = fieldsFromArgs(args, rawArgs);
7811
- const result = await amend({ platformDir, id, fields }, coldFreshTags(platformDir));
8314
+ const result = await amend({ platformDir, id, fields });
7812
8315
  if (!result.ok)
7813
8316
  exitOnFailure("spec amend", result);
7814
8317
  printWarnings("spec amend", result.warnings);
@@ -7930,7 +8433,7 @@ Target domain: ${opts.domain} (no charter/scope recorded \u2014 check placement
7930
8433
 
7931
8434
  // src/server/mcp.ts
7932
8435
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7933
- import { z as z3 } from "zod";
8436
+ import { z as z4 } from "zod";
7934
8437
  function jsonResult(value) {
7935
8438
  return { content: [{ type: "text", text: JSON.stringify(value) }] };
7936
8439
  }
@@ -7944,8 +8447,8 @@ function buildMcpServer(platformDir) {
7944
8447
  title: "Full-text requirement retrieval",
7945
8448
  description: "Search requirements by full text (SQLite FTS5 MATCH syntax; wrap phrases in double quotes). Returns ranked hits \u2014 use this to load the requirements a task touches instead of re-asking the user.",
7946
8449
  inputSchema: {
7947
- text: z3.string().min(1).describe("FTS5 query text"),
7948
- limit: z3.number().int().min(1).max(LIMIT_MAX).optional().describe(`Max hits (default ${DEFAULT_QUERY_LIMIT})`)
8450
+ text: z4.string().min(1).describe("FTS5 query text"),
8451
+ limit: z4.number().int().min(1).max(LIMIT_MAX).optional().describe(`Max hits (default ${DEFAULT_QUERY_LIMIT})`)
7949
8452
  }
7950
8453
  }, async ({ text, limit }) => fresh((s) => {
7951
8454
  const r = query(s, text, limit ?? DEFAULT_QUERY_LIMIT);
@@ -7955,13 +8458,13 @@ function buildMcpServer(platformDir) {
7955
8458
  title: "Files \u2192 requirements",
7956
8459
  description: "The requirements tagged in the given files (platform-relative paths, e.g. api/src/renew.ts). Load these before changing the files.",
7957
8460
  inputSchema: {
7958
- files: z3.array(z3.string().min(1)).min(1).max(100).describe("Platform-relative file paths")
8461
+ files: z4.array(z4.string().min(1)).min(1).max(100).describe("Platform-relative file paths")
7959
8462
  }
7960
8463
  }, async ({ files }) => fresh((s) => jsonResult(resolveFiles(s, files).rows)));
7961
8464
  server.registerTool("spec_req_tags", {
7962
8465
  title: "Requirement \u2192 tag sites (reverse query)",
7963
8466
  description: "Every code/test/doc site tagging the given requirement, across all repos \u2014 impact analysis and retag worklists.",
7964
- inputSchema: { req_id: z3.string().describe("Requirement id (KEY-NNN)") }
8467
+ inputSchema: { req_id: z4.string().describe("Requirement id (KEY-NNN)") }
7965
8468
  }, async ({ req_id }) => {
7966
8469
  if (!ID_RE.test(req_id)) {
7967
8470
  return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
@@ -7984,7 +8487,7 @@ function buildMcpServer(platformDir) {
7984
8487
  server.registerTool("spec_propagation", {
7985
8488
  title: "Cross-repo propagation for a superseded requirement",
7986
8489
  description: "Per member repo: migrated to the successor (verified or not), still on the predecessor, or unrelated \u2014 plus the drift flag.",
7987
- inputSchema: { req_id: z3.string().describe("Requirement id (KEY-NNN)") }
8490
+ inputSchema: { req_id: z4.string().describe("Requirement id (KEY-NNN)") }
7988
8491
  }, async ({ req_id }) => {
7989
8492
  if (!ID_RE.test(req_id)) {
7990
8493
  return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
@@ -7994,7 +8497,7 @@ function buildMcpServer(platformDir) {
7994
8497
  server.registerTool("spec_next_id", {
7995
8498
  title: "Next unused requirement id",
7996
8499
  description: "Allocate-preview the next unused id in a domain (filesystem-derived; nothing is written). Use before authoring a new requirement.",
7997
- inputSchema: { domain: z3.string().min(1).describe("Domain key (e.g. BILLING)") }
8500
+ inputSchema: { domain: z4.string().min(1).describe("Domain key (e.g. BILLING)") }
7998
8501
  }, async ({ domain: domain2 }) => {
7999
8502
  const r = await nextId(platformDir, domain2);
8000
8503
  return r.ok ? jsonResult({ domain: r.key, next_id: r.nextId }) : errorResult(r.detail);
@@ -8002,7 +8505,7 @@ function buildMcpServer(platformDir) {
8002
8505
  server.registerTool("spec_get", {
8003
8506
  title: "One requirement's full record",
8004
8507
  description: "The full record for one requirement id: status, key, seq, statement, why, versions, and source location. An unknown id returns [] (the `spec get --json` shape).",
8005
- inputSchema: { req_id: z3.string().describe("Requirement id (KEY-NNN)") }
8508
+ inputSchema: { req_id: z4.string().describe("Requirement id (KEY-NNN)") }
8006
8509
  }, async ({ req_id }) => {
8007
8510
  if (!ID_RE.test(req_id)) {
8008
8511
  return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
@@ -8016,8 +8519,8 @@ function buildMcpServer(platformDir) {
8016
8519
  title: "Every requirement record",
8017
8520
  description: "Every requirement record in (key, seq) order, superseded and deprecated history included. Optional domain and status filters. The `spec list --json` shape.",
8018
8521
  inputSchema: {
8019
- domain: z3.string().optional().describe("Only this domain key (e.g. BILLING)"),
8020
- status: z3.string().optional().describe(`Only this status: ${STATUS_WORDS.join(" | ")}`)
8522
+ domain: z4.string().optional().describe("Only this domain key (e.g. BILLING)"),
8523
+ status: z4.string().optional().describe(`Only this status: ${STATUS_WORDS.join(" | ")}`)
8021
8524
  }
8022
8525
  }, async ({ domain: domain2, status }) => {
8023
8526
  const filter = {};
@@ -8036,17 +8539,18 @@ function buildMcpServer(platformDir) {
8036
8539
  title: "Supersede a shipped requirement",
8037
8540
  description: "Flip an Active requirement to superseded, mint its successor in the same domain, and return the retag worklist (the tag sites spec check reports as SUPERSEDED_REFERENCED until retagged). The same JSON as `spec supersede --json`.",
8038
8541
  inputSchema: {
8039
- req_id: z3.string().describe("The Active requirement id to supersede (KEY-NNN)"),
8040
- statement: z3.string().min(1).describe("The successor's statement"),
8041
- why: z3.string().optional().describe("Successor's why (default: copied from the predecessor)"),
8042
- lives_in: z3.array(z3.string()).optional().describe("Successor's livesIn paths (default: copied from the predecessor)"),
8043
- issue: z3.string().optional().describe("Ticket recorded as supersedes-via on the predecessor and created on the successor")
8044
- }
8045
- }, async ({ req_id, statement, why, lives_in, issue }) => {
8542
+ req_id: z4.string().describe("The Active requirement id to supersede (KEY-NNN)"),
8543
+ statement: z4.string().min(1).describe("The successor's statement"),
8544
+ why: z4.string().optional().describe("Successor's why (default: copied from the predecessor)"),
8545
+ lives_in: z4.array(z4.string()).optional().describe("Successor's livesIn paths (default: copied from the predecessor)"),
8546
+ relates: z4.array(z4.string()).optional().describe("Successor's related ids, each comma-split; an empty list clears (default: copied from the predecessor)"),
8547
+ issue: z4.string().optional().describe("Ticket recorded as supersedes-via on the predecessor and created on the successor")
8548
+ }
8549
+ }, async ({ req_id, statement, why, lives_in, relates, issue }) => {
8046
8550
  if (!ID_RE.test(req_id)) {
8047
8551
  return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
8048
8552
  }
8049
- const r = await supersede({ platformDir, id: req_id, statement, why, livesIn: lives_in, issue }, coldFreshTags(platformDir));
8553
+ const r = await supersede({ platformDir, id: req_id, statement, why, livesIn: lives_in, relates, issue }, coldFreshTags(platformDir));
8050
8554
  if (!r.ok)
8051
8555
  return errorResult(r.detail);
8052
8556
  for (const w of r.warnings)
@@ -8063,8 +8567,8 @@ function buildMcpServer(platformDir) {
8063
8567
  title: "Deprecate a requirement",
8064
8568
  description: "Mark an Active or Draft requirement deprecated with a recorded reason and return the code tags still bound to it (each a DEPRECATED_REFERENCED error in spec check until removed). The same JSON as `spec deprecate --json`.",
8065
8569
  inputSchema: {
8066
- req_id: z3.string().describe("The requirement id to deprecate (KEY-NNN)"),
8067
- reason: z3.string().min(1).describe("Why the requirement ended; the durable record")
8570
+ req_id: z4.string().describe("The requirement id to deprecate (KEY-NNN)"),
8571
+ reason: z4.string().min(1).describe("Why the requirement ended; the durable record")
8068
8572
  }
8069
8573
  }, async ({ req_id, reason }) => {
8070
8574
  if (!ID_RE.test(req_id)) {
@@ -8080,7 +8584,7 @@ function buildMcpServer(platformDir) {
8080
8584
  title: "Accept a draft requirement",
8081
8585
  description: "Promote a Draft requirement to Active, leaving every other field untouched. The same JSON as `spec accept --json`.",
8082
8586
  inputSchema: {
8083
- req_id: z3.string().describe("The Draft requirement id to accept (KEY-NNN)")
8587
+ req_id: z4.string().describe("The Draft requirement id to accept (KEY-NNN)")
8084
8588
  }
8085
8589
  }, async ({ req_id }) => {
8086
8590
  if (!ID_RE.test(req_id)) {
@@ -8093,8 +8597,8 @@ function buildMcpServer(platformDir) {
8093
8597
  title: "Author requirements from a brief",
8094
8598
  description: "Turn a vague brief/ticket into well-formed Spec Engine requirements: one requirement per testable promise, placed by domain charter, deduped via spec query, drafted to the GUARD template + cold-read rubric, minted with spec req. The engine returns a static playbook template \u2014 your model runs it.",
8095
8599
  argsSchema: {
8096
- brief: z3.string().min(1).describe("The vague brief / ticket text to author from"),
8097
- domain: z3.string().optional().describe("Optional target domain KEY \u2014 its charter/scope is injected when given")
8600
+ brief: z4.string().min(1).describe("The vague brief / ticket text to author from"),
8601
+ domain: z4.string().optional().describe("Optional target domain KEY \u2014 its charter/scope is injected when given")
8098
8602
  }
8099
8603
  }, async ({ brief, domain: domain2 }) => {
8100
8604
  let resolvedDomain;
@@ -8175,7 +8679,7 @@ __export(exports_resolve, {
8175
8679
  resolveCommand: () => resolveCommand
8176
8680
  });
8177
8681
  import { statSync as statSync5 } from "fs";
8178
- import { join as join12, relative as relative2, resolve as resolve13, sep as sep3 } from "path";
8682
+ import { join as join14, relative as relative2, resolve as resolve13, sep as sep3 } from "path";
8179
8683
  import { defineCommand as defineCommand21 } from "citty";
8180
8684
  function classifyArg(a) {
8181
8685
  if (a.startsWith("--")) {
@@ -8214,7 +8718,7 @@ function splitFilesAndPlatformDir(positionals, fallbackPlatformDir) {
8214
8718
  const resolved = resolve13(last);
8215
8719
  if (statSync5(resolved).isDirectory()) {
8216
8720
  try {
8217
- if (statSync5(join12(resolved, "spec-engine")).isDirectory()) {
8721
+ if (statSync5(join14(resolved, "spec-engine")).isDirectory()) {
8218
8722
  lastIsPlatformDir = true;
8219
8723
  } else {
8220
8724
  lastIsDirWithoutSpecEngine = true;
@@ -8254,7 +8758,7 @@ async function runReverseQuery(opts) {
8254
8758
  if (outArg2)
8255
8759
  assertContainedPath(dbPath, platformDir, "spec resolve: --out");
8256
8760
  await maybePromptForOnboarding({ platformDir, args: { noPrompt } });
8257
- await withReadStorage({ platformDir, dbPath, fresh }, (storage2) => {
8761
+ await withReadStorage({ platformDir, dbPath, fresh, json }, (storage2) => {
8258
8762
  const { rows, known } = reqTags(storage2, reqId);
8259
8763
  if (rows.length === 0) {
8260
8764
  if (!known) {
@@ -8343,7 +8847,7 @@ var init_resolve2 = __esm(() => {
8343
8847
  },
8344
8848
  req: {
8345
8849
  type: "string",
8346
- description: "Reverse query (T8): list every tag site (repo, file, line, kind, level) for the given requirement id instead of resolving files. Takes no file positionals."
8850
+ description: "Reverse query: list every tag site (repo, file, line, kind, level) for the given requirement id instead of resolving files. Takes no file positionals."
8347
8851
  },
8348
8852
  out: {
8349
8853
  type: "string",
@@ -8365,7 +8869,7 @@ var init_resolve2 = __esm(() => {
8365
8869
  resolveCommand = defineCommand21({
8366
8870
  meta: {
8367
8871
  name: "resolve",
8368
- description: "Return the requirements tagged in the given files (RSLV-01). Accepts multiple positional file paths (e.g. `spec resolve api/src/renew.ts api/src/charge.ts`) and a comma-split fallback inside a single positional (`spec resolve a.ts,b.ts`). Paths may be platform-relative or absolute under the platform tree."
8872
+ description: "Return the requirements tagged in the given files. Accepts multiple positional file paths (e.g. `spec resolve api/src/renew.ts api/src/charge.ts`) and a comma-split fallback inside a single positional (`spec resolve a.ts,b.ts`). Paths may be platform-relative or absolute under the platform tree."
8369
8873
  },
8370
8874
  args: resolveArgs,
8371
8875
  async run({ args, rawArgs }) {
@@ -8398,7 +8902,7 @@ var init_resolve2 = __esm(() => {
8398
8902
  noPrompt: args.noPrompt
8399
8903
  }
8400
8904
  });
8401
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
8905
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
8402
8906
  const rows = storage2.resolveByFiles(normalized);
8403
8907
  if (!args.json && rows.length === 0 && storage2.listRequirements().length === 0) {
8404
8908
  console.error(formatNoRequirementsIndexed(platformDir));
@@ -8525,8 +9029,8 @@ var exports_gate = {};
8525
9029
  __export(exports_gate, {
8526
9030
  gateCommand: () => gateCommand
8527
9031
  });
8528
- import { existsSync as existsSync12 } from "fs";
8529
- import { join as join13 } from "path";
9032
+ import { existsSync as existsSync13 } from "fs";
9033
+ import { join as join15 } from "path";
8530
9034
  import { defineCommand as defineCommand22 } from "citty";
8531
9035
  function isEpipe(e) {
8532
9036
  if (e === null || typeof e !== "object" || !("code" in e))
@@ -8550,7 +9054,7 @@ function resolveGateArgs(args) {
8550
9054
  process.exit(EXIT.USAGE);
8551
9055
  }
8552
9056
  const platformDir = resolvePlatformDir(args);
8553
- if (!existsSync12(join13(platformDir, "spec-engine"))) {
9057
+ if (!existsSync13(join15(platformDir, "spec-engine"))) {
8554
9058
  console.error(formatNotASpecPlatform(platformDir));
8555
9059
  process.exit(EXIT.USAGE);
8556
9060
  }
@@ -8623,6 +9127,7 @@ var init_gate2 = __esm(() => {
8623
9127
  writeStdoutTolerant(`build_id: ${gated.buildId}`);
8624
9128
  }
8625
9129
  } catch (e) {
9130
+ handleInvalidDomainFiles(e, Boolean(args.json));
8626
9131
  const msg = e instanceof Error ? e.message : String(e);
8627
9132
  console.error(`spec gate: crashed: ${msg}`);
8628
9133
  process.exit(EXIT.USAGE);
@@ -8708,205 +9213,6 @@ function renderGuard(losses, mode, ref) {
8708
9213
  }
8709
9214
  var STOP = "\uD83D\uDED1";
8710
9215
 
8711
- // src/guard/collect.ts
8712
- import { join as join14 } from "path";
8713
- function isIgnored(path) {
8714
- return isPathIgnored(path);
8715
- }
8716
- function isSpecFile(path) {
8717
- return path.startsWith(`${CANONICAL_SPECS_DIR}/`) && path.endsWith("/SPEC.json") && !isIgnored(path);
8718
- }
8719
- function isCodeFile(path) {
8720
- if (isSpecFile(path) || isIgnored(path))
8721
- return false;
8722
- const ext = path.includes(".") ? path.split(".").pop() ?? "" : "";
8723
- return CODE_EXTS.has(ext);
8724
- }
8725
- function parseReqs(text) {
8726
- const v = parseDomainText(text, "");
8727
- return v.ok ? v.data.requirements : [];
8728
- }
8729
- function collectBaseReqs2(platformDir, ref, specPaths2) {
8730
- const reqs = [];
8731
- const path = new Map;
8732
- for (const p of specPaths2) {
8733
- const bytes = gitShow(platformDir, ref, p);
8734
- if (bytes === null)
8735
- continue;
8736
- for (const r of parseReqs(bytes)) {
8737
- reqs.push(r);
8738
- path.set(r.id, p);
8739
- }
8740
- }
8741
- return { reqs, path };
8742
- }
8743
- function collectBaseTags(platformDir, ref, codePaths) {
8744
- const implSite = new Map;
8745
- const verifySite = new Map;
8746
- for (const p of codePaths) {
8747
- const bytes = gitShow(platformDir, ref, p);
8748
- if (bytes === null)
8749
- continue;
8750
- for (const tag of scanTagsInFile("", p, bytes)) {
8751
- const target = tag.kind === "verifies" ? verifySite : implSite;
8752
- if (!target.has(tag.req_id))
8753
- target.set(tag.req_id, { file: tag.file, line: tag.line });
8754
- }
8755
- }
8756
- return { implSite, verifySite };
8757
- }
8758
- async function collectWorktreeReqs(canonicalDir) {
8759
- const ids = new Set;
8760
- const activeIds = new Set;
8761
- const supersedesTargets = new Set;
8762
- for (const rel of await findDomainJsonFiles(canonicalDir)) {
8763
- const text = await Bun.file(join14(canonicalDir, rel)).text();
8764
- for (const r of parseReqs(text)) {
8765
- ids.add(r.id);
8766
- if (r.status.toLowerCase() === "active")
8767
- activeIds.add(r.id);
8768
- if (r.supersedes != null)
8769
- supersedesTargets.add(r.supersedes);
8770
- }
8771
- }
8772
- return { ids, activeIds, supersedesTargets };
8773
- }
8774
- function tallyWorktreeTags(storage2) {
8775
- const implCount = new Map;
8776
- const verifyCount = new Map;
8777
- for (const t of storage2.listTags()) {
8778
- const bucket = t.kind === "implements" ? implCount : t.kind === "verifies" ? verifyCount : null;
8779
- if (bucket !== null)
8780
- bucket.set(t.req_id, (bucket.get(t.req_id) ?? 0) + 1);
8781
- }
8782
- return { implCount, verifyCount };
8783
- }
8784
- async function collectFacts(platformDir, ref, storage2) {
8785
- const changed = gitDiffNameStatus(platformDir, ref);
8786
- const specPaths2 = changed.filter((e) => isSpecFile(e.path)).map((e) => e.path);
8787
- const codePaths = changed.filter((e) => isCodeFile(e.path)).map((e) => e.path);
8788
- const deletedSpecFiles = changed.filter((e) => e.status === "D" && isSpecFile(e.path)).map((e) => e.path);
8789
- const base = collectBaseReqs2(platformDir, ref, specPaths2);
8790
- const baseTags = collectBaseTags(platformDir, ref, codePaths);
8791
- const worktree = await collectWorktreeReqs(join14(platformDir, CANONICAL_SPECS_DIR));
8792
- const counts = tallyWorktreeTags(storage2);
8793
- return {
8794
- baseReqs: base.reqs,
8795
- baseReqPath: base.path,
8796
- baseImplSite: baseTags.implSite,
8797
- baseVerifySite: baseTags.verifySite,
8798
- worktreeReqIds: worktree.ids,
8799
- worktreeActiveIds: worktree.activeIds,
8800
- worktreeSupersedesTargets: worktree.supersedesTargets,
8801
- worktreeImplCount: counts.implCount,
8802
- worktreeVerifyCount: counts.verifyCount,
8803
- deletedSpecFiles
8804
- };
8805
- }
8806
- var CODE_EXTS;
8807
- var init_collect = __esm(() => {
8808
- init_src();
8809
- init_constants();
8810
- init_fs();
8811
- init_tags();
8812
- CODE_EXTS = new Set(DEFAULT_EXTS);
8813
- });
8814
-
8815
- // src/guard/losses.ts
8816
- function isActive(status) {
8817
- return status.toLowerCase() === "active";
8818
- }
8819
- function isSuperseded(base, f) {
8820
- const forward = base.supersededBy != null && f.worktreeReqIds.has(base.supersededBy);
8821
- return forward || f.worktreeSupersedesTargets.has(base.id);
8822
- }
8823
- function reqDeletedLoss(base, f) {
8824
- return {
8825
- kind: "REQUIREMENT_REMOVED",
8826
- req_id: base.id,
8827
- file: f.baseReqPath.get(base.id) ?? "",
8828
- line: 0,
8829
- detail: `${base.id} was Active at the ref but is absent from the working-tree spec and was not superseded \u2014 a requirement is never deleted`
8830
- };
8831
- }
8832
- function historyRemovedLoss(base, f) {
8833
- const display = base.status.charAt(0).toUpperCase() + base.status.slice(1).toLowerCase();
8834
- return {
8835
- kind: "REQUIREMENT_REMOVED",
8836
- req_id: base.id,
8837
- file: f.baseReqPath.get(base.id) ?? "",
8838
- line: 0,
8839
- detail: `${base.id} was ${display} at the ref but is absent from the working-tree spec \u2014 a requirement is never deleted, and its history is the record`
8840
- };
8841
- }
8842
- function implLostLoss(id, site) {
8843
- return {
8844
- kind: "IMPL_LOST",
8845
- req_id: id,
8846
- file: site.file,
8847
- line: site.line,
8848
- detail: `${id} lost its last implementing @spec tag (was ${site.file}:${site.line})`
8849
- };
8850
- }
8851
- function verifyLostLoss(id, site) {
8852
- return {
8853
- kind: "VERIFY_LOST",
8854
- req_id: id,
8855
- file: site.file,
8856
- line: site.line,
8857
- detail: `${id} lost its last verifying @spec tag (was ${site.file}:${site.line})`
8858
- };
8859
- }
8860
- function specFileDeletedLoss(path) {
8861
- return {
8862
- kind: "SPEC_FILE_DELETED",
8863
- req_id: null,
8864
- file: path,
8865
- line: 0,
8866
- detail: `canonical spec file ${path} present at the ref is absent from the working tree`
8867
- };
8868
- }
8869
- function reqDeletedFor(base, f) {
8870
- if (f.worktreeReqIds.has(base.id))
8871
- return null;
8872
- if (!isActive(base.status))
8873
- return historyRemovedLoss(base, f);
8874
- if (isSuperseded(base, f))
8875
- return null;
8876
- return reqDeletedLoss(base, f);
8877
- }
8878
- function lastTagGone(id, count, f) {
8879
- return f.worktreeActiveIds.has(id) && count === 0;
8880
- }
8881
- function classifyLosses(f) {
8882
- const out = [];
8883
- for (const base of f.baseReqs) {
8884
- const loss = reqDeletedFor(base, f);
8885
- if (loss !== null)
8886
- out.push(loss);
8887
- }
8888
- for (const [id, site] of f.baseImplSite) {
8889
- if (lastTagGone(id, f.worktreeImplCount.get(id) ?? 0, f))
8890
- out.push(implLostLoss(id, site));
8891
- }
8892
- for (const [id, site] of f.baseVerifySite) {
8893
- if (lastTagGone(id, f.worktreeVerifyCount.get(id) ?? 0, f))
8894
- out.push(verifyLostLoss(id, site));
8895
- }
8896
- for (const path of f.deletedSpecFiles)
8897
- out.push(specFileDeletedLoss(path));
8898
- return out;
8899
- }
8900
-
8901
- // src/operations/guard.ts
8902
- async function guard(storage2, platformDir, ref) {
8903
- const losses = classifyLosses(await collectFacts(platformDir, ref, storage2));
8904
- return { ok: true, ref, losses };
8905
- }
8906
- var init_guard = __esm(() => {
8907
- init_collect();
8908
- });
8909
-
8910
9216
  // src/commands/guard.ts
8911
9217
  var exports_guard = {};
8912
9218
  __export(exports_guard, {
@@ -8945,7 +9251,7 @@ var init_guard2 = __esm(() => {
8945
9251
  process.exit(EXIT.OK);
8946
9252
  }
8947
9253
  let losses = [];
8948
- await withReadStorage({ platformDir, dbPath: defaultIndexPath(platformDir), fresh: true }, async (storage2) => {
9254
+ await withReadStorage({ platformDir, dbPath: defaultIndexPath(platformDir), fresh: true, json: jsonMode }, async (storage2) => {
8949
9255
  losses = (await guard(storage2, platformDir, ref)).losses;
8950
9256
  });
8951
9257
  console.log(renderGuard(losses, jsonMode ? "json" : "text", ref));
@@ -10962,6 +11268,10 @@ function mountEditor(app, bindHost = DEFAULT_BIND_HOST) {
10962
11268
  Lives in (optional, single path)
10963
11269
  <input type="text" name="livesIn" placeholder="path/to/impl.ts" />
10964
11270
  </label>
11271
+ <label>
11272
+ Relates to (optional, comma-separated ids)
11273
+ <input type="text" name="relates" placeholder="BILLING-001, BILLING-002" />
11274
+ </label>
10965
11275
  <button type="submit">Create</button>
10966
11276
  </form>
10967
11277
  </section>
@@ -10996,6 +11306,7 @@ function mountEditor(app, bindHost = DEFAULT_BIND_HOST) {
10996
11306
  const statement = field(form, "statement");
10997
11307
  const why = field(form, "why");
10998
11308
  const livesIn = field(form, "livesIn");
11309
+ const relates = field(form, "relates");
10999
11310
  const payload = {
11000
11311
  key: field(form, "key"),
11001
11312
  statement
@@ -11004,6 +11315,8 @@ function mountEditor(app, bindHost = DEFAULT_BIND_HOST) {
11004
11315
  payload.why = why;
11005
11316
  if (livesIn.length > 0)
11006
11317
  payload.livesIn = livesIn;
11318
+ if (relates.length > 0)
11319
+ payload.relates = relates;
11007
11320
  const res = await app.request("/api/requirements", {
11008
11321
  method: "POST",
11009
11322
  headers: { "content-type": "application/json" },
@@ -11904,7 +12217,7 @@ async function readJsonBody(c) {
11904
12217
  }
11905
12218
  return { ok: true, body: parsed };
11906
12219
  }
11907
- function toLivesIn(v) {
12220
+ function toStringList(v) {
11908
12221
  if (Array.isArray(v))
11909
12222
  return v.filter((x) => typeof x === "string");
11910
12223
  if (typeof v === "string" && v.length > 0)
@@ -11915,11 +12228,20 @@ function mintFieldsFromBody(body) {
11915
12228
  return {
11916
12229
  statement: typeof body.statement === "string" ? body.statement : "",
11917
12230
  why: typeof body.why === "string" ? body.why : "",
11918
- livesIn: toLivesIn(body.livesIn),
12231
+ livesIn: toStringList(body.livesIn),
12232
+ relates: toStringList(body.relates),
11919
12233
  issue: typeof body.issue === "string" && body.issue.trim() !== "" ? body.issue.trim() : undefined,
11920
12234
  status: body.status === "draft" ? "draft" : "active"
11921
12235
  };
11922
12236
  }
12237
+ function successorFieldsFromBody(body) {
12238
+ return {
12239
+ why: typeof body.why === "string" ? body.why : undefined,
12240
+ livesIn: "livesIn" in body ? toStringList(body.livesIn) : undefined,
12241
+ relates: "relates" in body ? toStringList(body.relates) : undefined,
12242
+ issue: typeof body.issue === "string" && body.issue.trim() !== "" ? body.issue.trim() : undefined
12243
+ };
12244
+ }
11923
12245
  function amendFieldsFromBody(body) {
11924
12246
  const fields = {};
11925
12247
  if (typeof body.statement === "string")
@@ -11927,7 +12249,9 @@ function amendFieldsFromBody(body) {
11927
12249
  if ("why" in body)
11928
12250
  fields.why = typeof body.why === "string" ? body.why : null;
11929
12251
  if ("livesIn" in body)
11930
- fields.livesIn = toLivesIn(body.livesIn);
12252
+ fields.livesIn = toStringList(body.livesIn);
12253
+ if ("relates" in body)
12254
+ fields.relates = toStringList(body.relates);
11931
12255
  return Object.keys(fields).length === 0 ? null : fields;
11932
12256
  }
11933
12257
  function failureResponse(c, failure) {
@@ -12034,12 +12358,9 @@ function mountApi(app, storage2, platformDir = process.cwd(), bindHost = DEFAULT
12034
12358
  return withWriteLock(async () => {
12035
12359
  const fields = amendFieldsFromBody(body);
12036
12360
  if (fields === null) {
12037
- return c.json({ error: "nothing to amend \u2014 provide statement, why, or livesIn" }, 400);
12361
+ return c.json({ error: "nothing to amend \u2014 provide statement, why, livesIn, or relates" }, 400);
12038
12362
  }
12039
- const r = await amend({ platformDir, id, fields }, async (reqId) => {
12040
- await runIndex({ platformDir, storage: storage2 });
12041
- return storage2.listTags({ req_id: reqId });
12042
- });
12363
+ const r = await amend({ platformDir, id, fields });
12043
12364
  if (!r.ok)
12044
12365
  return failureResponse(c, r);
12045
12366
  await runIndex({ platformDir, storage: storage2 });
@@ -12067,14 +12388,7 @@ function mountApi(app, storage2, platformDir = process.cwd(), bindHost = DEFAULT
12067
12388
  if (statement === "")
12068
12389
  return c.json({ error: "statement is required (non-empty)" }, 400);
12069
12390
  return withWriteLock(async () => {
12070
- const r = await supersede({
12071
- platformDir,
12072
- id,
12073
- statement,
12074
- why: typeof body.why === "string" ? body.why : undefined,
12075
- livesIn: "livesIn" in body ? toLivesIn(body.livesIn) : undefined,
12076
- issue: typeof body.issue === "string" && body.issue.trim() !== "" ? body.issue.trim() : undefined
12077
- }, async (reqId) => {
12391
+ const r = await supersede({ platformDir, id, statement, ...successorFieldsFromBody(body) }, async (reqId) => {
12078
12392
  await runIndex({ platformDir, storage: storage2 });
12079
12393
  return storage2.listTags({ req_id: reqId });
12080
12394
  });
@@ -12358,7 +12672,7 @@ __export(exports_serve, {
12358
12672
  resolveBind: () => resolveBind,
12359
12673
  composeServeApp: () => composeServeApp
12360
12674
  });
12361
- import { existsSync as existsSync13, mkdirSync as mkdirSync6 } from "fs";
12675
+ import { existsSync as existsSync14, mkdirSync as mkdirSync6 } from "fs";
12362
12676
  import { dirname as dirname8 } from "path";
12363
12677
  import { defineCommand as defineCommand24 } from "citty";
12364
12678
  import { Hono as Hono2 } from "hono";
@@ -12411,7 +12725,7 @@ async function runProbe() {
12411
12725
  }
12412
12726
  const body = await res.text();
12413
12727
  if (!body.includes("Spec Engine \u2014 coming online")) {
12414
- console.error(`serve --probe FAILED: body did not contain the placeholder string (D-14)`);
12728
+ console.error(`serve --probe FAILED: body did not contain the placeholder string`);
12415
12729
  server.stop();
12416
12730
  process.exit(EXIT.FAILURE);
12417
12731
  }
@@ -12440,7 +12754,7 @@ var init_serve = __esm(() => {
12440
12754
  serveCommand = defineCommand24({
12441
12755
  meta: {
12442
12756
  name: "serve",
12443
- description: "Run the local webapp. Binds 127.0.0.1 on --port, or one private --host address behind an access token. --probe is the SERV-04 asset-embedding smoke."
12757
+ description: "Run the local webapp. Binds 127.0.0.1 on --port, or one private --host address behind an access token. --probe is the asset-embedding smoke."
12444
12758
  },
12445
12759
  args: {
12446
12760
  platformDir: platformDirArg,
@@ -12510,7 +12824,7 @@ var init_serve = __esm(() => {
12510
12824
  }
12511
12825
  });
12512
12826
  mkdirSync6(dirname8(dbPath), { recursive: true });
12513
- const needsIndex = !existsSync13(dbPath);
12827
+ const needsIndex = !existsSync14(dbPath);
12514
12828
  let storage2;
12515
12829
  try {
12516
12830
  storage2 = openStorage(dbPath);
@@ -12531,6 +12845,7 @@ var init_serve = __esm(() => {
12531
12845
  });
12532
12846
  } catch (err) {
12533
12847
  storage2.close();
12848
+ handleInvalidDomainFiles(err, false);
12534
12849
  handleStorageUnavailable(err, dbPath);
12535
12850
  console.error(`spec serve: failed to start on ${urlHost(host, port)}:`, err);
12536
12851
  process.exit(EXIT.FAILURE);
@@ -12557,13 +12872,13 @@ __export(exports_docs, {
12557
12872
  docsCommand: () => docsCommand,
12558
12873
  createDocsFetchHandler: () => createDocsFetchHandler
12559
12874
  });
12560
- import { existsSync as existsSync14, statSync as statSync6 } from "fs";
12561
- import { join as join15, normalize, sep as sep4 } from "path";
12875
+ import { existsSync as existsSync15, statSync as statSync6 } from "fs";
12876
+ import { join as join16, normalize, sep as sep4 } from "path";
12562
12877
  import { fileURLToPath } from "url";
12563
12878
  import { defineCommand as defineCommand25 } from "citty";
12564
12879
  function resolveDocsRootFrom(candidates) {
12565
12880
  for (const candidate of candidates) {
12566
- if (existsSync14(join15(candidate, "index.html")))
12881
+ if (existsSync15(join16(candidate, "index.html")))
12567
12882
  return candidate;
12568
12883
  }
12569
12884
  return null;
@@ -12587,17 +12902,17 @@ function createDocsFetchHandler(root) {
12587
12902
  if (pathname.includes("\x00")) {
12588
12903
  return new Response("Bad request", { status: 400 });
12589
12904
  }
12590
- const target = normalize(join15(normalRoot, pathname));
12905
+ const target = normalize(join16(normalRoot, pathname));
12591
12906
  if (target !== normalRoot && !target.startsWith(rootPrefix)) {
12592
12907
  return new Response("Not found", { status: 404 });
12593
12908
  }
12594
- for (const candidate of [target, join15(target, "index.html"), `${target}.html`]) {
12595
- if (existsSync14(candidate) && statSync6(candidate).isFile()) {
12909
+ for (const candidate of [target, join16(target, "index.html"), `${target}.html`]) {
12910
+ if (existsSync15(candidate) && statSync6(candidate).isFile()) {
12596
12911
  return new Response(Bun.file(candidate));
12597
12912
  }
12598
12913
  }
12599
- const notFoundPage = join15(normalRoot, "404.html");
12600
- if (existsSync14(notFoundPage)) {
12914
+ const notFoundPage = join16(normalRoot, "404.html");
12915
+ if (existsSync15(notFoundPage)) {
12601
12916
  return new Response(Bun.file(notFoundPage), { status: 404 });
12602
12917
  }
12603
12918
  return new Response("Not found", { status: 404 });
@@ -12647,7 +12962,7 @@ var init_docs2 = __esm(() => {
12647
12962
  port: {
12648
12963
  type: "string",
12649
12964
  default: "4100",
12650
- description: "Port (default 4100; 0 = ephemeral). Hostname is hardcoded 127.0.0.1 (T-1-01); no --host accepted."
12965
+ description: "Port (default 4100; 0 = ephemeral). Hostname is hardcoded 127.0.0.1; no --host accepted."
12651
12966
  },
12652
12967
  probe: {
12653
12968
  type: "boolean",