@spec-engine/spec-engine 0.6.0 → 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.0",
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
@@ -373,10 +512,10 @@ var init_flags = __esm(() => {
373
512
  FEATURE_KEYS = Object.keys(FEATURES);
374
513
  });
375
514
  // ../shared/src/net.ts
376
- function isLoopbackHostname(hostname) {
377
- return LOOPBACK_HOSTNAMES.has(hostname);
515
+ function isServedHostname(hostname, bindHost = DEFAULT_BIND_HOST) {
516
+ return LOOPBACK_HOSTNAMES.has(hostname) || hostname === bindHost;
378
517
  }
379
- var LOOPBACK_HOSTNAMES;
518
+ var DEFAULT_BIND_HOST = "127.0.0.1", LOOPBACK_HOSTNAMES;
380
519
  var init_net = __esm(() => {
381
520
  LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
382
521
  });
@@ -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
 
@@ -1120,6 +1267,9 @@ function readPlatformMap(dir) {
1120
1267
  return null;
1121
1268
  return { mapped: map(abs), located };
1122
1269
  }
1270
+ function platformName(platformDir) {
1271
+ return readPlatformMap(platformDir)?.mapped.name ?? basename(resolve2(platformDir));
1272
+ }
1123
1273
  function platformMode(platformDir) {
1124
1274
  return readPlatformMap(platformDir)?.mapped.mode ?? "single-repo";
1125
1275
  }
@@ -1545,9 +1695,13 @@ SELECT
1545
1695
  t.file AS source_file,
1546
1696
  t.line AS line,
1547
1697
  t.req_id AS req_id,
1548
- ('Tag references Draft requirement ' || t.req_id ||
1549
- ' \u2014 code is bound to a promise nobody approved yet') AS detail,
1550
- '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
1551
1705
  FROM tags t
1552
1706
  JOIN requirements r ON r.id = t.req_id
1553
1707
  WHERE r.status = 'Draft'
@@ -1577,11 +1731,10 @@ SELECT
1577
1731
  'error' AS severity
1578
1732
  FROM requirements r
1579
1733
  WHERE r.status = 'Active'
1580
- -- code-coverage obligation \u2014 exclude the reserved TERM domain so a migrated
1581
- -- term never fires ORPHAN_REQ. Literal 'TERM' only, never a real domain key;
1582
- -- 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.
1583
1735
  -- @spec SCHM-017
1584
1736
  AND r.key != 'TERM'
1737
+ -- A doc paragraph citing a requirement is not an implementation.
1585
1738
  AND NOT EXISTS (
1586
1739
  SELECT 1 FROM tags t WHERE t.req_id = r.id AND t.kind != 'documents'
1587
1740
  )
@@ -1595,11 +1748,10 @@ SELECT
1595
1748
  r.id AS req_id,
1596
1749
  ('Requirement ' || r.id ||
1597
1750
  ' is implemented but has no verifies-kind tag') AS detail,
1598
- 'error' AS severity
1751
+ 'warning' AS severity
1599
1752
  FROM requirements r
1600
1753
  WHERE r.status = 'Active'
1601
- -- never implemented/verified by code, so it must never fire UNVERIFIED_REQ.
1602
- -- Literal 'TERM' only; mirrors Q4 + the coverage VIEW exclusion.
1754
+ -- Must stay in step with Q4 and the coverage view's TERM exclusion.
1603
1755
  AND r.key != 'TERM'
1604
1756
  AND EXISTS (SELECT 1 FROM tags t WHERE t.req_id = r.id AND t.kind = 'implements')
1605
1757
  AND NOT EXISTS (SELECT 1 FROM tags t WHERE t.req_id = r.id AND t.kind = 'verifies')
@@ -2442,7 +2594,7 @@ async function runIndex(opts) {
2442
2594
  unpinned,
2443
2595
  diagnostics: platformDiagnostics
2444
2596
  } = await discoverRepos(platformDir);
2445
- const { specs, structuralDiagnostics } = await readSpecs(canonical);
2597
+ const specs = await readSpecs(canonical);
2446
2598
  const { tagHits, docMentionCandidates } = await scanMembers(members);
2447
2599
  const rows = buildRows({
2448
2600
  canonical,
@@ -2450,7 +2602,6 @@ async function runIndex(opts) {
2450
2602
  members,
2451
2603
  specs,
2452
2604
  tagHits,
2453
- structuralDiagnostics,
2454
2605
  unpinned,
2455
2606
  platformDiagnostics
2456
2607
  });
@@ -2472,29 +2623,22 @@ async function runIndex(opts) {
2472
2623
  async function readSpecs(canonical) {
2473
2624
  const jsonPaths = await findDomainJsonFiles(canonical.path);
2474
2625
  const specs = [];
2475
- const structuralDiagnostics = [];
2626
+ const invalid = [];
2476
2627
  for (const rel of jsonPaths) {
2477
2628
  const absPath = join4(canonical.path, rel);
2478
2629
  const text = await Bun.file(absPath).text();
2479
2630
  const platformRelative = `${canonical.name}/${rel}`;
2480
2631
  const fallbackKey = basename2(dirname3(rel));
2481
2632
  const result = parseDomainJsonFile({ text, sourceFile: platformRelative, fallbackKey });
2482
- if (!result.ok) {
2483
- for (const d of result.diagnostics) {
2484
- structuralDiagnostics.push({
2485
- code: d.code,
2486
- source_file: d.source_file ?? platformRelative,
2487
- line: d.line ?? 0,
2488
- req_id: d.req_id,
2489
- detail: d.detail,
2490
- severity: d.severity
2491
- });
2492
- }
2493
- continue;
2494
- }
2495
- 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 });
2496
2638
  }
2497
- return { specs, structuralDiagnostics };
2639
+ if (invalid.length > 0)
2640
+ throw new InvalidDomainFilesError(invalid);
2641
+ return specs;
2498
2642
  }
2499
2643
  function repoExtraIgnore(member) {
2500
2644
  const repoIgnore = (member.ignore ?? []).map((e) => e.endsWith("/") ? e : `${e}/`);
@@ -2546,16 +2690,7 @@ async function scanMembers(members) {
2546
2690
  return { tagHits, docMentionCandidates };
2547
2691
  }
2548
2692
  function buildRows(input) {
2549
- const {
2550
- canonical,
2551
- platformVersion,
2552
- members,
2553
- specs,
2554
- tagHits,
2555
- structuralDiagnostics,
2556
- unpinned,
2557
- platformDiagnostics
2558
- } = input;
2693
+ const { canonical, platformVersion, members, specs, tagHits, unpinned, platformDiagnostics } = input;
2559
2694
  const sortedRepos = sortBy([
2560
2695
  {
2561
2696
  name: canonical.name,
@@ -2579,8 +2714,6 @@ function buildRows(input) {
2579
2714
  const { sortedTermAliases, sortedTermCitations } = buildTermRows(specs, flatRequirements);
2580
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)}`);
2581
2716
  const diagnostics2 = validateStructure(specs);
2582
- for (const d of structuralDiagnostics)
2583
- diagnostics2.push(d);
2584
2717
  for (const s of unpinned) {
2585
2718
  diagnostics2.push({
2586
2719
  code: DiagnosticCode.NO_SPEC_CONFIG,
@@ -3014,6 +3147,49 @@ var init_init = __esm(() => {
3014
3147
  KNOWN_KEYS = new Set(SpecConfigSchema.keyof().options);
3015
3148
  });
3016
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
+
3017
3193
  // src/operations/_index.ts
3018
3194
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, readdirSync, statSync as statSync3 } from "fs";
3019
3195
  import { dirname as dirname4, join as join6 } from "path";
@@ -3112,6 +3288,26 @@ function handleNotAPlatform(e) {
3112
3288
  }
3113
3289
  throw e;
3114
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
+ }
3115
3311
  function handleStorageUnavailable(e, dbPath) {
3116
3312
  const info = describeStorageError(e);
3117
3313
  if (info === null)
@@ -3140,7 +3336,7 @@ function exitOnFailure(cmdName, failure) {
3140
3336
  process.exit(EXIT.USAGE);
3141
3337
  }
3142
3338
  async function withReadStorage(opts, fn) {
3143
- const { platformDir, dbPath, fresh } = opts;
3339
+ const { platformDir, dbPath, fresh, json = false } = opts;
3144
3340
  try {
3145
3341
  await withIndex({ platformDir, dbPath, build: fresh ? "fresh" : "missing" }, async (h) => {
3146
3342
  for (const w of h.warnings)
@@ -3149,6 +3345,7 @@ async function withReadStorage(opts, fn) {
3149
3345
  });
3150
3346
  } catch (e) {
3151
3347
  handleStorageUnavailable(e, dbPath);
3348
+ handleInvalidDomainFiles(e, json);
3152
3349
  handleNotAPlatform(e);
3153
3350
  }
3154
3351
  }
@@ -3355,7 +3552,7 @@ function listFlag(rawArgs, name, parsed) {
3355
3552
  }
3356
3553
  return raw.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v !== "");
3357
3554
  }
3358
- var platformDirArg, outArg, jsonArg, freshArg, noPromptArg, livesArg;
3555
+ var platformDirArg, outArg, jsonArg, freshArg, noPromptArg, livesArg, relatesArg;
3359
3556
  var init__args = __esm(() => {
3360
3557
  init_constants();
3361
3558
  platformDirArg = {
@@ -3380,6 +3577,10 @@ var init__args = __esm(() => {
3380
3577
  type: "string",
3381
3578
  description: "Lives in (livesIn) paths: repeat the flag or comma-separate; an empty value clears the list"
3382
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
+ };
3383
3584
  });
3384
3585
 
3385
3586
  // src/commands/index.ts
@@ -3454,6 +3655,7 @@ var init_commands = __esm(() => {
3454
3655
  console.error(formatNotASpecPlatform(err.platformDir));
3455
3656
  process.exit(EXIT.USAGE);
3456
3657
  }
3658
+ handleInvalidDomainFiles(err, !!args.json);
3457
3659
  console.error("spec index FAILED:", err);
3458
3660
  process.exit(EXIT.FAILURE);
3459
3661
  } finally {
@@ -3463,49 +3665,6 @@ var init_commands = __esm(() => {
3463
3665
  });
3464
3666
  });
3465
3667
 
3466
- // src/check/format.ts
3467
- function compareRepoNullsLast(a, b) {
3468
- if (a === b)
3469
- return 0;
3470
- if (a === null)
3471
- return 1;
3472
- if (b === null)
3473
- return -1;
3474
- return a.localeCompare(b);
3475
- }
3476
- function compareDiagnostics(a, b) {
3477
- const codeCmp = a.code.localeCompare(b.code);
3478
- if (codeCmp !== 0)
3479
- return codeCmp;
3480
- const repoCmp = compareRepoNullsLast(a.repo, b.repo);
3481
- if (repoCmp !== 0)
3482
- return repoCmp;
3483
- const fileCmp = (a.source_file ?? "").localeCompare(b.source_file ?? "");
3484
- if (fileCmp !== 0)
3485
- return fileCmp;
3486
- const lineCmp = (a.line ?? 0) - (b.line ?? 0);
3487
- if (lineCmp !== 0)
3488
- return lineCmp;
3489
- return (a.req_id ?? "").localeCompare(b.req_id ?? "");
3490
- }
3491
- function sortDiagnostics(rows) {
3492
- return [...rows].sort(compareDiagnostics);
3493
- }
3494
- function renderDiagnostics(rows, mode) {
3495
- const sorted = sortDiagnostics(rows);
3496
- if (mode === "json") {
3497
- return JSON.stringify(sorted);
3498
- }
3499
- return sorted.map((d) => {
3500
- const repo = d.repo ?? "";
3501
- const file = d.source_file ?? "";
3502
- const line = d.line ?? "";
3503
- const reqId = d.req_id ?? "";
3504
- return `${d.code} ${repo} ${file}:${line} ${reqId} ${d.detail}`;
3505
- }).join(`
3506
- `);
3507
- }
3508
-
3509
3668
  // src/check/proven.ts
3510
3669
  function normalizeToSegments(path) {
3511
3670
  const out = [];
@@ -3688,6 +3847,14 @@ function gitDiffNameStatus(platformDir, ref) {
3688
3847
  }
3689
3848
  return rows;
3690
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
+ }
3691
3858
 
3692
3859
  // src/check/codeowners.ts
3693
3860
  function parseCodeowners(text) {
@@ -4060,6 +4227,60 @@ var init_removed = __esm(() => {
4060
4227
  init_src();
4061
4228
  });
4062
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
+
4063
4284
  // src/check/sqlDiagnostics.ts
4064
4285
  function collectDiagnostics(storage2) {
4065
4286
  const semantic = storage2.listSemanticDiagnostics();
@@ -4498,7 +4719,7 @@ function localToday() {
4498
4719
  }
4499
4720
 
4500
4721
  // src/operations/_envelope.ts
4501
- import { existsSync as existsSync7 } from "fs";
4722
+ import { existsSync as existsSync8 } from "fs";
4502
4723
  function domainKeyOf(id) {
4503
4724
  return id.slice(0, id.indexOf("-"));
4504
4725
  }
@@ -4507,7 +4728,7 @@ function displayStatus(raw) {
4507
4728
  }
4508
4729
  async function readEnvelope(platformDir, key, missingDetail) {
4509
4730
  const { abs: specPath, rel: relFile } = specPaths(platformDir, key);
4510
- if (!existsSync7(specPath)) {
4731
+ if (!existsSync8(specPath)) {
4511
4732
  return fail("not_found", missingDetail ?? `no domain ${key} (expected ${relFile} under ${platformDir})`);
4512
4733
  }
4513
4734
  const parsed = parseDomainText(await Bun.file(specPath).text(), relFile);
@@ -4523,32 +4744,58 @@ async function readEnvelope(platformDir, key, missingDetail) {
4523
4744
  const domain2 = parsed.data;
4524
4745
  return { ok: true, key, specPath, relFile, domain: domain2, requirements: domain2.requirements };
4525
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
+ }
4526
4760
  async function locateEntry(platformDir, id, missingDomainDetail) {
4527
- const file = await readEnvelope(platformDir, domainKeyOf(id), missingDomainDetail);
4528
- if (!file.ok)
4761
+ const prefixKey = domainKeyOf(id);
4762
+ const file = await readEnvelope(platformDir, prefixKey, missingDomainDetail);
4763
+ if (!file.ok && file.reason !== "not_found")
4529
4764
  return file;
4530
- const req = file.requirements.find((r) => r.id === id);
4531
- if (req === undefined)
4532
- return fail("not_found", `no entry ${id} in ${file.relFile}`);
4533
- return { ...file, req };
4534
- }
4535
- function successorEntry(id, fields) {
4536
- 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 = {
4537
4778
  id,
4538
4779
  status: "active",
4539
4780
  statement: fields.statement,
4540
4781
  why: fields.why === "" ? null : fields.why,
4541
4782
  supersedes: null,
4542
4783
  supersededBy: null,
4543
- relates: [],
4784
+ relates: fields.relates,
4544
4785
  livesIn: fields.livesIn,
4545
4786
  issues: fields.issue ? [{ role: "created", id: fields.issue }] : [],
4546
- aliases: [],
4547
- cites: []
4787
+ aliases: [...pred.aliases],
4788
+ cites: pred.cites.map((c) => ({ ...c }))
4548
4789
  };
4790
+ if (pred.term !== undefined)
4791
+ entry.term = pred.term;
4792
+ if (pred.section !== undefined)
4793
+ entry.section = pred.section;
4794
+ return entry;
4549
4795
  }
4550
4796
  var init__envelope = __esm(() => {
4551
4797
  init_src();
4798
+ init_domains();
4552
4799
  init_constants();
4553
4800
  init__result();
4554
4801
  });
@@ -4603,7 +4850,7 @@ var init_grammar2 = __esm(() => {
4603
4850
  });
4604
4851
 
4605
4852
  // src/operations/mint.ts
4606
- import { existsSync as existsSync8 } from "fs";
4853
+ import { existsSync as existsSync9 } from "fs";
4607
4854
  function unresolvableRefWarnings(platformDir, fieldValues) {
4608
4855
  const out = [];
4609
4856
  for (const value of fieldValues) {
@@ -4618,7 +4865,7 @@ function unresolvableRefWarnings(platformDir, fieldValues) {
4618
4865
  async function mint(input) {
4619
4866
  const { platformDir, key } = input;
4620
4867
  const { abs: specPath, rel: relFile } = specPaths(platformDir, key);
4621
- if (!existsSync8(specPath)) {
4868
+ if (!existsSync9(specPath)) {
4622
4869
  return fail("not_found", `no domain ${key} (expected ${relFile} under ${platformDir})`);
4623
4870
  }
4624
4871
  const warnings = unresolvableRefWarnings(platformDir, [
@@ -4646,7 +4893,7 @@ async function mint(input) {
4646
4893
  why: input.why || null,
4647
4894
  supersedes: null,
4648
4895
  supersededBy: null,
4649
- relates: input.relates ?? [],
4896
+ relates: splitList(input.relates ?? []),
4650
4897
  livesIn: input.livesIn,
4651
4898
  issues: input.issue ? [{ role: "created", id: input.issue }] : [],
4652
4899
  aliases: [],
@@ -4805,8 +5052,8 @@ var init_term = __esm(() => {
4805
5052
  });
4806
5053
 
4807
5054
  // src/operations/glossary.ts
4808
- import { existsSync as existsSync9, readFileSync } from "fs";
4809
- import { join as join8 } from "path";
5055
+ import { existsSync as existsSync10, readFileSync as readFileSync2 } from "fs";
5056
+ import { join as join9 } from "path";
4810
5057
  function parseGlossary(md) {
4811
5058
  const terms = [];
4812
5059
  let section = null;
@@ -4858,15 +5105,15 @@ function generateGlossary(terms) {
4858
5105
  }
4859
5106
  function readStoreTerms(platformDir) {
4860
5107
  const { abs: specPath, rel: relFile } = specPaths(platformDir, TERM_KEY);
4861
- if (!existsSync9(specPath))
5108
+ if (!existsSync10(specPath))
4862
5109
  return [];
4863
- const parsed = parseDomainText(readFileSync(specPath, "utf8"), relFile);
5110
+ const parsed = parseDomainText(readFileSync2(specPath, "utf8"), relFile);
4864
5111
  if (!parsed.ok)
4865
5112
  return [];
4866
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 }));
4867
5114
  }
4868
5115
  function glossaryPath(platformDir) {
4869
- return join8(platformDir, "GLOSSARY.md");
5116
+ return join9(platformDir, "GLOSSARY.md");
4870
5117
  }
4871
5118
  async function migrateGlossary(platformDir) {
4872
5119
  const store = await termStore(platformDir);
@@ -4877,10 +5124,10 @@ async function migrateGlossary(platformDir) {
4877
5124
  return { ok: true, skipped: true, existing: domain2.requirements.length };
4878
5125
  }
4879
5126
  const gPath = glossaryPath(platformDir);
4880
- if (!existsSync9(gPath)) {
5127
+ if (!existsSync10(gPath)) {
4881
5128
  return fail("not_found", `no GLOSSARY.md to migrate (expected ${gPath})`);
4882
5129
  }
4883
- const parsed = parseGlossary(readFileSync(gPath, "utf8"));
5130
+ const parsed = parseGlossary(readFileSync2(gPath, "utf8"));
4884
5131
  domain2.requirements = parsed.map((t, i) => ({
4885
5132
  id: `${TERM_KEY}-${String(i + 1).padStart(3, "0")}`,
4886
5133
  status: "active",
@@ -4917,10 +5164,10 @@ function glossaryDriftDiagnostic(platformDir) {
4917
5164
  if (terms.length === 0)
4918
5165
  return null;
4919
5166
  const gPath = glossaryPath(platformDir);
4920
- if (!existsSync9(gPath))
5167
+ if (!existsSync10(gPath))
4921
5168
  return null;
4922
5169
  const generated = generateGlossary(terms);
4923
- const committed = readFileSync(gPath, "utf8");
5170
+ const committed = readFileSync2(gPath, "utf8");
4924
5171
  if (generated === committed)
4925
5172
  return null;
4926
5173
  return {
@@ -4936,7 +5183,7 @@ function glossaryDriftDiagnostic(platformDir) {
4936
5183
  function checkGlossary(platformDir) {
4937
5184
  const generated = generateGlossary(readStoreTerms(platformDir));
4938
5185
  const gPath = glossaryPath(platformDir);
4939
- const committed = existsSync9(gPath) ? readFileSync(gPath, "utf8") : "";
5186
+ const committed = existsSync10(gPath) ? readFileSync2(gPath, "utf8") : "";
4940
5187
  return { ok: true, clean: generated === committed };
4941
5188
  }
4942
5189
  var GLOSSARY_INTRO = `Canonical names for Spec Engine concepts. When prose, code comments, diagnostics, or
@@ -4949,38 +5196,232 @@ var init_glossary = __esm(() => {
4949
5196
  init_term();
4950
5197
  });
4951
5198
 
4952
- // src/operations/check.ts
4953
- import { existsSync as existsSync10, readFileSync as readFileSync2 } from "fs";
4954
- import { join as join9 } from "path";
4955
- function readCodeownersText(platformDir) {
4956
- const path = join9(platformDir, ".github", "CODEOWNERS");
4957
- if (!existsSync10(path))
4958
- return null;
4959
- return readFileSync2(path, "utf-8");
5199
+ // src/guard/collect.ts
5200
+ import { join as join10 } from "path";
5201
+ function isIgnored(path) {
5202
+ return isPathIgnored(path);
4960
5203
  }
4961
- function domainReqsFromText(text, platformRel) {
4962
- const validated = parseDomainText(text, platformRel);
4963
- if (!validated.ok)
4964
- return { reqs: [], diagnostics: validated.diagnostics };
4965
- return { reqs: validated.data.requirements, diagnostics: [] };
5204
+ function isSpecFile(path) {
5205
+ return path.startsWith(`${CANONICAL_SPECS_DIR}/`) && path.endsWith("/SPEC.json") && !isIgnored(path);
4966
5206
  }
4967
- async function collectChangeReqs(canonicalDir) {
4968
- const reqs = [];
4969
- const relPathById = new Map;
4970
- for (const rel of await findDomainJsonFiles(canonicalDir)) {
4971
- const platformRel = `${CANONICAL_SPECS_DIR}/${rel}`;
4972
- const text = await Bun.file(join9(canonicalDir, rel)).text();
4973
- const parsed = domainReqsFromText(text, platformRel);
4974
- for (const r of parsed.reqs) {
4975
- reqs.push(r);
4976
- relPathById.set(r.id, platformRel);
4977
- }
4978
- }
4979
- return { reqs, relPathById };
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 : [];
4980
5213
  }
4981
- function collectBaseReqs(platformDir, ref, diagnostics2) {
5214
+ function collectBaseReqs(platformDir, ref, specPaths2) {
4982
5215
  const reqs = [];
4983
- const relPathById = new Map;
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 };
5388
+ }
5389
+ var init_guard = __esm(() => {
5390
+ init_collect();
5391
+ });
5392
+
5393
+ // src/operations/check.ts
5394
+ import { existsSync as existsSync11, readFileSync as readFileSync3 } from "fs";
5395
+ import { join as join11 } from "path";
5396
+ function readCodeownersText(platformDir) {
5397
+ const path = join11(platformDir, ".github", "CODEOWNERS");
5398
+ if (!existsSync11(path))
5399
+ return null;
5400
+ return readFileSync3(path, "utf-8");
5401
+ }
5402
+ function domainReqsFromText(text, platformRel) {
5403
+ const validated = parseDomainText(text, platformRel);
5404
+ if (!validated.ok)
5405
+ return { reqs: [], diagnostics: validated.diagnostics };
5406
+ return { reqs: validated.data.requirements, diagnostics: [] };
5407
+ }
5408
+ async function collectChangeReqs(canonicalDir) {
5409
+ const reqs = [];
5410
+ const relPathById = new Map;
5411
+ for (const rel of await findDomainJsonFiles(canonicalDir)) {
5412
+ const platformRel = `${CANONICAL_SPECS_DIR}/${rel}`;
5413
+ const text = await Bun.file(join11(canonicalDir, rel)).text();
5414
+ const parsed = domainReqsFromText(text, platformRel);
5415
+ for (const r of parsed.reqs) {
5416
+ reqs.push(r);
5417
+ relPathById.set(r.id, platformRel);
5418
+ }
5419
+ }
5420
+ return { reqs, relPathById };
5421
+ }
5422
+ function collectBaseReqs2(platformDir, ref, diagnostics2) {
5423
+ const reqs = [];
5424
+ const relPathById = new Map;
4984
5425
  for (const path of gitLsTree(platformDir, ref, CANONICAL_SPECS_DIR)) {
4985
5426
  if (!path.endsWith(`/${SPEC_FILENAME}`))
4986
5427
  continue;
@@ -5010,8 +5451,8 @@ async function ingestResults(storage2, resultsPath, diagnostics2) {
5010
5451
  }
5011
5452
  async function governanceGate(input, ref, diagnostics2, results) {
5012
5453
  const { platformDir } = input;
5013
- const change = await collectChangeReqs(join9(platformDir, CANONICAL_SPECS_DIR));
5014
- const base = collectBaseReqs(platformDir, ref, diagnostics2);
5454
+ const change = await collectChangeReqs(join11(platformDir, CANONICAL_SPECS_DIR));
5455
+ const base = collectBaseReqs2(platformDir, ref, diagnostics2);
5015
5456
  diagnostics2.push(...requirementRemoved(base.reqs, change.reqs, (id) => base.relPathById.get(id) ?? null));
5016
5457
  const codeowners = parseCodeowners(readCodeownersText(platformDir) ?? "");
5017
5458
  const approvedBy = (input.approvedBy ?? "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -5020,12 +5461,33 @@ async function governanceGate(input, ref, diagnostics2, results) {
5020
5461
  diagnostics2.push(...partialPropagation(changedRules(base.reqs, change.reqs), results.verifyingTags, results.resultsParsed, (id) => change.relPathById.get(id) ?? null));
5021
5462
  }
5022
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
+ }
5023
5482
  async function check(input, storage2) {
5024
5483
  const { platformDir } = input;
5025
5484
  if (input.base && !gitRefResolves(platformDir, input.base)) {
5026
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");
5027
5486
  }
5028
5487
  const result = await runIndex({ platformDir, storage: storage2 });
5488
+ const checkConfig2 = readCheckConfig(platformDir);
5489
+ if (!checkConfig2.ok)
5490
+ return fail("usage", checkConfig2.detail);
5029
5491
  const diagnostics2 = collectDiagnostics(storage2);
5030
5492
  if (input.unsourcedChange) {
5031
5493
  const superseded = storage2.listRequirements({ status: "Superseded" });
@@ -5040,20 +5502,24 @@ async function check(input, storage2) {
5040
5502
  const results = await ingestResults(storage2, input.resultsPath, diagnostics2);
5041
5503
  if (input.base) {
5042
5504
  await governanceGate(input, input.base, diagnostics2, results);
5505
+ diagnostics2.push(...await lossDiagnostics(storage2, platformDir, input.base));
5043
5506
  } else if (gitRefResolves(platformDir, "HEAD")) {
5044
- const change = await collectChangeReqs(join9(platformDir, CANONICAL_SPECS_DIR));
5507
+ const change = await collectChangeReqs(join11(platformDir, CANONICAL_SPECS_DIR));
5045
5508
  const baseParseSink = [];
5046
- const base = collectBaseReqs(platformDir, "HEAD", baseParseSink);
5509
+ const base = collectBaseReqs2(platformDir, "HEAD", baseParseSink);
5047
5510
  diagnostics2.push(...requirementRemoved(base.reqs, change.reqs, (id) => base.relPathById.get(id) ?? null));
5511
+ diagnostics2.push(...await lossDiagnostics(storage2, platformDir, "HEAD"));
5048
5512
  }
5513
+ const graded = applySeverity(diagnostics2, checkConfig2.config.severity);
5049
5514
  return {
5050
5515
  ok: true,
5051
- diagnostics: diagnostics2,
5516
+ diagnostics: graded,
5052
5517
  buildId: result.build_id,
5053
- failing: diagnostics2.some((d) => d.severity === "error"),
5518
+ failing: graded.some((d) => d.severity === "error"),
5054
5519
  proofsUnconfirmed: !input.resultsPath
5055
5520
  };
5056
5521
  }
5522
+ var LOSS_SEVERITY;
5057
5523
  var init_check = __esm(() => {
5058
5524
  init_src();
5059
5525
  init_filerefs2();
@@ -5061,6 +5527,7 @@ var init_check = __esm(() => {
5061
5527
  init_propagation_teeth();
5062
5528
  init_proven();
5063
5529
  init_removed();
5530
+ init_severity();
5064
5531
  init_sqlDiagnostics();
5065
5532
  init_statusflip();
5066
5533
  init_supersedes();
@@ -5071,6 +5538,12 @@ var init_check = __esm(() => {
5071
5538
  init_fs();
5072
5539
  init__result();
5073
5540
  init_glossary();
5541
+ init_guard();
5542
+ LOSS_SEVERITY = {
5543
+ IMPL_LOST: "error",
5544
+ SPEC_FILE_DELETED: "error",
5545
+ VERIFY_LOST: "warning"
5546
+ };
5074
5547
  });
5075
5548
 
5076
5549
  // src/commands/check.ts
@@ -5120,17 +5593,17 @@ var init_check2 = __esm(() => {
5120
5593
  out: outArg,
5121
5594
  ci: {
5122
5595
  type: "boolean",
5123
- description: "Force cold rebuild: rm db+wal+shm BEFORE indexing (CHCK-01)"
5596
+ description: "Force cold rebuild: rm db+wal+shm BEFORE indexing"
5124
5597
  },
5125
5598
  json: jsonArg,
5126
5599
  noPrompt: noPromptArg,
5127
5600
  unsourcedChange: {
5128
5601
  type: "boolean",
5129
- 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)"
5130
5603
  },
5131
5604
  results: {
5132
5605
  type: "string",
5133
- 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"
5134
5607
  },
5135
5608
  base: {
5136
5609
  type: "string",
@@ -5181,6 +5654,7 @@ var init_check2 = __esm(() => {
5181
5654
  return result;
5182
5655
  });
5183
5656
  } catch (e) {
5657
+ handleInvalidDomainFiles(e, jsonMode);
5184
5658
  exitAfterCrash(e);
5185
5659
  }
5186
5660
  if (outcome === undefined) {
@@ -5204,8 +5678,8 @@ var init_check2 = __esm(() => {
5204
5678
  });
5205
5679
 
5206
5680
  // src/operations/domain.ts
5207
- import { existsSync as existsSync11, mkdirSync as mkdirSync4 } from "fs";
5208
- 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";
5209
5683
  function definedFields(options) {
5210
5684
  const out = {};
5211
5685
  if (options.scope !== undefined)
@@ -5225,9 +5699,9 @@ async function newDomain(platformDir, key, options = {}) {
5225
5699
  if (!(resolvedDest === resolvedRoot || resolvedDest.startsWith(`${resolvedRoot}/`))) {
5226
5700
  return fail("usage", `refusing to write outside platformDir (${resolvedDest})`);
5227
5701
  }
5228
- if (existsSync11(dest))
5702
+ if (existsSync12(dest))
5229
5703
  return fail("conflict", `refusing to overwrite ${dest}`);
5230
- if (existsSync11(join10(dirname6(dest), "SPEC.md"))) {
5704
+ if (existsSync12(join12(dirname6(dest), "SPEC.md"))) {
5231
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)");
5232
5706
  }
5233
5707
  mkdirSync4(dirname6(dest), { recursive: true });
@@ -5525,7 +5999,7 @@ var init_map = __esm(() => {
5525
5999
  noPrompt: args.noPrompt
5526
6000
  }
5527
6001
  });
5528
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6002
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
5529
6003
  const { rows } = coverageMatrix(storage2);
5530
6004
  if (!args.json && rows.length === 0) {
5531
6005
  console.error(formatNoRequirementsIndexed(platformDir));
@@ -5638,7 +6112,7 @@ var init_get = __esm(() => {
5638
6112
  platformDir,
5639
6113
  args: { noPrompt: args.noPrompt }
5640
6114
  });
5641
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6115
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
5642
6116
  const result = getRecord(storage2, reqId);
5643
6117
  if (result.row === null) {
5644
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)`);
@@ -5712,7 +6186,7 @@ var init_list = __esm(() => {
5712
6186
  platformDir,
5713
6187
  args: { noPrompt: args.noPrompt }
5714
6188
  });
5715
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6189
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
5716
6190
  const result = listRecords(storage2, filter);
5717
6191
  if (!args.json && result.rows.length === 0) {
5718
6192
  console.error(result.platformEmpty ? formatNoRequirementsIndexed(platformDir) : `spec list: no requirements match under ${platformDir}`);
@@ -5789,7 +6263,7 @@ var init_propagation2 = __esm(() => {
5789
6263
  platformDir,
5790
6264
  args: { noPrompt: args.noPrompt }
5791
6265
  });
5792
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6266
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
5793
6267
  const result = propagation2(storage2, reqId);
5794
6268
  if (!args.json && result.platformEmpty) {
5795
6269
  console.error(formatNoRequirementsIndexed(platformDir));
@@ -5912,7 +6386,7 @@ var init_query = __esm(() => {
5912
6386
  platformDir,
5913
6387
  args: { noPrompt: args.noPrompt }
5914
6388
  });
5915
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6389
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
5916
6390
  const result = query(storage2, text, limit);
5917
6391
  if (!result.ok) {
5918
6392
  console.error(`spec query: ${result.detail}`);
@@ -6034,7 +6508,7 @@ var init_relations = __esm(() => {
6034
6508
  noPrompt: args.noPrompt
6035
6509
  }
6036
6510
  });
6037
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
6511
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
6038
6512
  const { rows } = relations(storage2);
6039
6513
  if (!args.json && rows.length === 0) {
6040
6514
  console.error(formatNoRelations(platformDir));
@@ -6159,7 +6633,7 @@ var init_adapter = () => {};
6159
6633
 
6160
6634
  // ../tracker/src/cache.ts
6161
6635
  import { mkdirSync as mkdirSync5 } from "fs";
6162
- import { dirname as dirname7, join as join11 } from "path";
6636
+ import { dirname as dirname7, join as join13 } from "path";
6163
6637
  function isFresh(entry, now, ttlMs = CACHE_TTL_MS) {
6164
6638
  const t = Date.parse(entry.fetched_at);
6165
6639
  if (Number.isNaN(t))
@@ -6173,7 +6647,7 @@ function isValidEntry(e) {
6173
6647
  return typeof r.title === "string" && typeof r.status === "string" && typeof r.url === "string" && typeof r.fetched_at === "string";
6174
6648
  }
6175
6649
  function sidecarPath(platformDir) {
6176
- return join11(platformDir, ".spec-engine", SIDECAR_FILE);
6650
+ return join13(platformDir, ".spec-engine", SIDECAR_FILE);
6177
6651
  }
6178
6652
  async function readCache(platformDir) {
6179
6653
  try {
@@ -6440,7 +6914,7 @@ var init_provenance = __esm(() => {
6440
6914
  noPrompt: args.noPrompt
6441
6915
  }
6442
6916
  });
6443
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, async (storage2) => {
6917
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, async (storage2) => {
6444
6918
  const { rows } = provenance(storage2, issueId || undefined);
6445
6919
  if (!args.json && rows.length === 0) {
6446
6920
  console.error(issueId ? formatNoProvenanceForIssue(issueId, platformDir) : formatNoProvenance(platformDir));
@@ -6504,6 +6978,7 @@ async function mintFromFlags(platformDir, key, textFlag, args, rawArgs) {
6504
6978
  statement,
6505
6979
  why: (args.why ?? "").trim(),
6506
6980
  livesIn: listFlag(rawArgs, "lives", args.lives) ?? [],
6981
+ relates: listFlag(rawArgs, "relates", args.relates) ?? [],
6507
6982
  issue: (args.issue ?? "").trim() || undefined,
6508
6983
  status: statusOf(args),
6509
6984
  json: Boolean(args.json)
@@ -6582,6 +7057,7 @@ var init_req = __esm(() => {
6582
7057
  },
6583
7058
  why: { type: "string", description: "Why it matters field (only with --text)" },
6584
7059
  lives: livesArg,
7060
+ relates: relatesArg,
6585
7061
  issue: {
6586
7062
  type: "string",
6587
7063
  description: "Originating ticket id, recorded as created-provenance on the entry (only with --text; opaque \u2014 never a requirement id)"
@@ -6614,9 +7090,9 @@ var init_req = __esm(() => {
6614
7090
  await mintFromFlags(platformDir, key, textFlag, args, rawArgs);
6615
7091
  return;
6616
7092
  }
6617
- 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";
6618
7094
  if (hasFieldFlags) {
6619
- 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)");
6620
7096
  process.exit(EXIT.USAGE);
6621
7097
  return;
6622
7098
  }
@@ -6752,6 +7228,9 @@ async function confirmTermCommand(opts) {
6752
7228
  exitOnFailure("spec term confirm", result);
6753
7229
  await withIndex({ platformDir, build: "fresh" }, () => {
6754
7230
  return;
7231
+ }).catch((e) => {
7232
+ handleInvalidDomainFiles(e, false, EXIT.USAGE);
7233
+ throw e;
6755
7234
  });
6756
7235
  const { pinned, file } = result;
6757
7236
  if (opts.json) {
@@ -7024,14 +7503,18 @@ async function supersedeTarget(platformDir, id) {
7024
7503
  return located;
7025
7504
  }
7026
7505
  function successorFields(input, req) {
7027
- const predLives = (req.livesIn[0] ?? "").trim();
7028
- const why = (input.why ?? req.why ?? "").trim();
7029
- const livesIn = input.livesIn ?? (predLives === "" ? [] : [predLives]);
7030
- 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
+ };
7031
7511
  }
7032
- function successorTermFields(input, req) {
7033
- const term = (input.term ?? req.term)?.trim() || req.term;
7034
- 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;
7035
7518
  }
7036
7519
  function flipPredecessor(req, newId, issue) {
7037
7520
  req.status = "superseded";
@@ -7067,18 +7550,19 @@ async function supersede(input, freshTags) {
7067
7550
  }
7068
7551
  warnings.push({ kind: "grammar", text: grammarWarningText("", verdict).slice(2) });
7069
7552
  }
7070
- const { why, livesIn } = successorFields(input, req);
7553
+ const { why, livesIn, relates } = successorFields(input, req);
7071
7554
  warnings.push(...unresolvableRefWarnings(platformDir, [input.statement, why, ...livesIn]));
7072
7555
  const newId = await nextRequirementId(platformDir, key);
7073
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);
7074
7565
  flipPredecessor(req, newId, issue);
7075
- const successor = successorEntry(newId, { statement: input.statement, why, livesIn, issue });
7076
- if (key === "TERM") {
7077
- const termFields = successorTermFields(input, req);
7078
- if (termFields.term !== undefined)
7079
- successor.term = termFields.term;
7080
- successor.aliases = termFields.aliases;
7081
- }
7082
7566
  requirements.push(successor);
7083
7567
  const specVersion = applyVersionStage(domain2, req, successor, Boolean(input.noBump));
7084
7568
  domain2.updated = localToday();
@@ -7187,6 +7671,9 @@ function inputFromArgs(args, rawArgs, platformDir, id, statement) {
7187
7671
  const livesIn = listFlag(rawArgs, "lives", args.lives);
7188
7672
  if (livesIn !== undefined)
7189
7673
  input.livesIn = livesIn;
7674
+ const relates = listFlag(rawArgs, "relates", args.relates);
7675
+ if (relates !== undefined)
7676
+ input.relates = relates;
7190
7677
  if (typeof args.term === "string")
7191
7678
  input.term = args.term;
7192
7679
  if (typeof args.aliases === "string")
@@ -7200,7 +7687,6 @@ var supersedeArgs, supersedeCommand;
7200
7687
  var init_supersede2 = __esm(() => {
7201
7688
  init_constants();
7202
7689
  init_discover();
7203
- init__index();
7204
7690
  init_supersede();
7205
7691
  init_grammar3();
7206
7692
  init__args();
@@ -7225,6 +7711,10 @@ var init_supersede2 = __esm(() => {
7225
7711
  type: "string",
7226
7712
  description: "Successor's Lives in: repeat or comma-separate; empty clears (default: copied from the old entry)"
7227
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
+ },
7228
7718
  term: {
7229
7719
  type: "string",
7230
7720
  description: "TERM successor's headword (default: copied from the old entry; TERM ids only)"
@@ -7265,7 +7755,7 @@ var init_supersede2 = __esm(() => {
7265
7755
  handleNotAPlatform(e);
7266
7756
  }
7267
7757
  const statement = await resolveSuccessorText(args, platformDir, id);
7268
- 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));
7269
7759
  if (!result.ok)
7270
7760
  exitOnFailure("spec supersede", result);
7271
7761
  printWarnings("spec supersede", result.warnings);
@@ -7332,7 +7822,6 @@ var deprecateCommand;
7332
7822
  var init_deprecate2 = __esm(() => {
7333
7823
  init_constants();
7334
7824
  init_discover();
7335
- init__index();
7336
7825
  init_deprecate();
7337
7826
  init_grammar3();
7338
7827
  init__args();
@@ -7375,7 +7864,7 @@ var init_deprecate2 = __esm(() => {
7375
7864
  } catch (e) {
7376
7865
  handleNotAPlatform(e);
7377
7866
  }
7378
- const result = await deprecate({ platformDir, id, reason }, coldFreshTags(platformDir));
7867
+ const result = await deprecate({ platformDir, id, reason }, cliFreshTags(platformDir));
7379
7868
  if (!result.ok)
7380
7869
  exitOnFailure("spec deprecate", result);
7381
7870
  const { file, sites } = result;
@@ -7394,30 +7883,28 @@ var init_deprecate2 = __esm(() => {
7394
7883
 
7395
7884
  // src/operations/move.ts
7396
7885
  async function locateMove(platformDir, id, targetKey) {
7397
- const sourceKey = domainKeyOf(id);
7398
- 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) {
7399
7892
  return fail("usage", `${id} is already in ${targetKey} \u2014 use spec supersede for an in-domain revision`);
7400
7893
  }
7401
- const source = await readEnvelope(platformDir, sourceKey, `no domain ${sourceKey} (expected spec-engine/${sourceKey}/SPEC.json)`);
7402
- if (!source.ok)
7403
- return source;
7404
7894
  const target = await readEnvelope(platformDir, targetKey, `no target domain ${targetKey} \u2014 run \`spec domain new ${targetKey}\` first`);
7405
7895
  if (!target.ok)
7406
7896
  return target;
7407
- const req = source.requirements.find((r) => r.id === id);
7408
- if (req === undefined)
7409
- return fail("not_found", `no entry ${id} in ${source.relFile}`);
7410
7897
  if (req.status.toLowerCase() !== "active") {
7411
7898
  return fail("conflict", `${id} is ${req.status} \u2014 only Active requirements move (superseded/retired entries stay as history)`);
7412
7899
  }
7413
7900
  return { ok: true, source, target, req };
7414
7901
  }
7415
7902
  function successorFields2(input, req) {
7416
- const srcLives = (req.livesIn[0] ?? "").trim();
7417
7903
  return {
7418
7904
  statement: (input.statement ?? req.statement).trim(),
7419
7905
  why: (input.why ?? req.why ?? "").trim(),
7420
- livesIn: input.livesIn ?? (srcLives === "" ? [] : [srcLives])
7906
+ livesIn: input.livesIn ?? [...req.livesIn],
7907
+ relates: [...req.relates]
7421
7908
  };
7422
7909
  }
7423
7910
  function versionSide(domain2, key, noBump) {
@@ -7466,7 +7953,7 @@ async function move(input, freshTags) {
7466
7953
  const sourceCurrent = source.domain.specVersion ?? 1;
7467
7954
  req.status = "superseded";
7468
7955
  req.supersededBy = newId;
7469
- target.requirements.push(successorEntry(newId, fields));
7956
+ target.requirements.push(successorEntry(newId, req, fields));
7470
7957
  const noBump = Boolean(input.noBump);
7471
7958
  const sourceSpecVersion = versionSide(source.domain, source.key, noBump);
7472
7959
  const targetSpecVersion = versionSide(target.domain, target.key, noBump);
@@ -7527,7 +8014,6 @@ var init_move2 = __esm(() => {
7527
8014
  init_domains();
7528
8015
  init_constants();
7529
8016
  init_discover();
7530
- init__index();
7531
8017
  init_move();
7532
8018
  init_grammar3();
7533
8019
  init__args();
@@ -7589,7 +8075,7 @@ var init_move2 = __esm(() => {
7589
8075
  } catch (e) {
7590
8076
  handleNotAPlatform(e);
7591
8077
  }
7592
- 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));
7593
8079
  if (!result.ok)
7594
8080
  exitOnFailure("spec move", result);
7595
8081
  printWarnings("spec move", result.warnings);
@@ -7623,18 +8109,30 @@ var init_move2 = __esm(() => {
7623
8109
  });
7624
8110
 
7625
8111
  // src/operations/amend.ts
7626
- 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;
7627
8124
  const statusLc = req.status.toLowerCase();
7628
8125
  if (statusLc !== "active" && statusLc !== "draft") {
7629
8126
  return fail("conflict", `${id} is ${displayStatus(req.status)} \u2014 only Active/Draft entries amend (a superseded entry is history; supersede its successor instead)`);
7630
8127
  }
7631
8128
  if (statusLc !== "active")
7632
8129
  return null;
7633
- const bound = (await freshTags(id)).filter((t) => t.kind === "implements" || t.kind === "verifies");
7634
- const site = bound[0];
7635
- if (site === undefined)
8130
+ if (!PROMISE_FIELDS.some((f) => fields[f] !== undefined))
7636
8131
  return null;
7637
- 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.");
7638
8136
  }
7639
8137
  function applyFields(req, fields) {
7640
8138
  const fieldsChanged = [];
@@ -7654,6 +8152,10 @@ function applyFields(req, fields) {
7654
8152
  fieldsChanged.push("lives");
7655
8153
  refValues.push(...fields.livesIn);
7656
8154
  }
8155
+ if (fields.relates !== undefined) {
8156
+ req.relates = splitList(fields.relates);
8157
+ fieldsChanged.push("relates");
8158
+ }
7657
8159
  if (fields.term !== undefined) {
7658
8160
  req.term = fields.term;
7659
8161
  fieldsChanged.push("term");
@@ -7669,18 +8171,17 @@ function applyFields(req, fields) {
7669
8171
  }
7670
8172
  return { fieldsChanged, refValues };
7671
8173
  }
7672
- async function amend(input, freshTags) {
8174
+ async function amend(input) {
7673
8175
  const { platformDir, id, fields } = input;
7674
8176
  const located = await locateEntry(platformDir, id);
7675
8177
  if (!located.ok)
7676
8178
  return located;
7677
- const { specPath, relFile, domain: domain2, req } = located;
7678
- const rejection = await gateRejection(id, req, freshTags);
8179
+ const { key, specPath, relFile, domain: domain2, req } = located;
8180
+ const rejection = gateRejection(platformDir, relFile, req, fields);
7679
8181
  if (rejection !== null)
7680
8182
  return rejection;
7681
8183
  const warnings = [];
7682
8184
  if (fields.statement !== undefined) {
7683
- const key = domainKeyOf(id);
7684
8185
  const verdict = await judgeStatementGrammar(platformDir, key, fields.statement);
7685
8186
  if (!verdict.ok) {
7686
8187
  if (verdict.severity === "error") {
@@ -7702,12 +8203,14 @@ async function amend(input, freshTags) {
7702
8203
  }
7703
8204
  return { ok: true, id, file: relFile, fieldsChanged: [...fieldsChanged].sort(), warnings };
7704
8205
  }
8206
+ var PROMISE_FIELDS;
7705
8207
  var init_amend = __esm(() => {
7706
8208
  init_src();
7707
8209
  init_grammar2();
7708
8210
  init__envelope();
7709
8211
  init__result();
7710
8212
  init_mint();
8213
+ PROMISE_FIELDS = ["statement", "why", "term", "aliases"];
7711
8214
  });
7712
8215
 
7713
8216
  // src/commands/amend.ts
@@ -7718,9 +8221,9 @@ __export(exports_amend, {
7718
8221
  import { defineCommand as defineCommand18 } from "citty";
7719
8222
  function assertAmendFlags(args) {
7720
8223
  const hasIssue = args.issue !== undefined && args.issue.trim() !== "";
7721
- 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;
7722
8225
  if (!hasField && !hasIssue) {
7723
- 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");
7724
8227
  process.exit(EXIT.USAGE);
7725
8228
  }
7726
8229
  if (args.text !== undefined && args.text.trim() === "") {
@@ -7747,6 +8250,9 @@ function fieldsFromArgs(args, rawArgs) {
7747
8250
  const livesIn = listFlag(rawArgs, "lives", args.lives);
7748
8251
  if (livesIn !== undefined)
7749
8252
  fields.livesIn = livesIn;
8253
+ const relates = listFlag(rawArgs, "relates", args.relates);
8254
+ if (relates !== undefined)
8255
+ fields.relates = relates;
7750
8256
  if (args.term !== undefined)
7751
8257
  fields.term = args.term.trim();
7752
8258
  if (args.issue !== undefined && args.issue.trim() !== "")
@@ -7759,7 +8265,6 @@ var amendArgs, amendCommand;
7759
8265
  var init_amend2 = __esm(() => {
7760
8266
  init_constants();
7761
8267
  init_discover();
7762
- init__index();
7763
8268
  init_amend();
7764
8269
  init_grammar3();
7765
8270
  init__args();
@@ -7774,6 +8279,7 @@ var init_amend2 = __esm(() => {
7774
8279
  text: { type: "string", description: "New Requirement (statement) field value" },
7775
8280
  why: { type: "string", description: "New Why it matters field value" },
7776
8281
  lives: livesArg,
8282
+ relates: relatesArg,
7777
8283
  issue: {
7778
8284
  type: "string",
7779
8285
  description: "Ticket that motivated this amendment \u2014 recorded as amends-via provenance (opaque, never a requirement id)"
@@ -7788,7 +8294,7 @@ var init_amend2 = __esm(() => {
7788
8294
  amendCommand = defineCommand18({
7789
8295
  meta: {
7790
8296
  name: "amend",
7791
- 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."
7792
8298
  },
7793
8299
  args: amendArgs,
7794
8300
  async run({ args, rawArgs }) {
@@ -7805,7 +8311,7 @@ var init_amend2 = __esm(() => {
7805
8311
  handleNotAPlatform(e);
7806
8312
  }
7807
8313
  const fields = fieldsFromArgs(args, rawArgs);
7808
- const result = await amend({ platformDir, id, fields }, coldFreshTags(platformDir));
8314
+ const result = await amend({ platformDir, id, fields });
7809
8315
  if (!result.ok)
7810
8316
  exitOnFailure("spec amend", result);
7811
8317
  printWarnings("spec amend", result.warnings);
@@ -7927,7 +8433,7 @@ Target domain: ${opts.domain} (no charter/scope recorded \u2014 check placement
7927
8433
 
7928
8434
  // src/server/mcp.ts
7929
8435
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7930
- import { z as z3 } from "zod";
8436
+ import { z as z4 } from "zod";
7931
8437
  function jsonResult(value) {
7932
8438
  return { content: [{ type: "text", text: JSON.stringify(value) }] };
7933
8439
  }
@@ -7935,14 +8441,14 @@ function errorResult(message) {
7935
8441
  return { content: [{ type: "text", text: message }], isError: true };
7936
8442
  }
7937
8443
  function buildMcpServer(platformDir) {
7938
- const server = new McpServer({ name: "spec", version: "0.0.6" });
8444
+ const server = new McpServer({ name: "spec", version: package_default.version });
7939
8445
  const fresh = (fn) => withIndex({ platformDir, build: "fresh" }, (h) => fn(h.storage));
7940
8446
  server.registerTool("spec_query", {
7941
8447
  title: "Full-text requirement retrieval",
7942
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.",
7943
8449
  inputSchema: {
7944
- text: z3.string().min(1).describe("FTS5 query text"),
7945
- 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})`)
7946
8452
  }
7947
8453
  }, async ({ text, limit }) => fresh((s) => {
7948
8454
  const r = query(s, text, limit ?? DEFAULT_QUERY_LIMIT);
@@ -7952,13 +8458,13 @@ function buildMcpServer(platformDir) {
7952
8458
  title: "Files \u2192 requirements",
7953
8459
  description: "The requirements tagged in the given files (platform-relative paths, e.g. api/src/renew.ts). Load these before changing the files.",
7954
8460
  inputSchema: {
7955
- 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")
7956
8462
  }
7957
8463
  }, async ({ files }) => fresh((s) => jsonResult(resolveFiles(s, files).rows)));
7958
8464
  server.registerTool("spec_req_tags", {
7959
8465
  title: "Requirement \u2192 tag sites (reverse query)",
7960
8466
  description: "Every code/test/doc site tagging the given requirement, across all repos \u2014 impact analysis and retag worklists.",
7961
- inputSchema: { req_id: z3.string().describe("Requirement id (KEY-NNN)") }
8467
+ inputSchema: { req_id: z4.string().describe("Requirement id (KEY-NNN)") }
7962
8468
  }, async ({ req_id }) => {
7963
8469
  if (!ID_RE.test(req_id)) {
7964
8470
  return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
@@ -7981,7 +8487,7 @@ function buildMcpServer(platformDir) {
7981
8487
  server.registerTool("spec_propagation", {
7982
8488
  title: "Cross-repo propagation for a superseded requirement",
7983
8489
  description: "Per member repo: migrated to the successor (verified or not), still on the predecessor, or unrelated \u2014 plus the drift flag.",
7984
- inputSchema: { req_id: z3.string().describe("Requirement id (KEY-NNN)") }
8490
+ inputSchema: { req_id: z4.string().describe("Requirement id (KEY-NNN)") }
7985
8491
  }, async ({ req_id }) => {
7986
8492
  if (!ID_RE.test(req_id)) {
7987
8493
  return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
@@ -7991,7 +8497,7 @@ function buildMcpServer(platformDir) {
7991
8497
  server.registerTool("spec_next_id", {
7992
8498
  title: "Next unused requirement id",
7993
8499
  description: "Allocate-preview the next unused id in a domain (filesystem-derived; nothing is written). Use before authoring a new requirement.",
7994
- 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)") }
7995
8501
  }, async ({ domain: domain2 }) => {
7996
8502
  const r = await nextId(platformDir, domain2);
7997
8503
  return r.ok ? jsonResult({ domain: r.key, next_id: r.nextId }) : errorResult(r.detail);
@@ -7999,7 +8505,7 @@ function buildMcpServer(platformDir) {
7999
8505
  server.registerTool("spec_get", {
8000
8506
  title: "One requirement's full record",
8001
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).",
8002
- inputSchema: { req_id: z3.string().describe("Requirement id (KEY-NNN)") }
8508
+ inputSchema: { req_id: z4.string().describe("Requirement id (KEY-NNN)") }
8003
8509
  }, async ({ req_id }) => {
8004
8510
  if (!ID_RE.test(req_id)) {
8005
8511
  return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
@@ -8013,8 +8519,8 @@ function buildMcpServer(platformDir) {
8013
8519
  title: "Every requirement record",
8014
8520
  description: "Every requirement record in (key, seq) order, superseded and deprecated history included. Optional domain and status filters. The `spec list --json` shape.",
8015
8521
  inputSchema: {
8016
- domain: z3.string().optional().describe("Only this domain key (e.g. BILLING)"),
8017
- 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(" | ")}`)
8018
8524
  }
8019
8525
  }, async ({ domain: domain2, status }) => {
8020
8526
  const filter = {};
@@ -8033,17 +8539,18 @@ function buildMcpServer(platformDir) {
8033
8539
  title: "Supersede a shipped requirement",
8034
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`.",
8035
8541
  inputSchema: {
8036
- req_id: z3.string().describe("The Active requirement id to supersede (KEY-NNN)"),
8037
- statement: z3.string().min(1).describe("The successor's statement"),
8038
- why: z3.string().optional().describe("Successor's why (default: copied from the predecessor)"),
8039
- lives_in: z3.array(z3.string()).optional().describe("Successor's livesIn paths (default: copied from the predecessor)"),
8040
- issue: z3.string().optional().describe("Ticket recorded as supersedes-via on the predecessor and created on the successor")
8041
- }
8042
- }, 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 }) => {
8043
8550
  if (!ID_RE.test(req_id)) {
8044
8551
  return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
8045
8552
  }
8046
- 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));
8047
8554
  if (!r.ok)
8048
8555
  return errorResult(r.detail);
8049
8556
  for (const w of r.warnings)
@@ -8060,8 +8567,8 @@ function buildMcpServer(platformDir) {
8060
8567
  title: "Deprecate a requirement",
8061
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`.",
8062
8569
  inputSchema: {
8063
- req_id: z3.string().describe("The requirement id to deprecate (KEY-NNN)"),
8064
- 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")
8065
8572
  }
8066
8573
  }, async ({ req_id, reason }) => {
8067
8574
  if (!ID_RE.test(req_id)) {
@@ -8077,7 +8584,7 @@ function buildMcpServer(platformDir) {
8077
8584
  title: "Accept a draft requirement",
8078
8585
  description: "Promote a Draft requirement to Active, leaving every other field untouched. The same JSON as `spec accept --json`.",
8079
8586
  inputSchema: {
8080
- 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)")
8081
8588
  }
8082
8589
  }, async ({ req_id }) => {
8083
8590
  if (!ID_RE.test(req_id)) {
@@ -8090,8 +8597,8 @@ function buildMcpServer(platformDir) {
8090
8597
  title: "Author requirements from a brief",
8091
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.",
8092
8599
  argsSchema: {
8093
- brief: z3.string().min(1).describe("The vague brief / ticket text to author from"),
8094
- 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")
8095
8602
  }
8096
8603
  }, async ({ brief, domain: domain2 }) => {
8097
8604
  let resolvedDomain;
@@ -8118,6 +8625,7 @@ function buildMcpServer(platformDir) {
8118
8625
  }
8119
8626
  var init_mcp = __esm(() => {
8120
8627
  init_src();
8628
+ init_package();
8121
8629
  init_domains();
8122
8630
  init__index();
8123
8631
  init_accept();
@@ -8159,7 +8667,7 @@ var init_mcp2 = __esm(() => {
8159
8667
  handleNotAPlatform(e);
8160
8668
  }
8161
8669
  const server = buildMcpServer(platformDir);
8162
- console.error(`spec mcp: serving ${platformDir} over stdio`);
8670
+ console.error(`spec mcp: serving ${platformName(platformDir)} over stdio`);
8163
8671
  await server.connect(new StdioServerTransport);
8164
8672
  }
8165
8673
  });
@@ -8171,7 +8679,7 @@ __export(exports_resolve, {
8171
8679
  resolveCommand: () => resolveCommand
8172
8680
  });
8173
8681
  import { statSync as statSync5 } from "fs";
8174
- 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";
8175
8683
  import { defineCommand as defineCommand21 } from "citty";
8176
8684
  function classifyArg(a) {
8177
8685
  if (a.startsWith("--")) {
@@ -8210,7 +8718,7 @@ function splitFilesAndPlatformDir(positionals, fallbackPlatformDir) {
8210
8718
  const resolved = resolve13(last);
8211
8719
  if (statSync5(resolved).isDirectory()) {
8212
8720
  try {
8213
- if (statSync5(join12(resolved, "spec-engine")).isDirectory()) {
8721
+ if (statSync5(join14(resolved, "spec-engine")).isDirectory()) {
8214
8722
  lastIsPlatformDir = true;
8215
8723
  } else {
8216
8724
  lastIsDirWithoutSpecEngine = true;
@@ -8250,7 +8758,7 @@ async function runReverseQuery(opts) {
8250
8758
  if (outArg2)
8251
8759
  assertContainedPath(dbPath, platformDir, "spec resolve: --out");
8252
8760
  await maybePromptForOnboarding({ platformDir, args: { noPrompt } });
8253
- await withReadStorage({ platformDir, dbPath, fresh }, (storage2) => {
8761
+ await withReadStorage({ platformDir, dbPath, fresh, json }, (storage2) => {
8254
8762
  const { rows, known } = reqTags(storage2, reqId);
8255
8763
  if (rows.length === 0) {
8256
8764
  if (!known) {
@@ -8339,7 +8847,7 @@ var init_resolve2 = __esm(() => {
8339
8847
  },
8340
8848
  req: {
8341
8849
  type: "string",
8342
- 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."
8343
8851
  },
8344
8852
  out: {
8345
8853
  type: "string",
@@ -8361,7 +8869,7 @@ var init_resolve2 = __esm(() => {
8361
8869
  resolveCommand = defineCommand21({
8362
8870
  meta: {
8363
8871
  name: "resolve",
8364
- 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."
8365
8873
  },
8366
8874
  args: resolveArgs,
8367
8875
  async run({ args, rawArgs }) {
@@ -8394,7 +8902,7 @@ var init_resolve2 = __esm(() => {
8394
8902
  noPrompt: args.noPrompt
8395
8903
  }
8396
8904
  });
8397
- await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh }, (storage2) => {
8905
+ await withReadStorage({ platformDir, dbPath, fresh: !!args.fresh, json: !!args.json }, (storage2) => {
8398
8906
  const rows = storage2.resolveByFiles(normalized);
8399
8907
  if (!args.json && rows.length === 0 && storage2.listRequirements().length === 0) {
8400
8908
  console.error(formatNoRequirementsIndexed(platformDir));
@@ -8521,8 +9029,8 @@ var exports_gate = {};
8521
9029
  __export(exports_gate, {
8522
9030
  gateCommand: () => gateCommand
8523
9031
  });
8524
- import { existsSync as existsSync12 } from "fs";
8525
- import { join as join13 } from "path";
9032
+ import { existsSync as existsSync13 } from "fs";
9033
+ import { join as join15 } from "path";
8526
9034
  import { defineCommand as defineCommand22 } from "citty";
8527
9035
  function isEpipe(e) {
8528
9036
  if (e === null || typeof e !== "object" || !("code" in e))
@@ -8546,7 +9054,7 @@ function resolveGateArgs(args) {
8546
9054
  process.exit(EXIT.USAGE);
8547
9055
  }
8548
9056
  const platformDir = resolvePlatformDir(args);
8549
- if (!existsSync12(join13(platformDir, "spec-engine"))) {
9057
+ if (!existsSync13(join15(platformDir, "spec-engine"))) {
8550
9058
  console.error(formatNotASpecPlatform(platformDir));
8551
9059
  process.exit(EXIT.USAGE);
8552
9060
  }
@@ -8619,6 +9127,7 @@ var init_gate2 = __esm(() => {
8619
9127
  writeStdoutTolerant(`build_id: ${gated.buildId}`);
8620
9128
  }
8621
9129
  } catch (e) {
9130
+ handleInvalidDomainFiles(e, Boolean(args.json));
8622
9131
  const msg = e instanceof Error ? e.message : String(e);
8623
9132
  console.error(`spec gate: crashed: ${msg}`);
8624
9133
  process.exit(EXIT.USAGE);
@@ -8704,205 +9213,6 @@ function renderGuard(losses, mode, ref) {
8704
9213
  }
8705
9214
  var STOP = "\uD83D\uDED1";
8706
9215
 
8707
- // src/guard/collect.ts
8708
- import { join as join14 } from "path";
8709
- function isIgnored(path) {
8710
- return isPathIgnored(path);
8711
- }
8712
- function isSpecFile(path) {
8713
- return path.startsWith(`${CANONICAL_SPECS_DIR}/`) && path.endsWith("/SPEC.json") && !isIgnored(path);
8714
- }
8715
- function isCodeFile(path) {
8716
- if (isSpecFile(path) || isIgnored(path))
8717
- return false;
8718
- const ext = path.includes(".") ? path.split(".").pop() ?? "" : "";
8719
- return CODE_EXTS.has(ext);
8720
- }
8721
- function parseReqs(text) {
8722
- const v = parseDomainText(text, "");
8723
- return v.ok ? v.data.requirements : [];
8724
- }
8725
- function collectBaseReqs2(platformDir, ref, specPaths2) {
8726
- const reqs = [];
8727
- const path = new Map;
8728
- for (const p of specPaths2) {
8729
- const bytes = gitShow(platformDir, ref, p);
8730
- if (bytes === null)
8731
- continue;
8732
- for (const r of parseReqs(bytes)) {
8733
- reqs.push(r);
8734
- path.set(r.id, p);
8735
- }
8736
- }
8737
- return { reqs, path };
8738
- }
8739
- function collectBaseTags(platformDir, ref, codePaths) {
8740
- const implSite = new Map;
8741
- const verifySite = new Map;
8742
- for (const p of codePaths) {
8743
- const bytes = gitShow(platformDir, ref, p);
8744
- if (bytes === null)
8745
- continue;
8746
- for (const tag of scanTagsInFile("", p, bytes)) {
8747
- const target = tag.kind === "verifies" ? verifySite : implSite;
8748
- if (!target.has(tag.req_id))
8749
- target.set(tag.req_id, { file: tag.file, line: tag.line });
8750
- }
8751
- }
8752
- return { implSite, verifySite };
8753
- }
8754
- async function collectWorktreeReqs(canonicalDir) {
8755
- const ids = new Set;
8756
- const activeIds = new Set;
8757
- const supersedesTargets = new Set;
8758
- for (const rel of await findDomainJsonFiles(canonicalDir)) {
8759
- const text = await Bun.file(join14(canonicalDir, rel)).text();
8760
- for (const r of parseReqs(text)) {
8761
- ids.add(r.id);
8762
- if (r.status.toLowerCase() === "active")
8763
- activeIds.add(r.id);
8764
- if (r.supersedes != null)
8765
- supersedesTargets.add(r.supersedes);
8766
- }
8767
- }
8768
- return { ids, activeIds, supersedesTargets };
8769
- }
8770
- function tallyWorktreeTags(storage2) {
8771
- const implCount = new Map;
8772
- const verifyCount = new Map;
8773
- for (const t of storage2.listTags()) {
8774
- const bucket = t.kind === "implements" ? implCount : t.kind === "verifies" ? verifyCount : null;
8775
- if (bucket !== null)
8776
- bucket.set(t.req_id, (bucket.get(t.req_id) ?? 0) + 1);
8777
- }
8778
- return { implCount, verifyCount };
8779
- }
8780
- async function collectFacts(platformDir, ref, storage2) {
8781
- const changed = gitDiffNameStatus(platformDir, ref);
8782
- const specPaths2 = changed.filter((e) => isSpecFile(e.path)).map((e) => e.path);
8783
- const codePaths = changed.filter((e) => isCodeFile(e.path)).map((e) => e.path);
8784
- const deletedSpecFiles = changed.filter((e) => e.status === "D" && isSpecFile(e.path)).map((e) => e.path);
8785
- const base = collectBaseReqs2(platformDir, ref, specPaths2);
8786
- const baseTags = collectBaseTags(platformDir, ref, codePaths);
8787
- const worktree = await collectWorktreeReqs(join14(platformDir, CANONICAL_SPECS_DIR));
8788
- const counts = tallyWorktreeTags(storage2);
8789
- return {
8790
- baseReqs: base.reqs,
8791
- baseReqPath: base.path,
8792
- baseImplSite: baseTags.implSite,
8793
- baseVerifySite: baseTags.verifySite,
8794
- worktreeReqIds: worktree.ids,
8795
- worktreeActiveIds: worktree.activeIds,
8796
- worktreeSupersedesTargets: worktree.supersedesTargets,
8797
- worktreeImplCount: counts.implCount,
8798
- worktreeVerifyCount: counts.verifyCount,
8799
- deletedSpecFiles
8800
- };
8801
- }
8802
- var CODE_EXTS;
8803
- var init_collect = __esm(() => {
8804
- init_src();
8805
- init_constants();
8806
- init_fs();
8807
- init_tags();
8808
- CODE_EXTS = new Set(DEFAULT_EXTS);
8809
- });
8810
-
8811
- // src/guard/losses.ts
8812
- function isActive(status) {
8813
- return status.toLowerCase() === "active";
8814
- }
8815
- function isSuperseded(base, f) {
8816
- const forward = base.supersededBy != null && f.worktreeReqIds.has(base.supersededBy);
8817
- return forward || f.worktreeSupersedesTargets.has(base.id);
8818
- }
8819
- function reqDeletedLoss(base, f) {
8820
- return {
8821
- kind: "REQUIREMENT_REMOVED",
8822
- req_id: base.id,
8823
- file: f.baseReqPath.get(base.id) ?? "",
8824
- line: 0,
8825
- 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`
8826
- };
8827
- }
8828
- function historyRemovedLoss(base, f) {
8829
- const display = base.status.charAt(0).toUpperCase() + base.status.slice(1).toLowerCase();
8830
- return {
8831
- kind: "REQUIREMENT_REMOVED",
8832
- req_id: base.id,
8833
- file: f.baseReqPath.get(base.id) ?? "",
8834
- line: 0,
8835
- 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`
8836
- };
8837
- }
8838
- function implLostLoss(id, site) {
8839
- return {
8840
- kind: "IMPL_LOST",
8841
- req_id: id,
8842
- file: site.file,
8843
- line: site.line,
8844
- detail: `${id} lost its last implementing @spec tag (was ${site.file}:${site.line})`
8845
- };
8846
- }
8847
- function verifyLostLoss(id, site) {
8848
- return {
8849
- kind: "VERIFY_LOST",
8850
- req_id: id,
8851
- file: site.file,
8852
- line: site.line,
8853
- detail: `${id} lost its last verifying @spec tag (was ${site.file}:${site.line})`
8854
- };
8855
- }
8856
- function specFileDeletedLoss(path) {
8857
- return {
8858
- kind: "SPEC_FILE_DELETED",
8859
- req_id: null,
8860
- file: path,
8861
- line: 0,
8862
- detail: `canonical spec file ${path} present at the ref is absent from the working tree`
8863
- };
8864
- }
8865
- function reqDeletedFor(base, f) {
8866
- if (f.worktreeReqIds.has(base.id))
8867
- return null;
8868
- if (!isActive(base.status))
8869
- return historyRemovedLoss(base, f);
8870
- if (isSuperseded(base, f))
8871
- return null;
8872
- return reqDeletedLoss(base, f);
8873
- }
8874
- function lastTagGone(id, count, f) {
8875
- return f.worktreeActiveIds.has(id) && count === 0;
8876
- }
8877
- function classifyLosses(f) {
8878
- const out = [];
8879
- for (const base of f.baseReqs) {
8880
- const loss = reqDeletedFor(base, f);
8881
- if (loss !== null)
8882
- out.push(loss);
8883
- }
8884
- for (const [id, site] of f.baseImplSite) {
8885
- if (lastTagGone(id, f.worktreeImplCount.get(id) ?? 0, f))
8886
- out.push(implLostLoss(id, site));
8887
- }
8888
- for (const [id, site] of f.baseVerifySite) {
8889
- if (lastTagGone(id, f.worktreeVerifyCount.get(id) ?? 0, f))
8890
- out.push(verifyLostLoss(id, site));
8891
- }
8892
- for (const path of f.deletedSpecFiles)
8893
- out.push(specFileDeletedLoss(path));
8894
- return out;
8895
- }
8896
-
8897
- // src/operations/guard.ts
8898
- async function guard(storage2, platformDir, ref) {
8899
- const losses = classifyLosses(await collectFacts(platformDir, ref, storage2));
8900
- return { ok: true, ref, losses };
8901
- }
8902
- var init_guard = __esm(() => {
8903
- init_collect();
8904
- });
8905
-
8906
9216
  // src/commands/guard.ts
8907
9217
  var exports_guard = {};
8908
9218
  __export(exports_guard, {
@@ -8941,7 +9251,7 @@ var init_guard2 = __esm(() => {
8941
9251
  process.exit(EXIT.OK);
8942
9252
  }
8943
9253
  let losses = [];
8944
- await withReadStorage({ platformDir, dbPath: defaultIndexPath(platformDir), fresh: true }, async (storage2) => {
9254
+ await withReadStorage({ platformDir, dbPath: defaultIndexPath(platformDir), fresh: true, json: jsonMode }, async (storage2) => {
8945
9255
  losses = (await guard(storage2, platformDir, ref)).losses;
8946
9256
  });
8947
9257
  console.log(renderGuard(losses, jsonMode ? "json" : "text", ref));
@@ -9248,6 +9558,18 @@ code {
9248
9558
  display: block;
9249
9559
  }
9250
9560
 
9561
+ .sidebar-platform {
9562
+ margin-left: auto;
9563
+ min-width: 0;
9564
+ overflow: hidden;
9565
+ text-overflow: ellipsis;
9566
+ white-space: nowrap;
9567
+ font-family: var(--font-mono);
9568
+ font-weight: 500;
9569
+ font-size: 0.75rem;
9570
+ color: var(--fg-dim);
9571
+ }
9572
+
9251
9573
  .sidebar-scroll {
9252
9574
  flex: 1;
9253
9575
  overflow-y: auto;
@@ -10854,9 +11176,9 @@ var init_coverage = __esm(() => {
10854
11176
  // ../webapp/src/pages/editor.ts
10855
11177
  import { bodyLimit } from "hono/body-limit";
10856
11178
  import { html as html5, raw as raw4 } from "hono/html";
10857
- function crossOriginRejected(c) {
11179
+ function crossOriginRejected(c, bindHost) {
10858
11180
  const selfUrl = new URL(c.req.url);
10859
- if (!isLoopbackHostname(selfUrl.hostname))
11181
+ if (!isServedHostname(selfUrl.hostname, bindHost))
10860
11182
  return true;
10861
11183
  const origin = c.req.header("origin");
10862
11184
  if (origin === undefined)
@@ -10914,7 +11236,7 @@ async function renderResult(res) {
10914
11236
  <p><a href="/editor">Back to editor</a></p>
10915
11237
  `);
10916
11238
  }
10917
- function mountEditor(app) {
11239
+ function mountEditor(app, bindHost = DEFAULT_BIND_HOST) {
10918
11240
  app.get("/editor", (c) => {
10919
11241
  if (!featureEnabled("editor"))
10920
11242
  return c.html(comingSoonDoc("editor", "Editor"));
@@ -10946,6 +11268,10 @@ function mountEditor(app) {
10946
11268
  Lives in (optional, single path)
10947
11269
  <input type="text" name="livesIn" placeholder="path/to/impl.ts" />
10948
11270
  </label>
11271
+ <label>
11272
+ Relates to (optional, comma-separated ids)
11273
+ <input type="text" name="relates" placeholder="BILLING-001, BILLING-002" />
11274
+ </label>
10949
11275
  <button type="submit">Create</button>
10950
11276
  </form>
10951
11277
  </section>
@@ -10974,12 +11300,13 @@ function mountEditor(app) {
10974
11300
  app.post("/editor/create", editorBodyLimit, async (c) => {
10975
11301
  if (!featureEnabled("editor"))
10976
11302
  return c.html(rejectPage(featureDisabledMessage("editor")), 404);
10977
- if (crossOriginRejected(c))
11303
+ if (crossOriginRejected(c, bindHost))
10978
11304
  return c.html(rejectPage("cross-origin request rejected"), 403);
10979
11305
  const form = await c.req.parseBody();
10980
11306
  const statement = field(form, "statement");
10981
11307
  const why = field(form, "why");
10982
11308
  const livesIn = field(form, "livesIn");
11309
+ const relates = field(form, "relates");
10983
11310
  const payload = {
10984
11311
  key: field(form, "key"),
10985
11312
  statement
@@ -10988,6 +11315,8 @@ function mountEditor(app) {
10988
11315
  payload.why = why;
10989
11316
  if (livesIn.length > 0)
10990
11317
  payload.livesIn = livesIn;
11318
+ if (relates.length > 0)
11319
+ payload.relates = relates;
10991
11320
  const res = await app.request("/api/requirements", {
10992
11321
  method: "POST",
10993
11322
  headers: { "content-type": "application/json" },
@@ -10998,7 +11327,7 @@ function mountEditor(app) {
10998
11327
  app.post("/editor/amend", editorBodyLimit, async (c) => {
10999
11328
  if (!featureEnabled("editor"))
11000
11329
  return c.html(rejectPage(featureDisabledMessage("editor")), 404);
11001
- if (crossOriginRejected(c))
11330
+ if (crossOriginRejected(c, bindHost))
11002
11331
  return c.html(rejectPage("cross-origin request rejected"), 403);
11003
11332
  const form = await c.req.parseBody();
11004
11333
  const id = field(form, "id");
@@ -11752,12 +12081,27 @@ var init_setup = __esm(() => {
11752
12081
 
11753
12082
  // ../webapp/src/server.ts
11754
12083
  import { Hono } from "hono";
12084
+ import { html as htmlTemplate } from "hono/html";
11755
12085
  function createApp() {
11756
12086
  const app = new Hono;
11757
12087
  app.get("/", (c) => c.html(index_html_default));
11758
12088
  return app;
11759
12089
  }
11760
- function mountWebapp(app) {
12090
+ function labelPlatform(app, platformName2) {
12091
+ const name = htmlTemplate`${platformName2}`.toString();
12092
+ app.use("*", async (c, next) => {
12093
+ await next();
12094
+ if (!c.res.headers.get("content-type")?.startsWith("text/html"))
12095
+ return;
12096
+ const body = (await c.res.text()).replace("<title>Spec Engine", `<title>${name} \xB7 Spec Engine`).replace("<span>Spec Engine</span></a>", `<span>Spec Engine</span><span class="sidebar-platform">${name}</span></a>`);
12097
+ const headers = new Headers(c.res.headers);
12098
+ headers.delete("content-length");
12099
+ c.res = new Response(body, { status: c.res.status, headers });
12100
+ });
12101
+ }
12102
+ function mountWebapp(app, bindHost = DEFAULT_BIND_HOST, platformName2) {
12103
+ if (platformName2 !== undefined)
12104
+ labelPlatform(app, platformName2);
11761
12105
  registerErrorBoundary(app);
11762
12106
  mountCoverage(app);
11763
12107
  mountRequirements(app);
@@ -11765,13 +12109,14 @@ function mountWebapp(app) {
11765
12109
  mountQuery(app);
11766
12110
  mountRelations(app);
11767
12111
  mountProvenance(app);
11768
- mountEditor(app);
12112
+ mountEditor(app, bindHost);
11769
12113
  mountSetup(app);
11770
12114
  mountGlossary(app);
11771
12115
  mountLogs(app);
11772
12116
  return app;
11773
12117
  }
11774
12118
  var init_server = __esm(() => {
12119
+ init_src();
11775
12120
  init_index_html();
11776
12121
  init_coverage();
11777
12122
  init_data();
@@ -11821,9 +12166,9 @@ function withWriteLock(fn) {
11821
12166
  writeChain = run.catch(() => {});
11822
12167
  return run;
11823
12168
  }
11824
- function rejectCrossOrigin(c) {
12169
+ function rejectCrossOrigin(c, bindHost) {
11825
12170
  const selfUrl = new URL(c.req.url);
11826
- if (!isLoopbackHostname(selfUrl.hostname)) {
12171
+ if (!isServedHostname(selfUrl.hostname, bindHost)) {
11827
12172
  return c.json({ error: "cross-origin request rejected" }, 403);
11828
12173
  }
11829
12174
  const origin = c.req.header("origin");
@@ -11872,7 +12217,7 @@ async function readJsonBody(c) {
11872
12217
  }
11873
12218
  return { ok: true, body: parsed };
11874
12219
  }
11875
- function toLivesIn(v) {
12220
+ function toStringList(v) {
11876
12221
  if (Array.isArray(v))
11877
12222
  return v.filter((x) => typeof x === "string");
11878
12223
  if (typeof v === "string" && v.length > 0)
@@ -11883,11 +12228,20 @@ function mintFieldsFromBody(body) {
11883
12228
  return {
11884
12229
  statement: typeof body.statement === "string" ? body.statement : "",
11885
12230
  why: typeof body.why === "string" ? body.why : "",
11886
- livesIn: toLivesIn(body.livesIn),
12231
+ livesIn: toStringList(body.livesIn),
12232
+ relates: toStringList(body.relates),
11887
12233
  issue: typeof body.issue === "string" && body.issue.trim() !== "" ? body.issue.trim() : undefined,
11888
12234
  status: body.status === "draft" ? "draft" : "active"
11889
12235
  };
11890
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
+ }
11891
12245
  function amendFieldsFromBody(body) {
11892
12246
  const fields = {};
11893
12247
  if (typeof body.statement === "string")
@@ -11895,7 +12249,9 @@ function amendFieldsFromBody(body) {
11895
12249
  if ("why" in body)
11896
12250
  fields.why = typeof body.why === "string" ? body.why : null;
11897
12251
  if ("livesIn" in body)
11898
- fields.livesIn = toLivesIn(body.livesIn);
12252
+ fields.livesIn = toStringList(body.livesIn);
12253
+ if ("relates" in body)
12254
+ fields.relates = toStringList(body.relates);
11899
12255
  return Object.keys(fields).length === 0 ? null : fields;
11900
12256
  }
11901
12257
  function failureResponse(c, failure) {
@@ -11926,7 +12282,7 @@ function parseQueryLimit2(c, rawLimit) {
11926
12282
  }
11927
12283
  return { ok: true, limit };
11928
12284
  }
11929
- function mountApi(app, storage2, platformDir = process.cwd()) {
12285
+ function mountApi(app, storage2, platformDir = process.cwd(), bindHost = DEFAULT_BIND_HOST) {
11930
12286
  app.use("/api/*", async (c, next) => {
11931
12287
  c.header("Cache-Control", "no-store");
11932
12288
  await next();
@@ -11935,7 +12291,12 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11935
12291
  app.get("/api/repos", guarded((c) => c.json(storage2.listRepos())));
11936
12292
  app.get("/api/platform", async (c) => {
11937
12293
  const version = await derivePlatformVersion(platformDir);
11938
- const info = { version, source: "derived", mode: platformMode(platformDir) };
12294
+ const info = {
12295
+ version,
12296
+ name: platformName(platformDir),
12297
+ source: "derived",
12298
+ mode: platformMode(platformDir)
12299
+ };
11939
12300
  return c.json(info);
11940
12301
  });
11941
12302
  app.get("/api/report", guarded((c) => c.json(coverageReport(storage2).rows)));
@@ -11957,7 +12318,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11957
12318
  if (!featureEnabled("editor")) {
11958
12319
  return c.json({ error: featureDisabledMessage("editor") }, 404);
11959
12320
  }
11960
- const originReject = rejectCrossOrigin(c);
12321
+ const originReject = rejectCrossOrigin(c, bindHost);
11961
12322
  if (originReject)
11962
12323
  return originReject;
11963
12324
  const headerReject = rejectBadWriteHeaders(c);
@@ -11983,7 +12344,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11983
12344
  if (!featureEnabled("editor")) {
11984
12345
  return c.json({ error: featureDisabledMessage("editor") }, 404);
11985
12346
  }
11986
- const originReject = rejectCrossOrigin(c);
12347
+ const originReject = rejectCrossOrigin(c, bindHost);
11987
12348
  if (originReject)
11988
12349
  return originReject;
11989
12350
  const headerReject = rejectBadWriteHeaders(c);
@@ -11997,12 +12358,9 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11997
12358
  return withWriteLock(async () => {
11998
12359
  const fields = amendFieldsFromBody(body);
11999
12360
  if (fields === null) {
12000
- 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);
12001
12362
  }
12002
- const r = await amend({ platformDir, id, fields }, async (reqId) => {
12003
- await runIndex({ platformDir, storage: storage2 });
12004
- return storage2.listTags({ req_id: reqId });
12005
- });
12363
+ const r = await amend({ platformDir, id, fields });
12006
12364
  if (!r.ok)
12007
12365
  return failureResponse(c, r);
12008
12366
  await runIndex({ platformDir, storage: storage2 });
@@ -12013,7 +12371,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
12013
12371
  if (!featureEnabled("editor")) {
12014
12372
  return c.json({ error: featureDisabledMessage("editor") }, 404);
12015
12373
  }
12016
- const originReject = rejectCrossOrigin(c);
12374
+ const originReject = rejectCrossOrigin(c, bindHost);
12017
12375
  if (originReject)
12018
12376
  return originReject;
12019
12377
  const headerReject = rejectBadWriteHeaders(c);
@@ -12030,14 +12388,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
12030
12388
  if (statement === "")
12031
12389
  return c.json({ error: "statement is required (non-empty)" }, 400);
12032
12390
  return withWriteLock(async () => {
12033
- const r = await supersede({
12034
- platformDir,
12035
- id,
12036
- statement,
12037
- why: typeof body.why === "string" ? body.why : undefined,
12038
- livesIn: "livesIn" in body ? toLivesIn(body.livesIn) : undefined,
12039
- issue: typeof body.issue === "string" && body.issue.trim() !== "" ? body.issue.trim() : undefined
12040
- }, async (reqId) => {
12391
+ const r = await supersede({ platformDir, id, statement, ...successorFieldsFromBody(body) }, async (reqId) => {
12041
12392
  await runIndex({ platformDir, storage: storage2 });
12042
12393
  return storage2.listTags({ req_id: reqId });
12043
12394
  });
@@ -12057,7 +12408,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
12057
12408
  if (!featureEnabled("editor")) {
12058
12409
  return c.json({ error: featureDisabledMessage("editor") }, 404);
12059
12410
  }
12060
- const originReject = rejectCrossOrigin(c);
12411
+ const originReject = rejectCrossOrigin(c, bindHost);
12061
12412
  if (originReject)
12062
12413
  return originReject;
12063
12414
  const headerReject = rejectBadWriteHeaders(c);
@@ -12087,7 +12438,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
12087
12438
  if (!featureEnabled("editor")) {
12088
12439
  return c.json({ error: featureDisabledMessage("editor") }, 404);
12089
12440
  }
12090
- const originReject = rejectCrossOrigin(c);
12441
+ const originReject = rejectCrossOrigin(c, bindHost);
12091
12442
  if (originReject)
12092
12443
  return originReject;
12093
12444
  const headerReject = rejectBadWriteHeaders(c);
@@ -12201,22 +12552,162 @@ var init_api = __esm(() => {
12201
12552
  writeChain = Promise.resolve();
12202
12553
  });
12203
12554
 
12555
+ // src/server/lan.ts
12556
+ import { createHash as createHash2, randomBytes, timingSafeEqual } from "crypto";
12557
+ import { isIP } from "net";
12558
+ function ipv4Prefix(ip) {
12559
+ const parts = ip.split(".");
12560
+ if (parts.length !== 4 || parts.some((p) => String(Number(p)) !== p))
12561
+ return null;
12562
+ return [Number(parts[0]), Number(parts[1])];
12563
+ }
12564
+ function isLoopbackAddress(ip) {
12565
+ if (ip === "::1")
12566
+ return true;
12567
+ return ipv4Prefix(ip)?.[0] === 127;
12568
+ }
12569
+ function isPrivateIPv4([a, b]) {
12570
+ if (a === 127 || a === 10)
12571
+ return true;
12572
+ if (a === 172 && b >= 16 && b <= 31)
12573
+ return true;
12574
+ if (a === 192 && b === 168)
12575
+ return true;
12576
+ return a === 100 && b >= 64 && b <= 127;
12577
+ }
12578
+ function bindHostRefusal(raw11) {
12579
+ const kind = isIP(raw11);
12580
+ if (kind === 0)
12581
+ return `--host must be a literal IP address, not "${raw11}"`;
12582
+ if (kind === 6) {
12583
+ return raw11 === "::1" ? null : `--host ${raw11}: the only IPv6 address accepted is ::1`;
12584
+ }
12585
+ const prefix = ipv4Prefix(raw11);
12586
+ if (prefix === null || !isPrivateIPv4(prefix)) {
12587
+ return `--host ${raw11} is not a loopback, RFC 1918, or 100.64.0.0/10 address`;
12588
+ }
12589
+ return null;
12590
+ }
12591
+ function urlHost(ip, port) {
12592
+ return `${isIP(ip) === 6 ? `[${ip}]` : ip}:${port}`;
12593
+ }
12594
+ function generateToken() {
12595
+ return randomBytes(32).toString("hex");
12596
+ }
12597
+ function pinnedTokenRefusal(token) {
12598
+ if (!/^[A-Za-z0-9_-]+$/.test(token))
12599
+ return "the access token may only contain A-Z a-z 0-9 _ -";
12600
+ if (token.length < MIN_PINNED_TOKEN_LENGTH) {
12601
+ return `the access token must be at least ${MIN_PINNED_TOKEN_LENGTH} characters`;
12602
+ }
12603
+ return null;
12604
+ }
12605
+ function digest(value) {
12606
+ return createHash2("sha256").update(value).digest();
12607
+ }
12608
+ function tokenMatches(candidate, token) {
12609
+ if (!candidate)
12610
+ return false;
12611
+ return timingSafeEqual(digest(candidate), digest(token));
12612
+ }
12613
+ function cookieValue(header, name) {
12614
+ for (const pair of (header ?? "").split(";")) {
12615
+ const eq = pair.indexOf("=");
12616
+ if (eq !== -1 && pair.slice(0, eq).trim() === name)
12617
+ return pair.slice(eq + 1).trim();
12618
+ }
12619
+ return null;
12620
+ }
12621
+ function isWrite(req, url) {
12622
+ if (req.method !== "GET" && req.method !== "HEAD")
12623
+ return true;
12624
+ if (url.pathname === "/provenance")
12625
+ return true;
12626
+ return url.pathname === "/api/provenance" && url.searchParams.has("resolve");
12627
+ }
12628
+ function plain(status, body, headers = {}) {
12629
+ return new Response(`${body}
12630
+ `, {
12631
+ status,
12632
+ headers: {
12633
+ "content-type": "text/plain; charset=utf-8",
12634
+ "cache-control": "no-store",
12635
+ ...headers
12636
+ }
12637
+ });
12638
+ }
12639
+ function lanGate(fetch2, { bindHost, token, allowWrites }) {
12640
+ return (req) => {
12641
+ const url = new URL(req.url);
12642
+ if (!isServedHostname(url.hostname, bindHost))
12643
+ return plain(403, "host rejected");
12644
+ const queryToken = url.searchParams.get("token");
12645
+ if (queryToken !== null) {
12646
+ if (!tokenMatches(queryToken, token))
12647
+ return plain(401, "invalid access token");
12648
+ url.searchParams.delete("token");
12649
+ return plain(302, "authenticated", {
12650
+ location: `${url.pathname}${url.search}`,
12651
+ "set-cookie": `${TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict`
12652
+ });
12653
+ }
12654
+ if (!tokenMatches(cookieValue(req.headers.get("cookie"), TOKEN_COOKIE), token)) {
12655
+ return plain(401, "access token required: open the tokenized URL spec serve printed");
12656
+ }
12657
+ if (!allowWrites && isWrite(req, url)) {
12658
+ return plain(403, "read-only: restart spec serve with --allow-writes to permit changes");
12659
+ }
12660
+ return fetch2(req);
12661
+ };
12662
+ }
12663
+ var TOKEN_COOKIE = "spec_token", MIN_PINNED_TOKEN_LENGTH = 16;
12664
+ var init_lan = __esm(() => {
12665
+ init_src();
12666
+ });
12667
+
12204
12668
  // src/commands/serve.ts
12205
12669
  var exports_serve = {};
12206
12670
  __export(exports_serve, {
12207
12671
  serveCommand: () => serveCommand,
12672
+ resolveBind: () => resolveBind,
12208
12673
  composeServeApp: () => composeServeApp
12209
12674
  });
12210
- import { existsSync as existsSync13, mkdirSync as mkdirSync6 } from "fs";
12675
+ import { existsSync as existsSync14, mkdirSync as mkdirSync6 } from "fs";
12211
12676
  import { dirname as dirname8 } from "path";
12212
12677
  import { defineCommand as defineCommand24 } from "citty";
12213
12678
  import { Hono as Hono2 } from "hono";
12214
- function composeServeApp(storage2, platformDir = process.cwd()) {
12679
+ function composeServeApp(storage2, platformDir = process.cwd(), bindHost = DEFAULT_BIND_HOST) {
12215
12680
  const app = new Hono2;
12216
- mountApi(app, storage2, platformDir);
12217
- mountWebapp(app);
12681
+ mountApi(app, storage2, platformDir, bindHost);
12682
+ mountWebapp(app, bindHost, platformName(platformDir));
12218
12683
  return app;
12219
12684
  }
12685
+ function usageError(message) {
12686
+ console.error(`spec serve: ${message}`);
12687
+ process.exit(EXIT.USAGE);
12688
+ }
12689
+ function resolveBind(args) {
12690
+ const host = args.host ?? DEFAULT_BIND_HOST;
12691
+ const hostRefusal = bindHostRefusal(host);
12692
+ if (hostRefusal !== null)
12693
+ usageError(hostRefusal);
12694
+ if (isLoopbackAddress(host))
12695
+ return { host, lan: null };
12696
+ const pinned = args.token ?? process.env.SPEC_SERVE_TOKEN;
12697
+ if (pinned !== undefined) {
12698
+ const tokenRefusal = pinnedTokenRefusal(pinned);
12699
+ if (tokenRefusal !== null)
12700
+ usageError(tokenRefusal);
12701
+ }
12702
+ return {
12703
+ host,
12704
+ lan: {
12705
+ bindHost: host,
12706
+ token: pinned ?? generateToken(),
12707
+ allowWrites: args.allowWrites === true
12708
+ }
12709
+ };
12710
+ }
12220
12711
  async function runProbe() {
12221
12712
  const app = createApp();
12222
12713
  const server = Bun.serve({
@@ -12234,7 +12725,7 @@ async function runProbe() {
12234
12725
  }
12235
12726
  const body = await res.text();
12236
12727
  if (!body.includes("Spec Engine \u2014 coming online")) {
12237
- 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`);
12238
12729
  server.stop();
12239
12730
  process.exit(EXIT.FAILURE);
12240
12731
  }
@@ -12249,26 +12740,41 @@ async function runProbe() {
12249
12740
  }
12250
12741
  var serveCommand;
12251
12742
  var init_serve = __esm(() => {
12743
+ init_src();
12252
12744
  init_server();
12253
12745
  init_constants();
12254
12746
  init_discover();
12255
12747
  init_pipeline();
12256
12748
  init_prompt();
12257
12749
  init_api();
12750
+ init_lan();
12258
12751
  init_sqlite();
12259
12752
  init__args();
12260
12753
  init__shared();
12261
12754
  serveCommand = defineCommand24({
12262
12755
  meta: {
12263
12756
  name: "serve",
12264
- description: "Run the local webapp. Binds 127.0.0.1 on --port (loopback only \u2014 no --host). --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."
12265
12758
  },
12266
12759
  args: {
12267
12760
  platformDir: platformDirArg,
12268
12761
  port: {
12269
12762
  type: "string",
12270
12763
  default: "4000",
12271
- description: "Port (default 4000; 0 = ephemeral). Hostname is hardcoded 127.0.0.1 (T-1-01); no --host accepted."
12764
+ description: "Port (default 4000; 0 = ephemeral)."
12765
+ },
12766
+ host: {
12767
+ type: "string",
12768
+ default: DEFAULT_BIND_HOST,
12769
+ description: "Address to bind (default 127.0.0.1). Only a literal loopback, RFC 1918, or 100.64.0.0/10 IP; a non-loopback address requires the printed access token and is read-only."
12770
+ },
12771
+ token: {
12772
+ type: "string",
12773
+ description: "Pin the access token for a non-loopback --host (else SPEC_SERVE_TOKEN, else random). At least 16 of A-Z a-z 0-9 _ -."
12774
+ },
12775
+ allowWrites: {
12776
+ type: "boolean",
12777
+ description: "On a non-loopback --host, permit editor writes and tracker resolution."
12272
12778
  },
12273
12779
  out: {
12274
12780
  type: "string",
@@ -12276,7 +12782,7 @@ var init_serve = __esm(() => {
12276
12782
  },
12277
12783
  probe: {
12278
12784
  type: "boolean",
12279
- description: "Smoke test: bind ephemeral port on 127.0.0.1, GET /, assert body contains placeholder, exit."
12785
+ description: "Smoke test: bind ephemeral port on 127.0.0.1 (ignores --host), GET /, assert body contains placeholder, exit."
12280
12786
  },
12281
12787
  noPrompt: {
12282
12788
  type: "boolean",
@@ -12300,6 +12806,7 @@ var init_serve = __esm(() => {
12300
12806
  process.exit(EXIT.USAGE);
12301
12807
  return;
12302
12808
  }
12809
+ const { host, lan } = resolveBind(args);
12303
12810
  const platformDir = resolvePlatformDir(args);
12304
12811
  const outArg2 = args.out;
12305
12812
  const dbPath = resolveDbPath(platformDir, outArg2);
@@ -12317,7 +12824,7 @@ var init_serve = __esm(() => {
12317
12824
  }
12318
12825
  });
12319
12826
  mkdirSync6(dirname8(dbPath), { recursive: true });
12320
- const needsIndex = !existsSync13(dbPath);
12827
+ const needsIndex = !existsSync14(dbPath);
12321
12828
  let storage2;
12322
12829
  try {
12323
12830
  storage2 = openStorage(dbPath);
@@ -12330,20 +12837,27 @@ var init_serve = __esm(() => {
12330
12837
  if (needsIndex || storage2.listRepos().length === 0) {
12331
12838
  await runIndex({ platformDir, storage: storage2 });
12332
12839
  }
12333
- const app = composeServeApp(storage2, platformDir);
12840
+ const app = composeServeApp(storage2, platformDir, host);
12334
12841
  server = Bun.serve({
12335
12842
  port,
12336
- hostname: "127.0.0.1",
12337
- fetch: app.fetch
12843
+ hostname: host,
12844
+ fetch: lan === null ? app.fetch : lanGate(app.fetch, lan)
12338
12845
  });
12339
12846
  } catch (err) {
12340
12847
  storage2.close();
12848
+ handleInvalidDomainFiles(err, false);
12341
12849
  handleStorageUnavailable(err, dbPath);
12342
- console.error(`spec serve: failed to start on 127.0.0.1:${port}:`, err);
12850
+ console.error(`spec serve: failed to start on ${urlHost(host, port)}:`, err);
12343
12851
  process.exit(EXIT.FAILURE);
12344
12852
  return;
12345
12853
  }
12346
- console.log(`spec: serving on http://127.0.0.1:${server.port}`);
12854
+ const serving = `spec: serving ${platformName(platformDir)} on ${server.url.origin}`;
12855
+ if (lan === null) {
12856
+ console.log(serving);
12857
+ return;
12858
+ }
12859
+ console.log(`${serving}/?token=${lan.token}`);
12860
+ console.log(lan.allowWrites ? "spec: writes ALLOWED (--allow-writes)" : "spec: read-only");
12347
12861
  }
12348
12862
  });
12349
12863
  });
@@ -12358,13 +12872,13 @@ __export(exports_docs, {
12358
12872
  docsCommand: () => docsCommand,
12359
12873
  createDocsFetchHandler: () => createDocsFetchHandler
12360
12874
  });
12361
- import { existsSync as existsSync14, statSync as statSync6 } from "fs";
12362
- 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";
12363
12877
  import { fileURLToPath } from "url";
12364
12878
  import { defineCommand as defineCommand25 } from "citty";
12365
12879
  function resolveDocsRootFrom(candidates) {
12366
12880
  for (const candidate of candidates) {
12367
- if (existsSync14(join15(candidate, "index.html")))
12881
+ if (existsSync15(join16(candidate, "index.html")))
12368
12882
  return candidate;
12369
12883
  }
12370
12884
  return null;
@@ -12388,17 +12902,17 @@ function createDocsFetchHandler(root) {
12388
12902
  if (pathname.includes("\x00")) {
12389
12903
  return new Response("Bad request", { status: 400 });
12390
12904
  }
12391
- const target = normalize(join15(normalRoot, pathname));
12905
+ const target = normalize(join16(normalRoot, pathname));
12392
12906
  if (target !== normalRoot && !target.startsWith(rootPrefix)) {
12393
12907
  return new Response("Not found", { status: 404 });
12394
12908
  }
12395
- for (const candidate of [target, join15(target, "index.html"), `${target}.html`]) {
12396
- 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()) {
12397
12911
  return new Response(Bun.file(candidate));
12398
12912
  }
12399
12913
  }
12400
- const notFoundPage = join15(normalRoot, "404.html");
12401
- if (existsSync14(notFoundPage)) {
12914
+ const notFoundPage = join16(normalRoot, "404.html");
12915
+ if (existsSync15(notFoundPage)) {
12402
12916
  return new Response(Bun.file(notFoundPage), { status: 404 });
12403
12917
  }
12404
12918
  return new Response("Not found", { status: 404 });
@@ -12448,7 +12962,7 @@ var init_docs2 = __esm(() => {
12448
12962
  port: {
12449
12963
  type: "string",
12450
12964
  default: "4100",
12451
- 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."
12452
12966
  },
12453
12967
  probe: {
12454
12968
  type: "boolean",