@spec-engine/spec-engine 0.8.1 → 0.9.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.
Files changed (41) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/AGENTS.md +678 -0
  3. package/dist/agent/mcp.json +11 -0
  4. package/dist/agent/pre-commit +3 -0
  5. package/dist/agent/skills/req-author/SKILL.md +102 -0
  6. package/dist/docs/agent-reference/index.html +1280 -33
  7. package/dist/docs/architecture/index.html +6 -6
  8. package/dist/docs/checks/index.html +19 -266
  9. package/dist/docs/commands/index.html +32 -164
  10. package/dist/docs/format/index.html +2 -9
  11. package/dist/docs/getting-started/index.html +3 -3
  12. package/dist/docs/guides/platform-setup/index.html +2 -2
  13. package/dist/docs/pagefind/fragment/en_10e3da6.pf_fragment +0 -0
  14. package/dist/docs/pagefind/fragment/en_37cae5e.pf_fragment +0 -0
  15. package/dist/docs/pagefind/fragment/en_7fc2fcd.pf_fragment +0 -0
  16. package/dist/docs/pagefind/fragment/en_96d3bc1.pf_fragment +0 -0
  17. package/dist/docs/pagefind/fragment/en_a26aa07.pf_fragment +0 -0
  18. package/dist/docs/pagefind/fragment/en_b7e68ef.pf_fragment +0 -0
  19. package/dist/docs/pagefind/fragment/en_d8a5bcd.pf_fragment +0 -0
  20. package/dist/docs/pagefind/fragment/en_de67937.pf_fragment +0 -0
  21. package/dist/docs/pagefind/fragment/en_fc3c55b.pf_fragment +0 -0
  22. package/dist/docs/pagefind/index/en_1664b45.pf_index +0 -0
  23. package/dist/docs/pagefind/index/en_a8b8bb6.pf_index +0 -0
  24. package/dist/docs/pagefind/pagefind-entry.json +1 -1
  25. package/dist/docs/pagefind/pagefind.en_bc3d4a7766.pf_meta +0 -0
  26. package/dist/docs/platform-mapping/index.html +2 -1
  27. package/dist/docs/tags/index.html +9 -92
  28. package/dist/impl.js +1948 -653
  29. package/package.json +1 -1
  30. package/dist/docs/pagefind/fragment/en_1f98c2d.pf_fragment +0 -0
  31. package/dist/docs/pagefind/fragment/en_24f76b6.pf_fragment +0 -0
  32. package/dist/docs/pagefind/fragment/en_2ee687a.pf_fragment +0 -0
  33. package/dist/docs/pagefind/fragment/en_4bd9de6.pf_fragment +0 -0
  34. package/dist/docs/pagefind/fragment/en_9cc1878.pf_fragment +0 -0
  35. package/dist/docs/pagefind/fragment/en_9cfc306.pf_fragment +0 -0
  36. package/dist/docs/pagefind/fragment/en_b81dc52.pf_fragment +0 -0
  37. package/dist/docs/pagefind/fragment/en_c3abf3d.pf_fragment +0 -0
  38. package/dist/docs/pagefind/fragment/en_c3e92c6.pf_fragment +0 -0
  39. package/dist/docs/pagefind/index/en_59c3abf.pf_index +0 -0
  40. package/dist/docs/pagefind/index/en_a630d23.pf_index +0 -0
  41. package/dist/docs/pagefind/pagefind.en_ced49c317e.pf_meta +0 -0
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.8.1",
23
+ version: "0.9.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",
@@ -109,15 +109,13 @@ var init_diagnostics = __esm(() => {
109
109
  SUPERSEDED_REFERENCED: "SUPERSEDED_REFERENCED",
110
110
  DEPRECATED_REFERENCED: "DEPRECATED_REFERENCED",
111
111
  STATEMENT_GRAMMAR: "STATEMENT_GRAMMAR",
112
+ STATEMENT_RULES: "STATEMENT_RULES",
112
113
  DRAFT_REFERENCED: "DRAFT_REFERENCED",
113
114
  GLOSSARY_DRIFT: "GLOSSARY_DRIFT",
114
115
  ORPHAN_REQ: "ORPHAN_REQ",
115
116
  UNVERIFIED_REQ: "UNVERIFIED_REQ",
116
117
  DRIFT: "DRIFT",
117
118
  NO_SPEC_CONFIG: "NO_SPEC_CONFIG",
118
- BROKEN_FILE_REF: "BROKEN_FILE_REF",
119
- UNIMPLEMENTED_IN_FILE: "UNIMPLEMENTED_IN_FILE",
120
- UNVERIFIED_IN_FILE: "UNVERIFIED_IN_FILE",
121
119
  BROKEN_RELATES: "BROKEN_RELATES",
122
120
  RELATES_SUPERSEDED: "RELATES_SUPERSEDED",
123
121
  CYCLIC_SUPERSEDE: "CYCLIC_SUPERSEDE",
@@ -125,6 +123,7 @@ var init_diagnostics = __esm(() => {
125
123
  UNKNOWN_ROLE: "UNKNOWN_ROLE",
126
124
  UNSOURCED_CHANGE: "UNSOURCED_CHANGE",
127
125
  INVALID_DOMAIN_FILE: "INVALID_DOMAIN_FILE",
126
+ MIGRATION_PENDING: "MIGRATION_PENDING",
128
127
  UNPROVEN_REQ: "UNPROVEN_REQ",
129
128
  PROOFS_UNCONFIRMED: "PROOFS_UNCONFIRMED",
130
129
  REQUIREMENT_REMOVED: "REQUIREMENT_REMOVED",
@@ -239,8 +238,6 @@ function orderDomain(d) {
239
238
  supersedes: r.supersedes ?? null,
240
239
  supersededBy: r.supersededBy ?? null,
241
240
  relates: r.relates,
242
- livesIn: r.livesIn,
243
- verifiedIn: r.verifiedIn,
244
241
  issues: r.issues.map((iss) => ({ role: iss.role, id: iss.id })),
245
242
  changedAtVersion: r.changedAtVersion,
246
243
  supersededAtVersion: r.supersededAtVersion,
@@ -287,6 +284,10 @@ function preserveShape(raw, rawDomain, domain) {
287
284
  const next = orderDomain(domain);
288
285
  patchFields(raw, before, next);
289
286
  const rawEntries = raw.requirements;
287
+ for (const entry of rawEntries) {
288
+ for (const key of LEGACY_ENTRY_KEYS)
289
+ delete entry[key];
290
+ }
290
291
  const byId = new Map;
291
292
  rawDomain.requirements.forEach((r, i) => {
292
293
  byId.set(r.id, [...byId.get(r.id) ?? [], i]);
@@ -357,7 +358,7 @@ async function validateAndWrite(path, domain, sourceFile) {
357
358
  await Bun.write(path, serialized);
358
359
  return { ok: true };
359
360
  }
360
- var ID_RE, KEY_RE, SpecIssueSchema, SpecCiteSchema, SpecRequirementSchema, SpecDomainSchema, sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
361
+ var ID_RE, KEY_RE, SpecIssueSchema, SpecCiteSchema, LEGACY_ENTRY_KEYS, SpecRequirementSchema, SpecDomainSchema, sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
361
362
  var init_domain = __esm(() => {
362
363
  init_diagnostics();
363
364
  ID_RE = /^[A-Z][A-Z0-9]*-\d+$/;
@@ -370,6 +371,7 @@ var init_domain = __esm(() => {
370
371
  term: z.string(),
371
372
  pinned: z.number().int()
372
373
  }).strict();
374
+ LEGACY_ENTRY_KEYS = ["livesIn", "verifiedIn"];
373
375
  SpecRequirementSchema = z.object({
374
376
  id: z.string().regex(ID_RE),
375
377
  status: z.string().min(1),
@@ -378,8 +380,8 @@ var init_domain = __esm(() => {
378
380
  supersedes: z.string().nullable().optional(),
379
381
  supersededBy: z.string().nullable().optional(),
380
382
  relates: z.array(z.string()).optional().default([]),
381
- livesIn: z.array(z.string()).optional().default([]),
382
- verifiedIn: z.array(z.string()).optional().default([]),
383
+ livesIn: z.unknown().optional(),
384
+ verifiedIn: z.unknown().optional(),
383
385
  issues: z.array(SpecIssueSchema).optional().default([]),
384
386
  changedAtVersion: z.number().int().optional(),
385
387
  supersededAtVersion: z.number().int().optional(),
@@ -388,7 +390,7 @@ var init_domain = __esm(() => {
388
390
  aliases: z.array(z.string()).optional().default([]),
389
391
  cites: z.array(SpecCiteSchema).optional().default([]),
390
392
  section: z.string().nullable().optional()
391
- }).strict();
393
+ }).strict().transform(({ livesIn: _l, verifiedIn: _v, ...entry }) => entry);
392
394
  SpecDomainSchema = z.object({
393
395
  key: z.string().regex(KEY_RE),
394
396
  owner: z.string().nullable().optional(),
@@ -401,6 +403,159 @@ var init_domain = __esm(() => {
401
403
  }).strict();
402
404
  });
403
405
 
406
+ // ../shared/src/statementRules.ts
407
+ function jargonPatterns(words) {
408
+ if (words.length === 0)
409
+ return [];
410
+ return [new RegExp(`\\b(?:${words.map(escapeRegExp).join("|")})\\b`, "i")];
411
+ }
412
+ function withoutExemptNames(statement, names) {
413
+ let out = statement;
414
+ for (const name of names) {
415
+ const tail = name.endsWith("/") ? "[^\\s`]*" : "(?![\\w-])";
416
+ out = out.replace(new RegExp(`(?<![\\w.-])${escapeRegExp(name)}${tail}`, "g"), "X");
417
+ }
418
+ return out;
419
+ }
420
+ function sentenceDetail(statement) {
421
+ if (/[\r\n]/.test(statement))
422
+ return "contains a line break";
423
+ const prose = statement.replace(QUOTED, "Q").replace(NON_TERMINAL, "N");
424
+ const terminals = prose.match(TERMINAL)?.length ?? 0;
425
+ if (terminals === 1 && /[.?!]$/.test(prose.trimEnd()))
426
+ return null;
427
+ return `${terminals} sentence terminators; exactly one, at the end`;
428
+ }
429
+ function hitsDetail(text, patterns) {
430
+ const hits = patterns.map((pattern) => text.match(pattern)?.[0]).filter((hit) => hit !== undefined);
431
+ return hits.length > 0 ? hits.map((hit) => `"${hit}"`).join(", ") : null;
432
+ }
433
+ function statementFindings(statement, config = {}) {
434
+ const maxLength = config.maxLength ?? DEFAULT_MAX_STATEMENT_LENGTH;
435
+ const details = {
436
+ "one-sentence": sentenceDetail(statement),
437
+ length: statement.length > maxLength ? `${statement.length} characters; at most ${maxLength}` : null,
438
+ "no-file-path": hitsDetail(withoutExemptNames(statement, config.exemptNames ?? []), FILE_PATH),
439
+ "no-test-narration": null,
440
+ "no-toolchain-layout": null,
441
+ "no-history": null,
442
+ "no-jargon": hitsDetail(statement, jargonPatterns(config.jargon ?? DEFAULT_JARGON)),
443
+ "no-tool-statement": hitsDetail(statement, TOOL_STATEMENT)
444
+ };
445
+ for (const { rule, patterns } of FIXED_RULES)
446
+ details[rule] = hitsDetail(statement, patterns);
447
+ const findings = [];
448
+ for (const rule of STATEMENT_RULES) {
449
+ const detail = details[rule];
450
+ if (detail !== null && config.severity?.[rule] !== "off") {
451
+ findings.push({ rule, detail, reason: STATEMENT_RULE_REASONS[rule] });
452
+ }
453
+ }
454
+ return findings;
455
+ }
456
+ function formatStatementFinding(f) {
457
+ return `${f.rule}: ${f.detail} \u2014 ${f.reason}`;
458
+ }
459
+ var RULE_ZERO = "A requirement is one rule of the product's behavior, stated from the user's point of view.", STATEMENT_RULES, DEFAULT_MAX_STATEMENT_LENGTH = 280, DEFAULT_JARGON, STATEMENT_RULE_REASONS, EXT = "ts|tsx|js|mjs|json|yml|yaml|sql|md|toml|css|astro|sh", FILE_PATH, FIXED_RULES, TOOL_STATEMENT, escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), QUOTED, NON_TERMINAL, TERMINAL;
460
+ var init_statementRules = __esm(() => {
461
+ STATEMENT_RULES = [
462
+ "one-sentence",
463
+ "length",
464
+ "no-file-path",
465
+ "no-test-narration",
466
+ "no-toolchain-layout",
467
+ "no-history",
468
+ "no-jargon",
469
+ "no-tool-statement"
470
+ ];
471
+ DEFAULT_JARGON = [
472
+ "seam",
473
+ "seams",
474
+ "rung",
475
+ "rungs",
476
+ "residue",
477
+ "matrix entry",
478
+ "stage runner",
479
+ "the stage"
480
+ ];
481
+ STATEMENT_RULE_REASONS = {
482
+ "one-sentence": "a statement is one testable sentence",
483
+ length: "a sentence a reader holds at once",
484
+ "no-file-path": "which file holds what is layout, not behaviour",
485
+ "no-test-narration": "how a test is written is not what the product does",
486
+ "no-toolchain-layout": "how CI and tooling are arranged is not a product behaviour",
487
+ "no-history": "a statement says what holds now, not how it got there",
488
+ "no-jargon": "a reader without the codebase cannot parse project jargon",
489
+ "no-tool-statement": "naming the tool in use is a decision record, not a requirement"
490
+ };
491
+ FILE_PATH = [
492
+ /(?<![\w/@-])(?:apps|packages|tooling|modules|spec-engine|\.github)\/[\w.@/-]*/i,
493
+ /`(?![@/<])(?![^`\s]*:\/\/)(?![^`\s./]*\/[^`\s./]*`)[^`\s]*\/[^`\s]*`/,
494
+ new RegExp(`\\b(?!(?:next|node|postgres)\\.js\\b|auth\\.md\\b)[\\w-]+(?:\\.[\\w-]+)*\\.(?:${EXT})\\b`, "i")
495
+ ];
496
+ FIXED_RULES = [
497
+ {
498
+ rule: "no-test-narration",
499
+ patterns: [
500
+ /\b(?:describe|it|test)\(/,
501
+ /\.(?:test|spec)\./i,
502
+ /\btest files?\b/i,
503
+ /\bthe tests?\b/i,
504
+ /\bvitest\b/i,
505
+ /\bplaywright\b/i,
506
+ /\bfixtures?\b(?! (?:accounts?|seed))/i,
507
+ /\bplanted\b/i
508
+ ]
509
+ },
510
+ {
511
+ rule: "no-toolchain-layout",
512
+ patterns: [
513
+ /\bworkflows?\b/i,
514
+ /\b(?:test|full|CI|job|build|RLS|per-table) matrix\b|\bmatrix (?:entry|entries|job)\b/i,
515
+ /\brunners?\b/i,
516
+ /\bGitHub Actions\b/i,
517
+ /\bci\.yml\b/i,
518
+ /(?<!`)\bpnpm\s/i,
519
+ /\bturbo\b/i,
520
+ /\bbiome\b/i,
521
+ /\bdrizzle-kit\b/i,
522
+ /\btsx\s/i,
523
+ /\bbun\s/i,
524
+ /\bShellCheck\b/i,
525
+ /\bcoverage thresholds?\b/i,
526
+ /`(?:pnpm )?ci:/i,
527
+ /@spec(?![\w-])/i,
528
+ /\btag(?:ged)? in\b/i
529
+ ]
530
+ },
531
+ {
532
+ rule: "no-history",
533
+ patterns: [
534
+ /\bamended\b/i,
535
+ /\bsupersed\w*(?: by)? `?[A-Z][A-Z0-9]*-\d/,
536
+ /\breplaced by\b|\breplace[sd] `?(?:ADR-|[A-Z][A-Z0-9]*-\d)/i,
537
+ /\bpreviously\b/i,
538
+ /\bformerly\b/i,
539
+ /\bused to\b/i,
540
+ /\bsince `?\d/i,
541
+ /\bmigration \d/i,
542
+ /\b0\d{3}_/,
543
+ /\bPORT-T/,
544
+ /\bADR-/
545
+ ]
546
+ }
547
+ ];
548
+ TOOL_STATEMENT = [
549
+ /\bwe use\b/i,
550
+ /\bis used for\b/i,
551
+ /\bis the tool\b/i,
552
+ /\bis pinned (?:to|at) (?:`?[\w@./-]+`? )?v?\d/i
553
+ ];
554
+ QUOTED = /`[^`]*`|"[^"]*"|\u201C[^\u201D]*\u201D/g;
555
+ NON_TERMINAL = /\b(?:e\.g|i\.e|etc|vs|cf)\.|\d+(?:\.\d+)+/gi;
556
+ TERMINAL = /[.?!](?=\s|$)/g;
557
+ });
558
+
404
559
  // ../shared/src/checkConfig.ts
405
560
  import { z as z2 } from "zod";
406
561
  function parseCheckConfigText(text, sourceFile) {
@@ -418,10 +573,11 @@ function parseCheckConfigText(text, sourceFile) {
418
573
  const path = issue && issue.path.length > 0 ? issue.path.join(".") : "(root)";
419
574
  return { ok: false, detail: `${sourceFile}: ${path}: ${issue?.message ?? "invalid"}` };
420
575
  }
421
- var CODES, matchEntry, SeverityRuleSchema, CheckConfigSchema;
576
+ var CODES, matchEntry, SeverityRuleSchema, StatementRulesConfigSchema, CheckConfigSchema;
422
577
  var init_checkConfig = __esm(() => {
423
578
  init_diagnostics();
424
579
  init_domain();
580
+ init_statementRules();
425
581
  CODES = Object.values(DiagnosticCode);
426
582
  matchEntry = z2.string().refine((s) => KEY_RE.test(s) || ID_RE.test(s), "must be a domain key or a requirement id");
427
583
  SeverityRuleSchema = z2.object({
@@ -429,7 +585,16 @@ var init_checkConfig = __esm(() => {
429
585
  severity: z2.enum(["error", "warning", "off"]),
430
586
  match: z2.array(matchEntry).optional()
431
587
  }).strict();
432
- CheckConfigSchema = z2.object({ severity: z2.array(SeverityRuleSchema) }).strict();
588
+ StatementRulesConfigSchema = z2.object({
589
+ maxLength: z2.number().int().positive().optional(),
590
+ jargon: z2.array(z2.string().min(1)).optional(),
591
+ exemptNames: z2.array(z2.string().min(1)).optional(),
592
+ severity: z2.partialRecord(z2.enum(STATEMENT_RULES), z2.enum(["error", "warning", "off"])).optional()
593
+ }).strict();
594
+ CheckConfigSchema = z2.object({
595
+ severity: z2.array(SeverityRuleSchema).default([]),
596
+ statementRules: StatementRulesConfigSchema.optional()
597
+ }).strict();
433
598
  });
434
599
 
435
600
  // ../shared/src/config.ts
@@ -869,6 +1034,7 @@ var init_src = __esm(() => {
869
1034
  init_net();
870
1035
  init_propagation();
871
1036
  init_schema();
1037
+ init_statementRules();
872
1038
  init_storage();
873
1039
  });
874
1040
 
@@ -1138,9 +1304,6 @@ function isPathIgnored(path, extra = []) {
1138
1304
  function isHiddenEntry(name) {
1139
1305
  return name.startsWith(".") && name !== ".github";
1140
1306
  }
1141
- function isHiddenPath(rel) {
1142
- return rel.split("/").some(isHiddenEntry);
1143
- }
1144
1307
  async function walkInto(rootDir, relDir, keep, extraIgnore, out) {
1145
1308
  const absDir = relDir ? join2(rootDir, relDir) : rootDir;
1146
1309
  let entries;
@@ -2686,25 +2849,6 @@ function repoExtraIgnore(member) {
2686
2849
  const repoIgnore = (member.ignore ?? []).map((e) => e.endsWith("/") ? e : `${e}/`);
2687
2850
  return [...member.selfMember ? ["spec-engine/"] : [], ...repoIgnore];
2688
2851
  }
2689
- function scannedTagFile(members, platformDir, platformRel) {
2690
- const abs = resolve3(platformDir, platformRel);
2691
- let owner = null;
2692
- for (const member2 of members) {
2693
- const rel2 = relative(member2.path, abs).split(sep2).join("/");
2694
- if (rel2 === "" || rel2 === ".." || rel2.startsWith("../") || isAbsolute(rel2))
2695
- continue;
2696
- if (owner === null || member2.path.length > owner.member.path.length)
2697
- owner = { member: member2, rel: rel2 };
2698
- }
2699
- if (owner === null)
2700
- return null;
2701
- const { member, rel } = owner;
2702
- if (isHiddenPath(rel) || isPathIgnored(rel, repoExtraIgnore(member)))
2703
- return null;
2704
- if (!isTagSource(rel) && !isDocSource(rel))
2705
- return null;
2706
- return tagFile(member, rel);
2707
- }
2708
2852
  function tagFile(member, rel) {
2709
2853
  return member.selfMember ? rel : `${member.name}/${rel}`;
2710
2854
  }
@@ -2919,6 +3063,944 @@ var init_pipeline = __esm(() => {
2919
3063
  init_discover();
2920
3064
  });
2921
3065
 
3066
+ // ../../.claude/skills/req-author/SKILL.md
3067
+ var SKILL_default = `---
3068
+ name: req-author
3069
+ description: Turn a rough brief or ticket into Spec Engine requirements \u2014 draft them in the fixed EARS shape, tie every draft to the exact words it came from, present the batch for human approval, and only then mint via the real \`spec\` CLI.
3070
+ ---
3071
+
3072
+ # req-author
3073
+
3074
+ > A requirement is one rule of the product's behavior, stated from the user's point of view.
3075
+
3076
+ The full requirement authoring standard is the "Requirement authoring
3077
+ standard" section of the agent reference
3078
+ (\`packages/site/src/content/docs/agent-reference.md\`); this skill applies it.
3079
+
3080
+ This skill drives the real \`spec\` CLI (\`spec req\`, \`spec query\`,
3081
+ \`spec domain list\`) and never reimplements minting. It is the front half of
3082
+ the lifecycle: brief in, approved requirements out; the route \u2192 tag \u2192 check
3083
+ loop consumes what it produces.
3084
+
3085
+ **Two non-negotiable rules:**
3086
+
3087
+ 1. **Never write without approval.** Draft, self-check, present the batch \u2014
3088
+ then STOP. No \`spec req\` runs until the human explicitly approves.
3089
+ 2. **Every draft quotes its source.** Each drafted requirement carries the
3090
+ exact phrase from the brief it derives from, verbatim. A requirement you
3091
+ cannot anchor to the author's words is labeled **inferred** so the human
3092
+ can accept or reject the inference knowingly. This exists because
3093
+ agent-drafted requirements have shipped that the product owner could not
3094
+ trace back to anything they said.
3095
+
3096
+ ## Procedure
3097
+
3098
+ 1. **Read the brief.** A ticket is temporary work; the requirements outlive
3099
+ it. One brief usually splits into several requirements \u2014 one per behavior,
3100
+ never one per ticket.
3101
+
3102
+ 2. **Draft each requirement in the fixed shape.** Every statement uses one of
3103
+ the five EARS shapes (the word "shall" separates who acts from the
3104
+ observable result):
3105
+
3106
+ | Situation | Shape |
3107
+ |---|---|
3108
+ | Always true | The \`<system>\` shall \`<result>\` |
3109
+ | Triggered by an event | When \`<trigger>\`, the \`<system>\` shall \`<result>\` |
3110
+ | Only in a certain state | While \`<state>\`, the \`<system>\` shall \`<result>\` |
3111
+ | Handling something going wrong | If \`<failure>\`, then the \`<system>\` shall \`<result>\` |
3112
+ | Only when a feature is present | Where \`<feature>\`, the \`<system>\` shall \`<result>\` |
3113
+
3114
+ Example: "When an unknown requirement id is passed, spec resolve shall
3115
+ print [] and exit 0." If a draft fits no row, you have not yet decided
3116
+ what triggers the behavior or what observably happens \u2014 decide, or mark
3117
+ the draft as a question for the human.
3118
+
3119
+ One promise per statement: a result chaining two behaviors with \`and\` is
3120
+ two drafts. Prefer \`When\`, \`If \u2026 then\`, and the bare \`shall\` form; \`While\`
3121
+ and \`Where\` only when a state or feature gates the behavior. Rationale
3122
+ belongs in the why, never in the statement.
3123
+
3124
+ The statement rules are checked the same way: one sentence ending in a
3125
+ period, at most 280 characters, and no repository file or folder, test
3126
+ mechanics, CI or tooling layout, history, project jargon, or tool choice.
3127
+ \`spec req\` refuses a draft that breaks one; \`spec lint <KEY-NNN> .\` lists
3128
+ the findings for an existing requirement.
3129
+
3130
+ 3. **Write a why that names what breaks.** The \`why\` answers "what goes wrong
3131
+ if this stops being true" \u2014 never a restatement of the statement in
3132
+ different words.
3133
+
3134
+ 4. **Place and dedup.** \`spec domain list --json\` shows every domain's
3135
+ charter; put each requirement in the domain whose promise it protects (not
3136
+ where the code file sits). Then \`spec query "<phrase>" . --json\` \u2014 if a
3137
+ close requirement already exists, propose amending or relating it instead
3138
+ of a new id.
3139
+
3140
+ 5. **Present the batch and STOP.** For each candidate show: the domain, the
3141
+ statement, the why, and the **source quote** (or the
3142
+ \`inferred\` label). Write nothing until the human approves. This gate is
3143
+ mandatory.
3144
+
3145
+ 6. **On approval, mint via the real CLI:**
3146
+
3147
+ \`\`\`
3148
+ spec req <domain> --text "<statement>" --why "<what breaks>"
3149
+ \`\`\`
3150
+
3151
+ (or the equivalent \`spec mcp\` tools). \`spec req\` validates through the one
3152
+ shared schema \u2014 in a domain that declares the ears grammar it also checks
3153
+ the statement shape at write time, and with \`--json\` it returns the parsed
3154
+ clauses.
3155
+
3156
+ 7. **Record the ticket as provenance only.** The originating ticket id is
3157
+ history, never a requirement id and never a code \`@spec\` tag.
3158
+
3159
+ 8. **Verify.** \`spec index . && spec check . --ci\` (exit 0) and
3160
+ \`spec guard .\` (no loss) before the change ships.
3161
+
3162
+ ## Invocation note
3163
+
3164
+ The repo's \`.mcp.json\` registers the local \`spec mcp\` server (stdio,
3165
+ loopback), so the tools and the \`author_requirements\` prompt are callable
3166
+ natively from the harness. From a source checkout the server runs as
3167
+ \`bun packages/engine/src/cli.ts mcp .\`; a compiled \`spec\` binary on PATH runs
3168
+ as \`spec mcp .\`.
3169
+ `;
3170
+ var init_SKILL = () => {};
3171
+
3172
+ // ../site/src/content/docs/agent-reference.md
3173
+ var agent_reference_default = `---
3174
+ title: Agent Reference
3175
+ description: The model, the authoring standard, and the exact contract of the spec CLI, for coding agents and the people who direct them
3176
+ ---
3177
+
3178
+ The reference for a coding agent working in a repository that uses Spec Engine. It has three parts: the model and the working loop, the standard every requirement is written against, and the exact contract of every \`spec\` command.
3179
+
3180
+ ## What Spec Engine is
3181
+
3182
+ Spec Engine helps developers, product teams, and agents write better specs and keep them current, so the software changes accurately.
3183
+
3184
+ It works with four things:
3185
+
3186
+ | Noun | What it is | Where it lives | Authored or derived |
3187
+ | --- | --- | --- | --- |
3188
+ | Domain | A named subject area of requirements with an UPPERCASE key (\`BILLING\`), an owner, and a charter that says what belongs in it | \`spec-engine/<KEY>/SPEC.json\`, one file per domain | Authored |
3189
+ | Requirement | One durable rule of the product's behavior, addressed by a permanent id \`KEY-NNN\` (\`BILLING-009\`), with a statement, a why, and a status | An entry in its domain's \`SPEC.json\` | Authored |
3190
+ | Tag | A comment \`@spec KEY-NNN\` that binds a line of code or a test to a requirement | Source and test files, in comments | Authored in code |
3191
+ | Index | A SQLite database of every domain, requirement, and tag, which the read and check commands query | \`.spec-engine/index.sqlite\` at the platform root | Derived |
3192
+
3193
+ - The \`SPEC.json\` files are the only authored truth about what the product promises. Write them through the \`spec\` commands. An id is never reused or renumbered: a changed promise is superseded by a new id, and an ended one is deprecated.
3194
+ - Tags in code are the only truth about where a requirement is implemented and where it is verified. A tag in implementation code implements; a tag in a test file verifies. The kind comes from the file path, never from the tag text.
3195
+ - The index is derived and disposable. Every command rebuilds it from the \`SPEC.json\` files and the tags, and deleting \`.spec-engine/\` is always safe.
3196
+ - Requirements are not tickets. One ticket usually becomes several requirements. A ticket id is provenance only: it goes in \`--issue\`, never in a requirement id or a tag.
3197
+
3198
+ ## The loop
3199
+
3200
+ \`\`\`shell
3201
+ spec query "renewal charge" . --json # 1. route: requirements on the topic
3202
+ spec resolve src/billing/renew.ts . --json # or the ones tagged in a file
3203
+ # 2. implement the change
3204
+ # 3. tag the function that produces the behavior and the test that proves it
3205
+ spec check . --ci --json # 4. exit 1: fix before the PR
3206
+ spec guard . --json # 5. exit 1: the change loses a requirement
3207
+ \`\`\`
3208
+
3209
+ When \`spec guard\` blocks, relay its \`\uD83D\uDED1 spec-guard:\` block verbatim to the user. Then either \`spec supersede\` the requirement with a successor or get the user's explicit decision to \`spec deprecate\` it. Never delete a requirement to make the gate pass.
3210
+
3211
+ ## What to tag
3212
+
3213
+ \`\`\`ts
3214
+ export function renew() { /* \u2026 */ } // @spec BILLING-009
3215
+ it("charges current price", () => {}) // @spec BILLING-009 unit
3216
+ \`\`\`
3217
+
3218
+ - A tag binds the one function that produces the behavior and the one test that proves it.
3219
+ - Helpers, glue code, and configuration carry no tag.
3220
+ - If you cannot name the behavior a line produces, do not tag it.
3221
+ - A test tag may end with a level: \`unit\`, \`integration\`, or \`e2e\`.
3222
+
3223
+ ## In CI
3224
+
3225
+ \`\`\`shell
3226
+ spec check . --ci --base "$(git merge-base HEAD origin/main)"
3227
+ \`\`\`
3228
+
3229
+ Without \`--base\`, \`check\` diffs against \`HEAD\`, which in a CI checkout already holds the change, so the removal and loss checks find nothing to report.
3230
+
3231
+ ## Requirement authoring standard
3232
+
3233
+ <!-- @spec CHRT-024 -->
3234
+
3235
+ The rules every new requirement is written against. The statement shapes and the statement rules are also checked at write time and by \`spec check\`.
3236
+
3237
+ 0. **A requirement is one rule of the product's behavior, stated from the user's point of view.** The statement rules below are the mechanical half of this rule: \`spec req\`, \`amend\`, \`supersede\`, \`move\`, and \`accept\` refuse a statement that breaks one, \`spec check\` reports an Active statement that breaks one as \`STATEMENT_RULES\`, and \`spec lint\` lists every finding. The judgment half stays with the author.
3238
+ 1. **One behavior per requirement.** A ticket usually splits into several requirements. Never mint one requirement per ticket.
3239
+ 2. **Write the statement in a fixed shape.** Pick the sentence shape that matches the situation. The word "shall" separates who acts from the observable result:
3240
+
3241
+ | Situation | Shape | Example |
3242
+ | --- | --- | --- |
3243
+ | Always true | The \`<system>\` shall \`<result>\` | The spec CLI shall write JSON output to stdout only. |
3244
+ | Triggered by an event | When \`<trigger>\`, the \`<system>\` shall \`<result>\` | When an unknown requirement id is passed, spec resolve shall print \`[]\` and exit 0. |
3245
+ | Only in a certain state | While \`<state>\`, the \`<system>\` shall \`<result>\` | While the index file is missing, read commands shall rebuild it before answering. |
3246
+ | Handling something going wrong | If \`<failure>\`, then the \`<system>\` shall \`<result>\` | If the results file cannot be parsed, then spec check shall report the file and exit 2. |
3247
+ | Only when a feature is present | Where \`<feature>\`, the \`<system>\` shall \`<result>\` | Where a member config lists ignore directories, the scanner shall skip them. |
3248
+
3249
+ A statement that fits no row has not yet decided what triggers the behavior or what observably happens.
3250
+
3251
+ A statement may end with a trailing \`so <observable consequence>\` clause ("\u2026shall report NO_SPEC_CONFIG at warning severity, so a lone warning never fails the gate") when the consequence is itself observable. The reason the behavior exists belongs in the why, never in the statement.
3252
+
3253
+ One promise per statement. A result that chains two behaviors with \`and\`, or that sets two severities, is two requirements: a single test must be able to prove the whole statement. Prefer \`When\`, \`If \u2026 then\`, and the bare \`shall\` form. Use \`While\` and \`Where\` only when a state or a feature is what gates the behavior.
3254
+ 3. **It must make sense on its own.** A reader who sees only this one statement, in search results or a report, understands it. A statement that says "the normalization" or "that prefix" without naming the subject fails.
3255
+ 4. **Describe what it does, not how.** Say what a user or calling agent observes. If renaming an internal function would make the statement wrong, it is written at the wrong level.
3256
+ 5. **No expiration dates.** No "currently", no "the new behavior", no references to things that were removed.
3257
+ 6. **The why states what breaks.** Answer "what goes wrong if this stops being true". Never restate the requirement in other words.
3258
+ 7. **Three link types, three jobs.** Tests prove it. Code implements it. Tickets are only history: never an id, never proof.
3259
+ 8. **File it where the promise lives, and search first.** Pick the domain whose charter covers the behavior, and run \`spec query\` before minting. If something close exists, amend it instead.
3260
+
3261
+ ### Requirement id format
3262
+
3263
+ <!-- @spec CHRT-023 -->
3264
+
3265
+ - Ids are \`KEY-NNN\`: the domain key, a hyphen, and a three-digit zero-padded number (\`BILLING-010\`).
3266
+ - Ids are permanent. A requirement is superseded or deprecated, never renumbered and never deleted.
3267
+
3268
+ ### The one-promise lint
3269
+
3270
+ A domain envelope may declare \`grammar: "ears"\` with \`grammarSeverity: "warning" | "error"\` (default warning). Set both with \`spec domain grammar <KEY>\`, never by hand. In such a domain a statement must fit one of the five shapes, and a statement that fits a shape still fails when:
3271
+
3272
+ | Rule | Fails | Passes |
3273
+ | --- | --- | --- |
3274
+ | Semicolon clause | \`; \` anywhere after the first \`shall\` | \`When a; b holds, X shall \u2026\` |
3275
+ | Second subject | \`, and <subject> shall\` after the first \`shall\` | \`X shall A, and shall B\` |
3276
+ | Rationale | \`because\`, \`so that\`, \`in order to\` anywhere | a trailing \`, so <observable consequence>\` |
3277
+
3278
+ - \`spec check\` reports a nonconforming Active or Draft statement as \`STATEMENT_GRAMMAR\` at the declared severity, one row per statement, the detail naming the first failed shape or rule.
3279
+ - \`spec req\`, \`amend\`, \`supersede\`, and \`move\` judge new text at write time: they warn and write under warning, and refuse (exit 2) under error.
3280
+ - The lint is a pattern, not a reading: a statement that passes can still chain two behaviors, and the reviewer still owns that call.
3281
+ - The TERM domain is exempt.
3282
+
3283
+ ### The statement rules
3284
+
3285
+ Every domain is judged by these rules, whatever its grammar. \`spec req\`, \`amend\`, \`supersede\`, \`move\` (with new text), and \`accept\` refuse a statement that breaks a rule graded \`error\` (the default): exit 2, one \`<rule>: <detail> \u2014 <reason>\` line per finding, rule 0 last. A rule graded \`warning\` writes the statement and prints the same finding lines on stderr. There is no override flag; the \`statementRules\` block of \`check.json\` grades a rule, tunes it, or turns it off. \`spec check\` reports an Active statement that breaks one as \`STATEMENT_RULES\`, one row per rule per statement. The TERM domain is never judged.
3286
+
3287
+ | Rule | Reports | Printed reason |
3288
+ | --- | --- | --- |
3289
+ | \`one-sentence\` | more than one sentence, no closing period, or a line break | a statement is one testable sentence |
3290
+ | \`length\` | over \`maxLength\` characters (280 by default) | a sentence a reader holds at once |
3291
+ | \`no-file-path\` | a repository path or file name | which file holds what is layout, not behaviour |
3292
+ | \`no-test-narration\` | how a test is written | how a test is written is not what the product does |
3293
+ | \`no-toolchain-layout\` | CI or tooling layout | how CI and tooling are arranged is not a product behaviour |
3294
+ | \`no-history\` | how the requirement changed | a statement says what holds now, not how it got there |
3295
+ | \`no-jargon\` | a word from the jargon list | a reader without the codebase cannot parse project jargon |
3296
+ | \`no-tool-statement\` | which tool is in use | naming the tool in use is a decision record, not a requirement |
3297
+
3298
+ ## Authoring requirements (brief \u2192 mint)
3299
+
3300
+ <!-- @spec AUTHOR-013 -->
3301
+
3302
+ <!-- @spec AUTHOR-012 -->
3303
+
3304
+ The procedure for turning a brief or ticket into requirements. Each draft is written to the requirement authoring standard above.
3305
+
3306
+ 1. **Split the brief.** One requirement per behavior, never one per ticket.
3307
+ 2. **Place each.** \`spec domain list --json\` prints every charter. File the requirement where the promise lives.
3308
+ 3. **Dedup.** \`spec query "<phrase>" . --json\` before minting. Overlap means \`spec amend\` or relate the existing requirement.
3309
+ 4. **Draft to the standard.** Every draft quotes the phrase of the brief it derives from.
3310
+ 5. **Mint.** \`spec req <domain> --text "<statement>" --why "<what breaks>" [--issue <ticket>]\`.
3311
+ 6. **Provenance, not identity.** The ticket goes in \`--issue\`, never in an id or a tag.
3312
+ 7. **Verify.** \`spec index . && spec check . --ci\` (exit 0) and \`spec guard .\` before the PR.
3313
+
3314
+ ## Command reference
3315
+
3316
+ ### Invocation
3317
+
3318
+ \`\`\`shell
3319
+ spec <command> [...] # after: bun add -g @spec-engine/spec-engine
3320
+ bunx @spec-engine/spec-engine <command> [...] # without a global install (requires Bun)
3321
+ \`\`\`
3322
+
3323
+ - Almost every command takes an optional trailing \`platformDir\` positional (default: cwd). The platform dir is the directory that contains \`spec-engine/\`.
3324
+ - Every command is non-interactive when stdin is not a TTY.
3325
+ - \`--json\` goes to **stdout only**. Guidance, warnings, and diagnostics chrome go to **stderr**. Parse stdout, surface stderr.
3326
+
3327
+ ### Platforms, members, and pins
3328
+
3329
+ - A platform is the directory holding \`spec-engine/\`. When declared, its platform file \`platform-map.json\` is \`{ name, members, ignore }\`.
3330
+ - A member is a repository named in that file, carrying a \`{ platform, member }\` marker. A monorepo's coverage columns are its workspace packages, named by path (\`packages/engine\`). A lone repository with no platform file is one column or its packages. A monorepo's files outside every package are one more column, named by the member or, for a lone monorepo, the directory.
3331
+ - \`spec-engine.member.json\` adds one fact: the member's pin, \`spec-engine@N\`. \`spec init <name>\` declares and pins in one step.
3332
+ - A requirement domain's version is derived from its supersede history. The platform version is the max domain version. Drift compares a member's pin against each referenced requirement's \`changed_at_version\`. A stray \`spec-engine.platform.json\` is ignored with a warning.
3333
+ - A member's \`dependsOn\` (the platform's own package names in its manifest) orders \`spec propagation\`: providers before consumers.
3334
+
3335
+ ### Tags
3336
+
3337
+ - The tag is \`@spec KEY-NNN\`, optionally followed by a level token \`unit\` | \`integration\` | \`e2e\` on a test tag. Never write "implements" or "verifies" in the tag.
3338
+ - The scanner reads \`.ts\`, \`.tsx\`, \`.js\`, \`.jsx\`, \`.mjs\`, \`.sh\`, \`.lua\`, \`.luau\`, \`.go\`, \`.rs\`, \`.tf\`, \`.toml\`, \`.sql\`, \`.css\`, \`.yml\`, \`.yaml\`, and \`.astro\` files, any file named \`Dockerfile\` or \`Dockerfile.<suffix>\`, and \`.md\` documents, whose tags document.
3339
+ - The tag regex ignores the comment leader, so \`--\`, \`//\`, \`#\`, \`<!-- -->\`, and \`/* \u2026 */\` all work.
3340
+ - Dot-prefixed directories are skipped except \`.github/\`. \`fixtures/\` directories are ignored everywhere.
3341
+ - A tag in implementation code implements; a tag in a test path verifies. A test path contains \`.test.\`, \`.spec.\`, \`__tests__/\`, \`/tests/\`, \`/e2e/\`, or \`.e2e.\`, or ends in \`_test.go\`, \`_test.rs\`, \`_spec.lua\`, or \`_test.lua\`, or lives under \`spec/\` (busted).
3342
+ - Rust tests inline under \`#[cfg(test)]\` sit in the implementation file and count as implements; put a verifying tag in \`tests/\` or a \`*_test.rs\` file.
3343
+
3344
+ ### Exit code contract
3345
+
3346
+ | Code | Meaning |
3347
+ | --- | --- |
3348
+ | 0 | Success. Empty results are success (\`[]\` on stdout, guidance on stderr). |
3349
+ | 1 | Data-level failure: \`check\` found an error-severity diagnostic; \`guard\` found a loss; \`gate\` failed; \`index\` crashed mid-build. |
3350
+ | 2 | Usage/environment error: bad args, not a Spec Engine platform (no \`spec-engine/\`), path-containment violation, invalid \`--limit\`/\`--port\`, FTS5 syntax error. |
3351
+
3352
+ Branch on exit codes, not on output text.
3353
+
3354
+ ### Commands
3355
+
3356
+ | Command | Synopsis | \`--json\` | Exits |
3357
+ | --- | --- | --- | --- |
3358
+ | \`spec index [platformDir]\` | Build or refresh the derived index | yes (IndexResult) | 0 / 1 / 2 |
3359
+ | \`spec check [platformDir] [--ci]\` | Integrity + coverage + drift diagnostics | yes (array) | 0 / 1 / 2 |
3360
+ | \`spec guard [platformDir] [--against <ref>]\` | Block a change about to lose a requirement, its last implementation, or its last test | yes (array) | 0 / 1 / 2 |
3361
+ | \`spec map [platformDir]\` | Requirement \xD7 repo coverage matrix | yes (array) | 0 / 2 |
3362
+ | \`spec get <KEY-NNN> [platformDir]\` | One requirement's full record and its tag sites; unknown id is \`[]\` | yes (object, or \`[]\`) | 0 / 2 |
3363
+ | \`spec list [platformDir] [--domain KEY] [--status S]\` | Every requirement record in (key, seq) order, history included | yes (array) | 0 / 2 |
3364
+ | \`spec query <text> [platformDir]\` | Full-text retrieval over requirements | yes (array) | 0 / 2 |
3365
+ | \`spec relations [platformDir]\` | Mermaid graph of \`relates\` links | yes (array) | 0 / 2 |
3366
+ | \`spec provenance [issueId] [platformDir]\` | Per-requirement provenance matrix | yes (array) | 0 / 2 |
3367
+ | \`spec resolve <files\u2026> [platformDir]\` | Requirements tagged in the given files | yes (array) | 0 / 2 |
3368
+ | \`spec propagation <KEY-NNN> [platformDir]\` | Per-repo migrated/drifted state for a superseded requirement | yes (array) | 0 / 2 |
3369
+ | \`spec gate <repo> <KEY-NNN> [platformDir]\` | Approval gate: pass iff Active and the pin covers it | yes (object) | 0 / 1 / 2 |
3370
+ | \`spec init [repo]\` | Declare a member through platform-map and write its \`spec-engine.member.json\` pin | yes (object) | 0 / 2 |
3371
+ | \`spec domain new <KEY>\` / \`spec domain list\` / \`spec domain grammar <KEY>\` | Scaffold / list \`spec-engine/<KEY>/SPEC.json\` / set its statement grammar and severity | \`list\`: yes (array); \`grammar\`: yes (object) | 0 / 2 |
3372
+ | \`spec migrate [platformDir]\` | Strip the retired \`livesIn\` and \`verifiedIn\` fields from every \`SPEC.json\` (idempotent) | yes (object) | 0 / 2 |
3373
+ | \`spec req <domain-prefix> [platformDir]\` | Piped: next unused id. \`--text\`: author non-interactively | yes (object) | 0 / 2 |
3374
+ | \`spec term \u2026\` | Author, list, revise, and confirm glossary terms | yes | 0 / 2 |
3375
+ | \`spec glossary [platformDir]\` | Generate \`GLOSSARY.md\` from the TERM store; \`--migrate\`, \`--check\` | yes (object) | 0 / 1 / 2 |
3376
+ | \`spec deprecate <KEY-NNN> --reason "\u2026" [platformDir]\` | End an Active/Draft requirement with a recorded reason | yes (object) | 0 / 2 |
3377
+ | \`spec supersede <KEY-NNN> [platformDir]\` | Flip to superseded, mint the successor, retag every site to it | yes (object) | 0 / 2 |
3378
+ | \`spec move <KEY-NNN> <NEW-DOMAIN> [platformDir]\` | Cross-domain supersede | yes (object) | 0 / 2 |
3379
+ | \`spec amend <KEY-NNN> [platformDir]\` | Revise an unshipped entry in place | yes (object) | 0 / 2 |
3380
+ | \`spec accept <KEY-NNN> [platformDir]\` | Promote a Draft requirement to Active | yes (object) | 0 / 2 |
3381
+ | \`spec lint [KEY \\| KEY-NNN] [platformDir] [--all]\` | Every statement-rule finding for the platform, a domain, or a requirement | yes (array) | 0 / 1 / 2 |
3382
+ | \`spec serve [platformDir] [--port N] [--host IP]\` | Local webapp + \`/api/*\` over the index | n/a (HTTP) | 0 / 1 / 2 |
3383
+ | \`spec docs [--port N]\` | Serve the bundled docs site offline | n/a (HTTP) | 0 / 1 / 2 |
3384
+ | \`spec mcp [platformDir]\` | MCP server over stdio | n/a (JSON-RPC) | 0 / 2 |
3385
+
3386
+ Common flags on the index-touching commands (\`index\`, \`check\`, \`map\`, \`get\`, \`list\`, \`query\`, \`relations\`, \`resolve\`, \`propagation\`, \`provenance\`, \`gate\`, \`serve\`):
3387
+
3388
+ | Flag | Effect |
3389
+ | --- | --- |
3390
+ | \`--out <path>\` | Index DB path override. Must resolve inside \`platformDir\` (exit 2 otherwise). |
3391
+ | \`--no-prompt\` | Suppress the member-onboarding prompt. |
3392
+ | \`--json\` | Machine output (all except \`serve\`). |
3393
+ | \`--fresh\` | Read commands only: delete the DB plus WAL/SHM and reindex before answering. |
3394
+
3395
+ \`init\`, \`domain list\`, \`req\` register \`--json\` only. \`domain new\` registers none.
3396
+
3397
+ **Index freshness.** Read commands (\`map\`, \`get\`, \`list\`, \`query\`, \`relations\`, \`resolve\`, \`propagation\`, \`provenance\`) build the index when it is missing and otherwise trust it. A warm index older than a changed \`SPEC.json\` produces a stderr warning. Tag-only code edits are not detected. \`gate\` always rebuilds cold, and so does \`check --ci\`. After editing specs or tags, pass \`--fresh\`.
3398
+
3399
+ ### spec index
3400
+
3401
+ Build or refresh the derived index.
3402
+
3403
+ \`--json\`: \`{ build_id, repos, domains, requirements, tags, diagnostics }\`.
3404
+
3405
+ Exit: 0 built / 1 an invalid SPEC.json refused the build, or the build crashed / 2 not a platform or bad args.
3406
+
3407
+ - \`build_id\` is deterministic for identical inputs (cold-rebuild equivalence).
3408
+ - A \`SPEC.json\` that is not JSON or fails the shared schema refuses the whole build, and the index on disk is left as it was. Every file is parsed first, so one run reports every invalid file. Under \`--json\` stdout holds only the \`INVALID_DOMAIN_FILE\` rows in the \`spec check --json\` row shape; text mode prints only those rows on stderr. Every other index-building command prints only the same rows and exits 1 (\`check\`, \`gate\`, \`guard\`, \`serve\`, and the read commands); a lifecycle command that reindexes for its retag (\`supersede\`, \`amend\`, \`deprecate\`, \`move\`, \`term confirm\`) exits 2 after its write.
3409
+ - Also writes \`.spec-engine/doctor.md\`, a list of doc lines that mention a requirement id without an explicit \`<!-- @spec ID -->\` binding.
3410
+
3411
+ ### spec check
3412
+
3413
+ Integrity, coverage, drift, and governance diagnostics.
3414
+
3415
+ | Flag | Effect |
3416
+ | --- | --- |
3417
+ | \`--ci\` | Delete the DB first. Correctness never trusts a warm index. |
3418
+ | \`--results <junit.xml>\` | The trusted-red gate: a verifying tag counts only when its test passed. Path resolves inside \`platformDir\`. |
3419
+ | \`--base <ref>\` | Diff the requirement set against a git ref (deletion and status-flip governance). |
3420
+ | \`--approved-by <list>\` | Approver set for the status-flip check. |
3421
+ | \`--require-owner-approval\` | Escalate \`UNAPPROVED_STATUS_FLIP\` from warning to error. |
3422
+ | \`--unsourced-change\` | Also warn on superseded requirements with no \`supersedes-via\` issue. |
3423
+
3424
+ \`--json\` rows: \`{ code, severity, repo, source_file, line, req_id, detail }\`, nullable except \`code\`, \`severity\`, \`detail\`, sorted deterministically.
3425
+
3426
+ Exit: 0 clean or warnings only / 1 any error-severity row / 2 usage.
3427
+
3428
+ <!-- @spec CHCK-053 -->
3429
+
3430
+ Codes, in \`DiagnosticCode\` order:
3431
+
3432
+ | Code | Severity | Condition |
3433
+ | --- | --- | --- |
3434
+ | \`DUP_ID\` | error | The same requirement id appears twice |
3435
+ | \`BROKEN_SUPERSEDE\` | error | A \`supersededBy\` names a missing id, or a \`supersedes\` names a missing id or the entry itself |
3436
+ | \`BAD_STATUS\` | error | An entry status outside \`active\` / \`draft\` / \`superseded\` / \`deprecated\` |
3437
+ | \`DANGLING_TAG\` | error | A tag references no requirement |
3438
+ | \`SUPERSEDED_REFERENCED\` | error | Code still tags a superseded requirement (retag to the successor) |
3439
+ | \`DEPRECATED_REFERENCED\` | error | Code still tags a deprecated requirement |
3440
+ | \`STATEMENT_GRAMMAR\` | per domain, default warning | A statement fails its domain's declared grammar |
3441
+ | \`STATEMENT_RULES\` | error; per rule from \`statementRules.severity\` | An Active statement breaks a statement rule: one row per rule per statement, the detail \`<rule>: <detail> \u2014 <reason>\` |
3442
+ | \`DRAFT_REFERENCED\` | error on a verifying tag, warning on an implementing or documenting tag | A tag references a Draft requirement |
3443
+ | \`GLOSSARY_DRIFT\` | warning | The committed glossary differs from the one generated from the term store |
3444
+ | \`ORPHAN_REQ\` | error | An Active requirement has no implementing tag |
3445
+ | \`UNVERIFIED_REQ\` | warning | An Active requirement is implemented but has no verifying tag |
3446
+ | \`DRIFT\` | error | A member is pinned behind the version at which a requirement it references changed |
3447
+ | \`NO_SPEC_CONFIG\` | warning | A declared member is present on disk with no \`spec-engine.member.json\` |
3448
+ | \`BROKEN_RELATES\` | warning | A \`relates\` entry names a nonexistent requirement |
3449
+ | \`RELATES_SUPERSEDED\` | warning | A \`relates\` entry names a superseded requirement |
3450
+ | \`CYCLIC_SUPERSEDE\` | error | A circular supersession chain |
3451
+ | \`SELF_RELATES\` | warning | A \`relates\` entry names its own requirement (dropped, but surfaced) |
3452
+ | \`UNKNOWN_ROLE\` | warning | An \`issues\` entry uses a role outside \`created\` / \`supersedes-via\` / \`amends-via\` (surfaced and dropped, never stored) |
3453
+ | \`UNSOURCED_CHANGE\` | warning, only with \`--unsourced-change\` | A superseded requirement carries no \`supersedes-via\` issue |
3454
+ | \`INVALID_DOMAIN_FILE\` | error | A \`SPEC.json\` is not JSON or fails the shared schema; one row per failing field |
3455
+ | \`MIGRATION_PENDING\` | warning | A \`SPEC.json\` still carries the retired \`livesIn\` or \`verifiedIn\` fields; run \`spec migrate\` |
3456
+ | \`UNPROVEN_REQ\` | error, only with \`--results\` | An Active requirement has verifying tags but no passing correlated test |
3457
+ | \`PROOFS_UNCONFIRMED\` | warning, only without \`--results\` | The proof gate fell back to presence-only mode |
3458
+ | \`REQUIREMENT_REMOVED\` | error | An Active requirement present at the base ref is gone with no same-change supersession |
3459
+ | \`IMPL_LOST\` | error, when a base ref resolves | The change removes the last implementing tag of a surviving Active requirement |
3460
+ | \`VERIFY_LOST\` | warning, when a base ref resolves | The change removes the last verifying tag of a surviving Active requirement |
3461
+ | \`SPEC_FILE_DELETED\` | error, when a base ref resolves | A \`SPEC.json\` present at the base ref is gone |
3462
+ | \`UNAPPROVED_STATUS_FLIP\` | warning; error with \`--require-owner-approval\` | A flip to superseded or deprecated whose CODEOWNERS domain owner is absent from \`--approved-by\` |
3463
+ | \`PARTIAL_PROPAGATION\` | error, needs \`--base\` and \`--results\` | A changed requirement where some bound tests pass and some fail |
3464
+ | \`MALFORMED_FILE\` | platform-map's severity: error, or warning for a manifest | A platform file, a package or workspace manifest, or platform-map's per-user file failed to parse or validate |
3465
+ | \`MEMBER_MISSING\` | warning | A declared member is not on this machine; it is not scanned |
3466
+ | \`MARKER_MISSING\` | warning | A declared member has no marker |
3467
+ | \`MARKER_MISMATCH\` | error | A member's marker names a different platform |
3468
+ | \`UNLISTED_REPO\` | warning (platform-map info, promoted) | A repository in the platform folder is not a member; its tags are not scanned |
3469
+ | \`PLATFORM_NOT_LOCATED\` | warning | A marker names a platform this machine cannot find |
3470
+ | \`UNDECLARED_PLATFORM\` | warning (platform-map info, promoted) | A folder of repositories with no platform file; nothing in it is a member |
3471
+ | \`SCAN_TRUNCATED\` | warning | A workspace walk hit its depth or entry cap |
3472
+ | \`UNDEFINED_TERM\` | error | A \`cites\` entry resolves to no glossary term |
3473
+ | \`ORPHAN_TERM\` | warning | An Active term that nothing cites |
3474
+ | \`TERM_DRIFT\` | warning | A citation's pin is behind the term's current version |
3475
+ | \`SUPERSEDED_TERM_REFERENCED\` | error | A citation names a superseded term |
3476
+
3477
+ The four TERM codes are the last four rows. Only \`error\` rows drive exit 1.
3478
+
3479
+ The eight codes before them are platform-map's, surfaced with the platform-map code: every platform-map error and warning keeps its severity; \`UNLISTED_REPO\` and \`UNDECLARED_PLATFORM\` are promoted from info to warning because they mean a repository's tags are not scanned; \`UNMATCHED_PATTERN\` and \`AMBIGUOUS_ECOSYSTEM\` are not surfaced. \`repo\` is the diagnostic's subject when it names a member; \`source_file\` is the subject. \`NO_SPEC_CONFIG\` stays Spec Engine's own code: a declared member present on disk with no \`spec-engine.member.json\`.
3480
+
3481
+ - Deletion detection runs by default: with no \`--base\`, the working tree is diffed against \`HEAD\` whenever git resolves. An Active requirement absent from the tree without a same-change supersession is \`REQUIREMENT_REMOVED\`. History (superseded and deprecated entries) may never be deleted. Outside git the check stays quiet.
3482
+ - Without \`--results\`, \`check\` emits \`PROOFS_UNCONFIRMED\` and skips proof enforcement. The results file is never hashed into \`build_id\`. Generate it with the test runner's JUnit reporter, for example \`bun test --reporter=junit --reporter-outfile=.spec-engine/results.xml\`.
3483
+ - Term citations: \`UNDEFINED_TERM\`, \`SUPERSEDED_TERM_REFERENCED\`, and \`TERM_DRIFT\` clear with \`spec term confirm\`.
3484
+ - Losses: whenever a base ref resolves (\`--base\`, else \`HEAD\`), \`check\` reports the \`IMPL_LOST\`, \`VERIFY_LOST\`, and \`SPEC_FILE_DELETED\` rows \`spec guard\` would report against that ref. Guard's \`REQUIREMENT_REMOVED\` rows are dropped; the removal check already reports that id.
3485
+ - Under \`--ci\` with no \`--base\`, a working tree whose tracked files equal \`HEAD\` makes every loss check pass vacuously, so \`check\` warns on stderr with the line to run instead: \`spec check . --ci --base "$(git merge-base HEAD origin/<default>)"\`. The exit code and stdout are unchanged. <!-- @spec GUARD-031 -->
3486
+ - Coverage counts only Active requirements: \`ORPHAN_REQ\` and \`UNVERIFIED_REQ\` fire for Active entries only. <!-- @spec CHCK-032 -->
3487
+
3488
+ #### check.json
3489
+
3490
+ \`spec-engine/check.json\` regrades diagnostics. Absent means no rules.
3491
+
3492
+ \`\`\`json
3493
+ { "severity": [
3494
+ { "code": "UNVERIFIED_REQ", "severity": "error" },
3495
+ { "code": "UNVERIFIED_REQ", "severity": "warning", "match": ["LEGACY", "BILLING-004"] }
3496
+ ] }
3497
+ \`\`\`
3498
+
3499
+ - \`code\` is any diagnostic code; \`severity\` is \`error\`, \`warning\`, or \`off\`.
3500
+ - \`match\` lists domain keys and requirement ids. A scoped rule applies only to rows whose \`req_id\` is listed or carries a listed key.
3501
+ - The most specific rule wins: an id match, then a domain match, then an unscoped rule. Among equals, the later rule wins.
3502
+ - \`off\` drops the row from every output.
3503
+ - Grading runs inside the operation, so the CLI, \`spec_check\`, and every other surface agree, and exit 1 follows the graded severities.
3504
+ - A file that fails the schema (unknown code or key, a bad severity, a malformed match entry) exits 2 with \`spec-engine/check.json: <path>: <reason>\`.
3505
+
3506
+ \`statementRules\` configures the statement rules on every surface: the write-time refusal, \`STATEMENT_RULES\`, and \`spec lint\`.
3507
+
3508
+ \`\`\`json
3509
+ { "severity": [],
3510
+ "statementRules": {
3511
+ "maxLength": 280,
3512
+ "jargon": ["seam", "rung"],
3513
+ "exemptNames": ["SPEC.json", "spec-engine/"],
3514
+ "severity": { "no-jargon": "off", "length": "error" }
3515
+ } }
3516
+ \`\`\`
3517
+
3518
+ - \`maxLength\` defaults to 280. \`jargon\` replaces the default list (\`seam\`, \`seams\`, \`rung\`, \`rungs\`, \`residue\`, \`matrix entry\`, \`stage runner\`, \`the stage\`).
3519
+ - \`exemptNames\` are product names \`no-file-path\` never reports. A name ending in \`/\` exempts every path under it.
3520
+ - \`severity\` maps a rule to \`error\`, \`warning\`, or \`off\`. A rule with no entry is \`error\`. \`error\` refuses the write and grades the \`STATEMENT_RULES\` row error; \`warning\` writes, prints the finding on stderr, and grades the row warning; \`off\` turns the rule off everywhere. The \`severity\` array regrades by code after it.
3521
+ - The rules: \`one-sentence\`, \`length\`, \`no-file-path\`, \`no-test-narration\`, \`no-toolchain-layout\`, \`no-history\`, \`no-jargon\`, \`no-tool-statement\`. Unknown keys exit 2.
3522
+
3523
+ ### spec guard
3524
+
3525
+ The pre-commit loss gate. Diffs the requirement derivation at \`--against <ref>\` (default \`HEAD\`) against the working tree, scoped to the files in \`git diff\`. Rebuilds the worktree index cold every run.
3526
+
3527
+ \`--json\` rows: \`{ kind, req_id, file, line, detail }\`, sorted by \`(req_id, kind, file, line)\`. \`kind\` is one of \`REQUIREMENT_REMOVED\`, \`IMPL_LOST\`, \`VERIFY_LOST\`, \`SPEC_FILE_DELETED\` (\`req_id\` null).
3528
+
3529
+ Exit: 0 clean / 1 any loss / 2 usage.
3530
+
3531
+ - A loss is suppressed only by ending the requirement in the same change: supersede it (either direction) or \`spec deprecate\` it. There is no override comment.
3532
+ - A non-git tree, a repo with no \`HEAD\`, or an unknown ref prints \`NOT_A_GIT_REPO\` to stderr and exits 0.
3533
+ - A ref that begins with \`-\` or holds a character outside \`A-Z a-z 0-9 . _ / -\` is refused before git runs: \`guard\` treats it as an unknown ref, and \`check --base\` exits 2.
3534
+ - Text mode prints a \`\uD83D\uDED1 spec-guard:\` block per requirement, written in the second person. Relay it verbatim to the user before proceeding.
3535
+
3536
+ ### spec map
3537
+
3538
+ \`--json\` rows keyed \`(req_id, repo)\`: \`{ req_id, domain_key, req_status, req_spec_version, req_changed_at_version, repo, repo_pin, implemented: 0|1, verified: 0|1, test_levels }\`. Text mode renders \`src\`, \`test\`, \`src+test\`, \`\u2014\`.
3539
+
3540
+ Exit: 0 / 2.
3541
+
3542
+ ### spec get
3543
+
3544
+ One requirement's full record from the derived index, with the code sites its tags name. Tags are the only record of where a requirement is implemented and verified.
3545
+
3546
+ \`--json\`: the \`Requirement\` row \`{ id, key, seq, status, superseded_by, text, why, source_file, line, spec_version, changed_at_version, superseded_at_version }\` plus \`implemented_in: [{ repo, file, line, kind }]\` (implementing and documenting tags) and \`verified_in: [{ repo, file, line, level }]\` (verifying tags), each sorted by (repo, file, line). The same object is served by \`GET /api/requirements/:id\` and \`spec_get\`, and shown on the webapp \`/requirements/:id\` page. Text mode is a one-row table (\`ID STATUS KEY SEQ CHANGED_AT TEXT\`) followed by an \`implemented in:\` and a \`verified in:\` list of \`file:line\`.
3547
+
3548
+ Exit: 0 / 2. An unknown id prints \`[]\` on stdout, guidance on stderr, and exits 0. A malformed id exits 2.
3549
+
3550
+ ### spec list
3551
+
3552
+ Every requirement record in (key, seq) order. Unlike \`query\`, superseded and deprecated rows are included and nothing is ranked.
3553
+
3554
+ | Flag | Effect |
3555
+ | --- | --- |
3556
+ | \`--domain KEY\` | Only that domain (case-insensitive key). |
3557
+ | \`--status S\` | Only that status: \`active\`, \`draft\`, \`superseded\`, \`deprecated\`. Any other word exits 2. |
3558
+
3559
+ \`--json\`: an array of \`Requirement\` rows, the same rows \`GET /api/requirements\` serves. Text mode is the \`ID STATUS KEY SEQ CHANGED_AT TEXT\` table.
3560
+
3561
+ Exit: 0 / 2. An empty result is \`[]\` under \`--json\`, guidance on stderr in text mode, exit 0.
3562
+
3563
+ ### spec query
3564
+
3565
+ FTS5 \`MATCH\` syntax. \`--limit N\` (default 10, max 1000).
3566
+
3567
+ Exit: 0 / 2 (bare \`AND\` or \`OR\` is an FTS5 grammar error).
3568
+
3569
+ - Wrap multi-word phrases in double quotes inside the shell-quoted arg.
3570
+ - Results rank ascending (best first). Superseded requirements are excluded.
3571
+ - Glossary term definitions ride the same index. A matching term appears in a separate Terms group in text mode, discriminated by \`key\` under \`--json\`.
3572
+
3573
+ ### spec relations
3574
+
3575
+ Text mode: mermaid \`graph LR\`, one node per requirement in a \`relates\` field, one undirected deduped edge per pair. \`--json\` rows: \`{ from_id, to_id, source_file, line }\`, sorted by (from, to).
3576
+
3577
+ Exit: 0 / 2. Empty graph is \`[]\` and exit 0.
3578
+
3579
+ ### spec provenance
3580
+
3581
+ Per requirement: the creating issue, the revising issues (\`supersedes-via\`, \`amends-via\`), the backing tests, and the \`source_file:line\` pointer.
3582
+
3583
+ | Flag | Effect |
3584
+ | --- | --- |
3585
+ | \`[issueId]\` positional | Display-only reverse filter. Never a join key. |
3586
+ | \`--resolve-issues\` | Overlay tracker title/status/URL. Needs \`SPEC_TRACKER_TOKEN\`; degrades to bare ids without it. |
3587
+
3588
+ \`--json\` rows: \`{ req_id, role, issue_id, source_file, line, req_status, implemented, verified, test_levels }\`, sorted on the full composite key.
3589
+
3590
+ Exit: 0 / 2. Empty matrix is \`[]\` and exit 0.
3591
+
3592
+ ### spec resolve
3593
+
3594
+ Accepts multiple positionals, comma-split inside one positional, and absolute paths under the platform dir.
3595
+
3596
+ | Flag | Effect |
3597
+ | --- | --- |
3598
+ | \`--req KEY-NNN\` | Reverse query: every tag site for that requirement. Takes no file positionals. |
3599
+
3600
+ \`--json\` rows (\`--req\`): \`{ req_id, repo, file, line, kind, level }\` sorted by (repo, file, line). \`kind\` is \`implements\`, \`verifies\`, or \`documents\`.
3601
+
3602
+ Exit: 0 / 2. Empty result is \`[]\` and exit 0. Unknown id under \`--req\` is \`[]\`.
3603
+
3604
+ Path rules. \`tags.file\` is platform-relative: \`<member>/<path>\` for a file inside a declared member or a workspace package, and the bare \`<path>\` for a file a lone repository owns itself (its directory-named or \`root\` column). The key never carries the checkout's folder name.
3605
+
3606
+ - Multi-repo platform: pass \`<repo>/<path>\`, e.g. \`spec resolve api/src/renew.ts .\`
3607
+ - Single repo: pass the path from the platform root, e.g. \`spec resolve src/orders.ts .\` or \`spec resolve .github/workflows/ci.yml .\`
3608
+ - Absolute paths are normalized against \`platformDir\`. A path outside it exits 2.
3609
+
3610
+ ### spec propagation
3611
+
3612
+ One row per member repo: \`{ repo, state, via_req_id, drifted }\`. \`state\` is one of \`MIGRATED_VERIFIED\`, \`MIGRATED_UNVERIFIED\`, \`ON_PREDECESSOR\`, \`ON_OTHER_DOMAIN_REQ\`, \`NO_DOMAIN_REFERENCE\`.
3613
+
3614
+ Rows are in dependency order: a member comes after every member it depends on, read from platform-map's \`dependsOn\` (the platform's own package names in a member's manifest), and members at the same depth come by name. The same order is served by \`/api/propagation/:id\`, \`spec_propagation\`, and the webapp's \`/propagation/:id\` page. A platform with no in-platform dependencies lists members by name.
3615
+
3616
+ Exit: 0 / 2.
3617
+
3618
+ ### spec gate
3619
+
3620
+ \`--json\`: \`{ pass, reason, repo, req_id, detail, status, changed_at_version, pinned_spec_version }\`. \`reason\` is one of \`PASS\`, \`NOT_FOUND\`, \`DRAFT\`, \`SUPERSEDED\`, \`DEPRECATED\`, \`VERSION_PIN\`, decided in that order. Pin equality passes.
3621
+
3622
+ Exit: 0 pass / 1 fail / 2 unknown repo name or bad args.
3623
+
3624
+ ### spec init
3625
+
3626
+ Declares a member through platform-map and writes \`spec-engine.member.json\`.
3627
+
3628
+ | Flag | Effect |
3629
+ | --- | --- |
3630
+ | \`--specs spec-engine@N\` | Override the pin. |
3631
+ | \`--force\` | Rewrite an existing config, preserving \`ignore\`. Any other key refuses, as does a malformed \`ignore\`. |
3632
+ | \`--platform <dir>\` | The platform root, for a declared member checked out outside the platform folder. Its location is recorded in platform-map's per-user file first. |
3633
+
3634
+ \`--json\`: \`{ action: "wrote", path, pin, source, declared, linked, agent_surface }\`, \`{ action: "already-configured", path, pin, extra_fields, declared, linked, agent_surface }\`, or, on a platform directory, \`{ action: "surface-only", agent_surface }\`. \`declared\` lists the platform-map files written (the platform file, the marker), empty when the member was already declared; \`linked\` is the per-user file written under \`--platform\`, else null; \`agent_surface\` lists the agent-surface files written or changed, \`[]\` on a re-run.
3635
+
3636
+ Exit: 0 wrote or already configured / 2. Branch on \`action\`, not the exit code.
3637
+
3638
+ - Where the target sits decides what is written:
3639
+ - a repository directly under a platform folder (a directory holding \`spec-engine/\`) that the platform file does not list is declared: a \`platform-map.json\` platform file entry at the root (the file is created when absent) and a \`{ platform, member }\` marker in the repository, then the pin. platform-map declares only a directory with a \`.git\` entry or a \`package.json\`; a plain folder is refused with exit 2;
3640
+ - a declared member only gets the pin (a missing marker is written back);
3641
+ - a workspace package of a monorepo gets a nested pin and no declaration; a repository inside a lone monorepo that is not a workspace package is refused;
3642
+ - a directory under no platform gets the \`spec-engine@1\` fallback pin;
3643
+ - a platform directory (holding \`spec-engine/\`) gets the agent surface and no pin.
3644
+ - Every action then installs the agent surface into the repository (the member; the monorepo root for a workspace package), bundled in the npm package under \`dist/agent/\`. Hints for anything left alone go to stderr.
3645
+ - \`AGENTS.md\`: the agent reference between a \`spec-engine:agent-reference:begin\` and a \`spec-engine:agent-reference:end\` HTML comment marker, appended or created, replaced in place when the markers exist; text outside them is never touched. A \`CLAUDE.md\` that does not mention \`AGENTS.md\` gets a hint only.
3646
+ - \`.claude/skills/req-author/\`: written when absent; a differing copy is replaced only under \`--force\`.
3647
+ - \`.mcp.json\`: a \`spec\` server entry (\`spec mcp <platform>\`) merged in; other keys and an existing \`spec\` entry are kept.
3648
+ - \`pre-commit\`: \`spec guard <platform>\`, written to \`.githooks/\` or else \`.git/hooks/\` when no hook exists there; an existing hook gets a hint.
3649
+ - With no platform located, the MCP entry and the hook are skipped.
3650
+ - The platform root is platform-map's \`locate()\`: the nearest \`platform-map.json\` or \`.git\` boundary, a marker resolved through the parent directory or the per-user file; the parent directory when it holds \`spec-engine/\`. A \`.git\` boundary between the target and an outer \`spec-engine/\` hides that platform.
3651
+ - Default pin: \`--specs\`, else the derived platform version, else \`spec-engine@1\` with a printed note.
3652
+ - A resolved path with a \`spec-engine\` segment is refused, except on the engine's own checkout (root package named \`@spec-engine/spec-engine\`), where only the path below the platform root is judged.
3653
+ - \`ignore: ["dir", \u2026]\` excludes repo-relative directory prefixes from that repo's scans: \`vendor\` hides \`vendor/x.ts\` but not \`src/vendor/x.ts\`. A monorepo root column excludes its workspace package paths the same way. A monorepo's columns are its workspace manifest's packages.
3654
+
3655
+ ### spec domain
3656
+
3657
+ \`spec domain new <KEY>\` scaffolds \`spec-engine/<KEY>/SPEC.json\`. \`spec domain list\` prints one key per line; \`--json\` prints a sorted array of \`{ key, scope }\` (scope is the charter sentence or null), read from the filesystem.
3658
+
3659
+ \`spec domain grammar <KEY> [platformDir] [--grammar ears|freeform] [--severity warning|error]\` sets the envelope's \`grammar\` and/or \`grammarSeverity\` through the validating write path; every other field is written back as read. \`--json\` prints \`{ key, grammar, grammar_severity, file }\` (an unset field is null). An unknown domain, neither flag, or a value outside the enums exits 2. CLI only: no MCP tool or HTTP route.
3660
+
3661
+ \`\`\`shell
3662
+ spec domain grammar CHCK --severity error
3663
+ \`\`\`
3664
+
3665
+ Exit: 0 / 2.
3666
+
3667
+ ### spec migrate
3668
+
3669
+ Rewrites every \`SPEC.json\` that still carries the retired \`livesIn\` or \`verifiedIn\` fields through the write seam, which drops them. A file without them is not touched, so a second run writes nothing. Until a file is migrated, the fields are read and ignored, and the next write of a file drops them.
3670
+
3671
+ \`--json\`: \`{ files }\`, the platform-relative paths rewritten.
3672
+
3673
+ Exit: 0 / 2.
3674
+
3675
+ ### spec req
3676
+
3677
+ | Mode | Behavior |
3678
+ | --- | --- |
3679
+ | stdin not a TTY, no \`--text\` | Print the bare next unused id (e.g. \`BILLING-010\`), exit 0. No prompts, no writes. |
3680
+ | \`--json\`, no \`--text\` | Print \`{ domain, next_id }\`. Same zero-write contract, even on a TTY. |
3681
+ | \`--text "<statement>"\` | Append an Active entry with zero prompts. \`--why\`, \`--relates\`, \`--issue <ticket>\` fill the other fields and error without \`--text\`. \`--relates\` repeats and comma-separates. |
3682
+ | \`--draft\` | Author the entry as Draft (with \`--text\`, or on a TTY). Off a TTY without \`--text\` it exits 2. |
3683
+ | TTY, no flags | Interactive authoring. |
3684
+
3685
+ \`--json\` with \`--text\`: \`{ id, file }\`, plus \`clauses: { pattern, condition, system, response }\` when the statement parses as EARS.
3686
+
3687
+ Exit: 0 / 2 (ambiguous prefix, unknown domain, grammar refusal, statement-rule refusal).
3688
+
3689
+ - The domain prefix is case-insensitive: \`bil\` resolves to \`BILLING\`.
3690
+ - The resolved domain's charter is echoed to stderr on every authoring path.
3691
+ - Unresolvable \`@<path>\` refs warn on stderr and never block.
3692
+
3693
+ ### spec term
3694
+
3695
+ A term is a requirement row in the reserved \`TERM\` domain: definition in \`statement\`, headword in \`term\`, synonyms in \`aliases\`.
3696
+
3697
+ | Form | Effect |
3698
+ | --- | --- |
3699
+ | \`spec term "<name>" --def "<definition>" [--aliases a,b] [--section s]\` | Append a \`TERM-NNN\`. \`--json\`: \`{ id, file }\`. |
3700
+ | \`spec term\` with no \`--def\` | Pure id query, mirrors \`spec req\`: bare next \`TERM\` id, or \`{ domain, next_id }\`. |
3701
+ | \`spec term list\` | \`id name status\` per line, or \`[{ id, term, status }]\`. Reads the filesystem. |
3702
+ | \`spec term revise <TERM-NNN> --def "\u2026"\` | Rewrite the definition in place and bump the TERM envelope's \`specVersion\`. \`--no-bump\` opts out. \`--json\`: \`{ id, file, spec_version }\`. |
3703
+ | \`spec term confirm <KEY-NNN> <TERM-NNN>\` | Re-pin a citation to the term's current version (clears \`TERM_DRIFT\`); re-point to the successor when the term is superseded (clears \`SUPERSEDED_TERM_REFERENCED\`). \`--json\`: \`{ req_id, term_id, pinned, file }\`. |
3704
+
3705
+ \`spec supersede TERM-NNN\` and \`spec amend TERM-NNN --term/--aliases\` work on term ids as on requirements.
3706
+
3707
+ Exit: 0 / 2.
3708
+
3709
+ ### spec glossary
3710
+
3711
+ | Form | Effect |
3712
+ | --- | --- |
3713
+ | \`spec glossary\` | Overwrite \`GLOSSARY.md\` from the TERM store. Byte-stable: fixed header, terms in id order, \`## {section}\` once per change, one \`- **{term}** \u2014 {statement}\` bullet each. |
3714
+ | \`--migrate\` | One-time parse of an existing \`GLOSSARY.md\` into \`TERM-001..N\`. Skips when the TERM domain already has entries. |
3715
+ | \`--check\` | Regenerate into a buffer and diff against the committed file. Exit 1 on drift. |
3716
+
3717
+ \`--json\`: \`{ generated }\`, \`{ migrated }\`, or \`{ ok }\`.
3718
+
3719
+ Exit: 0 / 1 (\`--check\` drift) / 2.
3720
+
3721
+ ### spec deprecate
3722
+
3723
+ Marks an Active/Draft requirement \`deprecated\`, records \`--reason\` on the entry, and removes every \`@spec\` tag of the id from the code. A line that holds only the tag (and its comment leader) is deleted; on a line with other text, only the tag and its level token go. Text mode prints the removed sites.
3724
+
3725
+ \`--json\`: \`{ id, file, reason, removed: [{ req_id, repo, file, line, kind, level }] }\`, the sites as they stood before the removal.
3726
+
3727
+ Tag sites come from the freshly built index, so \`fixtures/\` and dot-prefixed directories are never touched. Each rewritten line keeps its comment leader, level token, and line ending, and no other line changes. Running the command again finds no sites.
3728
+
3729
+ Exit: 0 / 2.
3730
+
3731
+ ### spec supersede
3732
+
3733
+ Requires an Active target. Flips it to \`superseded by NEW\`, mints the successor in the domain file that holds the target, reindexes fresh, and rewrites every \`@spec OLD\` tag in the code to \`@spec NEW\`, keeping the level token and the rest of the line. Text mode prints the rewritten sites. Tag sites come from the freshly built index, so \`fixtures/\` and dot-prefixed directories are never touched. Each rewritten line keeps its comment leader, level token, and line ending, and no other line changes. Running the command again finds no sites.
3734
+
3735
+ The successor copies the predecessor's \`why\`, \`relates\`, \`cites\`, \`aliases\`, \`term\`, and \`section\` unless a flag overrides the field. Its \`issues\` hold only the \`created\` row from \`--issue\`.
3736
+
3737
+ | Flag | Effect |
3738
+ | --- | --- |
3739
+ | \`--text\` | The successor statement. Required when not a TTY. |
3740
+ | \`--why\`, \`--relates\` | Successor fields. Default to copies of the predecessor's. \`--relates\` repeats or comma-separates and replaces the whole list; an empty value clears it. |
3741
+ | \`--issue <ticket>\` | Recorded as \`supersedes-via\` on the predecessor and \`created\` on the successor. |
3742
+ | \`--no-bump\` | TERM domain only: keep the authored \`specVersion\`. No-op elsewhere. |
3743
+
3744
+ \`--json\`: \`{ old_id, new_id, file, spec_version, retagged: [{ req_id, repo, file, line, kind, level }] }\`, the rewritten sites (\`req_id\` is the old id). On a requirement domain \`spec_version\` is the derived domain version after the supersession, and the same number is stamped as the predecessor's \`supersededAtVersion\`.
3745
+
3746
+ Exit: 0 / 2. All guards run before the first byte is written.
3747
+
3748
+ ### spec move
3749
+
3750
+ Cross-domain supersede. Mints the successor as the next id in \`<NEW-DOMAIN>\` (which must exist), copies \`statement\`, \`why\`, \`relates\`, \`cites\`, \`aliases\`, \`term\`, and \`section\` (\`--text\` and \`--why\` override), flips the source to superseded with a cross-domain \`supersededBy\`, and rewrites every tag of the source to the successor as \`spec supersede\` does. The successor carries no \`issues\`.
3751
+
3752
+ \`--json\`: \`{ old_id, new_id, from_file, to_file, source_spec_version, target_spec_version, retagged: [...] }\`.
3753
+
3754
+ Exit: 0 / 2 (non-Active source, or the target is the source's own domain).
3755
+
3756
+ - Both envelopes validate before either is written.
3757
+ - The source version advances; the target version is unchanged.
3758
+
3759
+ ### spec amend
3760
+
3761
+ Revise an Active or Draft entry in place. Same id, no version change.
3762
+
3763
+ | Flag | Effect |
3764
+ | --- | --- |
3765
+ | \`--text\`, \`--why\`, \`--relates\` | Field to change. At least one flag is required. Untouched fields stay byte-identical. \`--relates\` repeats or comma-separates and replaces the whole list (the \`--aliases\` idiom); an empty value clears it. |
3766
+ | \`--issue <ticket>\` | Append \`amends-via\` provenance. |
3767
+ | \`--term\`, \`--aliases\` | TERM ids only. |
3768
+
3769
+ \`--json\`: \`{ id, file, fields_changed }\`.
3770
+
3771
+ A released requirement is an Active entry present in its domain file at the newest \`v*\` tag merged into HEAD. Its promise does not change in place: a \`--text\`, \`--why\`, \`--term\`, or \`--aliases\` amend refuses with exit 2, naming the tag. Two paths stay open: \`spec supersede\` changes the promise, and an amend of only \`--relates\` or \`--issue\` writes in place. With no \`v*\` tag merged into HEAD, or outside git, every entry is unreleased. A Draft entry always amends.
3772
+
3773
+ Exit: 0 / 2 (superseded or deprecated target; a released entry's promise fields).
3774
+
3775
+ ### spec accept
3776
+
3777
+ Promotes a Draft requirement to Active. Every other field stays byte-identical and the domain version does not move. Reword a draft with \`spec amend\` first; end one with \`spec deprecate\`.
3778
+
3779
+ \`--json\`: \`{ id, file }\`.
3780
+
3781
+ Exit: 0 / 2 (the target is not Draft: nothing is written).
3782
+
3783
+ - A Draft never raises \`ORPHAN_REQ\` or \`UNVERIFIED_REQ\` and is not counted in the coverage report. A code tag on one is \`DRAFT_REFERENCED\` (warning).
3784
+
3785
+ ### spec lint
3786
+
3787
+ Every statement-rule finding. Reads \`SPEC.json\` directly, not the index.
3788
+
3789
+ | Form | Scope |
3790
+ | --- | --- |
3791
+ | \`spec lint [platformDir]\` | the platform |
3792
+ | \`spec lint <KEY> [platformDir]\` | one domain |
3793
+ | \`spec lint <KEY-NNN> [platformDir]\` | one requirement |
3794
+ | \`--all\` | Draft requirements as well as Active ones |
3795
+
3796
+ Text mode prints \`<id> <rule>: <detail> \u2014 <reason>\` per finding on stdout, then rule 0 on stderr. \`--json\` prints a sorted array of \`{ req_id, rule, detail, reason, source_file, line }\`. The TERM domain is never judged. A lone positional that holds \`spec-engine/\` is the platform dir. CLI only: no MCP tool or HTTP route.
3797
+
3798
+ Exit: 0 clean / 1 any finding / 2 usage, unknown domain or id, or a bad \`check.json\`.
3799
+
3800
+ ### spec serve
3801
+
3802
+ Binds one address: \`127.0.0.1\`, or \`--host <ip>\` (a literal loopback, RFC 1918, or \`100.64/10\` address only; anything else exits 2). \`--port 0\` (default) picks a free port. \`--probe\` boots on loopback, fetches \`/\`, and exits 0 or 1. The startup line, \`/api/platform\` (\`name\`), and every page's title and sidebar name the platform (platform file \`name\`, else the directory name), never a path; \`spec mcp\` logs the same name on stderr.
3803
+
3804
+ A non-loopback \`--host\` is LAN mode: stdout prints \`http://<ip>:<port>/?token=\u2026\`; every request needs that token (the query parameter is exchanged for an \`HttpOnly; SameSite=Strict\` cookie, then a 302 drops it), else 401. A \`Host\` other than loopback or the bound address is 403. Read-only unless \`--allow-writes\`: non-GET requests, \`/provenance\`, and \`/api/provenance?resolve=1\` answer 403. \`--token\` / \`SPEC_SERVE_TOKEN\` pin the token (\u2265 16 of \`A-Z a-z 0-9 _ -\`); otherwise it is 256 random bits.
3805
+
3806
+ When the reindex after a write refuses on an invalid \`SPEC.json\` elsewhere on the platform, the route answers 422 \`{ error: "invalid_domain_file", diagnostics: [...] }\`, the rows in the \`check --json\` shape (the CLI exits 2 with the same rows).
3807
+
3808
+ Always-on routes: \`/\`, \`/requirements[/:id]\`, \`/propagation/:id\`, \`/api/coverage\`, \`/api/report\`, \`/api/repos\`, \`/api/platform\`, \`/api/requirements[/:id]\`, \`/api/propagation/:id\`, \`/api/resolve?files=\u2026\`.
3809
+
3810
+ Flag-gated, 404 until enabled. \`SPEC_FLAGS\` is comma-separated; unknown keys are ignored.
3811
+
3812
+ | \`SPEC_FLAGS\` key | Routes |
3813
+ | --- | --- |
3814
+ | \`query\` | \`/query\`, \`/api/query?q=&limit=\` |
3815
+ | \`relations\` | \`/relations\`, \`/api/relations[?format=mermaid]\` |
3816
+ | \`provenance\` | \`/provenance\`, \`/api/provenance[?resolve=1]\`, \`/api/provenance/by-issue?issue=\` |
3817
+ | \`editor\` | \`/editor\`, \`POST\` and \`PUT /api/requirements\`, \`POST /api/requirements/:id/supersede\`, \`POST /api/requirements/:id/move\`, \`POST /api/requirements/:id/deprecate\`, \`POST /api/requirements/:id/accept\` |
3818
+ | \`glossary\`, \`logs\` | Placeholder pages |
3819
+
3820
+ Exit: 0 / 1 / 2.
3821
+
3822
+ ### spec docs
3823
+
3824
+ Serves the prebuilt docs site on \`127.0.0.1\`, fully offline.
3825
+
3826
+ - Takes no platform dir. Registers none of \`--json\`, \`--out\`, \`--no-prompt\`.
3827
+ - The npm artifact ships the site at \`dist/docs/\`. A source checkout serves \`packages/site/dist\`; run \`bun run build:site\` first, else exit 2.
3828
+ - \`--probe\` mirrors \`serve --probe\`.
3829
+
3830
+ Exit: 0 / 1 / 2.
3831
+
3832
+ ### spec mcp
3833
+
3834
+ MCP server over stdio. Register it as \`{ "command": "spec", "args": ["mcp", "<platformDir>"] }\`.
3835
+
3836
+ | Tool | Returns |
3837
+ | --- | --- |
3838
+ | \`spec_query\` | FTS hits, the \`spec query --json\` shape |
3839
+ | \`spec_resolve\` | files \u2192 requirements |
3840
+ | \`spec_req_tags\` | requirement \u2192 tag sites |
3841
+ | \`spec_coverage_report\` | per-domain rollup \`{ domain, active, implemented, verified, orphans, unverified }\` |
3842
+ | \`spec_check\` | diagnostic rows |
3843
+ | \`spec_propagation\` | per-repo migration state |
3844
+ | \`spec_next_id\` | \`{ domain, next_id }\`, nothing written |
3845
+ | \`spec_get\` | one \`Requirement\` row with \`implemented_in\` and \`verified_in\`, or \`[]\` for an unknown id; the \`spec get --json\` shape |
3846
+ | \`spec_list\` | \`Requirement\` rows in (key, seq) order, \`domain\` and \`status\` filters; the \`spec list --json\` shape |
3847
+ | \`spec_supersede\` | \`{ old_id, new_id, file, spec_version, retagged }\`, the \`spec supersede --json\` shape; optional \`why\`, \`relates\`, \`issue\` arguments |
3848
+ | \`spec_deprecate\` | \`{ id, file, reason, removed }\`, the \`spec deprecate --json\` shape |
3849
+ | \`spec_accept\` | \`{ id, file }\`, the \`spec accept --json\` shape |
3850
+ | \`spec_req\` | \`{ id, file, clauses? }\`, the \`spec req --text --json\` shape; \`domain\` (key or prefix) and \`text\` required, optional \`why\`, \`relates\`, \`issue\`, \`draft\` |
3851
+ | \`spec_amend\` | \`{ id, file, fields_changed }\`, the \`spec amend --json\` shape; \`id\` required, optional \`text\`, \`why\`, \`relates\`, \`issue\` |
3852
+ | \`spec_move\` | \`{ old_id, new_id, from_file, to_file, source_spec_version, target_spec_version, retagged }\`, the \`spec move --json\` shape; \`id\` and \`newDomain\` required, optional \`text\`, \`why\` |
3853
+
3854
+ Every tool call reindexes fresh. stdout is the protocol channel; chrome goes to stderr. One prompt, \`author_requirements\` (\`brief\` required, \`domain\` optional), returns a static authoring playbook with the brief and the domain's charter substituted. The engine runs no model.
3855
+
3856
+ Exit: 0 / 2.
3857
+
3858
+ ### Gotchas
3859
+
3860
+ - **\`fixtures/\` is globally ignored by the tag scanner.** Tags under any \`fixtures/\` folder are test data, not coverage claims.
3861
+ - **The index owns nothing.** Never edit \`.spec-engine/index.sqlite\`. Edit the \`SPEC.json\` source and re-run \`spec index\`.
3862
+ - **\`storage_unavailable\` / \`SQLITE_IOERR*\` means your environment.** The index runs in WAL mode and needs real file locks. A sandboxed process gets \`SQLITE_IOERR_VNODE\`; re-run unsandboxed or grant write and lock access to \`<platformDir>/.spec-engine/\`. \`SQLITE_BUSY\` means another \`spec\` process holds the index.
3863
+ - **Empty is not an error.** Read commands exit 0 with \`[]\` and guidance on stderr.
3864
+ - **Determinism is contractual.** JSON outputs are sorted and byte-stable.
3865
+ - A directory passed as the last positional is \`platformDir\` only if it contains \`spec-engine/\`. Otherwise it is treated as a file, with a warning.
3866
+ `;
3867
+ var init_agent_reference = () => {};
3868
+
3869
+ // src/operations/agentSurface.ts
3870
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
3871
+ import { dirname as dirname4, join as join5, relative as relative2 } from "path";
3872
+ function mcpTemplate(platformArg) {
3873
+ const body = { mcpServers: { spec: { command: "spec", args: ["mcp", platformArg] } } };
3874
+ return `${JSON.stringify(body, null, 2)}
3875
+ `;
3876
+ }
3877
+ function shellArg(arg) {
3878
+ return /^[\w./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`;
3879
+ }
3880
+ function writeIfChanged(path, content, out) {
3881
+ if (existsSync3(path) && readFileSync(path, "utf8") === content)
3882
+ return;
3883
+ mkdirSync2(dirname4(path), { recursive: true });
3884
+ writeFileSync(path, content);
3885
+ out.written.push(path);
3886
+ }
3887
+ function writeReferenceBlock(dir, out) {
3888
+ const path = join5(dir, "AGENTS.md");
3889
+ const block = `${AGENT_BLOCK_BEGIN}
3890
+ ${AGENT_REFERENCE.trimEnd()}
3891
+ ${AGENT_BLOCK_END}`;
3892
+ const text = existsSync3(path) ? readFileSync(path, "utf8") : "";
3893
+ const begin = text.indexOf(AGENT_BLOCK_BEGIN);
3894
+ const end = text.indexOf(AGENT_BLOCK_END, begin);
3895
+ let next;
3896
+ if (begin !== -1 && end !== -1) {
3897
+ next = text.slice(0, begin) + block + text.slice(end + AGENT_BLOCK_END.length);
3898
+ } else if (begin !== -1 || text.includes(AGENT_BLOCK_END)) {
3899
+ out.hints.push(`${path} holds only one agent-reference marker; restore the pair to let spec init refresh the block`);
3900
+ return;
3901
+ } else {
3902
+ const gap = text === "" ? "" : text.endsWith(`
3903
+ `) ? `
3904
+ ` : `
3905
+
3906
+ `;
3907
+ next = `${text}${gap}${block}
3908
+ `;
3909
+ }
3910
+ writeIfChanged(path, next, out);
3911
+ const claude = join5(dir, "CLAUDE.md");
3912
+ if (existsSync3(claude) && !readFileSync(claude, "utf8").includes("AGENTS.md")) {
3913
+ out.hints.push(`${claude} does not reference AGENTS.md; add the line @AGENTS.md so Claude Code loads the spec reference`);
3914
+ }
3915
+ }
3916
+ function writeSkill(dir, force, out) {
3917
+ for (const [rel, content] of Object.entries(SKILL_FILES)) {
3918
+ const path = join5(dir, ".claude", "skills", rel);
3919
+ if (existsSync3(path) && !force && readFileSync(path, "utf8") !== content) {
3920
+ out.hints.push(`${path} differs from the bundled skill; re-run with --force to replace it`);
3921
+ continue;
3922
+ }
3923
+ writeIfChanged(path, content, out);
3924
+ }
3925
+ }
3926
+ function isRecord(value) {
3927
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3928
+ }
3929
+ function writeMcpEntry(dir, platformArg, out) {
3930
+ const path = join5(dir, ".mcp.json");
3931
+ const entry = { command: "spec", args: ["mcp", platformArg] };
3932
+ const wanted = `add to its mcpServers: "spec": ${JSON.stringify(entry)}`;
3933
+ if (!existsSync3(path)) {
3934
+ writeIfChanged(path, mcpTemplate(platformArg), out);
3935
+ return;
3936
+ }
3937
+ let config2;
3938
+ try {
3939
+ config2 = JSON.parse(readFileSync(path, "utf8"));
3940
+ } catch {
3941
+ out.hints.push(`${path} is not valid JSON; left unchanged. ${wanted}`);
3942
+ return;
3943
+ }
3944
+ if (!isRecord(config2) || config2.mcpServers !== undefined && !isRecord(config2.mcpServers)) {
3945
+ out.hints.push(`${path} is not an MCP server config object; left unchanged. ${wanted}`);
3946
+ return;
3947
+ }
3948
+ const servers = config2.mcpServers ?? {};
3949
+ if (servers.spec !== undefined) {
3950
+ if (JSON.stringify(servers.spec) !== JSON.stringify(entry)) {
3951
+ out.hints.push(`${path} already has a spec server entry; left unchanged. Expected ${wanted}`);
3952
+ }
3953
+ return;
3954
+ }
3955
+ config2.mcpServers = { ...servers, spec: entry };
3956
+ writeIfChanged(path, `${JSON.stringify(config2, null, 2)}
3957
+ `, out);
3958
+ }
3959
+ function writeHook(dir, platformArg, out) {
3960
+ const command = `spec guard ${shellArg(platformArg)}`;
3961
+ const hooksDir = [join5(dir, ".githooks"), join5(dir, ".git", "hooks")].find(isExistingDir);
3962
+ if (hooksDir === undefined) {
3963
+ out.hints.push(`${dir} has no hooks directory; add a pre-commit hook that runs: ${command}`);
3964
+ return;
3965
+ }
3966
+ const path = join5(hooksDir, "pre-commit");
3967
+ if (existsSync3(path)) {
3968
+ if (!readFileSync(path, "utf8").includes(command)) {
3969
+ out.hints.push(`${path} already exists; left unchanged. Add this line to it: ${command}`);
3970
+ }
3971
+ return;
3972
+ }
3973
+ writeFileSync(path, PRE_COMMIT.replace("spec guard .", command), { mode: 493 });
3974
+ out.written.push(path);
3975
+ if (hooksDir.endsWith(".githooks")) {
3976
+ out.hints.push(`git runs ${path} only after: git config core.hooksPath .githooks`);
3977
+ }
3978
+ }
3979
+ function writeAgentSurface(input) {
3980
+ const out = { written: [], hints: [] };
3981
+ writeReferenceBlock(input.dir, out);
3982
+ writeSkill(input.dir, input.force, out);
3983
+ if (input.platformDir === null) {
3984
+ out.hints.push("no platform was located, so the MCP server and the pre-commit hook were skipped; re-run spec init once the repository belongs to a platform");
3985
+ return out;
3986
+ }
3987
+ const platformArg = relative2(input.dir, input.platformDir) || ".";
3988
+ writeMcpEntry(input.dir, platformArg, out);
3989
+ writeHook(input.dir, platformArg, out);
3990
+ return out;
3991
+ }
3992
+ var AGENT_BLOCK_BEGIN = "<!-- spec-engine:agent-reference:begin -->", AGENT_BLOCK_END = "<!-- spec-engine:agent-reference:end -->", AGENT_REFERENCE, SKILL_FILES, PRE_COMMIT = `#!/bin/sh
3993
+ spec guard .
3994
+ exit $?
3995
+ `;
3996
+ var init_agentSurface = __esm(() => {
3997
+ init_SKILL();
3998
+ init_agent_reference();
3999
+ init_constants();
4000
+ AGENT_REFERENCE = agent_reference_default.replace(/^---\n[\s\S]*?\n---\n+/, "").replace(/^<!-- @spec [A-Z][A-Z0-9]*-\d+ -->\n\n?/gm, "").replace(/ <!-- @spec [A-Z][A-Z0-9]*-\d+ -->/g, "");
4001
+ SKILL_FILES = { "req-author/SKILL.md": SKILL_default };
4002
+ });
4003
+
2922
4004
  // src/operations/_result.ts
2923
4005
  function fail(reason, detail, extra = {}) {
2924
4006
  const out = { ok: false, reason, detail };
@@ -2940,8 +4022,8 @@ var init__result = __esm(() => {
2940
4022
  });
2941
4023
 
2942
4024
  // src/operations/init.ts
2943
- import { existsSync as existsSync3, realpathSync, statSync as statSync2 } from "fs";
2944
- import { basename as basename3, join as join5, relative as relative2, resolve as resolve4, sep as sep3 } from "path";
4025
+ import { existsSync as existsSync4, realpathSync, statSync as statSync2 } from "fs";
4026
+ import { basename as basename3, join as join6, relative as relative3, resolve as resolve4, sep as sep3 } from "path";
2945
4027
  import {
2946
4028
  applyInit,
2947
4029
  applyLink,
@@ -2975,7 +4057,7 @@ function canonicalize(repoDir) {
2975
4057
  function judgeSpecTree(repoDir, canonical, root) {
2976
4058
  if (!hasSpecEngineSegment(canonical))
2977
4059
  return { ok: true };
2978
- const judged = root !== null && isEnginePlatform(root, ENGINE_PACKAGE_NAME) ? relative2(root, canonical) : canonical;
4060
+ const judged = root !== null && isEnginePlatform(root, ENGINE_PACKAGE_NAME) ? relative3(root, canonical) : canonical;
2979
4061
  if (hasSpecEngineSegment(judged)) {
2980
4062
  return fail("usage", `${repoDir} resolves to ${canonical}, which is inside a spec-engine/ tree \u2014 refusing to scaffold there.`);
2981
4063
  }
@@ -3023,7 +4105,7 @@ function place(repoDir, canonical) {
3023
4105
  return fail("usage", `${canonical} is neither a declared member nor a workspace package of the platform at ${root}`);
3024
4106
  }
3025
4107
  function link(canonical, platformRoot) {
3026
- if (!existsSync3(join5(canonical, PLATFORM_MAP_FILENAME))) {
4108
+ if (!existsSync4(join6(canonical, PLATFORM_MAP_FILENAME))) {
3027
4109
  return fail("usage", `${canonical} carries no platform-map.json marker, so it is not a declared member; declare it from the platform folder with \`spec init <name>\``);
3028
4110
  }
3029
4111
  const plan = planLink(canonical, { root: resolve4(platformRoot) });
@@ -3038,7 +4120,7 @@ async function resolveMemberPin(input) {
3038
4120
  if (!canon.ok)
3039
4121
  return canon;
3040
4122
  const { canonical } = canon;
3041
- if (isExistingDir(join5(canonical, CANONICAL_SPECS_DIR))) {
4123
+ if (isExistingDir(join6(canonical, CANONICAL_SPECS_DIR))) {
3042
4124
  return fail("usage", `${canonical} is a platform dir (contains spec-engine/) \u2014 pass a member subdir or cd into one`);
3043
4125
  }
3044
4126
  let linked = null;
@@ -3088,7 +4170,7 @@ function planDeclare(platformDir, name) {
3088
4170
  }
3089
4171
  const candidate = plan.candidates.find((c) => c.name === name);
3090
4172
  if (candidate === undefined) {
3091
- return fail("usage", `${join5(platformDir, name)} has no .git entry or package.json, so platform-map cannot declare it as a member`);
4173
+ return fail("usage", `${join6(platformDir, name)} has no .git entry or package.json, so platform-map cannot declare it as a member`);
3092
4174
  }
3093
4175
  if (candidate.marker !== undefined && candidate.marker !== plan.platformName) {
3094
4176
  return fail("conflict", `${name} carries a platform-map.json marker for platform "${candidate.marker}", not "${plan.platformName}"`);
@@ -3131,8 +4213,8 @@ async function write(configPath, specs, ignore) {
3131
4213
  }
3132
4214
  async function writeMemberConfig(input) {
3133
4215
  const { canonical, pin } = input;
3134
- const configPath = join5(canonical, MEMBER_CONFIG_FILENAME);
3135
- if (!existsSync3(configPath)) {
4216
+ const configPath = join6(canonical, MEMBER_CONFIG_FILENAME);
4217
+ if (!existsSync4(configPath)) {
3136
4218
  await write(configPath, pin, input.ignore);
3137
4219
  return { ok: true, action: "wrote", path: configPath, pin };
3138
4220
  }
@@ -3255,16 +4337,16 @@ function renderDiagnostics(rows, mode) {
3255
4337
  }
3256
4338
 
3257
4339
  // src/operations/_index.ts
3258
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readdirSync, statSync as statSync3 } from "fs";
3259
- import { dirname as dirname4, join as join6 } from "path";
4340
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, statSync as statSync3 } from "fs";
4341
+ import { dirname as dirname5, join as join7 } from "path";
3260
4342
  async function withIndex(opts, fn) {
3261
4343
  const { platformDir, build } = opts;
3262
4344
  const dbPath = opts.dbPath ?? defaultIndexPath(platformDir);
3263
4345
  assertSpecPlatform(platformDir);
3264
- mkdirSync2(dirname4(dbPath), { recursive: true });
4346
+ mkdirSync3(dirname5(dbPath), { recursive: true });
3265
4347
  if (build === "fresh" || build === "reset")
3266
4348
  coldResetDb(dbPath);
3267
- const wasMissing = !existsSync4(dbPath);
4349
+ const wasMissing = !existsSync5(dbPath);
3268
4350
  const storage2 = openStorage(dbPath);
3269
4351
  try {
3270
4352
  const warnings = [];
@@ -3285,12 +4367,12 @@ async function withIndex(opts, fn) {
3285
4367
  function staleIndexWarning(platformDir, dbPath) {
3286
4368
  try {
3287
4369
  const dbMtime = statSync3(dbPath).mtimeMs;
3288
- const specsDir = join6(platformDir, CANONICAL_SPECS_DIR);
4370
+ const specsDir = join7(platformDir, CANONICAL_SPECS_DIR);
3289
4371
  for (const entry of readdirSync(specsDir, { withFileTypes: true })) {
3290
4372
  if (!entry.isDirectory())
3291
4373
  continue;
3292
- const specPath = join6(specsDir, entry.name, SPEC_FILENAME);
3293
- if (!existsSync4(specPath))
4374
+ const specPath = join7(specsDir, entry.name, SPEC_FILENAME);
4375
+ if (!existsSync5(specPath))
3294
4376
  continue;
3295
4377
  if (statSync3(specPath).mtimeMs > dbMtime) {
3296
4378
  return `spec: warning \u2014 ${CANONICAL_SPECS_DIR}/${entry.name}/${SPEC_FILENAME} changed after the index was built; results may be stale. Pass --fresh to rebuild.`;
@@ -3383,7 +4465,7 @@ function printWarnings(cmdName, warnings) {
3383
4465
  for (const w of warnings ?? []) {
3384
4466
  if (w.kind === "ref")
3385
4467
  console.error(`spec req: warning \u2014 ${w.text}`);
3386
- else if (w.kind === "grammar")
4468
+ else if (w.kind === "grammar" || w.kind === "statement")
3387
4469
  console.error(`${cmdName}: ${w.text}`);
3388
4470
  else
3389
4471
  console.error(w.text);
@@ -3429,7 +4511,7 @@ __export(exports_init, {
3429
4511
  runInit: () => runInit,
3430
4512
  initCommand: () => initCommand
3431
4513
  });
3432
- import { resolve as resolve5 } from "path";
4514
+ import { dirname as dirname6, join as join8, resolve as resolve5 } from "path";
3433
4515
  import { defineCommand } from "citty";
3434
4516
  function errMessage2(err) {
3435
4517
  return err instanceof Error ? err.message : String(err);
@@ -3461,19 +4543,54 @@ function sourceText(result, json) {
3461
4543
  }
3462
4544
  return "fallback @1";
3463
4545
  }
4546
+ function printSurface(surface, json) {
4547
+ for (const hint of surface.hints)
4548
+ console.error(`spec init: ${hint}`);
4549
+ if (json)
4550
+ return;
4551
+ for (const file of surface.written)
4552
+ console.log(` agent surface: ${file}`);
4553
+ }
4554
+ function surfaceFor(result, force) {
4555
+ const { placement } = result;
4556
+ return writeAgentSurface({
4557
+ dir: placement.kind === "package" ? placement.platformDir : dirname6(result.path),
4558
+ platformDir: placement.kind === "loose" ? null : placement.platformDir,
4559
+ force
4560
+ });
4561
+ }
4562
+ function isPlatformDir(dir) {
4563
+ try {
4564
+ return isExistingDir(join8(dir, CANONICAL_SPECS_DIR));
4565
+ } catch {
4566
+ return false;
4567
+ }
4568
+ }
4569
+ function initPlatformDir(dir, force, json) {
4570
+ const surface = writeAgentSurface({ dir, platformDir: dir, force });
4571
+ if (json) {
4572
+ console.log(JSON.stringify({ action: "surface-only", agent_surface: surface.written }));
4573
+ } else {
4574
+ console.log(`spec init: ${dir} is a platform directory; wrote no member config`);
4575
+ }
4576
+ printSurface(surface, json);
4577
+ }
3464
4578
  async function runInit(args) {
3465
4579
  const json = Boolean(args.json);
4580
+ const force = Boolean(args.force);
3466
4581
  const override = typeof args.specs === "string" ? validateOverride(args.specs) : undefined;
3467
4582
  const result = await initMember({
3468
4583
  repoDir: resolveRepoArg(args.repo),
3469
4584
  override,
3470
4585
  platformRoot: typeof args.platform === "string" ? args.platform : undefined,
3471
- force: Boolean(args.force)
4586
+ force
3472
4587
  });
3473
4588
  if (!result.ok)
3474
4589
  exitOnFailure("spec init", result);
3475
4590
  const source = sourceText(result, json);
3476
4591
  const { declared, linked } = result;
4592
+ const surface = surfaceFor(result, force);
4593
+ const agent_surface = surface.written;
3477
4594
  if (result.action === "already-configured") {
3478
4595
  if (json) {
3479
4596
  console.log(JSON.stringify({
@@ -3482,8 +4599,10 @@ async function runInit(args) {
3482
4599
  pin: result.pin,
3483
4600
  extra_fields: result.extraFields,
3484
4601
  declared,
3485
- linked
4602
+ linked,
4603
+ agent_surface
3486
4604
  }));
4605
+ printSurface(surface, json);
3487
4606
  return;
3488
4607
  }
3489
4608
  console.log("spec init: already configured");
@@ -3496,6 +4615,7 @@ async function runInit(args) {
3496
4615
  console.log(` declared: ${file}`);
3497
4616
  if (linked !== null)
3498
4617
  console.log(` linked: ${linked}`);
4618
+ printSurface(surface, json);
3499
4619
  return;
3500
4620
  }
3501
4621
  if (json) {
@@ -3505,8 +4625,10 @@ async function runInit(args) {
3505
4625
  pin: result.pin,
3506
4626
  source,
3507
4627
  declared,
3508
- linked
4628
+ linked,
4629
+ agent_surface
3509
4630
  }));
4631
+ printSurface(surface, json);
3510
4632
  return;
3511
4633
  }
3512
4634
  console.log(`spec init: wrote ${result.path}`);
@@ -3516,17 +4638,19 @@ async function runInit(args) {
3516
4638
  console.log(` declared: ${file}`);
3517
4639
  if (linked !== null)
3518
4640
  console.log(` linked: ${linked}`);
4641
+ printSurface(surface, json);
3519
4642
  }
3520
4643
  var initCommand;
3521
4644
  var init_init2 = __esm(() => {
3522
4645
  init_src();
3523
4646
  init_constants();
4647
+ init_agentSurface();
3524
4648
  init_init();
3525
4649
  init__shared();
3526
4650
  initCommand = defineCommand({
3527
4651
  meta: {
3528
4652
  name: "init",
3529
- description: "Declare a member repo and scaffold its spec-engine.member.json"
4653
+ description: "Declare a member repo, scaffold its spec-engine.member.json, and install the agent surface"
3530
4654
  },
3531
4655
  args: {
3532
4656
  repo: {
@@ -3544,7 +4668,7 @@ var init_init2 = __esm(() => {
3544
4668
  },
3545
4669
  force: {
3546
4670
  type: "boolean",
3547
- description: "Overwrite an existing spec-engine.member.json (refuses if existing has extra keys)."
4671
+ description: "Overwrite an existing spec-engine.member.json (refuses if existing has extra keys) and a differing req-author skill."
3548
4672
  },
3549
4673
  json: {
3550
4674
  type: "boolean",
@@ -3552,7 +4676,11 @@ var init_init2 = __esm(() => {
3552
4676
  }
3553
4677
  },
3554
4678
  async run({ args }) {
3555
- await runInit(args);
4679
+ const repoDir = resolveRepoArg(args.repo);
4680
+ if (isPlatformDir(repoDir))
4681
+ initPlatformDir(repoDir, Boolean(args.force), Boolean(args.json));
4682
+ else
4683
+ await runInit(args);
3556
4684
  }
3557
4685
  });
3558
4686
  });
@@ -3617,7 +4745,7 @@ function listFlag(rawArgs, name, parsed) {
3617
4745
  }
3618
4746
  return raw.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v !== "");
3619
4747
  }
3620
- var platformDirArg, outArg, jsonArg, freshArg, noPromptArg, livesArg, testsArg, relatesArg;
4748
+ var platformDirArg, outArg, jsonArg, freshArg, noPromptArg, relatesArg;
3621
4749
  var init__args = __esm(() => {
3622
4750
  init_constants();
3623
4751
  platformDirArg = {
@@ -3638,14 +4766,6 @@ var init__args = __esm(() => {
3638
4766
  type: "boolean",
3639
4767
  description: "Suppress the interactive onboarding prompt for siblings missing spec-engine.member.json (they surface as NO_SPEC_CONFIG warnings)"
3640
4768
  };
3641
- livesArg = {
3642
- type: "string",
3643
- description: "Lives in (livesIn) paths: repeat the flag or comma-separate; an empty value clears the list"
3644
- };
3645
- testsArg = {
3646
- type: "string",
3647
- description: "Verified in (verifiedIn) test paths: repeat the flag or comma-separate; an empty value clears the list"
3648
- };
3649
4769
  relatesArg = {
3650
4770
  type: "string",
3651
4771
  description: "Related requirement ids (relates): repeat the flag or comma-separate; an empty value clears the list"
@@ -3657,8 +4777,8 @@ var exports_commands = {};
3657
4777
  __export(exports_commands, {
3658
4778
  indexCommand: () => indexCommand
3659
4779
  });
3660
- import { mkdirSync as mkdirSync3 } from "fs";
3661
- import { dirname as dirname5 } from "path";
4780
+ import { mkdirSync as mkdirSync4 } from "fs";
4781
+ import { dirname as dirname7 } from "path";
3662
4782
  import { defineCommand as defineCommand2 } from "citty";
3663
4783
  var indexCommand;
3664
4784
  var init_commands = __esm(() => {
@@ -3705,7 +4825,7 @@ var init_commands = __esm(() => {
3705
4825
  let storage2;
3706
4826
  try {
3707
4827
  assertSpecPlatform(platformDir);
3708
- mkdirSync3(dirname5(dbPath), { recursive: true });
4828
+ mkdirSync4(dirname7(dbPath), { recursive: true });
3709
4829
  storage2 = openStorage(dbPath);
3710
4830
  const result = await runIndex({ platformDir, storage: storage2 });
3711
4831
  if (args.json) {
@@ -3734,6 +4854,87 @@ var init_commands = __esm(() => {
3734
4854
  });
3735
4855
  });
3736
4856
 
4857
+ // src/base/gitBase.ts
4858
+ function gitEnv() {
4859
+ return Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_") || k.startsWith("GIT_CONFIG")));
4860
+ }
4861
+ function isSafeRef(ref) {
4862
+ return /^[A-Za-z0-9._/][A-Za-z0-9._/-]*$/.test(ref);
4863
+ }
4864
+ function gitRepoPrefix(platformDir) {
4865
+ const proc = Bun.spawnSync(["git", "rev-parse", "--show-prefix"], {
4866
+ cwd: platformDir,
4867
+ env: gitEnv()
4868
+ });
4869
+ if (proc.exitCode !== 0)
4870
+ return "";
4871
+ return proc.stdout.toString().trim();
4872
+ }
4873
+ function gitRefResolves(platformDir, ref) {
4874
+ if (!isSafeRef(ref))
4875
+ return false;
4876
+ const proc = Bun.spawnSync(["git", "rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
4877
+ cwd: platformDir,
4878
+ env: gitEnv()
4879
+ });
4880
+ return proc.exitCode === 0;
4881
+ }
4882
+ function gitShow(platformDir, ref, relPath) {
4883
+ if (!isSafeRef(ref))
4884
+ return null;
4885
+ const full = `${gitRepoPrefix(platformDir)}${relPath}`;
4886
+ const proc = Bun.spawnSync(["git", "show", `${ref}:${full}`], {
4887
+ cwd: platformDir,
4888
+ env: gitEnv()
4889
+ });
4890
+ if (proc.exitCode !== 0)
4891
+ return null;
4892
+ return proc.stdout.toString();
4893
+ }
4894
+ function gitLsTree(platformDir, ref, dir) {
4895
+ if (!isSafeRef(ref))
4896
+ return [];
4897
+ const prefix = gitRepoPrefix(platformDir);
4898
+ const proc = Bun.spawnSync(["git", "ls-tree", "-r", "--name-only", ref, "--", `${prefix}${dir}`], { cwd: platformDir, env: gitEnv() });
4899
+ if (proc.exitCode !== 0)
4900
+ return [];
4901
+ return proc.stdout.toString().split(`
4902
+ `).map((l) => l.trim()).filter((l) => l.length > 0).map((l) => prefix.length > 0 && l.startsWith(prefix) ? l.slice(prefix.length) : l);
4903
+ }
4904
+ function gitTreeMatchesHead(platformDir) {
4905
+ return Bun.spawnSync(["git", "diff", "--quiet", "HEAD", "--", ".", ":!.spec-engine"], {
4906
+ cwd: platformDir
4907
+ }).exitCode === 0;
4908
+ }
4909
+ function gitDiffNameStatus(platformDir, ref) {
4910
+ if (!isSafeRef(ref))
4911
+ return [];
4912
+ const proc = Bun.spawnSync(["git", "diff", "--name-status", "--no-renames", "--relative", ref, "--"], { cwd: platformDir, env: gitEnv() });
4913
+ if (proc.exitCode !== 0)
4914
+ return [];
4915
+ const rows = [];
4916
+ for (const line of proc.stdout.toString().split(`
4917
+ `)) {
4918
+ if (line.length === 0)
4919
+ continue;
4920
+ const parts = line.split("\t");
4921
+ const status = (parts[0] ?? "").trim().toUpperCase();
4922
+ const path = (parts[parts.length - 1] ?? "").trim();
4923
+ if (status.length === 0 || path.length === 0)
4924
+ continue;
4925
+ rows.push({ status, path });
4926
+ }
4927
+ return rows;
4928
+ }
4929
+ function newestReleaseTag(platformDir) {
4930
+ const proc = Bun.spawnSync(["git", "tag", "--list", "v*", "--merged", "HEAD", "--sort=-v:refname"], { cwd: platformDir, env: gitEnv() });
4931
+ if (proc.exitCode !== 0)
4932
+ return null;
4933
+ const tag = proc.stdout.toString().split(`
4934
+ `)[0]?.trim() ?? "";
4935
+ return tag !== "" && isSafeRef(tag) ? tag : null;
4936
+ }
4937
+
3737
4938
  // src/check/proven.ts
3738
4939
  function normalizeToSegments(path) {
3739
4940
  const out = [];
@@ -3857,74 +5058,6 @@ var init_proven = __esm(() => {
3857
5058
  init_src();
3858
5059
  });
3859
5060
 
3860
- // src/base/gitBase.ts
3861
- function isSafeRef(ref) {
3862
- return /^[A-Za-z0-9._/][A-Za-z0-9._/-]*$/.test(ref);
3863
- }
3864
- function gitRepoPrefix(platformDir) {
3865
- const proc = Bun.spawnSync(["git", "rev-parse", "--show-prefix"], { cwd: platformDir });
3866
- if (proc.exitCode !== 0)
3867
- return "";
3868
- return proc.stdout.toString().trim();
3869
- }
3870
- function gitRefResolves(platformDir, ref) {
3871
- if (!isSafeRef(ref))
3872
- return false;
3873
- const proc = Bun.spawnSync(["git", "rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
3874
- cwd: platformDir
3875
- });
3876
- return proc.exitCode === 0;
3877
- }
3878
- function gitShow(platformDir, ref, relPath) {
3879
- if (!isSafeRef(ref))
3880
- return null;
3881
- const full = `${gitRepoPrefix(platformDir)}${relPath}`;
3882
- const proc = Bun.spawnSync(["git", "show", `${ref}:${full}`], { cwd: platformDir });
3883
- if (proc.exitCode !== 0)
3884
- return null;
3885
- return proc.stdout.toString();
3886
- }
3887
- function gitLsTree(platformDir, ref, dir) {
3888
- if (!isSafeRef(ref))
3889
- return [];
3890
- const prefix = gitRepoPrefix(platformDir);
3891
- const proc = Bun.spawnSync(["git", "ls-tree", "-r", "--name-only", ref, "--", `${prefix}${dir}`], { cwd: platformDir });
3892
- if (proc.exitCode !== 0)
3893
- return [];
3894
- return proc.stdout.toString().split(`
3895
- `).map((l) => l.trim()).filter((l) => l.length > 0).map((l) => prefix.length > 0 && l.startsWith(prefix) ? l.slice(prefix.length) : l);
3896
- }
3897
- function gitDiffNameStatus(platformDir, ref) {
3898
- if (!isSafeRef(ref))
3899
- return [];
3900
- const proc = Bun.spawnSync(["git", "diff", "--name-status", "--no-renames", "--relative", ref, "--"], {
3901
- cwd: platformDir
3902
- });
3903
- if (proc.exitCode !== 0)
3904
- return [];
3905
- const rows = [];
3906
- for (const line of proc.stdout.toString().split(`
3907
- `)) {
3908
- if (line.length === 0)
3909
- continue;
3910
- const parts = line.split("\t");
3911
- const status = (parts[0] ?? "").trim().toUpperCase();
3912
- const path = (parts[parts.length - 1] ?? "").trim();
3913
- if (status.length === 0 || path.length === 0)
3914
- continue;
3915
- rows.push({ status, path });
3916
- }
3917
- return rows;
3918
- }
3919
- function newestReleaseTag(platformDir) {
3920
- const proc = Bun.spawnSync(["git", "tag", "--list", "v*", "--merged", "HEAD", "--sort=-v:refname"], { cwd: platformDir });
3921
- if (proc.exitCode !== 0)
3922
- return null;
3923
- const tag = proc.stdout.toString().split(`
3924
- `)[0]?.trim() ?? "";
3925
- return tag !== "" && isSafeRef(tag) ? tag : null;
3926
- }
3927
-
3928
5061
  // src/check/codeowners.ts
3929
5062
  function parseCodeowners(text) {
3930
5063
  const rules = [];
@@ -4002,18 +5135,18 @@ function matchesSegment(pat, seg) {
4002
5135
  }
4003
5136
 
4004
5137
  // src/authoring/domains.ts
4005
- import { existsSync as existsSync5, readdirSync as readdirSync2 } from "fs";
4006
- import { join as join7 } from "path";
5138
+ import { existsSync as existsSync6, readdirSync as readdirSync2 } from "fs";
5139
+ import { join as join9 } from "path";
4007
5140
  function normalizeDomainKey(raw) {
4008
5141
  return raw.toUpperCase().replace(/\s+/g, "");
4009
5142
  }
4010
5143
  function listDomainKeys(platformDir) {
4011
- const specsDir = join7(platformDir, "spec-engine");
4012
- return readdirSync2(specsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync5(join7(specsDir, e.name, "SPEC.json"))).map((e) => e.name).sort();
5144
+ const specsDir = join9(platformDir, "spec-engine");
5145
+ return readdirSync2(specsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync6(join9(specsDir, e.name, "SPEC.json"))).map((e) => e.name).sort();
4013
5146
  }
4014
5147
  async function domainScope(platformDir, key) {
4015
5148
  const { abs: jsonPath, rel: relFile } = specPaths(platformDir, key);
4016
- if (!existsSync5(jsonPath))
5149
+ if (!existsSync6(jsonPath))
4017
5150
  return null;
4018
5151
  let text;
4019
5152
  try {
@@ -4054,166 +5187,28 @@ function requirementsOf(text, sourceFile) {
4054
5187
  }
4055
5188
  async function headMaxSeq(platformDir, key) {
4056
5189
  try {
4057
- const proc = Bun.spawn(["git", "-C", join7(platformDir, "spec-engine", key), "show", "HEAD:./SPEC.json"], { stdout: "pipe", stderr: "ignore" });
5190
+ const proc = Bun.spawn(["git", "-C", join9(platformDir, "spec-engine", key), "show", "HEAD:./SPEC.json"], { stdout: "pipe", stderr: "ignore", env: gitEnv() });
4058
5191
  const text = await new Response(proc.stdout).text();
4059
- if (await proc.exited !== 0)
4060
- return 0;
4061
- return maxSeqOf(requirementsOf(text, specPaths(platformDir, key).rel));
4062
- } catch {
4063
- return 0;
4064
- }
4065
- }
4066
- async function nextRequirementId(platformDir, key) {
4067
- const { abs: jsonPath, rel: relFile } = specPaths(platformDir, key);
4068
- if (!existsSync5(jsonPath))
4069
- return `${key}-001`;
4070
- const requirements = requirementsOf(await Bun.file(jsonPath).text(), relFile);
4071
- const maxSeq = Math.max(maxSeqOf(requirements), await headMaxSeq(platformDir, key));
4072
- const next = String(maxSeq + 1).padStart(3, "0");
4073
- return `${key}-${next}`;
4074
- }
4075
- var init_domains = __esm(() => {
4076
- init_src();
4077
- init_constants();
4078
- init_src();
4079
- });
4080
-
4081
- // src/authoring/filerefs.ts
4082
- import { existsSync as existsSync6 } from "fs";
4083
- import { resolve as resolve7 } from "path";
4084
- function extractRefsFromText(s) {
4085
- const out = [];
4086
- for (const m of s.matchAll(FILE_REF_RE)) {
4087
- const candidate = (m[1] ?? "").replace(/\.+$/, "");
4088
- if (candidate.includes("/"))
4089
- out.push(candidate);
4090
- }
4091
- return out;
4092
- }
4093
- function resolveFileRef(platformDir, ref) {
4094
- const root = resolve7(platformDir);
4095
- const abs = resolve7(root, ref);
4096
- if (!(abs === root || abs.startsWith(`${root}/`)))
4097
- return false;
4098
- return existsSync6(abs);
4099
- }
4100
- var FILE_REF_RE;
4101
- var init_filerefs = __esm(() => {
4102
- FILE_REF_RE = /(?<![^\s])@([A-Za-z0-9_][A-Za-z0-9_\-./]*)/g;
4103
- });
4104
-
4105
- // src/check/filerefs.ts
4106
- function locateInDomain(raw, sourceFile, requirements, statuses) {
4107
- const out = [];
4108
- const rawLines = raw.split(`
4109
- `);
4110
- let lineCursor = 0;
4111
- for (const req of requirements) {
4112
- const found = findRequirementIdLine(rawLines, req.id, lineCursor);
4113
- if (found >= 0)
4114
- lineCursor = found + 1;
4115
- if (statuses.has(req.status.toLowerCase())) {
4116
- out.push({ sourceFile, line: found >= 0 ? found + 1 : 0, req });
4117
- }
4118
- }
4119
- return out;
4120
- }
4121
- async function locatedEntries(platformDir, statuses) {
4122
- const out = [];
4123
- for (const key of listDomainKeys(platformDir)) {
4124
- const { abs, rel } = specPaths(platformDir, key);
4125
- let raw;
4126
- try {
4127
- raw = await Bun.file(abs).text();
4128
- } catch {
4129
- continue;
4130
- }
4131
- const parsed = parseDomainText(raw, rel);
4132
- if (parsed.ok)
4133
- out.push(...locateInDomain(raw, rel, parsed.data.requirements, statuses));
4134
- }
4135
- return out;
4136
- }
4137
- function sortRows(rows) {
4138
- return rows.sort((a, b) => (a.source_file ?? "").localeCompare(b.source_file ?? "") || (a.line ?? 0) - (b.line ?? 0) || (a.req_id ?? "").localeCompare(b.req_id ?? "") || a.code.localeCompare(b.code) || a.detail.localeCompare(b.detail));
4139
- }
4140
- function row(at, code, severity, detail) {
4141
- return {
4142
- code,
4143
- severity,
4144
- repo: null,
4145
- source_file: at.sourceFile,
4146
- line: at.line,
4147
- req_id: at.req.id,
4148
- detail
4149
- };
4150
- }
4151
- async function brokenFileRefDiagnostics(platformDir) {
4152
- const rows = [];
4153
- for (const at of await locatedEntries(platformDir, FILE_REF_STATUSES)) {
4154
- for (const [field, entries] of [
4155
- ["livesIn", at.req.livesIn],
4156
- ["verifiedIn", at.req.verifiedIn]
4157
- ]) {
4158
- for (const entry of entries) {
4159
- const ref = refOf(entry);
4160
- if (ref.length === 0 || resolveFileRef(platformDir, ref))
4161
- continue;
4162
- rows.push(row(at, DiagnosticCode.BROKEN_FILE_REF, "error", `${field} entry ${entry} does not resolve to a file under the platform root`));
4163
- }
4164
- }
4165
- }
4166
- return sortRows(rows);
4167
- }
4168
- function tagKeys(tags, kinds) {
4169
- return new Set(tags.filter((t) => kinds.includes(t.kind)).map((t) => `${t.req_id} ${t.file}`));
4170
- }
4171
- function inFileRows(platformDir, members, at, check) {
4172
- const rows = [];
4173
- for (const entry of at.req[check.field]) {
4174
- const ref = refOf(entry);
4175
- if (ref.length === 0 || !resolveFileRef(platformDir, ref))
4176
- continue;
4177
- const file = scannedTagFile(members, platformDir, ref);
4178
- if (file === null || check.keys.has(`${at.req.id} ${file}`))
4179
- continue;
4180
- rows.push(row(at, check.code, "warning", `${check.field} entry ${entry} holds no ${check.missing} tag for ${at.req.id}`));
5192
+ if (await proc.exited !== 0)
5193
+ return 0;
5194
+ return maxSeqOf(requirementsOf(text, specPaths(platformDir, key).rel));
5195
+ } catch {
5196
+ return 0;
4181
5197
  }
4182
- return rows;
4183
5198
  }
4184
- async function inFileTagDiagnostics(platformDir, tags) {
4185
- const { members } = await discoverRepos(platformDir, { quiet: true });
4186
- const checks = [
4187
- {
4188
- field: "livesIn",
4189
- code: DiagnosticCode.UNIMPLEMENTED_IN_FILE,
4190
- keys: tagKeys(tags, ["implements", "documents"]),
4191
- missing: "implementing or documenting"
4192
- },
4193
- {
4194
- field: "verifiedIn",
4195
- code: DiagnosticCode.UNVERIFIED_IN_FILE,
4196
- keys: tagKeys(tags, ["verifies"]),
4197
- missing: "verifying"
4198
- }
4199
- ];
4200
- const rows = [];
4201
- for (const at of await locatedEntries(platformDir, IN_FILE_STATUSES)) {
4202
- for (const check of checks)
4203
- rows.push(...inFileRows(platformDir, members, at, check));
4204
- }
4205
- return sortRows(rows);
5199
+ async function nextRequirementId(platformDir, key) {
5200
+ const { abs: jsonPath, rel: relFile } = specPaths(platformDir, key);
5201
+ if (!existsSync6(jsonPath))
5202
+ return `${key}-001`;
5203
+ const requirements = requirementsOf(await Bun.file(jsonPath).text(), relFile);
5204
+ const maxSeq = Math.max(maxSeqOf(requirements), await headMaxSeq(platformDir, key));
5205
+ const next = String(maxSeq + 1).padStart(3, "0");
5206
+ return `${key}-${next}`;
4206
5207
  }
4207
- var FILE_REF_STATUSES, IN_FILE_STATUSES, refOf = (entry) => entry.startsWith("@") ? entry.slice(1) : entry;
4208
- var init_filerefs2 = __esm(() => {
5208
+ var init_domains = __esm(() => {
4209
5209
  init_src();
4210
- init_domains();
4211
- init_filerefs();
4212
5210
  init_constants();
4213
- init_discover();
4214
- init_pipeline();
4215
- FILE_REF_STATUSES = new Set(["active", "draft"]);
4216
- IN_FILE_STATUSES = new Set(["active"]);
5211
+ init_src();
4217
5212
  });
4218
5213
 
4219
5214
  // src/check/grammar.ts
@@ -4346,20 +5341,20 @@ var init_removed = __esm(() => {
4346
5341
  });
4347
5342
 
4348
5343
  // src/check/severity.ts
4349
- import { existsSync as existsSync7, readFileSync } from "fs";
4350
- import { join as join8 } from "path";
5344
+ import { existsSync as existsSync7, readFileSync as readFileSync2 } from "fs";
5345
+ import { join as join10 } from "path";
4351
5346
  function readCheckConfig(platformDir) {
4352
- const path = join8(platformDir, CHECK_CONFIG_REL);
5347
+ const path = join10(platformDir, CHECK_CONFIG_REL);
4353
5348
  if (!existsSync7(path))
4354
5349
  return { ok: true, config: { severity: [] } };
4355
- return parseCheckConfigText(readFileSync(path, "utf-8"), CHECK_CONFIG_REL);
5350
+ return parseCheckConfigText(readFileSync2(path, "utf-8"), CHECK_CONFIG_REL);
4356
5351
  }
4357
- function specificity(rule, row2) {
4358
- if (rule.code !== row2.code)
5352
+ function specificity(rule, row) {
5353
+ if (rule.code !== row.code)
4359
5354
  return Specificity.none;
4360
5355
  if (rule.match === undefined)
4361
5356
  return Specificity.code;
4362
- const id = row2.req_id;
5357
+ const id = row.req_id;
4363
5358
  if (id === null)
4364
5359
  return Specificity.none;
4365
5360
  if (rule.match.includes(id))
@@ -4369,20 +5364,20 @@ function specificity(rule, row2) {
4369
5364
  }
4370
5365
  function applySeverity(rows, rules) {
4371
5366
  const out = [];
4372
- for (const row2 of rows) {
5367
+ for (const row of rows) {
4373
5368
  let best;
4374
5369
  let bestScore = Specificity.none;
4375
5370
  for (const rule of rules) {
4376
- const score = specificity(rule, row2);
5371
+ const score = specificity(rule, row);
4377
5372
  if (score !== Specificity.none && score >= bestScore) {
4378
5373
  best = rule;
4379
5374
  bestScore = score;
4380
5375
  }
4381
5376
  }
4382
5377
  if (best === undefined)
4383
- out.push(row2);
5378
+ out.push(row);
4384
5379
  else if (best.severity !== "off")
4385
- out.push({ ...row2, severity: best.severity });
5380
+ out.push({ ...row, severity: best.severity });
4386
5381
  }
4387
5382
  return out;
4388
5383
  }
@@ -4429,6 +5424,60 @@ var init_sqlDiagnostics = __esm(() => {
4429
5424
  ]);
4430
5425
  });
4431
5426
 
5427
+ // src/check/statementRules.ts
5428
+ async function domainRows(platformDir, key, config2, scope) {
5429
+ const { abs, rel } = specPaths(platformDir, key);
5430
+ const raw = await Bun.file(abs).text().catch(() => null);
5431
+ if (raw === null)
5432
+ return [];
5433
+ const parsed = parseDomainText(raw, rel);
5434
+ if (!parsed.ok)
5435
+ return [];
5436
+ const rawLines = raw.split(`
5437
+ `);
5438
+ const rows = [];
5439
+ let lineCursor = 0;
5440
+ for (const req of parsed.data.requirements) {
5441
+ const found = findRequirementIdLine(rawLines, req.id, lineCursor);
5442
+ if (found >= 0)
5443
+ lineCursor = found + 1;
5444
+ if (scope.id !== undefined && req.id !== scope.id)
5445
+ continue;
5446
+ if (!scope.statuses.has(req.status.toLowerCase()))
5447
+ continue;
5448
+ for (const f of statementFindings(req.statement, config2)) {
5449
+ rows.push({ req_id: req.id, ...f, source_file: rel, line: found >= 0 ? found + 1 : 0 });
5450
+ }
5451
+ }
5452
+ return rows;
5453
+ }
5454
+ async function statementRuleRows(platformDir, config2, scope) {
5455
+ const rows = [];
5456
+ for (const key of scope.keys ?? listDomainKeys(platformDir)) {
5457
+ if (key !== "TERM")
5458
+ rows.push(...await domainRows(platformDir, key, config2, scope));
5459
+ }
5460
+ rows.sort((a, b) => a.source_file.localeCompare(b.source_file) || a.line - b.line || a.req_id.localeCompare(b.req_id));
5461
+ return rows;
5462
+ }
5463
+ async function statementRulesDiagnostics(platformDir, config2) {
5464
+ const rows = await statementRuleRows(platformDir, config2, { statuses: new Set(["active"]) });
5465
+ return rows.map((r) => ({
5466
+ code: DiagnosticCode.STATEMENT_RULES,
5467
+ severity: config2?.severity?.[r.rule] === "warning" ? "warning" : "error",
5468
+ repo: null,
5469
+ source_file: r.source_file,
5470
+ line: r.line,
5471
+ req_id: r.req_id,
5472
+ detail: formatStatementFinding(r)
5473
+ }));
5474
+ }
5475
+ var init_statementRules2 = __esm(() => {
5476
+ init_src();
5477
+ init_domains();
5478
+ init_constants();
5479
+ });
5480
+
4432
5481
  // src/check/statusflip.ts
4433
5482
  function normalizeHandle(h) {
4434
5483
  return (h.startsWith("@") ? h.slice(1) : h).toLowerCase();
@@ -4900,8 +5949,6 @@ function successorEntry(id, pred, fields) {
4900
5949
  supersedes: null,
4901
5950
  supersededBy: null,
4902
5951
  relates: fields.relates,
4903
- livesIn: fields.livesIn,
4904
- verifiedIn: fields.verifiedIn,
4905
5952
  issues: fields.issue ? [{ role: "created", id: fields.issue }] : [],
4906
5953
  aliases: [...pred.aliases],
4907
5954
  cites: pred.cites.map((c) => ({ ...c }))
@@ -4919,6 +5966,30 @@ var init__envelope = __esm(() => {
4919
5966
  init__result();
4920
5967
  });
4921
5968
 
5969
+ // src/authoring/filerefs.ts
5970
+ import { existsSync as existsSync9 } from "fs";
5971
+ import { resolve as resolve7 } from "path";
5972
+ function extractRefsFromText(s) {
5973
+ const out = [];
5974
+ for (const m of s.matchAll(FILE_REF_RE)) {
5975
+ const candidate = (m[1] ?? "").replace(/\.+$/, "");
5976
+ if (candidate.includes("/"))
5977
+ out.push(candidate);
5978
+ }
5979
+ return out;
5980
+ }
5981
+ function resolveFileRef(platformDir, ref) {
5982
+ const root = resolve7(platformDir);
5983
+ const abs = resolve7(root, ref);
5984
+ if (!(abs === root || abs.startsWith(`${root}/`)))
5985
+ return false;
5986
+ return existsSync9(abs);
5987
+ }
5988
+ var FILE_REF_RE;
5989
+ var init_filerefs = __esm(() => {
5990
+ FILE_REF_RE = /(?<![^\s])@([A-Za-z0-9_][A-Za-z0-9_\-./]*)/g;
5991
+ });
5992
+
4922
5993
  // src/authoring/grammar.ts
4923
5994
  async function grammarConfig(platformDir, key) {
4924
5995
  const freeform = { grammar: "freeform", severity: "warning" };
@@ -4967,8 +6038,35 @@ var init_grammar2 = __esm(() => {
4967
6038
  init_constants();
4968
6039
  });
4969
6040
 
6041
+ // src/authoring/statementRules.ts
6042
+ function statementRulesVerdict(platformDir, key, statement) {
6043
+ if (key === "TERM" || statement.trim() === "")
6044
+ return { refusal: null, warnings: [] };
6045
+ const config2 = readCheckConfig(platformDir);
6046
+ if (!config2.ok)
6047
+ return { refusal: config2.detail, warnings: [] };
6048
+ const rules = config2.config.statementRules;
6049
+ const findings = statementFindings(statement, rules);
6050
+ const errors = findings.filter((f) => (rules?.severity?.[f.rule] ?? "error") === "error");
6051
+ if (errors.length > 0) {
6052
+ return { refusal: [...errors.map(formatStatementFinding), RULE_ZERO].join(`
6053
+ `), warnings: [] };
6054
+ }
6055
+ return {
6056
+ refusal: null,
6057
+ warnings: findings.map((f) => ({
6058
+ kind: "statement",
6059
+ text: `warning \u2014 ${formatStatementFinding(f)}`
6060
+ }))
6061
+ };
6062
+ }
6063
+ var init_statementRules3 = __esm(() => {
6064
+ init_src();
6065
+ init_severity();
6066
+ });
6067
+
4970
6068
  // src/operations/mint.ts
4971
- import { existsSync as existsSync9 } from "fs";
6069
+ import { existsSync as existsSync10 } from "fs";
4972
6070
  function unresolvableRefWarnings(platformDir, fieldValues) {
4973
6071
  const out = [];
4974
6072
  for (const value of fieldValues) {
@@ -4983,15 +6081,10 @@ function unresolvableRefWarnings(platformDir, fieldValues) {
4983
6081
  async function mint(input) {
4984
6082
  const { platformDir, key } = input;
4985
6083
  const { abs: specPath, rel: relFile } = specPaths(platformDir, key);
4986
- if (!existsSync9(specPath)) {
6084
+ if (!existsSync10(specPath)) {
4987
6085
  return fail("not_found", `no domain ${key} (expected ${relFile} under ${platformDir})`);
4988
6086
  }
4989
- const warnings = unresolvableRefWarnings(platformDir, [
4990
- input.statement,
4991
- input.why,
4992
- ...input.livesIn,
4993
- ...input.verifiedIn ?? []
4994
- ]);
6087
+ const warnings = unresolvableRefWarnings(platformDir, [input.statement, input.why]);
4995
6088
  const verdict = await judgeStatementGrammar(platformDir, key, input.statement);
4996
6089
  if (!verdict.ok) {
4997
6090
  if (verdict.severity === "error") {
@@ -4999,6 +6092,10 @@ async function mint(input) {
4999
6092
  }
5000
6093
  warnings.push({ kind: "grammar", text: grammarWarningText("", verdict).slice(2) });
5001
6094
  }
6095
+ const rules = statementRulesVerdict(platformDir, key, input.statement);
6096
+ if (rules.refusal !== null)
6097
+ return fail("usage", rules.refusal, { warnings });
6098
+ warnings.push(...rules.warnings);
5002
6099
  const file = await readEnvelope(platformDir, key);
5003
6100
  if (!file.ok) {
5004
6101
  return fail(file.reason, file.detail, { diagnostics: file.diagnostics, warnings });
@@ -5013,8 +6110,6 @@ async function mint(input) {
5013
6110
  supersedes: null,
5014
6111
  supersededBy: null,
5015
6112
  relates: splitList(input.relates ?? []),
5016
- livesIn: input.livesIn,
5017
- verifiedIn: input.verifiedIn ?? [],
5018
6113
  issues: input.issue ? [{ role: "created", id: input.issue }] : [],
5019
6114
  aliases: [],
5020
6115
  cites: input.cites ?? []
@@ -5036,6 +6131,7 @@ var init_mint = __esm(() => {
5036
6131
  init_domains();
5037
6132
  init_filerefs();
5038
6133
  init_grammar2();
6134
+ init_statementRules3();
5039
6135
  init_constants();
5040
6136
  init__envelope();
5041
6137
  init__result();
@@ -5071,8 +6167,6 @@ async function mintTerm(input) {
5071
6167
  supersedes: null,
5072
6168
  supersededBy: null,
5073
6169
  relates: [],
5074
- livesIn: [],
5075
- verifiedIn: [],
5076
6170
  issues: [],
5077
6171
  aliases: input.aliases,
5078
6172
  cites: [],
@@ -5173,8 +6267,8 @@ var init_term = __esm(() => {
5173
6267
  });
5174
6268
 
5175
6269
  // src/operations/glossary.ts
5176
- import { existsSync as existsSync10, readFileSync as readFileSync2 } from "fs";
5177
- import { join as join9 } from "path";
6270
+ import { existsSync as existsSync11, readFileSync as readFileSync3 } from "fs";
6271
+ import { join as join11 } from "path";
5178
6272
  function parseGlossary(md) {
5179
6273
  const terms = [];
5180
6274
  let section = null;
@@ -5226,15 +6320,15 @@ function generateGlossary(terms) {
5226
6320
  }
5227
6321
  function readStoreTerms(platformDir) {
5228
6322
  const { abs: specPath, rel: relFile } = specPaths(platformDir, TERM_KEY);
5229
- if (!existsSync10(specPath))
6323
+ if (!existsSync11(specPath))
5230
6324
  return [];
5231
- const parsed = parseDomainText(readFileSync2(specPath, "utf8"), relFile);
6325
+ const parsed = parseDomainText(readFileSync3(specPath, "utf8"), relFile);
5232
6326
  if (!parsed.ok)
5233
6327
  return [];
5234
6328
  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 }));
5235
6329
  }
5236
6330
  function glossaryPath(platformDir) {
5237
- return join9(platformDir, "GLOSSARY.md");
6331
+ return join11(platformDir, "GLOSSARY.md");
5238
6332
  }
5239
6333
  async function migrateGlossary(platformDir) {
5240
6334
  const store = await termStore(platformDir);
@@ -5245,10 +6339,10 @@ async function migrateGlossary(platformDir) {
5245
6339
  return { ok: true, skipped: true, existing: domain2.requirements.length };
5246
6340
  }
5247
6341
  const gPath = glossaryPath(platformDir);
5248
- if (!existsSync10(gPath)) {
6342
+ if (!existsSync11(gPath)) {
5249
6343
  return fail("not_found", `no GLOSSARY.md to migrate (expected ${gPath})`);
5250
6344
  }
5251
- const parsed = parseGlossary(readFileSync2(gPath, "utf8"));
6345
+ const parsed = parseGlossary(readFileSync3(gPath, "utf8"));
5252
6346
  domain2.requirements = parsed.map((t, i) => ({
5253
6347
  id: `${TERM_KEY}-${String(i + 1).padStart(3, "0")}`,
5254
6348
  status: "active",
@@ -5258,8 +6352,6 @@ async function migrateGlossary(platformDir) {
5258
6352
  supersedes: null,
5259
6353
  supersededBy: null,
5260
6354
  relates: [],
5261
- livesIn: [],
5262
- verifiedIn: [],
5263
6355
  issues: [],
5264
6356
  aliases: [],
5265
6357
  cites: [],
@@ -5286,10 +6378,10 @@ function glossaryDriftDiagnostic(platformDir) {
5286
6378
  if (terms.length === 0)
5287
6379
  return null;
5288
6380
  const gPath = glossaryPath(platformDir);
5289
- if (!existsSync10(gPath))
6381
+ if (!existsSync11(gPath))
5290
6382
  return null;
5291
6383
  const generated = generateGlossary(terms);
5292
- const committed = readFileSync2(gPath, "utf8");
6384
+ const committed = readFileSync3(gPath, "utf8");
5293
6385
  if (generated === committed)
5294
6386
  return null;
5295
6387
  return {
@@ -5305,7 +6397,7 @@ function glossaryDriftDiagnostic(platformDir) {
5305
6397
  function checkGlossary(platformDir) {
5306
6398
  const generated = generateGlossary(readStoreTerms(platformDir));
5307
6399
  const gPath = glossaryPath(platformDir);
5308
- const committed = existsSync10(gPath) ? readFileSync2(gPath, "utf8") : "";
6400
+ const committed = existsSync11(gPath) ? readFileSync3(gPath, "utf8") : "";
5309
6401
  return { ok: true, clean: generated === committed };
5310
6402
  }
5311
6403
  var GLOSSARY_INTRO = `Canonical names for Spec Engine concepts. When prose, code comments, diagnostics, or
@@ -5319,7 +6411,7 @@ var init_glossary = __esm(() => {
5319
6411
  });
5320
6412
 
5321
6413
  // src/guard/collect.ts
5322
- import { join as join10 } from "path";
6414
+ import { join as join12 } from "path";
5323
6415
  function isIgnored(path) {
5324
6416
  return isPathIgnored(path);
5325
6417
  }
@@ -5367,7 +6459,7 @@ async function collectWorktreeReqs(canonicalDir) {
5367
6459
  const activeIds = new Set;
5368
6460
  const supersedesTargets = new Set;
5369
6461
  for (const rel of await findDomainJsonFiles(canonicalDir)) {
5370
- const text = await Bun.file(join10(canonicalDir, rel)).text();
6462
+ const text = await Bun.file(join12(canonicalDir, rel)).text();
5371
6463
  for (const r of parseReqs(text)) {
5372
6464
  ids.add(r.id);
5373
6465
  if (r.status.toLowerCase() === "active")
@@ -5395,7 +6487,7 @@ async function collectFacts(platformDir, ref, storage2) {
5395
6487
  const deletedSpecFiles = changed.filter((e) => e.status === "D" && isSpecFile(e.path)).map((e) => e.path);
5396
6488
  const base = collectBaseReqs(platformDir, ref, specPaths2);
5397
6489
  const baseTags = collectBaseTags(platformDir, ref, codePaths);
5398
- const worktree = await collectWorktreeReqs(join10(platformDir, CANONICAL_SPECS_DIR));
6490
+ const worktree = await collectWorktreeReqs(join12(platformDir, CANONICAL_SPECS_DIR));
5399
6491
  const counts = tallyWorktreeTags(storage2);
5400
6492
  return {
5401
6493
  baseReqs: base.reqs,
@@ -5512,14 +6604,63 @@ var init_guard = __esm(() => {
5512
6604
  init_collect();
5513
6605
  });
5514
6606
 
6607
+ // src/operations/migrate.ts
6608
+ async function migrationPendingDiagnostics(platformDir) {
6609
+ const rows = [];
6610
+ for (const key of listDomainKeys(platformDir)) {
6611
+ const { abs, rel } = specPaths(platformDir, key);
6612
+ const raw = await Bun.file(abs).text().catch(() => "");
6613
+ if (!LEGACY_KEY_RE.test(raw))
6614
+ continue;
6615
+ rows.push({
6616
+ code: DiagnosticCode.MIGRATION_PENDING,
6617
+ severity: "warning",
6618
+ repo: null,
6619
+ source_file: rel,
6620
+ line: null,
6621
+ req_id: null,
6622
+ detail: `${rel} still carries the retired livesIn or verifiedIn fields; run spec migrate to strip them`
6623
+ });
6624
+ }
6625
+ return rows;
6626
+ }
6627
+ async function migrate(platformDir) {
6628
+ const files = [];
6629
+ for (const key of listDomainKeys(platformDir)) {
6630
+ const file = await readEnvelope(platformDir, key);
6631
+ if (!file.ok)
6632
+ return file;
6633
+ if (!LEGACY_KEY_RE.test(await Bun.file(file.specPath).text()))
6634
+ continue;
6635
+ const res = await validateAndWrite(file.specPath, file.domain, file.relFile);
6636
+ if (!res.ok) {
6637
+ return fail("invalid_domain_file", res.diagnostics.map((d) => d.detail).join(`
6638
+ `), {
6639
+ diagnostics: res.diagnostics
6640
+ });
6641
+ }
6642
+ files.push(file.relFile);
6643
+ }
6644
+ return { ok: true, files };
6645
+ }
6646
+ var LEGACY_KEY_RE;
6647
+ var init_migrate = __esm(() => {
6648
+ init_src();
6649
+ init_domains();
6650
+ init_constants();
6651
+ init__envelope();
6652
+ init__result();
6653
+ LEGACY_KEY_RE = /(?<!\\)"(?:livesIn|verifiedIn)"\s*:/;
6654
+ });
6655
+
5515
6656
  // src/operations/check.ts
5516
- import { existsSync as existsSync11, readFileSync as readFileSync3 } from "fs";
5517
- import { join as join11 } from "path";
6657
+ import { existsSync as existsSync12, readFileSync as readFileSync4 } from "fs";
6658
+ import { join as join13 } from "path";
5518
6659
  function readCodeownersText(platformDir) {
5519
- const path = join11(platformDir, ".github", "CODEOWNERS");
5520
- if (!existsSync11(path))
6660
+ const path = join13(platformDir, ".github", "CODEOWNERS");
6661
+ if (!existsSync12(path))
5521
6662
  return null;
5522
- return readFileSync3(path, "utf-8");
6663
+ return readFileSync4(path, "utf-8");
5523
6664
  }
5524
6665
  function domainReqsFromText(text, platformRel) {
5525
6666
  const validated = parseDomainText(text, platformRel);
@@ -5532,7 +6673,7 @@ async function collectChangeReqs(canonicalDir) {
5532
6673
  const relPathById = new Map;
5533
6674
  for (const rel of await findDomainJsonFiles(canonicalDir)) {
5534
6675
  const platformRel = `${CANONICAL_SPECS_DIR}/${rel}`;
5535
- const text = await Bun.file(join11(canonicalDir, rel)).text();
6676
+ const text = await Bun.file(join13(canonicalDir, rel)).text();
5536
6677
  const parsed = domainReqsFromText(text, platformRel);
5537
6678
  for (const r of parsed.reqs) {
5538
6679
  reqs.push(r);
@@ -5573,7 +6714,7 @@ async function ingestResults(storage2, resultsPath, diagnostics2) {
5573
6714
  }
5574
6715
  async function governanceGate(input, ref, diagnostics2, results) {
5575
6716
  const { platformDir } = input;
5576
- const change = await collectChangeReqs(join11(platformDir, CANONICAL_SPECS_DIR));
6717
+ const change = await collectChangeReqs(join13(platformDir, CANONICAL_SPECS_DIR));
5577
6718
  const base = collectBaseReqs2(platformDir, ref, diagnostics2);
5578
6719
  diagnostics2.push(...requirementRemoved(base.reqs, change.reqs, (id) => base.relPathById.get(id) ?? null));
5579
6720
  const codeowners = parseCodeowners(readCodeownersText(platformDir) ?? "");
@@ -5616,9 +6757,9 @@ async function check(input, storage2) {
5616
6757
  diagnostics2.push(...unsourcedChanges(superseded, storage2.listProvenance()));
5617
6758
  }
5618
6759
  diagnostics2.push(...await statementGrammarDiagnostics(platformDir));
5619
- diagnostics2.push(...await brokenFileRefDiagnostics(platformDir));
5620
- diagnostics2.push(...await inFileTagDiagnostics(platformDir, storage2.listTags()));
6760
+ diagnostics2.push(...await statementRulesDiagnostics(platformDir, checkConfig2.config.statementRules));
5621
6761
  diagnostics2.push(...await supersedesPointerDiagnostics(platformDir));
6762
+ diagnostics2.push(...await migrationPendingDiagnostics(platformDir));
5622
6763
  const glossaryDrift = glossaryDriftDiagnostic(platformDir);
5623
6764
  if (glossaryDrift !== null)
5624
6765
  diagnostics2.push(glossaryDrift);
@@ -5627,7 +6768,7 @@ async function check(input, storage2) {
5627
6768
  await governanceGate(input, input.base, diagnostics2, results);
5628
6769
  diagnostics2.push(...await lossDiagnostics(storage2, platformDir, input.base));
5629
6770
  } else if (gitRefResolves(platformDir, "HEAD")) {
5630
- const change = await collectChangeReqs(join11(platformDir, CANONICAL_SPECS_DIR));
6771
+ const change = await collectChangeReqs(join13(platformDir, CANONICAL_SPECS_DIR));
5631
6772
  const baseParseSink = [];
5632
6773
  const base = collectBaseReqs2(platformDir, "HEAD", baseParseSink);
5633
6774
  diagnostics2.push(...requirementRemoved(base.reqs, change.reqs, (id) => base.relPathById.get(id) ?? null));
@@ -5645,13 +6786,13 @@ async function check(input, storage2) {
5645
6786
  var LOSS_SEVERITY;
5646
6787
  var init_check = __esm(() => {
5647
6788
  init_src();
5648
- init_filerefs2();
5649
6789
  init_grammar();
5650
6790
  init_propagation_teeth();
5651
6791
  init_proven();
5652
6792
  init_removed();
5653
6793
  init_severity();
5654
6794
  init_sqlDiagnostics();
6795
+ init_statementRules2();
5655
6796
  init_statusflip();
5656
6797
  init_supersedes();
5657
6798
  init_unsourced();
@@ -5662,6 +6803,7 @@ var init_check = __esm(() => {
5662
6803
  init__result();
5663
6804
  init_glossary();
5664
6805
  init_guard();
6806
+ init_migrate();
5665
6807
  LOSS_SEVERITY = {
5666
6808
  IMPL_LOST: "error",
5667
6809
  SPEC_FILE_DELETED: "error",
@@ -5672,7 +6814,8 @@ var init_check = __esm(() => {
5672
6814
  // src/commands/check.ts
5673
6815
  var exports_check = {};
5674
6816
  __export(exports_check, {
5675
- checkCommand: () => checkCommand
6817
+ checkCommand: () => checkCommand,
6818
+ CI_BASE_WARNING: () => CI_BASE_WARNING
5676
6819
  });
5677
6820
  import { resolve as resolve8 } from "path";
5678
6821
  import { defineCommand as defineCommand3 } from "citty";
@@ -5695,7 +6838,7 @@ function exitAfterCrash(e) {
5695
6838
  console.error(`spec check: crashed: ${msg}`);
5696
6839
  process.exit(EXIT.USAGE);
5697
6840
  }
5698
- var checkCommand;
6841
+ var CI_BASE_WARNING = 'spec check --ci: losses not checked, the working tree equals HEAD. In CI run: spec check . --ci --base "$(git merge-base HEAD origin/<default>)"', checkCommand;
5699
6842
  var init_check2 = __esm(() => {
5700
6843
  init_src();
5701
6844
  init_proven();
@@ -5785,6 +6928,9 @@ var init_check2 = __esm(() => {
5785
6928
  process.exit(EXIT.USAGE);
5786
6929
  return;
5787
6930
  }
6931
+ if (args.ci && !args.base && gitTreeMatchesHead(platformDir)) {
6932
+ console.error(CI_BASE_WARNING);
6933
+ }
5788
6934
  const diagnostics2 = [...outcome.diagnostics];
5789
6935
  if (outcome.proofsUnconfirmed) {
5790
6936
  if (jsonMode) {
@@ -5802,8 +6948,8 @@ var init_check2 = __esm(() => {
5802
6948
  });
5803
6949
 
5804
6950
  // src/operations/domain.ts
5805
- import { existsSync as existsSync12, mkdirSync as mkdirSync4 } from "fs";
5806
- import { dirname as dirname6, join as join12, resolve as resolve9 } from "path";
6951
+ import { existsSync as existsSync13, mkdirSync as mkdirSync5 } from "fs";
6952
+ import { dirname as dirname8, join as join14, resolve as resolve9 } from "path";
5807
6953
  function definedFields(options) {
5808
6954
  const out = {};
5809
6955
  if (options.scope !== undefined)
@@ -5823,12 +6969,12 @@ async function newDomain(platformDir, key, options = {}) {
5823
6969
  if (!(resolvedDest === resolvedRoot || resolvedDest.startsWith(`${resolvedRoot}/`))) {
5824
6970
  return fail("usage", `refusing to write outside platformDir (${resolvedDest})`);
5825
6971
  }
5826
- if (existsSync12(dest))
6972
+ if (existsSync13(dest))
5827
6973
  return fail("conflict", `refusing to overwrite ${dest}`);
5828
- if (existsSync12(join12(dirname6(dest), "SPEC.md"))) {
6974
+ if (existsSync13(join14(dirname8(dest), "SPEC.md"))) {
5829
6975
  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)");
5830
6976
  }
5831
- mkdirSync4(dirname6(dest), { recursive: true });
6977
+ mkdirSync5(dirname8(dest), { recursive: true });
5832
6978
  const envelope = { ...scaffoldDomainObject(key, localToday()), ...definedFields(options) };
5833
6979
  const res = await validateAndWrite(dest, envelope, relFile);
5834
6980
  if (!res.ok) {
@@ -6046,12 +7192,12 @@ function sortMatrix(rows) {
6046
7192
  return a.repo.localeCompare(b.repo);
6047
7193
  });
6048
7194
  }
6049
- function cellStatus(row2) {
6050
- if (row2.implemented === 1 && row2.verified === 1)
7195
+ function cellStatus(row) {
7196
+ if (row.implemented === 1 && row.verified === 1)
6051
7197
  return "src+test";
6052
- if (row2.implemented === 1)
7198
+ if (row.implemented === 1)
6053
7199
  return "src";
6054
- if (row2.verified === 1)
7200
+ if (row.verified === 1)
6055
7201
  return "test";
6056
7202
  return EMPTY_CELL;
6057
7203
  }
@@ -6064,18 +7210,18 @@ function renderMatrix(rows, mode) {
6064
7210
  return "";
6065
7211
  const repos = Array.from(new Set(sorted.map((r) => r.repo))).sort((a, b) => a.localeCompare(b));
6066
7212
  const byReq = new Map;
6067
- for (const row2 of sorted) {
6068
- let entry = byReq.get(row2.req_id);
7213
+ for (const row of sorted) {
7214
+ let entry = byReq.get(row.req_id);
6069
7215
  if (!entry) {
6070
7216
  entry = {
6071
- domain_key: row2.domain_key,
6072
- req_id: row2.req_id,
6073
- req_status: row2.req_status,
7217
+ domain_key: row.domain_key,
7218
+ req_id: row.req_id,
7219
+ req_status: row.req_status,
6074
7220
  cells: new Map
6075
7221
  };
6076
- byReq.set(row2.req_id, entry);
7222
+ byReq.set(row.req_id, entry);
6077
7223
  }
6078
- entry.cells.set(row2.repo, cellStatus(row2));
7224
+ entry.cells.set(row.repo, cellStatus(row));
6079
7225
  }
6080
7226
  const header = ["DOMAIN", "REQUIREMENT", "STATUS", ...repos];
6081
7227
  const lines = [header];
@@ -6083,8 +7229,8 @@ function renderMatrix(rows, mode) {
6083
7229
  const cells = repos.map((r) => entry.cells.get(r) ?? EMPTY_CELL);
6084
7230
  lines.push([entry.domain_key, entry.req_id, entry.req_status, ...cells]);
6085
7231
  }
6086
- const widths = header.map((_, col) => Math.max(...lines.map((row2) => row2[col]?.length ?? 0)));
6087
- return lines.map((row2) => row2.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ")).join(`
7232
+ const widths = header.map((_, col) => Math.max(...lines.map((row) => row[col]?.length ?? 0)));
7233
+ return lines.map((row) => row.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ")).join(`
6088
7234
  `);
6089
7235
  }
6090
7236
  var EMPTY_CELL = "\u2014";
@@ -6220,12 +7366,19 @@ var init_map = __esm(() => {
6220
7366
 
6221
7367
  // src/operations/records.ts
6222
7368
  function getRecord(storage2, id) {
6223
- const row2 = storage2.getRequirement(id);
6224
- return {
6225
- ok: true,
6226
- row: row2,
6227
- platformEmpty: row2 === null && storage2.listRequirements().length === 0
6228
- };
7369
+ const req = storage2.getRequirement(id);
7370
+ if (req === null) {
7371
+ return { ok: true, row: null, platformEmpty: storage2.listRequirements().length === 0 };
7372
+ }
7373
+ const implemented_in = [];
7374
+ const verified_in = [];
7375
+ for (const { repo, file, line, kind, level } of storage2.listTags({ req_id: id })) {
7376
+ if (kind === "verifies")
7377
+ verified_in.push({ repo, file, line, level: level ?? null });
7378
+ else
7379
+ implemented_in.push({ repo, file, line, kind });
7380
+ }
7381
+ return { ok: true, row: { ...req, implemented_in, verified_in }, platformEmpty: false };
6229
7382
  }
6230
7383
  function listRecords(storage2, filter = {}) {
6231
7384
  const rows = storage2.listRequirements(filter);
@@ -6261,8 +7414,18 @@ function renderRecords(rows, mode) {
6261
7414
  lines.push([r.id, r.status, r.key, String(r.seq), String(r.changed_at_version), r.text]);
6262
7415
  }
6263
7416
  const fixed = HEADER.length - 1;
6264
- const widths = HEADER.slice(0, fixed).map((_, col) => Math.max(...lines.map((row2) => row2[col]?.length ?? 0)));
6265
- return lines.map((row2) => row2.map((cell, i) => i < fixed ? cell.padEnd(widths[i] ?? 0) : cell).join(" ").trimEnd()).join(`
7417
+ const widths = HEADER.slice(0, fixed).map((_, col) => Math.max(...lines.map((row) => row[col]?.length ?? 0)));
7418
+ return lines.map((row) => row.map((cell, i) => i < fixed ? cell.padEnd(widths[i] ?? 0) : cell).join(" ").trimEnd()).join(`
7419
+ `);
7420
+ }
7421
+ function renderRecord(record) {
7422
+ const sites = (label, list) => list.length === 0 ? [`${label}: none`] : [`${label}:`, ...list.map((s) => ` ${s.file}:${s.line}`)];
7423
+ return [
7424
+ renderRecords([record], "text"),
7425
+ "",
7426
+ ...sites("implemented in", record.implemented_in),
7427
+ ...sites("verified in", record.verified_in)
7428
+ ].join(`
6266
7429
  `);
6267
7430
  }
6268
7431
  var HEADER;
@@ -6289,7 +7452,7 @@ var init_get = __esm(() => {
6289
7452
  getCommand = defineCommand6({
6290
7453
  meta: {
6291
7454
  name: "get",
6292
- description: "Print one requirement's full record (id, status, key, seq, versions, statement) from the derived index."
7455
+ description: "Print one requirement's full record (id, status, key, seq, versions, statement) and the file:line of every tag that implements or verifies it, from the derived index."
6293
7456
  },
6294
7457
  args: {
6295
7458
  reqId: {
@@ -6326,7 +7489,7 @@ var init_get = __esm(() => {
6326
7489
  console.log("[]");
6327
7490
  return;
6328
7491
  }
6329
- console.log(args.json ? JSON.stringify(result.row) : renderRecords([result.row], "text"));
7492
+ console.log(args.json ? JSON.stringify(result.row) : renderRecord(result.row));
6330
7493
  });
6331
7494
  }
6332
7495
  });
@@ -6414,11 +7577,11 @@ function renderPropagation(rows, mode) {
6414
7577
  return "";
6415
7578
  const header = ["REPO", "STATE", "VIA", "DRIFT?"];
6416
7579
  const lines = [header];
6417
- for (const row2 of rows) {
6418
- lines.push([row2.repo, row2.state, row2.via_req_id ?? EMPTY_CELL2, row2.drifted ? "yes" : "no"]);
7580
+ for (const row of rows) {
7581
+ lines.push([row.repo, row.state, row.via_req_id ?? EMPTY_CELL2, row.drifted ? "yes" : "no"]);
6419
7582
  }
6420
- const widths = header.map((_, col) => Math.max(...lines.map((row2) => row2[col]?.length ?? 0)));
6421
- return lines.map((row2) => row2.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ")).join(`
7583
+ const widths = header.map((_, col) => Math.max(...lines.map((row) => row[col]?.length ?? 0)));
7584
+ return lines.map((row) => row.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ")).join(`
6422
7585
  `);
6423
7586
  }
6424
7587
  var EMPTY_CELL2 = "\u2014";
@@ -6492,8 +7655,8 @@ function excerptOf(raw) {
6492
7655
  }
6493
7656
  function renderTable(header, rows) {
6494
7657
  const lines = [header, ...rows];
6495
- const widths = header.map((_, col) => Math.max(...lines.map((row2) => row2[col]?.length ?? 0)));
6496
- return lines.map((row2) => row2.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ")).join(`
7658
+ const widths = header.map((_, col) => Math.max(...lines.map((row) => row[col]?.length ?? 0)));
7659
+ return lines.map((row) => row.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ")).join(`
6497
7660
  `);
6498
7661
  }
6499
7662
  function renderQuery(hits, mode) {
@@ -6748,17 +7911,17 @@ function compareReqIds2(a, b) {
6748
7911
  function sortProvenance(rows) {
6749
7912
  return [...rows].sort((a, b) => compareReqIds2(a.req_id, b.req_id) || a.role.localeCompare(b.role) || a.issue_id.localeCompare(b.issue_id) || a.source_file.localeCompare(b.source_file) || a.line - b.line);
6750
7913
  }
6751
- function backingTests(row2) {
7914
+ function backingTests(row) {
6752
7915
  let base;
6753
- if (row2.implemented === 1 && row2.verified === 1)
7916
+ if (row.implemented === 1 && row.verified === 1)
6754
7917
  base = "src+test";
6755
- else if (row2.implemented === 1)
7918
+ else if (row.implemented === 1)
6756
7919
  base = "src";
6757
- else if (row2.verified === 1)
7920
+ else if (row.verified === 1)
6758
7921
  base = "test";
6759
7922
  else
6760
7923
  base = "\u2014";
6761
- return row2.test_levels ? `${base} (${row2.test_levels})` : base;
7924
+ return row.test_levels ? `${base} (${row.test_levels})` : base;
6762
7925
  }
6763
7926
  function renderProvenance(rows, mode) {
6764
7927
  const sorted = sortProvenance(rows);
@@ -6768,12 +7931,12 @@ function renderProvenance(rows, mode) {
6768
7931
  if (sorted.length === 0)
6769
7932
  return "";
6770
7933
  const byReq = new Map;
6771
- for (const row2 of sorted) {
6772
- const bucket = byReq.get(row2.req_id);
7934
+ for (const row of sorted) {
7935
+ const bucket = byReq.get(row.req_id);
6773
7936
  if (bucket)
6774
- bucket.push(row2);
7937
+ bucket.push(row);
6775
7938
  else
6776
- byReq.set(row2.req_id, [row2]);
7939
+ byReq.set(row.req_id, [row]);
6777
7940
  }
6778
7941
  const lines = [];
6779
7942
  for (const [reqId, links] of byReq) {
@@ -6788,8 +7951,8 @@ function renderProvenance(rows, mode) {
6788
7951
  return lines.join(`
6789
7952
  `);
6790
7953
  }
6791
- function decorateRow(row2, resolved) {
6792
- const prefix = ` ${row2.role} ${row2.issue_id} ${row2.source_file}:${row2.line}`;
7954
+ function decorateRow(row, resolved) {
7955
+ const prefix = ` ${row.role} ${row.issue_id} ${row.source_file}:${row.line}`;
6793
7956
  if (resolved?.ok && resolved.title && resolved.url) {
6794
7957
  const staleMark = resolved.stale ? " (cached \u2014 refresh failed)" : "";
6795
7958
  return `${prefix} ${resolved.title} [${resolved.status ?? ""}] ${resolved.url}${staleMark}`;
@@ -6813,12 +7976,12 @@ function renderProvenanceDecorated(rows, resolved, mode) {
6813
7976
  if (sorted.length === 0)
6814
7977
  return "";
6815
7978
  const byReq = new Map;
6816
- for (const row2 of sorted) {
6817
- const bucket = byReq.get(row2.req_id);
7979
+ for (const row of sorted) {
7980
+ const bucket = byReq.get(row.req_id);
6818
7981
  if (bucket)
6819
- bucket.push(row2);
7982
+ bucket.push(row);
6820
7983
  else
6821
- byReq.set(row2.req_id, [row2]);
7984
+ byReq.set(row.req_id, [row]);
6822
7985
  }
6823
7986
  const lines = [];
6824
7987
  for (const [reqId, links] of byReq) {
@@ -6839,8 +8002,8 @@ var TOKEN_HINT = "set SPEC_TRACKER_TOKEN to resolve issue titles";
6839
8002
  var init_adapter = () => {};
6840
8003
 
6841
8004
  // ../tracker/src/cache.ts
6842
- import { mkdirSync as mkdirSync5 } from "fs";
6843
- import { dirname as dirname7, join as join13 } from "path";
8005
+ import { mkdirSync as mkdirSync6 } from "fs";
8006
+ import { dirname as dirname9, join as join15 } from "path";
6844
8007
  function isFresh(entry, now, ttlMs = CACHE_TTL_MS) {
6845
8008
  const t = Date.parse(entry.fetched_at);
6846
8009
  if (Number.isNaN(t))
@@ -6854,7 +8017,7 @@ function isValidEntry(e) {
6854
8017
  return typeof r.title === "string" && typeof r.status === "string" && typeof r.url === "string" && typeof r.fetched_at === "string";
6855
8018
  }
6856
8019
  function sidecarPath(platformDir) {
6857
- return join13(platformDir, ".spec-engine", SIDECAR_FILE);
8020
+ return join15(platformDir, ".spec-engine", SIDECAR_FILE);
6858
8021
  }
6859
8022
  async function readCache(platformDir) {
6860
8023
  try {
@@ -6878,7 +8041,7 @@ async function readCache(platformDir) {
6878
8041
  async function writeCache(platformDir, cache) {
6879
8042
  try {
6880
8043
  const path = sidecarPath(platformDir);
6881
- mkdirSync5(dirname7(path), { recursive: true });
8044
+ mkdirSync6(dirname9(path), { recursive: true });
6882
8045
  await Bun.write(path, `${JSON.stringify(cache, null, 2)}
6883
8046
  `);
6884
8047
  return true;
@@ -7184,8 +8347,6 @@ async function mintFromFlags(platformDir, key, textFlag, args, rawArgs) {
7184
8347
  await mintAndReport(platformDir, key, {
7185
8348
  statement,
7186
8349
  why: (args.why ?? "").trim(),
7187
- livesIn: listFlag(rawArgs, "lives", args.lives) ?? [],
7188
- verifiedIn: listFlag(rawArgs, "tests", args.tests) ?? [],
7189
8350
  relates: listFlag(rawArgs, "relates", args.relates) ?? [],
7190
8351
  issue: (args.issue ?? "").trim() || undefined,
7191
8352
  status: statusOf(args),
@@ -7205,11 +8366,9 @@ async function mintInteractively(platformDir, key, id, status) {
7205
8366
  return;
7206
8367
  }
7207
8368
  const why = (await askLine("Why it matters: ")).trim();
7208
- const livesIn = listFlag([], "lives", await askLine("Lives in: ")) ?? [];
7209
8369
  await mintAndReport(platformDir, key, {
7210
8370
  statement: requirement,
7211
8371
  why,
7212
- livesIn,
7213
8372
  issue: undefined,
7214
8373
  status,
7215
8374
  json: false
@@ -7264,8 +8423,6 @@ var init_req = __esm(() => {
7264
8423
  description: "Non-interactive authoring: the Requirement field. When set, the entry appends with zero prompts."
7265
8424
  },
7266
8425
  why: { type: "string", description: "Why it matters field (only with --text)" },
7267
- lives: livesArg,
7268
- tests: testsArg,
7269
8426
  relates: relatesArg,
7270
8427
  issue: {
7271
8428
  type: "string",
@@ -7299,9 +8456,9 @@ var init_req = __esm(() => {
7299
8456
  await mintFromFlags(platformDir, key, textFlag, args, rawArgs);
7300
8457
  return;
7301
8458
  }
7302
- const hasFieldFlags = typeof args.why === "string" || typeof args.lives === "string" || typeof args.tests === "string" || typeof args.relates === "string" || typeof args.issue === "string";
8459
+ const hasFieldFlags = typeof args.why === "string" || typeof args.relates === "string" || typeof args.issue === "string";
7303
8460
  if (hasFieldFlags) {
7304
- console.error("spec req: --why/--lives/--tests/--relates/--issue require --text (the Requirement field is mandatory)");
8461
+ console.error("spec req: --why/--relates/--issue require --text (the Requirement field is mandatory)");
7305
8462
  process.exit(EXIT.USAGE);
7306
8463
  return;
7307
8464
  }
@@ -7389,8 +8546,8 @@ async function listTermsCommand(platformDirRaw, json) {
7389
8546
  console.log(JSON.stringify(result.rows));
7390
8547
  return;
7391
8548
  }
7392
- for (const row2 of result.rows) {
7393
- console.log(`${row2.id} ${row2.term} ${row2.status}`);
8549
+ for (const row of result.rows) {
8550
+ console.log(`${row.id} ${row.term} ${row.status}`);
7394
8551
  }
7395
8552
  }
7396
8553
  async function reviseTermCommand(opts) {
@@ -7621,7 +8778,7 @@ function resolvePlatform2(platformDirRaw) {
7621
8778
  }
7622
8779
  return platformDir;
7623
8780
  }
7624
- async function migrate(platformDir, json) {
8781
+ async function migrate2(platformDir, json) {
7625
8782
  const result = await migrateGlossary(platformDir);
7626
8783
  if (!result.ok)
7627
8784
  exitOnFailure("spec glossary", result);
@@ -7688,7 +8845,7 @@ var init_glossary2 = __esm(() => {
7688
8845
  const platformDir = resolvePlatform2(args.platformDir);
7689
8846
  const json = Boolean(args.json);
7690
8847
  if (args.migrate)
7691
- await migrate(platformDir, json);
8848
+ await migrate2(platformDir, json);
7692
8849
  else if (args.check)
7693
8850
  await check2(platformDir, json);
7694
8851
  else
@@ -7697,6 +8854,60 @@ var init_glossary2 = __esm(() => {
7697
8854
  });
7698
8855
  });
7699
8856
 
8857
+ // src/operations/_retag.ts
8858
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
8859
+ import { join as join16 } from "path";
8860
+ function retagLine(line, oldId, newId, doc) {
8861
+ const cr = line.endsWith("\r") ? "\r" : "";
8862
+ const body = cr ? line.slice(0, -1) : line;
8863
+ const re = doc ? DOC_BINDING_RE : SPEC_TAG_RE;
8864
+ if (newId !== null) {
8865
+ return body.replace(re, (m, id) => id === oldId ? m.replace(id, newId) : m) + cr;
8866
+ }
8867
+ const stripped = body.replace(new RegExp(`\\s*(?:${re.source})`, "g"), (m, id, level) => {
8868
+ if (id !== oldId)
8869
+ return m;
8870
+ return typeof level === "string" && !LEVELS.has(level) ? m.slice(m.search(TRAILING_WORD)) : "";
8871
+ });
8872
+ if (stripped === body)
8873
+ return line;
8874
+ const rest = stripped.replace(EMPTY_COMMENT_TAIL, "");
8875
+ const residue = rest.trim();
8876
+ return residue === "" || residue === "*" ? null : rest + cr;
8877
+ }
8878
+ async function applyRetag(platformDir, oldId, newId, freshTags) {
8879
+ const sites = toReqTagRows(await freshTags(oldId));
8880
+ const byFile = new Map;
8881
+ for (const site of sites) {
8882
+ const entry = byFile.get(site.file) ?? { doc: site.kind === "documents", lines: new Set };
8883
+ entry.lines.add(site.line);
8884
+ byFile.set(site.file, entry);
8885
+ }
8886
+ for (const [file, { doc, lines }] of byFile) {
8887
+ const abs = join16(platformDir, file);
8888
+ const out = [];
8889
+ readFileSync5(abs, "utf8").split(`
8890
+ `).forEach((line, i) => {
8891
+ const next = lines.has(i + 1) ? retagLine(line, oldId, newId, doc) : line;
8892
+ if (next !== null)
8893
+ out.push(next);
8894
+ });
8895
+ writeFileSync2(abs, out.join(`
8896
+ `));
8897
+ }
8898
+ if (sites.length > 0)
8899
+ await freshTags(oldId);
8900
+ return sites;
8901
+ }
8902
+ var EMPTY_COMMENT_TAIL, TRAILING_WORD;
8903
+ var init__retag = __esm(() => {
8904
+ init_docs();
8905
+ init_tags();
8906
+ init_reads2();
8907
+ EMPTY_COMMENT_TAIL = /\s*(?:\/\/|#|--|\/\*+\s*\*+\/|<!--\s*-->)\s*$/;
8908
+ TRAILING_WORD = /\s+[a-z][a-z0-9]*$/;
8909
+ });
8910
+
7700
8911
  // src/operations/supersede.ts
7701
8912
  async function supersedeTarget(platformDir, id) {
7702
8913
  const located = await locateEntry(platformDir, id);
@@ -7715,8 +8926,6 @@ async function supersedeTarget(platformDir, id) {
7715
8926
  function successorFields(input, req) {
7716
8927
  return {
7717
8928
  why: (input.why ?? req.why ?? "").trim(),
7718
- livesIn: input.livesIn ?? [...req.livesIn],
7719
- verifiedIn: input.verifiedIn ?? [...req.verifiedIn],
7720
8929
  relates: input.relates === undefined ? [...req.relates] : splitList(input.relates)
7721
8930
  };
7722
8931
  }
@@ -7761,15 +8970,17 @@ async function supersede(input, freshTags) {
7761
8970
  }
7762
8971
  warnings.push({ kind: "grammar", text: grammarWarningText("", verdict).slice(2) });
7763
8972
  }
7764
- const { why, livesIn, verifiedIn, relates } = successorFields(input, req);
7765
- warnings.push(...unresolvableRefWarnings(platformDir, [input.statement, why, ...livesIn, ...verifiedIn]));
8973
+ const rules = statementRulesVerdict(platformDir, key, input.statement);
8974
+ if (rules.refusal !== null)
8975
+ return fail("usage", rules.refusal);
8976
+ warnings.push(...rules.warnings);
8977
+ const { why, relates } = successorFields(input, req);
8978
+ warnings.push(...unresolvableRefWarnings(platformDir, [input.statement, why]));
7766
8979
  const newId = await nextRequirementId(platformDir, key);
7767
8980
  const issue = input.issue?.trim() || undefined;
7768
8981
  const successor = successorEntry(newId, req, {
7769
8982
  statement: input.statement,
7770
8983
  why,
7771
- livesIn,
7772
- verifiedIn,
7773
8984
  relates,
7774
8985
  issue
7775
8986
  });
@@ -7786,18 +8997,19 @@ async function supersede(input, freshTags) {
7786
8997
  warnings
7787
8998
  });
7788
8999
  }
7789
- const retag = toReqTagRows(await freshTags(id));
7790
- return { ok: true, oldId: id, newId, file: relFile, specVersion, retag, warnings };
9000
+ const retagged = await applyRetag(platformDir, id, newId, freshTags);
9001
+ return { ok: true, oldId: id, newId, file: relFile, specVersion, retagged, warnings };
7791
9002
  }
7792
9003
  var init_supersede = __esm(() => {
7793
9004
  init_src();
7794
9005
  init_domains();
7795
9006
  init_grammar2();
9007
+ init_statementRules3();
7796
9008
  init_domainJson();
7797
9009
  init__envelope();
7798
9010
  init__result();
9011
+ init__retag();
7799
9012
  init_mint();
7800
- init_reads2();
7801
9013
  });
7802
9014
 
7803
9015
  // src/resolve/format.ts
@@ -7813,14 +9025,14 @@ function renderResolve(rows, mode) {
7813
9025
  return "";
7814
9026
  const header = ["REQ_ID", "STATUS", "TEXT"];
7815
9027
  const lines = [header];
7816
- for (const row2 of sorted) {
7817
- const raw = row2.text ?? "";
9028
+ for (const row of sorted) {
9029
+ const raw = row.text ?? "";
7818
9030
  const truncated = raw.length > TEXT_MAX ? raw.slice(0, TEXT_MAX) + ELLIPSIS2 : raw;
7819
9031
  const text = truncated.replace(/\s+/g, " ").trim();
7820
- lines.push([row2.id, row2.status, text || EMPTY_CELL4]);
9032
+ lines.push([row.id, row.status, text || EMPTY_CELL4]);
7821
9033
  }
7822
- const widths = header.map((_, col) => Math.max(...lines.map((row2) => row2[col]?.length ?? 0)));
7823
- return lines.map((row2) => row2.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ")).join(`
9034
+ const widths = header.map((_, col) => Math.max(...lines.map((row) => row[col]?.length ?? 0)));
9035
+ return lines.map((row) => row.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ")).join(`
7824
9036
  `);
7825
9037
  }
7826
9038
  function sortReqTags(rows) {
@@ -7835,8 +9047,8 @@ function renderReqTags(rows, mode) {
7835
9047
  return "";
7836
9048
  const header = ["REPO", "FILE", "LINE", "KIND", "LEVEL"];
7837
9049
  const lines = [header];
7838
- for (const row2 of sorted) {
7839
- lines.push([row2.repo, row2.file, String(row2.line), row2.kind, row2.level ?? EMPTY_CELL4]);
9050
+ for (const row of sorted) {
9051
+ lines.push([row.repo, row.file, String(row.line), row.kind, row.level ?? EMPTY_CELL4]);
7840
9052
  }
7841
9053
  const widths = header.map((_, col) => Math.max(...lines.map((l) => (l[col] ?? "").length)));
7842
9054
  return lines.map((l) => l.map((cell, col) => cell.padEnd(widths[col])).join(" ").trimEnd()).join(`
@@ -7880,12 +9092,6 @@ function inputFromArgs(args, rawArgs, platformDir, id, statement) {
7880
9092
  const input = { platformDir, id, statement, noBump: Boolean(args.noBump) };
7881
9093
  if (typeof args.why === "string")
7882
9094
  input.why = args.why;
7883
- const livesIn = listFlag(rawArgs, "lives", args.lives);
7884
- if (livesIn !== undefined)
7885
- input.livesIn = livesIn;
7886
- const verifiedIn = listFlag(rawArgs, "tests", args.tests);
7887
- if (verifiedIn !== undefined)
7888
- input.verifiedIn = verifiedIn;
7889
9095
  const relates = listFlag(rawArgs, "relates", args.relates);
7890
9096
  if (relates !== undefined)
7891
9097
  input.relates = relates;
@@ -7922,14 +9128,6 @@ var init_supersede2 = __esm(() => {
7922
9128
  type: "string",
7923
9129
  description: "Successor's Why it matters (default: copied from the old entry)"
7924
9130
  },
7925
- lives: {
7926
- type: "string",
7927
- description: "Successor's Lives in: repeat or comma-separate; empty clears (default: copied from the old entry)"
7928
- },
7929
- tests: {
7930
- type: "string",
7931
- description: "Successor's Verified in (verifiedIn): repeat or comma-separate; empty clears (default: copied from the old entry)"
7932
- },
7933
9131
  relates: {
7934
9132
  type: "string",
7935
9133
  description: "Successor's related ids: repeat or comma-separate; empty clears (default: copied from the old entry)"
@@ -7952,13 +9150,13 @@ var init_supersede2 = __esm(() => {
7952
9150
  },
7953
9151
  json: {
7954
9152
  type: "boolean",
7955
- description: "Emit { old_id, new_id, file, spec_version, retag } as JSON instead of the text summary"
9153
+ description: "Emit { old_id, new_id, file, spec_version, retagged } as JSON instead of the text summary"
7956
9154
  }
7957
9155
  };
7958
9156
  supersedeCommand = defineCommand15({
7959
9157
  meta: {
7960
9158
  name: "supersede",
7961
- description: "Supersede a shipped requirement: flip it to superseded, mint the successor, bump specVersion, and emit the retag worklist."
9159
+ description: "Supersede a shipped requirement: flip it to superseded, mint the successor, bump specVersion, and retag every site of it to the successor."
7962
9160
  },
7963
9161
  args: supersedeArgs,
7964
9162
  async run({ args, rawArgs }) {
@@ -7978,21 +9176,18 @@ var init_supersede2 = __esm(() => {
7978
9176
  if (!result.ok)
7979
9177
  exitOnFailure("spec supersede", result);
7980
9178
  printWarnings("spec supersede", result.warnings);
7981
- const { newId, file, specVersion, retag } = result;
9179
+ const { newId, file, specVersion, retagged } = result;
7982
9180
  if (args.json) {
7983
- console.log(JSON.stringify({ old_id: id, new_id: newId, file, spec_version: specVersion, retag }));
9181
+ console.log(JSON.stringify({ old_id: id, new_id: newId, file, spec_version: specVersion, retagged }));
7984
9182
  } else {
7985
9183
  console.log(`superseded ${id} \u2192 ${newId} in ${file}`);
7986
9184
  if (specVersion !== null)
7987
9185
  console.log(`specVersion bumped to ${specVersion}`);
7988
- if (retag.length > 0) {
7989
- console.log(`retag ${retag.length} site(s) from ${id} to ${newId}:`);
7990
- console.log(renderReqTags(retag, "text"));
9186
+ if (retagged.length > 0) {
9187
+ console.log(`retagged ${retagged.length} site(s) from ${id} to ${newId}:`);
9188
+ console.log(renderReqTags(retagged, "text"));
7991
9189
  }
7992
9190
  }
7993
- if (retag.length > 0) {
7994
- console.error(`note: spec check will report SUPERSEDED_REFERENCED at each remaining ${id} tag until the sites are retagged to ${newId}`);
7995
- }
7996
9191
  }
7997
9192
  });
7998
9193
  });
@@ -8021,14 +9216,14 @@ async function deprecate(input, freshTags) {
8021
9216
  diagnostics: res.diagnostics
8022
9217
  });
8023
9218
  }
8024
- const sites = toReqTagRows(await freshTags(id));
8025
- return { ok: true, id, file: relFile, reason, sites };
9219
+ const removed = await applyRetag(platformDir, id, null, freshTags);
9220
+ return { ok: true, id, file: relFile, reason, removed };
8026
9221
  }
8027
9222
  var init_deprecate = __esm(() => {
8028
9223
  init_src();
8029
9224
  init__envelope();
8030
9225
  init__result();
8031
- init_reads2();
9226
+ init__retag();
8032
9227
  });
8033
9228
 
8034
9229
  // src/commands/deprecate.ts
@@ -8063,7 +9258,7 @@ var init_deprecate2 = __esm(() => {
8063
9258
  },
8064
9259
  json: {
8065
9260
  type: "boolean",
8066
- description: "Print { id, file, reason, sites } instead of text"
9261
+ description: "Print { id, file, reason, removed } instead of text"
8067
9262
  }
8068
9263
  },
8069
9264
  async run({ args }) {
@@ -8086,15 +9281,15 @@ var init_deprecate2 = __esm(() => {
8086
9281
  const result = await deprecate({ platformDir, id, reason }, cliFreshTags(platformDir));
8087
9282
  if (!result.ok)
8088
9283
  exitOnFailure("spec deprecate", result);
8089
- const { file, sites } = result;
9284
+ const { file, removed } = result;
8090
9285
  if (args.json) {
8091
- console.log(JSON.stringify({ id, file, reason, sites }));
9286
+ console.log(JSON.stringify({ id, file, reason, removed }));
8092
9287
  return;
8093
9288
  }
8094
9289
  console.log(`deprecated ${id} in ${file} \u2014 reason recorded`);
8095
- if (sites.length > 0) {
8096
- console.error(`${sites.length} code tag(s) still bind ${id} \u2014 remove or retag them (spec check reports each as DEPRECATED_REFERENCED):`);
8097
- console.error(renderReqTags(sites, "text"));
9290
+ if (removed.length > 0) {
9291
+ console.log(`removed ${removed.length} tag(s) of ${id}:`);
9292
+ console.log(renderReqTags(removed, "text"));
8098
9293
  }
8099
9294
  }
8100
9295
  });
@@ -8122,8 +9317,6 @@ function successorFields2(input, req) {
8122
9317
  return {
8123
9318
  statement: (input.statement ?? req.statement).trim(),
8124
9319
  why: (input.why ?? req.why ?? "").trim(),
8125
- livesIn: input.livesIn ?? [...req.livesIn],
8126
- verifiedIn: input.verifiedIn ?? [...req.verifiedIn],
8127
9320
  relates: [...req.relates]
8128
9321
  };
8129
9322
  }
@@ -8167,13 +9360,12 @@ async function move(input, freshTags) {
8167
9360
  }
8168
9361
  warnings.push({ kind: "grammar", text: grammarWarningText("", verdict).slice(2) });
8169
9362
  }
9363
+ const rules = statementRulesVerdict(platformDir, targetKey, fields.statement);
9364
+ if (rules.refusal !== null)
9365
+ return fail("usage", rules.refusal);
9366
+ warnings.push(...rules.warnings);
8170
9367
  }
8171
- warnings.push(...unresolvableRefWarnings(platformDir, [
8172
- fields.statement,
8173
- fields.why,
8174
- ...fields.livesIn,
8175
- ...fields.verifiedIn
8176
- ]));
9368
+ warnings.push(...unresolvableRefWarnings(platformDir, [fields.statement, fields.why]));
8177
9369
  const newId = await nextRequirementId(platformDir, targetKey);
8178
9370
  const sourceCurrent = source.domain.specVersion ?? 1;
8179
9371
  req.status = "superseded";
@@ -8193,7 +9385,7 @@ async function move(input, freshTags) {
8193
9385
  }
8194
9386
  await validateAndWrite(target.specPath, target.domain, target.relFile);
8195
9387
  await validateAndWrite(source.specPath, source.domain, source.relFile);
8196
- const retag = toReqTagRows(await freshTags(id));
9388
+ const retagged = await applyRetag(platformDir, id, newId, freshTags);
8197
9389
  return {
8198
9390
  ok: true,
8199
9391
  oldId: id,
@@ -8202,7 +9394,7 @@ async function move(input, freshTags) {
8202
9394
  toFile: target.relFile,
8203
9395
  sourceSpecVersion,
8204
9396
  targetSpecVersion,
8205
- retag,
9397
+ retagged,
8206
9398
  warnings
8207
9399
  };
8208
9400
  }
@@ -8210,11 +9402,12 @@ var init_move = __esm(() => {
8210
9402
  init_src();
8211
9403
  init_domains();
8212
9404
  init_grammar2();
9405
+ init_statementRules3();
8213
9406
  init_domainJson();
8214
9407
  init__envelope();
8215
9408
  init__result();
9409
+ init__retag();
8216
9410
  init_mint();
8217
- init_reads2();
8218
9411
  });
8219
9412
 
8220
9413
  // src/commands/move.ts
@@ -8223,18 +9416,12 @@ __export(exports_move, {
8223
9416
  moveCommand: () => moveCommand
8224
9417
  });
8225
9418
  import { defineCommand as defineCommand17 } from "citty";
8226
- function inputFromArgs2(args, rawArgs, platformDir, id, targetKey) {
9419
+ function inputFromArgs2(args, platformDir, id, targetKey) {
8227
9420
  const input = { platformDir, id, targetKey, noBump: Boolean(args.noBump) };
8228
9421
  if (typeof args.text === "string")
8229
9422
  input.statement = args.text;
8230
9423
  if (typeof args.why === "string")
8231
9424
  input.why = args.why;
8232
- const livesIn = listFlag(rawArgs, "lives", args.lives);
8233
- if (livesIn !== undefined)
8234
- input.livesIn = livesIn;
8235
- const verifiedIn = listFlag(rawArgs, "tests", args.tests);
8236
- if (verifiedIn !== undefined)
8237
- input.verifiedIn = verifiedIn;
8238
9425
  return input;
8239
9426
  }
8240
9427
  var moveArgs, moveCommand;
@@ -8266,30 +9453,22 @@ var init_move2 = __esm(() => {
8266
9453
  type: "string",
8267
9454
  description: "Rewrite the successor's Why (default: copied from the source)"
8268
9455
  },
8269
- lives: {
8270
- type: "string",
8271
- description: "Rewrite the successor's Lives in: repeat or comma-separate; empty clears (default: copied from the source)"
8272
- },
8273
- tests: {
8274
- type: "string",
8275
- description: "Rewrite the successor's Verified in (verifiedIn): repeat or comma-separate; empty clears (default: copied from the source)"
8276
- },
8277
9456
  noBump: {
8278
9457
  type: "boolean",
8279
9458
  description: "Do not bump either envelope's specVersion"
8280
9459
  },
8281
9460
  json: {
8282
9461
  type: "boolean",
8283
- description: "Emit { old_id, new_id, from_file, to_file, source_spec_version, target_spec_version, retag } as JSON"
9462
+ description: "Emit { old_id, new_id, from_file, to_file, source_spec_version, target_spec_version, retagged } as JSON"
8284
9463
  }
8285
9464
  };
8286
9465
  moveCommand = defineCommand17({
8287
9466
  meta: {
8288
9467
  name: "move",
8289
- description: "Move a requirement to another domain: mint the successor in <NEW-DOMAIN> carrying the source's fields, mark the source superseded, bump both specVersions, and emit the retag worklist."
9468
+ description: "Move a requirement to another domain: mint the successor in <NEW-DOMAIN> carrying the source's fields, mark the source superseded, bump both specVersions, and retag every site of it to the successor."
8290
9469
  },
8291
9470
  args: moveArgs,
8292
- async run({ args, rawArgs }) {
9471
+ async run({ args }) {
8293
9472
  const id = args.id;
8294
9473
  const rawTargetKey = args.newDomain;
8295
9474
  const platformDir = resolvePlatformDir(args);
@@ -8307,11 +9486,11 @@ var init_move2 = __esm(() => {
8307
9486
  } catch (e) {
8308
9487
  handleNotAPlatform(e);
8309
9488
  }
8310
- const result = await move(inputFromArgs2(args, rawArgs, platformDir, id, targetKey), cliFreshTags(platformDir));
9489
+ const result = await move(inputFromArgs2(args, platformDir, id, targetKey), cliFreshTags(platformDir));
8311
9490
  if (!result.ok)
8312
9491
  exitOnFailure("spec move", result);
8313
9492
  printWarnings("spec move", result.warnings);
8314
- const { newId, fromFile, toFile, sourceSpecVersion, targetSpecVersion, retag } = result;
9493
+ const { newId, fromFile, toFile, sourceSpecVersion, targetSpecVersion, retagged } = result;
8315
9494
  const sourceKey = id.slice(0, id.indexOf("-"));
8316
9495
  if (args.json) {
8317
9496
  console.log(JSON.stringify({
@@ -8321,21 +9500,18 @@ var init_move2 = __esm(() => {
8321
9500
  to_file: toFile,
8322
9501
  source_spec_version: sourceSpecVersion,
8323
9502
  target_spec_version: targetSpecVersion,
8324
- retag
9503
+ retagged
8325
9504
  }));
8326
9505
  } else {
8327
9506
  console.log(`moved ${id} \u2192 ${newId} (${sourceKey} \u2192 ${targetKey})`);
8328
9507
  if (sourceSpecVersion !== null || targetSpecVersion !== null) {
8329
9508
  console.log(`specVersion: ${sourceKey}\u2192${sourceSpecVersion}, ${targetKey}\u2192${targetSpecVersion}`);
8330
9509
  }
8331
- if (retag.length > 0) {
8332
- console.log(`retag ${retag.length} site(s) from ${id} to ${newId}:`);
8333
- console.log(renderReqTags(retag, "text"));
9510
+ if (retagged.length > 0) {
9511
+ console.log(`retagged ${retagged.length} site(s) from ${id} to ${newId}:`);
9512
+ console.log(renderReqTags(retagged, "text"));
8334
9513
  }
8335
9514
  }
8336
- if (retag.length > 0) {
8337
- console.error(`note: spec check will report SUPERSEDED_REFERENCED at each remaining ${id} tag until the sites are retagged to ${newId}`);
8338
- }
8339
9515
  }
8340
9516
  });
8341
9517
  });
@@ -8364,7 +9540,7 @@ function gateRejection(platformDir, relFile, req, fields) {
8364
9540
  const tag = releaseHolding(platformDir, relFile, id);
8365
9541
  if (tag === null)
8366
9542
  return null;
8367
- 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, --tests, or --relates to amend its metadata in place.");
9543
+ 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 --relates or --issue to amend its metadata in place.");
8368
9544
  }
8369
9545
  function applyFields(req, fields) {
8370
9546
  const fieldsChanged = [];
@@ -8379,16 +9555,6 @@ function applyFields(req, fields) {
8379
9555
  fieldsChanged.push("why");
8380
9556
  refValues.push(fields.why ?? "");
8381
9557
  }
8382
- if (fields.livesIn !== undefined) {
8383
- req.livesIn = fields.livesIn;
8384
- fieldsChanged.push("lives");
8385
- refValues.push(...fields.livesIn);
8386
- }
8387
- if (fields.verifiedIn !== undefined) {
8388
- req.verifiedIn = fields.verifiedIn;
8389
- fieldsChanged.push("tests");
8390
- refValues.push(...fields.verifiedIn);
8391
- }
8392
9558
  if (fields.relates !== undefined) {
8393
9559
  req.relates = splitList(fields.relates);
8394
9560
  fieldsChanged.push("relates");
@@ -8426,6 +9592,10 @@ async function amend(input) {
8426
9592
  }
8427
9593
  warnings.push({ kind: "grammar", text: grammarWarningText("", verdict).slice(2) });
8428
9594
  }
9595
+ const rules = statementRulesVerdict(platformDir, key, fields.statement);
9596
+ if (rules.refusal !== null)
9597
+ return fail("usage", rules.refusal);
9598
+ warnings.push(...rules.warnings);
8429
9599
  }
8430
9600
  const { fieldsChanged, refValues } = applyFields(req, fields);
8431
9601
  warnings.push(...unresolvableRefWarnings(platformDir, refValues));
@@ -8444,6 +9614,7 @@ var PROMISE_FIELDS;
8444
9614
  var init_amend = __esm(() => {
8445
9615
  init_src();
8446
9616
  init_grammar2();
9617
+ init_statementRules3();
8447
9618
  init__envelope();
8448
9619
  init__result();
8449
9620
  init_mint();
@@ -8458,9 +9629,9 @@ __export(exports_amend, {
8458
9629
  import { defineCommand as defineCommand18 } from "citty";
8459
9630
  function assertAmendFlags(args) {
8460
9631
  const hasIssue = args.issue !== undefined && args.issue.trim() !== "";
8461
- const hasField = args.text !== undefined || args.why !== undefined || args.lives !== undefined || args.tests !== undefined || args.relates !== undefined || args.term !== undefined || args.aliases !== undefined;
9632
+ const hasField = args.text !== undefined || args.why !== undefined || args.relates !== undefined || args.term !== undefined || args.aliases !== undefined;
8462
9633
  if (!hasField && !hasIssue) {
8463
- console.error("spec amend: nothing to amend \u2014 pass at least one of --text / --why / --lives / --tests / --relates / --term / --aliases");
9634
+ console.error("spec amend: nothing to amend \u2014 pass at least one of --text / --why / --relates / --term / --aliases");
8464
9635
  process.exit(EXIT.USAGE);
8465
9636
  }
8466
9637
  if (args.text !== undefined && args.text.trim() === "") {
@@ -8484,12 +9655,6 @@ function fieldsFromArgs(args, rawArgs) {
8484
9655
  const v = args.why.trim();
8485
9656
  fields.why = v === "" ? null : v;
8486
9657
  }
8487
- const livesIn = listFlag(rawArgs, "lives", args.lives);
8488
- if (livesIn !== undefined)
8489
- fields.livesIn = livesIn;
8490
- const verifiedIn = listFlag(rawArgs, "tests", args.tests);
8491
- if (verifiedIn !== undefined)
8492
- fields.verifiedIn = verifiedIn;
8493
9658
  const relates = listFlag(rawArgs, "relates", args.relates);
8494
9659
  if (relates !== undefined)
8495
9660
  fields.relates = relates;
@@ -8518,8 +9683,6 @@ var init_amend2 = __esm(() => {
8518
9683
  platformDir: platformDirArg,
8519
9684
  text: { type: "string", description: "New Requirement (statement) field value" },
8520
9685
  why: { type: "string", description: "New Why it matters field value" },
8521
- lives: livesArg,
8522
- tests: testsArg,
8523
9686
  relates: relatesArg,
8524
9687
  issue: {
8525
9688
  type: "string",
@@ -8535,7 +9698,7 @@ var init_amend2 = __esm(() => {
8535
9698
  amendCommand = defineCommand18({
8536
9699
  meta: {
8537
9700
  name: "amend",
8538
- description: "Revise a requirement's fields in place (same id, no version bump). A released requirement's statement and why are superseded instead; --lives, --tests, and --relates still amend."
9701
+ description: "Revise a requirement's fields in place (same id, no version bump). A released requirement's statement and why are superseded instead; --relates and --issue still amend."
8539
9702
  },
8540
9703
  args: amendArgs,
8541
9704
  async run({ args, rawArgs }) {
@@ -8575,6 +9738,9 @@ async function accept(input) {
8575
9738
  if (req.status.toLowerCase() !== "draft") {
8576
9739
  return fail("conflict", `${id} is ${displayStatus(req.status)} \u2014 only a Draft entry accepts`);
8577
9740
  }
9741
+ const rules = statementRulesVerdict(platformDir, located.key, req.statement);
9742
+ if (rules.refusal !== null)
9743
+ return fail("usage", rules.refusal);
8578
9744
  req.status = "active";
8579
9745
  domain2.updated = localToday();
8580
9746
  const res = await validateAndWrite(specPath, domain2, relFile);
@@ -8584,10 +9750,11 @@ async function accept(input) {
8584
9750
  diagnostics: res.diagnostics
8585
9751
  });
8586
9752
  }
8587
- return { ok: true, id, file: relFile };
9753
+ return { ok: true, id, file: relFile, warnings: rules.warnings };
8588
9754
  }
8589
9755
  var init_accept = __esm(() => {
8590
9756
  init_src();
9757
+ init_statementRules3();
8591
9758
  init__envelope();
8592
9759
  init__result();
8593
9760
  });
@@ -8636,6 +9803,7 @@ var init_accept2 = __esm(() => {
8636
9803
  const result = await accept({ platformDir, id });
8637
9804
  if (!result.ok)
8638
9805
  exitOnFailure("spec accept", result);
9806
+ printWarnings("spec accept", result.warnings);
8639
9807
  if (args.json) {
8640
9808
  console.log(JSON.stringify({ id, file: result.file }));
8641
9809
  return;
@@ -8645,6 +9813,158 @@ var init_accept2 = __esm(() => {
8645
9813
  });
8646
9814
  });
8647
9815
 
9816
+ // src/operations/lint.ts
9817
+ import { existsSync as existsSync14 } from "fs";
9818
+ async function lint2(input) {
9819
+ const { platformDir, target } = input;
9820
+ const config2 = readCheckConfig(platformDir);
9821
+ if (!config2.ok)
9822
+ return fail("usage", config2.detail);
9823
+ const statuses = new Set(input.all ? ["active", "draft"] : ["active"]);
9824
+ let keys;
9825
+ let id;
9826
+ if (target !== undefined && ID_RE.test(target)) {
9827
+ const located = await locateEntry(platformDir, target);
9828
+ if (!located.ok)
9829
+ return located;
9830
+ keys = [located.key];
9831
+ id = target;
9832
+ } else if (target !== undefined && KEY_RE.test(target)) {
9833
+ const { abs, rel } = specPaths(platformDir, target);
9834
+ if (!existsSync14(abs))
9835
+ return fail("not_found", `no domain ${target} (expected ${rel})`);
9836
+ keys = [target];
9837
+ } else if (target !== undefined) {
9838
+ return fail("usage", `${target} is neither a domain key nor a requirement id`);
9839
+ }
9840
+ const findings = await statementRuleRows(platformDir, config2.config.statementRules, {
9841
+ keys,
9842
+ id,
9843
+ statuses
9844
+ });
9845
+ return { ok: true, findings };
9846
+ }
9847
+ var init_lint = __esm(() => {
9848
+ init_src();
9849
+ init_severity();
9850
+ init_statementRules2();
9851
+ init_constants();
9852
+ init__envelope();
9853
+ init__result();
9854
+ });
9855
+
9856
+ // src/commands/lint.ts
9857
+ var exports_lint = {};
9858
+ __export(exports_lint, {
9859
+ lintCommand: () => lintCommand
9860
+ });
9861
+ import { existsSync as existsSync15 } from "fs";
9862
+ import { join as join17, resolve as resolve13 } from "path";
9863
+ import { defineCommand as defineCommand20 } from "citty";
9864
+ function splitPositionals(args) {
9865
+ const { target, platformDir } = args;
9866
+ if (target !== undefined && platformDir === undefined && existsSync15(join17(resolve13(target), CANONICAL_SPECS_DIR))) {
9867
+ return { target: undefined, platformDir: resolve13(target) };
9868
+ }
9869
+ return { target, platformDir: resolvePlatformDir({ platformDir }) };
9870
+ }
9871
+ var lintCommand;
9872
+ var init_lint2 = __esm(() => {
9873
+ init_src();
9874
+ init_constants();
9875
+ init_discover();
9876
+ init_lint();
9877
+ init_stdout();
9878
+ init__args();
9879
+ init__shared();
9880
+ lintCommand = defineCommand20({
9881
+ meta: {
9882
+ name: "lint",
9883
+ description: "Print every statement-rule finding for the platform, one domain (KEY), or one requirement (KEY-NNN); exit 1 on any"
9884
+ },
9885
+ args: {
9886
+ target: {
9887
+ type: "positional",
9888
+ required: false,
9889
+ description: "Domain key or requirement id (default: the whole platform)"
9890
+ },
9891
+ platformDir: platformDirArg,
9892
+ all: { type: "boolean", description: "Judge Draft requirements as well as Active ones" },
9893
+ json: jsonArg
9894
+ },
9895
+ async run({ args }) {
9896
+ const { target, platformDir } = splitPositionals(args);
9897
+ try {
9898
+ assertSpecPlatform(platformDir);
9899
+ } catch (e) {
9900
+ handleNotAPlatform(e);
9901
+ }
9902
+ const result = await lint2({ platformDir, target, all: !!args.all });
9903
+ if (!result.ok)
9904
+ exitOnFailure("spec lint", result);
9905
+ if (args.json) {
9906
+ console.log(JSON.stringify(result.findings.map(({ req_id, rule, detail, reason, source_file, line }) => ({
9907
+ req_id,
9908
+ rule,
9909
+ detail,
9910
+ reason,
9911
+ source_file,
9912
+ line
9913
+ }))));
9914
+ } else {
9915
+ for (const f of result.findings)
9916
+ console.log(`${f.req_id} ${formatStatementFinding(f)}`);
9917
+ if (result.findings.length > 0)
9918
+ console.error(RULE_ZERO);
9919
+ }
9920
+ await exit(result.findings.length > 0 ? EXIT.FAILURE : EXIT.OK);
9921
+ }
9922
+ });
9923
+ });
9924
+
9925
+ // src/commands/migrate.ts
9926
+ var exports_migrate = {};
9927
+ __export(exports_migrate, {
9928
+ migrateCommand: () => migrateCommand
9929
+ });
9930
+ import { defineCommand as defineCommand21 } from "citty";
9931
+ var migrateCommand;
9932
+ var init_migrate2 = __esm(() => {
9933
+ init_discover();
9934
+ init_migrate();
9935
+ init__args();
9936
+ init__shared();
9937
+ migrateCommand = defineCommand21({
9938
+ meta: {
9939
+ name: "migrate",
9940
+ description: "Remove the retired livesIn and verifiedIn fields from every SPEC.json, rewriting only the files that carry one"
9941
+ },
9942
+ args: {
9943
+ platformDir: platformDirArg,
9944
+ json: jsonArg
9945
+ },
9946
+ async run({ args }) {
9947
+ const platformDir = resolvePlatformDir(args);
9948
+ try {
9949
+ assertSpecPlatform(platformDir);
9950
+ } catch (e) {
9951
+ handleNotAPlatform(e);
9952
+ }
9953
+ const result = await migrate(platformDir);
9954
+ if (!result.ok)
9955
+ exitOnFailure("spec migrate", result);
9956
+ if (args.json) {
9957
+ console.log(JSON.stringify({ files: result.files }));
9958
+ return;
9959
+ }
9960
+ if (result.files.length === 0)
9961
+ console.error("spec migrate: nothing to migrate");
9962
+ for (const f of result.files)
9963
+ console.log(`migrated ${f}`);
9964
+ }
9965
+ });
9966
+ });
9967
+
8648
9968
  // src/server/authorPrompt.ts
8649
9969
  function renderAuthorPrompt(opts) {
8650
9970
  const charterBlock = opts.charter ? `
@@ -8654,6 +9974,8 @@ Domain charter (scope): ${opts.charter}
8654
9974
  Target domain: ${opts.domain} (no charter/scope recorded \u2014 check placement carefully)
8655
9975
  ` : "\nNo target domain given \u2014 determine placement from `spec domain list`.\n";
8656
9976
  return [
9977
+ RULE_ZERO,
9978
+ "",
8657
9979
  "You are authoring Spec Engine requirements from a brief. Follow this playbook exactly.",
8658
9980
  charterBlock,
8659
9981
  "Brief:",
@@ -8663,14 +9985,17 @@ Target domain: ${opts.domain} (no charter/scope recorded \u2014 check placement
8663
9985
  "1. One requirement per behavior \u2014 never one per ticket; a brief fans out into several.",
8664
9986
  "2. Placement: the domain whose promise the requirement protects (see the charter above); run `spec query <text>` first \u2014 if a close requirement exists, amend or relate it instead of minting.",
8665
9987
  '3. Draft each statement in one of the five EARS shapes ("shall" separates who acts from the observable result): `The <system> shall <result>` / `When <trigger>, the <system> shall <result>` / `While <state>, \u2026` / `If <failure>, then \u2026` / `Where <feature>, \u2026`. Example: "When an unknown id is passed, spec resolve shall print [] and exit 0."',
8666
- "4. Self-check: the statement makes sense read alone; it describes what the system does, not how the code does it; no expiration dates ('currently', removed things); the why names what breaks if the promise stops holding \u2014 never a restatement.",
9988
+ "4. Self-check: the statement is one sentence of at most 280 characters that names no repository file or folder, test mechanics, CI or tooling layout, history, project jargon, or the tool in use (spec req refuses it otherwise; `spec lint` lists the findings); it makes sense read alone; it describes what the system does, not how the code does it; no expiration dates ('currently', removed things); the why names what breaks if the promise stops holding \u2014 never a restatement.",
8667
9989
  "5. Tie each draft to its source: quote the exact phrase of the brief it derives from; label anything you inferred as inferred.",
8668
9990
  "6. Present the full batch (statement, why, placement, source quote) for approval BEFORE writing.",
8669
- '7. Mint each approved requirement: `spec req <domain> --text "\u2026" --why "\u2026" --lives "\u2026"`.',
9991
+ '7. Mint each approved requirement: `spec req <domain> --text "\u2026" --why "\u2026"`.',
8670
9992
  "8. The originating ticket is provenance only \u2014 never a requirement id, never a code tag."
8671
9993
  ].join(`
8672
9994
  `);
8673
9995
  }
9996
+ var init_authorPrompt = __esm(() => {
9997
+ init_src();
9998
+ });
8674
9999
 
8675
10000
  // src/server/mcp.ts
8676
10001
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -8687,10 +10012,6 @@ function amendFieldsOf(args) {
8687
10012
  fields.statement = args.text;
8688
10013
  if (args.why !== undefined)
8689
10014
  fields.why = args.why || null;
8690
- if (args.lives !== undefined)
8691
- fields.livesIn = args.lives;
8692
- if (args.verified_in !== undefined)
8693
- fields.verifiedIn = args.verified_in;
8694
10015
  if (args.relates !== undefined)
8695
10016
  fields.relates = args.relates;
8696
10017
  if (args.issue)
@@ -8720,7 +10041,7 @@ function buildMcpServer(platformDir) {
8720
10041
  }, async ({ files }) => fresh((s) => jsonResult(resolveFiles(s, files).rows)));
8721
10042
  server.registerTool("spec_req_tags", {
8722
10043
  title: "Requirement \u2192 tag sites (reverse query)",
8723
- description: "Every code/test/doc site tagging the given requirement, across all repos \u2014 impact analysis and retag worklists.",
10044
+ description: "Every code/test/doc site tagging the given requirement, across all repos \u2014 impact analysis.",
8724
10045
  inputSchema: { req_id: z4.string().describe("Requirement id (KEY-NNN)") }
8725
10046
  }, async ({ req_id }) => {
8726
10047
  if (!ID_RE.test(req_id)) {
@@ -8761,15 +10082,15 @@ function buildMcpServer(platformDir) {
8761
10082
  });
8762
10083
  server.registerTool("spec_get", {
8763
10084
  title: "One requirement's full record",
8764
- 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).",
10085
+ description: "The full record for one requirement id: status, key, seq, statement, why, versions, source location, and the implemented_in and verified_in tag sites. An unknown id returns [] (the `spec get --json` shape).",
8765
10086
  inputSchema: { req_id: z4.string().describe("Requirement id (KEY-NNN)") }
8766
10087
  }, async ({ req_id }) => {
8767
10088
  if (!ID_RE.test(req_id)) {
8768
10089
  return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
8769
10090
  }
8770
10091
  return fresh((s) => {
8771
- const { row: row2 } = getRecord(s, req_id);
8772
- return jsonResult(row2 ?? []);
10092
+ const { row } = getRecord(s, req_id);
10093
+ return jsonResult(row ?? []);
8773
10094
  });
8774
10095
  });
8775
10096
  server.registerTool("spec_list", {
@@ -8794,17 +10115,15 @@ function buildMcpServer(platformDir) {
8794
10115
  });
8795
10116
  server.registerTool("spec_supersede", {
8796
10117
  title: "Supersede a shipped requirement",
8797
- 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`.",
10118
+ description: "Flip an Active requirement to superseded, mint its successor in the same domain, rewrite every tag of the predecessor to the successor, and return the rewritten sites. The same JSON as `spec supersede --json`.",
8798
10119
  inputSchema: {
8799
10120
  req_id: z4.string().describe("The Active requirement id to supersede (KEY-NNN)"),
8800
10121
  statement: z4.string().min(1).describe("The successor's statement"),
8801
10122
  why: z4.string().optional().describe("Successor's why (default: copied from the predecessor)"),
8802
- lives_in: z4.array(z4.string()).optional().describe("Successor's livesIn paths (default: copied from the predecessor)"),
8803
- verified_in: z4.array(z4.string()).optional().describe("Successor's verifiedIn paths (default: copied from the predecessor)"),
8804
10123
  relates: z4.array(z4.string()).optional().describe("Successor's related ids, each comma-split; an empty list clears (default: copied from the predecessor)"),
8805
10124
  issue: z4.string().optional().describe("Ticket recorded as supersedes-via on the predecessor and created on the successor")
8806
10125
  }
8807
- }, async ({ req_id, statement, why, lives_in, verified_in, relates, issue }) => {
10126
+ }, async ({ req_id, statement, why, relates, issue }) => {
8808
10127
  if (!ID_RE.test(req_id)) {
8809
10128
  return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
8810
10129
  }
@@ -8813,8 +10132,6 @@ function buildMcpServer(platformDir) {
8813
10132
  id: req_id,
8814
10133
  statement,
8815
10134
  why,
8816
- livesIn: lives_in,
8817
- verifiedIn: verified_in,
8818
10135
  relates,
8819
10136
  issue
8820
10137
  }, coldFreshTags(platformDir));
@@ -8827,12 +10144,12 @@ function buildMcpServer(platformDir) {
8827
10144
  new_id: r.newId,
8828
10145
  file: r.file,
8829
10146
  spec_version: r.specVersion,
8830
- retag: r.retag
10147
+ retagged: r.retagged
8831
10148
  });
8832
10149
  });
8833
10150
  server.registerTool("spec_deprecate", {
8834
10151
  title: "Deprecate a requirement",
8835
- 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`.",
10152
+ description: "Mark an Active or Draft requirement deprecated with a recorded reason, remove every tag of it from the code, and return the removed sites. The same JSON as `spec deprecate --json`.",
8836
10153
  inputSchema: {
8837
10154
  req_id: z4.string().describe("The requirement id to deprecate (KEY-NNN)"),
8838
10155
  reason: z4.string().min(1).describe("Why the requirement ended; the durable record")
@@ -8845,7 +10162,7 @@ function buildMcpServer(platformDir) {
8845
10162
  if (trimmed === "")
8846
10163
  return errorResult("reason must be non-empty");
8847
10164
  const r = await deprecate({ platformDir, id: req_id, reason: trimmed }, coldFreshTags(platformDir));
8848
- return r.ok ? jsonResult({ id: r.id, file: r.file, reason: r.reason, sites: r.sites }) : errorResult(r.detail);
10165
+ return r.ok ? jsonResult({ id: r.id, file: r.file, reason: r.reason, removed: r.removed }) : errorResult(r.detail);
8849
10166
  });
8850
10167
  server.registerTool("spec_accept", {
8851
10168
  title: "Accept a draft requirement",
@@ -8858,7 +10175,11 @@ function buildMcpServer(platformDir) {
8858
10175
  return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
8859
10176
  }
8860
10177
  const r = await accept({ platformDir, id: req_id });
8861
- return r.ok ? jsonResult({ id: r.id, file: r.file }) : errorResult(r.detail);
10178
+ if (!r.ok)
10179
+ return errorResult(r.detail);
10180
+ for (const w of r.warnings)
10181
+ console.error(`spec_accept: ${w.text}`);
10182
+ return jsonResult({ id: r.id, file: r.file });
8862
10183
  });
8863
10184
  server.registerTool("spec_req", {
8864
10185
  title: "Author a requirement",
@@ -8867,13 +10188,11 @@ function buildMcpServer(platformDir) {
8867
10188
  domain: z4.string().min(1).describe("Domain key or unique case-insensitive prefix"),
8868
10189
  text: z4.string().trim().min(1).describe("The requirement statement"),
8869
10190
  why: z4.string().trim().default("").describe("What breaks without it"),
8870
- lives: z4.array(z4.string()).default([]).describe("livesIn paths"),
8871
- verified_in: z4.array(z4.string()).default([]).describe("verifiedIn test paths"),
8872
10191
  relates: z4.array(z4.string()).default([]).describe("Related requirement ids, each comma-split"),
8873
10192
  issue: z4.string().trim().optional().describe("Originating ticket, recorded as created"),
8874
10193
  draft: z4.boolean().default(false).describe("Author as Draft instead of Active")
8875
10194
  }
8876
- }, async ({ domain: domain2, text, why, lives, verified_in, relates, issue, draft }) => {
10195
+ }, async ({ domain: domain2, text, why, relates, issue, draft }) => {
8877
10196
  const allocated = await nextId(platformDir, domain2);
8878
10197
  if (!allocated.ok)
8879
10198
  return errorResult(allocated.detail);
@@ -8882,8 +10201,6 @@ function buildMcpServer(platformDir) {
8882
10201
  key: allocated.key,
8883
10202
  statement: text,
8884
10203
  why,
8885
- livesIn: lives,
8886
- verifiedIn: verified_in,
8887
10204
  relates,
8888
10205
  issue: issue || undefined,
8889
10206
  status: draft ? "draft" : "active"
@@ -8901,17 +10218,15 @@ function buildMcpServer(platformDir) {
8901
10218
  id: z4.string().describe("The requirement id to amend (KEY-NNN)"),
8902
10219
  text: z4.string().trim().min(1).optional().describe("New statement"),
8903
10220
  why: z4.string().trim().optional().describe("New why; empty clears it"),
8904
- lives: z4.array(z4.string()).optional().describe("Replacement livesIn list; empty clears"),
8905
- verified_in: z4.array(z4.string()).optional().describe("Replacement verifiedIn list; empty clears"),
8906
10221
  relates: z4.array(z4.string()).optional().describe("Replacement related ids, each comma-split; empty clears"),
8907
10222
  issue: z4.string().trim().optional().describe("Ticket recorded as amends-via")
8908
10223
  }
8909
- }, async ({ id, text, why, lives, verified_in, relates, issue }) => {
10224
+ }, async ({ id, text, why, relates, issue }) => {
8910
10225
  if (!ID_RE.test(id))
8911
10226
  return errorResult(`id must be a requirement id (KEY-NNN); got ${id}`);
8912
- const fields = amendFieldsOf({ text, why, lives, verified_in, relates, issue });
10227
+ const fields = amendFieldsOf({ text, why, relates, issue });
8913
10228
  if (Object.keys(fields).length === 0) {
8914
- return errorResult("nothing to amend \u2014 pass at least one of text, why, lives, verified_in, relates");
10229
+ return errorResult("nothing to amend \u2014 pass at least one of text, why, relates, issue");
8915
10230
  }
8916
10231
  const r = await amend({ platformDir, id, fields });
8917
10232
  if (!r.ok)
@@ -8922,16 +10237,14 @@ function buildMcpServer(platformDir) {
8922
10237
  });
8923
10238
  server.registerTool("spec_move", {
8924
10239
  title: "Move a requirement to another domain",
8925
- description: "Mint the successor in newDomain carrying the source's fields, mark the source superseded, and return the retag worklist. The same JSON as `spec move --json`.",
10240
+ description: "Mint the successor in newDomain carrying the source's fields, mark the source superseded, rewrite every tag of the source to the successor, and return the rewritten sites. The same JSON as `spec move --json`.",
8926
10241
  inputSchema: {
8927
10242
  id: z4.string().describe("The Active requirement id to move (KEY-NNN)"),
8928
10243
  newDomain: z4.string().min(1).describe("The existing target domain key"),
8929
10244
  text: z4.string().optional().describe("Successor statement (default: copied)"),
8930
- why: z4.string().optional().describe("Successor why (default: copied)"),
8931
- lives: z4.array(z4.string()).optional().describe("Successor livesIn list; empty clears (default: copied)"),
8932
- verified_in: z4.array(z4.string()).optional().describe("Successor verifiedIn list; empty clears (default: copied)")
10245
+ why: z4.string().optional().describe("Successor why (default: copied)")
8933
10246
  }
8934
- }, async ({ id, newDomain: newDomain2, text, why, lives, verified_in }) => {
10247
+ }, async ({ id, newDomain: newDomain2, text, why }) => {
8935
10248
  if (!ID_RE.test(id))
8936
10249
  return errorResult(`id must be a requirement id (KEY-NNN); got ${id}`);
8937
10250
  const targetKey = normalizeDomainKey(newDomain2);
@@ -8942,9 +10255,7 @@ function buildMcpServer(platformDir) {
8942
10255
  id,
8943
10256
  targetKey,
8944
10257
  statement: text,
8945
- why,
8946
- livesIn: lives,
8947
- verifiedIn: verified_in
10258
+ why
8948
10259
  }, coldFreshTags(platformDir));
8949
10260
  if (!r.ok)
8950
10261
  return errorResult(r.detail);
@@ -8957,7 +10268,7 @@ function buildMcpServer(platformDir) {
8957
10268
  to_file: r.toFile,
8958
10269
  source_spec_version: r.sourceSpecVersion,
8959
10270
  target_spec_version: r.targetSpecVersion,
8960
- retag: r.retag
10271
+ retagged: r.retagged
8961
10272
  });
8962
10273
  });
8963
10274
  server.registerPrompt("author_requirements", {
@@ -9006,6 +10317,7 @@ var init_mcp = __esm(() => {
9006
10317
  init_records();
9007
10318
  init_supersede();
9008
10319
  init_grammar3();
10320
+ init_authorPrompt();
9009
10321
  });
9010
10322
 
9011
10323
  // src/commands/mcp.ts
@@ -9014,14 +10326,14 @@ __export(exports_mcp, {
9014
10326
  mcpCommand: () => mcpCommand
9015
10327
  });
9016
10328
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9017
- import { defineCommand as defineCommand20 } from "citty";
10329
+ import { defineCommand as defineCommand22 } from "citty";
9018
10330
  var mcpCommand;
9019
10331
  var init_mcp2 = __esm(() => {
9020
10332
  init_discover();
9021
10333
  init_mcp();
9022
10334
  init__args();
9023
10335
  init__shared();
9024
- mcpCommand = defineCommand20({
10336
+ mcpCommand = defineCommand22({
9025
10337
  meta: {
9026
10338
  name: "mcp",
9027
10339
  description: "Serve Spec Engine as an MCP (Model Context Protocol) server over stdio \u2014 agent-native query/resolve/check/report tools."
@@ -9049,8 +10361,8 @@ __export(exports_resolve, {
9049
10361
  resolveCommand: () => resolveCommand
9050
10362
  });
9051
10363
  import { statSync as statSync5 } from "fs";
9052
- import { join as join14, relative as relative3, resolve as resolve13, sep as sep4 } from "path";
9053
- import { defineCommand as defineCommand21 } from "citty";
10364
+ import { join as join18, relative as relative4, resolve as resolve14, sep as sep4 } from "path";
10365
+ import { defineCommand as defineCommand23 } from "citty";
9054
10366
  function classifyArg(a) {
9055
10367
  if (a.startsWith("--")) {
9056
10368
  const eq = a.indexOf("=");
@@ -9085,10 +10397,10 @@ function splitFilesAndPlatformDir(positionals, fallbackPlatformDir) {
9085
10397
  let lastIsPlatformDir = false;
9086
10398
  let lastIsDirWithoutSpecEngine = false;
9087
10399
  try {
9088
- const resolved = resolve13(last);
10400
+ const resolved = resolve14(last);
9089
10401
  if (statSync5(resolved).isDirectory()) {
9090
10402
  try {
9091
- if (statSync5(join14(resolved, "spec-engine")).isDirectory()) {
10403
+ if (statSync5(join18(resolved, "spec-engine")).isDirectory()) {
9092
10404
  lastIsPlatformDir = true;
9093
10405
  } else {
9094
10406
  lastIsDirWithoutSpecEngine = true;
@@ -9175,7 +10487,7 @@ async function dispatchReverseQuery(reqArg, resolved, args) {
9175
10487
  }
9176
10488
  await runReverseQuery({
9177
10489
  reqId: reqArg,
9178
- platformDir: resolve13(lone ?? process.cwd()),
10490
+ platformDir: resolve14(lone ?? process.cwd()),
9179
10491
  outArg: args.out,
9180
10492
  json: Boolean(args.json),
9181
10493
  fresh: Boolean(args.fresh),
@@ -9185,7 +10497,7 @@ async function dispatchReverseQuery(reqArg, resolved, args) {
9185
10497
  function normalizeFileInputs(files, platformDir) {
9186
10498
  const normalized = [];
9187
10499
  for (const input of files) {
9188
- const rel = relative3(platformDir, resolve13(platformDir, input));
10500
+ const rel = relative4(platformDir, resolve14(platformDir, input));
9189
10501
  const segments = rel.split(sep4);
9190
10502
  if (rel === "" || rel === "." || segments[0] === "..") {
9191
10503
  console.error(`spec resolve: file path must be inside platformDir: ${input}`);
@@ -9236,7 +10548,7 @@ var init_resolve2 = __esm(() => {
9236
10548
  description: "Suppress interactive onboarding prompt for siblings missing spec-engine.member.json (defaults to NO_SPEC_CONFIG warning)"
9237
10549
  }
9238
10550
  };
9239
- resolveCommand = defineCommand21({
10551
+ resolveCommand = defineCommand23({
9240
10552
  meta: {
9241
10553
  name: "resolve",
9242
10554
  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."
@@ -9260,7 +10572,7 @@ var init_resolve2 = __esm(() => {
9260
10572
  process.exit(EXIT.USAGE);
9261
10573
  return;
9262
10574
  }
9263
- const platformDir = resolve13(resolved.platformDir ?? process.cwd());
10575
+ const platformDir = resolve14(resolved.platformDir ?? process.cwd());
9264
10576
  const outArg2 = args.out;
9265
10577
  const dbPath = resolveDbPath(platformDir, outArg2);
9266
10578
  if (outArg2)
@@ -9399,9 +10711,9 @@ var exports_gate = {};
9399
10711
  __export(exports_gate, {
9400
10712
  gateCommand: () => gateCommand
9401
10713
  });
9402
- import { existsSync as existsSync13 } from "fs";
9403
- import { join as join15 } from "path";
9404
- import { defineCommand as defineCommand22 } from "citty";
10714
+ import { existsSync as existsSync16 } from "fs";
10715
+ import { join as join19 } from "path";
10716
+ import { defineCommand as defineCommand24 } from "citty";
9405
10717
  function isEpipe2(e) {
9406
10718
  if (e === null || typeof e !== "object" || !("code" in e))
9407
10719
  return false;
@@ -9424,7 +10736,7 @@ function resolveGateArgs(args) {
9424
10736
  process.exit(EXIT.USAGE);
9425
10737
  }
9426
10738
  const platformDir = resolvePlatformDir(args);
9427
- if (!existsSync13(join15(platformDir, "spec-engine"))) {
10739
+ if (!existsSync16(join19(platformDir, "spec-engine"))) {
9428
10740
  console.error(formatNotASpecPlatform(platformDir));
9429
10741
  process.exit(EXIT.USAGE);
9430
10742
  }
@@ -9465,7 +10777,7 @@ var init_gate2 = __esm(() => {
9465
10777
  description: "Suppress interactive onboarding prompt for siblings missing spec-engine.member.json (defaults to NO_SPEC_CONFIG warning)"
9466
10778
  }
9467
10779
  };
9468
- gateCommand = defineCommand22({
10780
+ gateCommand = defineCommand24({
9469
10781
  meta: {
9470
10782
  name: "gate",
9471
10783
  description: "Rung-3 approval primitive: passes iff <reqId> is Active and <repo>'s pinned spec_version covers its changed_at_version. Exits 0 PASS / 1 any gate failure / 2 crash or bad args."
@@ -9589,7 +10901,7 @@ var exports_guard = {};
9589
10901
  __export(exports_guard, {
9590
10902
  guardCommand: () => guardCommand
9591
10903
  });
9592
- import { defineCommand as defineCommand23 } from "citty";
10904
+ import { defineCommand as defineCommand25 } from "citty";
9593
10905
  var guardCommand;
9594
10906
  var init_guard2 = __esm(() => {
9595
10907
  init_constants();
@@ -9597,7 +10909,7 @@ var init_guard2 = __esm(() => {
9597
10909
  init_stdout();
9598
10910
  init__args();
9599
10911
  init__shared();
9600
- guardCommand = defineCommand23({
10912
+ guardCommand = defineCommand25({
9601
10913
  meta: {
9602
10914
  name: "guard",
9603
10915
  description: "Loss detection: diff the requirement derivation at a git ref (default HEAD) against the working tree and block requirements about to be steamrolled."
@@ -11229,9 +12541,9 @@ function requirementStatusBadge(status) {
11229
12541
  const variant = status === "Active" ? "active" : status === "Superseded" ? "superseded" : status === "Draft" ? "draft" : "retired";
11230
12542
  return badge(variant, STATUS_LABEL[status]);
11231
12543
  }
11232
- function propagationStateBadge(row2) {
12544
+ function propagationStateBadge(row) {
11233
12545
  let variant;
11234
- switch (row2.state) {
12546
+ switch (row.state) {
11235
12547
  case "MIGRATED_VERIFIED":
11236
12548
  variant = "verified";
11237
12549
  break;
@@ -11244,7 +12556,7 @@ function propagationStateBadge(row2) {
11244
12556
  default:
11245
12557
  variant = "unbound";
11246
12558
  }
11247
- return badge(variant, row2.state);
12559
+ return badge(variant, row.state);
11248
12560
  }
11249
12561
  function coverageGlyph(impl, verif) {
11250
12562
  const [variant, char, title] = impl && verif ? ["both", "\u2713", "src + test"] : impl ? ["impl", "S", "src only"] : verif ? ["test", "T", "test only"] : ["none", "", "none"];
@@ -11378,18 +12690,18 @@ import { html as html4, raw as raw3 } from "hono/html";
11378
12690
  function pivot(rows) {
11379
12691
  const repos = [...new Set(rows.map((r) => r.repo))].sort();
11380
12692
  const byDomain = new Map;
11381
- for (const row2 of rows) {
11382
- let dom = byDomain.get(row2.domain_key);
12693
+ for (const row of rows) {
12694
+ let dom = byDomain.get(row.domain_key);
11383
12695
  if (!dom) {
11384
12696
  dom = new Map;
11385
- byDomain.set(row2.domain_key, dom);
12697
+ byDomain.set(row.domain_key, dom);
11386
12698
  }
11387
- let req = dom.get(row2.req_id);
12699
+ let req = dom.get(row.req_id);
11388
12700
  if (!req) {
11389
- req = { id: row2.req_id, status: row2.req_status, cells: new Map };
11390
- dom.set(row2.req_id, req);
12701
+ req = { id: row.req_id, status: row.req_status, cells: new Map };
12702
+ dom.set(row.req_id, req);
11391
12703
  }
11392
- req.cells.set(row2.repo, { impl: row2.implemented, verif: row2.verified });
12704
+ req.cells.set(row.repo, { impl: row.implemented, verif: row.verified });
11393
12705
  }
11394
12706
  const domains = [...byDomain.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, reqMap]) => ({
11395
12707
  key,
@@ -11636,14 +12948,6 @@ function mountEditor(app, bindHost = DEFAULT_BIND_HOST) {
11636
12948
  Why (optional)
11637
12949
  <textarea name="why" rows="2"></textarea>
11638
12950
  </label>
11639
- <label>
11640
- Lives in (optional, single path)
11641
- <input type="text" name="livesIn" placeholder="path/to/impl.ts" />
11642
- </label>
11643
- <label>
11644
- Verified in (optional, single path)
11645
- <input type="text" name="verifiedIn" placeholder="path/to/impl.test.ts" />
11646
- </label>
11647
12951
  <label>
11648
12952
  Relates to (optional, comma-separated ids)
11649
12953
  <input type="text" name="relates" placeholder="BILLING-001, BILLING-002" />
@@ -11681,8 +12985,6 @@ function mountEditor(app, bindHost = DEFAULT_BIND_HOST) {
11681
12985
  const form = await c.req.parseBody();
11682
12986
  const statement = field(form, "statement");
11683
12987
  const why = field(form, "why");
11684
- const livesIn = field(form, "livesIn");
11685
- const verifiedIn = field(form, "verifiedIn");
11686
12988
  const relates = field(form, "relates");
11687
12989
  const payload = {
11688
12990
  key: field(form, "key"),
@@ -11690,10 +12992,6 @@ function mountEditor(app, bindHost = DEFAULT_BIND_HOST) {
11690
12992
  };
11691
12993
  if (why.length > 0)
11692
12994
  payload.why = why;
11693
- if (livesIn.length > 0)
11694
- payload.livesIn = livesIn;
11695
- if (verifiedIn.length > 0)
11696
- payload.verifiedIn = verifiedIn;
11697
12995
  if (relates.length > 0)
11698
12996
  payload.relates = relates;
11699
12997
  const res = await app.request("/api/requirements", {
@@ -12010,15 +13308,15 @@ var init_relations2 = __esm(() => {
12010
13308
  import { html as html10, raw as raw9 } from "hono/html";
12011
13309
  function coverageRollups(coverage) {
12012
13310
  const cov = new Map;
12013
- for (const row2 of coverage) {
12014
- if (row2.req_status !== "Active")
13311
+ for (const row of coverage) {
13312
+ if (row.req_status !== "Active")
12015
13313
  continue;
12016
- const c = cov.get(row2.req_id) ?? { impl: 0, verif: 0 };
12017
- if (row2.implemented)
13314
+ const c = cov.get(row.req_id) ?? { impl: 0, verif: 0 };
13315
+ if (row.implemented)
12018
13316
  c.impl += 1;
12019
- if (row2.verified)
13317
+ if (row.verified)
12020
13318
  c.verif += 1;
12021
- cov.set(row2.req_id, c);
13319
+ cov.set(row.req_id, c);
12022
13320
  }
12023
13321
  return cov;
12024
13322
  }
@@ -12262,6 +13560,9 @@ function mountRequirements(app) {
12262
13560
  </html>`);
12263
13561
  }
12264
13562
  const r = await res.json();
13563
+ const sites = (list) => list.length === 0 ? "\u2014" : html10`<ul>
13564
+ ${list.map((s) => html10`<li><code>${s.file}:${s.line}</code></li>`)}
13565
+ </ul>`;
12265
13566
  const body = html10`
12266
13567
  ${navBar("requirements")}
12267
13568
  <div class="eyebrow">/ Requirement</div>
@@ -12281,6 +13582,8 @@ function mountRequirements(app) {
12281
13582
  <dt>line</dt><dd>${r.line}</dd>
12282
13583
  <dt>text</dt><dd>${r.text}</dd>
12283
13584
  <dt>why</dt><dd>${r.why ?? "\u2014"}</dd>
13585
+ <dt>implemented_in</dt><dd>${sites(r.implemented_in)}</dd>
13586
+ <dt>verified_in</dt><dd>${sites(r.verified_in)}</dd>
12284
13587
  </dl>
12285
13588
  `;
12286
13589
  return c.html(html10`<!doctype html>
@@ -12365,15 +13668,15 @@ function mountSetup(app) {
12365
13668
  const memberCount = members.length;
12366
13669
  const note = readingNote(mode, memberCount);
12367
13670
  const boundByRepo = new Map;
12368
- for (const row2 of cov) {
12369
- if (!row2.implemented)
13671
+ for (const row of cov) {
13672
+ if (!row.implemented)
12370
13673
  continue;
12371
- let set = boundByRepo.get(row2.repo);
13674
+ let set = boundByRepo.get(row.repo);
12372
13675
  if (!set) {
12373
13676
  set = new Set;
12374
- boundByRepo.set(row2.repo, set);
13677
+ boundByRepo.set(row.repo, set);
12375
13678
  }
12376
- set.add(row2.req_id);
13679
+ set.add(row.req_id);
12377
13680
  }
12378
13681
  const body = html11`
12379
13682
  ${navBar("setup")}
@@ -12610,8 +13913,6 @@ function mintFieldsFromBody(body) {
12610
13913
  return {
12611
13914
  statement: typeof body.statement === "string" ? body.statement : "",
12612
13915
  why: typeof body.why === "string" ? body.why : "",
12613
- livesIn: toStringList(body.livesIn),
12614
- verifiedIn: toStringList(body.verifiedIn),
12615
13916
  relates: toStringList(body.relates),
12616
13917
  issue: typeof body.issue === "string" && body.issue.trim() !== "" ? body.issue.trim() : undefined,
12617
13918
  status: body.status === "draft" ? "draft" : "active"
@@ -12620,8 +13921,6 @@ function mintFieldsFromBody(body) {
12620
13921
  function successorFieldsFromBody(body) {
12621
13922
  return {
12622
13923
  why: typeof body.why === "string" ? body.why : undefined,
12623
- livesIn: "livesIn" in body ? toStringList(body.livesIn) : undefined,
12624
- verifiedIn: "verifiedIn" in body ? toStringList(body.verifiedIn) : undefined,
12625
13924
  relates: "relates" in body ? toStringList(body.relates) : undefined,
12626
13925
  issue: typeof body.issue === "string" && body.issue.trim() !== "" ? body.issue.trim() : undefined
12627
13926
  };
@@ -12632,10 +13931,6 @@ function amendFieldsFromBody(body) {
12632
13931
  fields.statement = body.statement;
12633
13932
  if ("why" in body)
12634
13933
  fields.why = typeof body.why === "string" ? body.why : null;
12635
- if ("livesIn" in body)
12636
- fields.livesIn = toStringList(body.livesIn);
12637
- if ("verifiedIn" in body)
12638
- fields.verifiedIn = toStringList(body.verifiedIn);
12639
13934
  if ("relates" in body)
12640
13935
  fields.relates = toStringList(body.relates);
12641
13936
  return Object.keys(fields).length === 0 ? null : fields;
@@ -12697,8 +13992,8 @@ function mountApi(app, storage2, platformDir = process.cwd(), bindHost = DEFAULT
12697
13992
  }));
12698
13993
  app.get("/api/requirements/:id", guarded((c) => {
12699
13994
  const id = c.req.param("id") ?? "";
12700
- const { row: row2 } = getRecord(storage2, id);
12701
- return row2 ? c.json(row2) : c.json({ error: "not found" }, 404);
13995
+ const { row } = getRecord(storage2, id);
13996
+ return row ? c.json(row) : c.json({ error: "not found" }, 404);
12702
13997
  }));
12703
13998
  app.post("/api/requirements", guarded(async function createRequirement(c) {
12704
13999
  if (!featureEnabled("editor")) {
@@ -12744,7 +14039,7 @@ function mountApi(app, storage2, platformDir = process.cwd(), bindHost = DEFAULT
12744
14039
  return withWriteLock(async () => {
12745
14040
  const fields = amendFieldsFromBody(body);
12746
14041
  if (fields === null) {
12747
- return c.json({ error: "nothing to amend \u2014 provide statement, why, livesIn, verifiedIn, or relates" }, 400);
14042
+ return c.json({ error: "nothing to amend \u2014 provide statement, why, or relates" }, 400);
12748
14043
  }
12749
14044
  const r = await amend({ platformDir, id, fields });
12750
14045
  if (!r.ok)
@@ -12786,7 +14081,7 @@ function mountApi(app, storage2, platformDir = process.cwd(), bindHost = DEFAULT
12786
14081
  new_id: r.newId,
12787
14082
  file: r.file,
12788
14083
  spec_version: r.specVersion,
12789
- retag: r.retag
14084
+ retagged: r.retagged
12790
14085
  }, 201);
12791
14086
  });
12792
14087
  }));
@@ -12816,9 +14111,7 @@ function mountApi(app, storage2, platformDir = process.cwd(), bindHost = DEFAULT
12816
14111
  id,
12817
14112
  targetKey,
12818
14113
  statement: typeof body.text === "string" ? body.text : undefined,
12819
- why: typeof body.why === "string" ? body.why : undefined,
12820
- livesIn: "lives" in body ? toStringList(body.lives) : undefined,
12821
- verifiedIn: "verifiedIn" in body ? toStringList(body.verifiedIn) : undefined
14114
+ why: typeof body.why === "string" ? body.why : undefined
12822
14115
  }, async (reqId) => {
12823
14116
  await runIndex({ platformDir, storage: storage2 });
12824
14117
  return storage2.listTags({ req_id: reqId });
@@ -12833,7 +14126,7 @@ function mountApi(app, storage2, platformDir = process.cwd(), bindHost = DEFAULT
12833
14126
  to_file: r.toFile,
12834
14127
  source_spec_version: r.sourceSpecVersion,
12835
14128
  target_spec_version: r.targetSpecVersion,
12836
- retag: r.retag
14129
+ retagged: r.retagged
12837
14130
  }, 201);
12838
14131
  });
12839
14132
  }));
@@ -12864,7 +14157,7 @@ function mountApi(app, storage2, platformDir = process.cwd(), bindHost = DEFAULT
12864
14157
  });
12865
14158
  if (!r.ok)
12866
14159
  return failureResponse(c, r);
12867
- return c.json({ ok: true, id: r.id, file: r.file, reason: r.reason, sites: r.sites }, 200);
14160
+ return c.json({ ok: true, id: r.id, file: r.file, reason: r.reason, removed: r.removed }, 200);
12868
14161
  });
12869
14162
  }));
12870
14163
  app.post("/api/requirements/:id/accept", guarded(async function acceptRequirement(c) {
@@ -13106,9 +14399,9 @@ __export(exports_serve, {
13106
14399
  resolveBind: () => resolveBind,
13107
14400
  composeServeApp: () => composeServeApp
13108
14401
  });
13109
- import { existsSync as existsSync14, mkdirSync as mkdirSync6 } from "fs";
13110
- import { dirname as dirname8 } from "path";
13111
- import { defineCommand as defineCommand24 } from "citty";
14402
+ import { existsSync as existsSync17, mkdirSync as mkdirSync7 } from "fs";
14403
+ import { dirname as dirname10 } from "path";
14404
+ import { defineCommand as defineCommand26 } from "citty";
13112
14405
  import { Hono as Hono2 } from "hono";
13113
14406
  function composeServeApp(storage2, platformDir = process.cwd(), bindHost = DEFAULT_BIND_HOST) {
13114
14407
  const app = new Hono2;
@@ -13186,7 +14479,7 @@ var init_serve = __esm(() => {
13186
14479
  init_sqlite();
13187
14480
  init__args();
13188
14481
  init__shared();
13189
- serveCommand = defineCommand24({
14482
+ serveCommand = defineCommand26({
13190
14483
  meta: {
13191
14484
  name: "serve",
13192
14485
  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."
@@ -13258,8 +14551,8 @@ var init_serve = __esm(() => {
13258
14551
  noPrompt: args.noPrompt
13259
14552
  }
13260
14553
  });
13261
- mkdirSync6(dirname8(dbPath), { recursive: true });
13262
- const needsIndex = !existsSync14(dbPath);
14554
+ mkdirSync7(dirname10(dbPath), { recursive: true });
14555
+ const needsIndex = !existsSync17(dbPath);
13263
14556
  let storage2;
13264
14557
  try {
13265
14558
  storage2 = openStorage(dbPath);
@@ -13307,13 +14600,13 @@ __export(exports_docs, {
13307
14600
  docsCommand: () => docsCommand,
13308
14601
  createDocsFetchHandler: () => createDocsFetchHandler
13309
14602
  });
13310
- import { existsSync as existsSync15, statSync as statSync6 } from "fs";
13311
- import { join as join16, normalize, sep as sep5 } from "path";
14603
+ import { existsSync as existsSync18, statSync as statSync6 } from "fs";
14604
+ import { join as join20, normalize, sep as sep5 } from "path";
13312
14605
  import { fileURLToPath } from "url";
13313
- import { defineCommand as defineCommand25 } from "citty";
14606
+ import { defineCommand as defineCommand27 } from "citty";
13314
14607
  function resolveDocsRootFrom(candidates) {
13315
14608
  for (const candidate of candidates) {
13316
- if (existsSync15(join16(candidate, "index.html")))
14609
+ if (existsSync18(join20(candidate, "index.html")))
13317
14610
  return candidate;
13318
14611
  }
13319
14612
  return null;
@@ -13337,17 +14630,17 @@ function createDocsFetchHandler(root) {
13337
14630
  if (pathname.includes("\x00")) {
13338
14631
  return new Response("Bad request", { status: 400 });
13339
14632
  }
13340
- const target = normalize(join16(normalRoot, pathname));
14633
+ const target = normalize(join20(normalRoot, pathname));
13341
14634
  if (target !== normalRoot && !target.startsWith(rootPrefix)) {
13342
14635
  return new Response("Not found", { status: 404 });
13343
14636
  }
13344
- for (const candidate of [target, join16(target, "index.html"), `${target}.html`]) {
13345
- if (existsSync15(candidate) && statSync6(candidate).isFile()) {
14637
+ for (const candidate of [target, join20(target, "index.html"), `${target}.html`]) {
14638
+ if (existsSync18(candidate) && statSync6(candidate).isFile()) {
13346
14639
  return new Response(Bun.file(candidate));
13347
14640
  }
13348
14641
  }
13349
- const notFoundPage = join16(normalRoot, "404.html");
13350
- if (existsSync15(notFoundPage)) {
14642
+ const notFoundPage = join20(normalRoot, "404.html");
14643
+ if (existsSync18(notFoundPage)) {
13351
14644
  return new Response(Bun.file(notFoundPage), { status: 404 });
13352
14645
  }
13353
14646
  return new Response("Not found", { status: 404 });
@@ -13389,7 +14682,7 @@ var docsCommand;
13389
14682
  var init_docs2 = __esm(() => {
13390
14683
  init_constants();
13391
14684
  init_stdout();
13392
- docsCommand = defineCommand25({
14685
+ docsCommand = defineCommand27({
13393
14686
  meta: {
13394
14687
  name: "docs",
13395
14688
  description: "Serve the bundled documentation site offline. Binds 127.0.0.1 on --port (loopback only \u2014 no --host). --probe is the boot-and-fetch smoke."
@@ -13450,10 +14743,10 @@ __export(exports___smoke, {
13450
14743
  schemaSmokeCommand: () => schemaSmokeCommand,
13451
14744
  schemaMismatchSmokeCommand: () => schemaMismatchSmokeCommand
13452
14745
  });
13453
- import { defineCommand as defineCommand26 } from "citty";
14746
+ import { defineCommand as defineCommand28 } from "citty";
13454
14747
  var schemaSmokeCommand, schemaMismatchSmokeCommand;
13455
14748
  var init___smoke = __esm(() => {
13456
- schemaSmokeCommand = defineCommand26({
14749
+ schemaSmokeCommand = defineCommand28({
13457
14750
  meta: {
13458
14751
  name: "__schema-smoke",
13459
14752
  description: "(hidden CI) open a fresh DB, exec DDL, assert the schema shape",
@@ -13496,7 +14789,7 @@ var init___smoke = __esm(() => {
13496
14789
  console.log("__schema-smoke OK");
13497
14790
  }
13498
14791
  });
13499
- schemaMismatchSmokeCommand = defineCommand26({
14792
+ schemaMismatchSmokeCommand = defineCommand28({
13500
14793
  meta: {
13501
14794
  name: "__schema-mismatch-smoke",
13502
14795
  description: "(hidden CI) corrupt _schema_version, re-open, assert silent rebuild",
@@ -13523,9 +14816,9 @@ var init___smoke = __esm(() => {
13523
14816
  // src/cli.ts
13524
14817
  init_package();
13525
14818
  init_stdout();
13526
- import { defineCommand as defineCommand27, runMain, showUsage } from "citty";
14819
+ import { defineCommand as defineCommand29, runMain, showUsage } from "citty";
13527
14820
  routeStdout();
13528
- var main = defineCommand27({
14821
+ var main = defineCommand29({
13529
14822
  meta: {
13530
14823
  name: "spec",
13531
14824
  version: package_default.version,
@@ -13551,6 +14844,8 @@ var main = defineCommand27({
13551
14844
  move: () => Promise.resolve().then(() => (init_move2(), exports_move)).then((m) => m.moveCommand),
13552
14845
  amend: () => Promise.resolve().then(() => (init_amend2(), exports_amend)).then((m) => m.amendCommand),
13553
14846
  accept: () => Promise.resolve().then(() => (init_accept2(), exports_accept)).then((m) => m.acceptCommand),
14847
+ lint: () => Promise.resolve().then(() => (init_lint2(), exports_lint)).then((m) => m.lintCommand),
14848
+ migrate: () => Promise.resolve().then(() => (init_migrate2(), exports_migrate)).then((m) => m.migrateCommand),
13554
14849
  mcp: () => Promise.resolve().then(() => (init_mcp2(), exports_mcp)).then((m) => m.mcpCommand),
13555
14850
  resolve: () => Promise.resolve().then(() => (init_resolve2(), exports_resolve)).then((m) => m.resolveCommand),
13556
14851
  gate: () => Promise.resolve().then(() => (init_gate2(), exports_gate)).then((m) => m.gateCommand),