@spec-engine/spec-engine 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.4.0",
23
+ version: "0.6.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",
@@ -432,19 +432,20 @@ function buildCoverageReport(rows) {
432
432
  }
433
433
 
434
434
  // ../shared/src/schema.ts
435
- var SCHEMA_VERSION = 10, FILES_MAX = 1000, DEFAULT_QUERY_LIMIT = 10, LIMIT_MAX = 1000, TABLES_DDL = `
435
+ var SCHEMA_VERSION = 11, FILES_MAX = 1000, DEFAULT_QUERY_LIMIT = 10, LIMIT_MAX = 1000, TABLES_DDL = `
436
436
  -- _schema_version: single-row table holding the integer schema_version on disk.
437
437
  -- Engine reads this on open; if it doesn't match SCHEMA_VERSION, the DB is wiped
438
- -- and rebuilt from scratch (D-12).
438
+ -- and rebuilt from scratch.
439
439
  CREATE TABLE IF NOT EXISTS _schema_version (
440
440
  version INTEGER NOT NULL
441
441
  );
442
442
 
443
- -- Per D-03 / SCHM-07: NO CHECK / FK / UNIQUE on domain fields. PRIMARY KEY only.
443
+ -- No CHECK, FK, or UNIQUE constraint on any domain field. PRIMARY KEY only.
444
444
  CREATE TABLE IF NOT EXISTS repos (
445
445
  name TEXT PRIMARY KEY,
446
446
  path TEXT NOT NULL,
447
- pinned_spec_version INTEGER NOT NULL
447
+ pinned_spec_version INTEGER NOT NULL,
448
+ dependency_depth INTEGER NOT NULL
448
449
  );
449
450
 
450
451
  CREATE TABLE IF NOT EXISTS domains (
@@ -483,12 +484,10 @@ CREATE INDEX IF NOT EXISTS idx_tags_by_req ON tags(req_id);
483
484
  CREATE INDEX IF NOT EXISTS idx_tags_by_repo ON tags(repo);
484
485
  CREATE INDEX IF NOT EXISTS idx_tags_by_file ON tags(file);
485
486
 
486
- -- relations (RED-16): one row per **Relates:** link in a SPEC.md entry.
487
- -- from_id carries the field; to_id is the linked requirement. Per
488
- -- Invariant #4 there is NO FK \u2014 a to_id pointing at a missing requirement
487
+ -- relations: one row per relates link. from_id carries the field; to_id is
488
+ -- the linked requirement. No FK: a to_id pointing at a missing requirement
489
489
  -- lands verbatim so spec check can surface BROKEN_RELATES; a to_id whose
490
- -- requirement was superseded surfaces RELATES_SUPERSEDED. source_file +
491
- -- line locate the authored Relates field line.
490
+ -- requirement was superseded surfaces RELATES_SUPERSEDED.
492
491
  CREATE TABLE IF NOT EXISTS relations (
493
492
  id INTEGER PRIMARY KEY AUTOINCREMENT,
494
493
  from_id TEXT NOT NULL,
@@ -499,11 +498,8 @@ CREATE TABLE IF NOT EXISTS relations (
499
498
  CREATE INDEX IF NOT EXISTS idx_relations_from ON relations(from_id);
500
499
  CREATE INDEX IF NOT EXISTS idx_relations_to ON relations(to_id);
501
500
 
502
- -- term_aliases (TERM-01, Phase 6): one row per glossary-term alias. Cloned 1:1
503
- -- from the relations table \u2014 id AUTOINCREMENT (excluded from build_id), no
504
- -- FK/CHECK/UNIQUE (SCHM-07 / Invariant #4, or the arch-fence trips). term_id is
505
- -- the owning TERM's id; name the synonym string. Present-and-empty until Wave C
506
- -- flattens the requirement aliases field into rows.
501
+ -- term_aliases: one row per glossary-term alias. id AUTOINCREMENT is excluded
502
+ -- from build_id. term_id is the owning TERM's id; name the synonym string.
507
503
  CREATE TABLE IF NOT EXISTS term_aliases (
508
504
  id INTEGER PRIMARY KEY AUTOINCREMENT,
509
505
  term_id TEXT NOT NULL,
@@ -512,15 +508,12 @@ CREATE TABLE IF NOT EXISTS term_aliases (
512
508
  CREATE INDEX IF NOT EXISTS idx_term_aliases_term ON term_aliases(term_id);
513
509
  CREATE INDEX IF NOT EXISTS idx_term_aliases_name ON term_aliases(name);
514
510
 
515
- -- term_citations (TERM-01, Phase 6): one row per pinned cites reference from a
516
- -- requirement to a glossary TERM. Cloned from the relations table: id
517
- -- AUTOINCREMENT (excluded from build_id), NO FK/CHECK/UNIQUE (SCHM-07 /
518
- -- Invariant #4) \u2014 a term_id pointing at a missing/superseded TERM lands verbatim
519
- -- so spec check can surface UNDEFINED_TERM / TERM_DRIFT (Wave D). req_id carries
520
- -- the citing requirement; term_id the cited TERM (nullable \u2014 an unresolved cite
521
- -- still lands); cited_as the authored surface form; pinned_version the term
522
- -- spec_version the citation was pinned to; source_file + line locate the cite.
523
- -- Present-and-empty until Wave C wires the flatten.
511
+ -- term_citations: one row per pinned cites reference from a requirement to a
512
+ -- glossary TERM. id AUTOINCREMENT is excluded from build_id. No FK: a term_id
513
+ -- pointing at a missing or superseded TERM lands verbatim so spec check can
514
+ -- surface UNDEFINED_TERM / TERM_DRIFT. term_id is nullable (an unresolved cite
515
+ -- still lands); cited_as is the authored surface form; pinned_version the term
516
+ -- spec_version the citation was pinned to.
524
517
  CREATE TABLE IF NOT EXISTS term_citations (
525
518
  id INTEGER PRIMARY KEY AUTOINCREMENT,
526
519
  req_id TEXT NOT NULL,
@@ -533,30 +526,25 @@ CREATE TABLE IF NOT EXISTS term_citations (
533
526
  CREATE INDEX IF NOT EXISTS idx_term_citations_req ON term_citations(req_id);
534
527
  CREATE INDEX IF NOT EXISTS idx_term_citations_term ON term_citations(term_id);
535
528
 
536
- -- provenance (PROV-02 / PROV-04): one row per **Issues:** role:ID link in a
537
- -- SPEC.md entry. req_id carries the field; issue_id is the OPAQUE external
538
- -- tracker payload; role is a closed-allow-list label (created | supersedes-via
539
- -- | amends-via), validated at PARSE time, never as a DB constraint (SCHM-07 /
540
- -- Invariant #4). source_file + line locate the authored **Issues:** field line.
541
- -- Exactly ONE index, on req_id (SC3) \u2014 never on issue_id, which would read as
542
- -- treating the opaque payload as a lookup key (PROV-02, grep-fenced in Plan 04).
529
+ -- provenance: one row per issues role:ID link. issue_id is the opaque
530
+ -- external tracker payload; role is a closed-allow-list label (created |
531
+ -- supersedes-via | amends-via), validated at parse time, never as a DB
532
+ -- constraint. Exactly one index, on req_id; never on issue_id, which would
533
+ -- treat the opaque payload as a lookup key.
543
534
  -- @spec PROV-004
544
535
  CREATE TABLE IF NOT EXISTS provenance (
545
536
  id INTEGER PRIMARY KEY AUTOINCREMENT,
546
537
  req_id TEXT NOT NULL,
547
- issue_id TEXT NOT NULL, -- OPAQUE payload string \u2014 never PK/FK/UNIQUE/JOIN key (PROV-02/SC3)
538
+ issue_id TEXT NOT NULL, -- opaque payload string, never a PK/FK/UNIQUE/JOIN key
548
539
  role TEXT NOT NULL, -- created | supersedes-via | amends-via
549
540
  source_file TEXT NOT NULL,
550
541
  line INTEGER NOT NULL
551
542
  );
552
- CREATE INDEX IF NOT EXISTS idx_provenance_by_req ON provenance(req_id); -- ONLY index, on req_id (SC3)
553
-
554
- -- parse_diagnostics: structural diagnostics carried forward from Phase 2's
555
- -- validate-on-parse pass (DUP_ID, BROKEN_SUPERSEDE, BAD_STATUS). Stored so
556
- -- spec check can render them with file+line; not used by Phase 1's smoke.
557
- -- req_id is nullable per WR-02 (03-REVIEW): the parser knows the implicated
558
- -- id for BROKEN_SUPERSEDE and BAD_STATUS, and for the SECOND DUP_ID
559
- -- occurrence. NULL when no specific id is implicated.
543
+ CREATE INDEX IF NOT EXISTS idx_provenance_by_req ON provenance(req_id); -- the only index
544
+
545
+ -- parse_diagnostics: structural diagnostics from the validate-on-parse pass
546
+ -- (DUP_ID, BROKEN_SUPERSEDE, BAD_STATUS), stored so spec check can render them
547
+ -- with file+line. req_id is NULL when no specific id is implicated.
560
548
  CREATE TABLE IF NOT EXISTS parse_diagnostics (
561
549
  id INTEGER PRIMARY KEY AUTOINCREMENT,
562
550
  code TEXT NOT NULL,
@@ -591,13 +579,9 @@ FROM requirements r
591
579
  CROSS JOIN repos
592
580
  LEFT JOIN tags t
593
581
  ON t.req_id = r.id AND t.repo = repos.name
594
- -- TERM-01 (Phase 6): a glossary TERM is a requirement row but NEVER a
595
- -- code-coverage obligation \u2014 it carries no @spec tag. Excluding key='TERM' here
596
- -- drops terms from spec map / spec report (and provenance_matrix, which joins
597
- -- coverage) in ONE edit, so a migrated term never renders an empty coverage
598
- -- cell. The Q4 ORPHAN_REQ / Q5 UNVERIFIED_REQ queries (sqlite.ts) carry the
599
- -- SAME literal exclusion so a term never fires a coverage ERROR. The match is
600
- -- the exact literal 'TERM' \u2014 never a real domain key.
582
+ -- A glossary TERM is a requirement row but never a code-coverage obligation.
583
+ -- The ORPHAN_REQ / UNVERIFIED_REQ queries in sqlite.ts carry the same literal
584
+ -- exclusion. The match is the exact literal 'TERM', never a real domain key.
601
585
  -- @spec SCHM-017
602
586
  WHERE r.key != 'TERM'
603
587
  GROUP BY r.id, repos.name;
@@ -616,15 +600,9 @@ JOIN requirements r ON r.id = t.req_id
616
600
  JOIN repos ON repos.name = t.repo
617
601
  WHERE r.changed_at_version > repos.pinned_spec_version;
618
602
 
619
- -- term_drift (TERM-01 / Phase 6): the citation-drift VIEW, a 1:1 shape-clone of
620
- -- the member-pin drift VIEW but keyed on the CITATION's pin instead of a repo's.
621
- -- One row per cites reference whose pinned_version is behind the cited TERM's
622
- -- changed_at_version \u2014 i.e. the term's definition moved on since the citation
623
- -- was pinned. INNER JOIN (not LEFT): an unresolved term_id (no matching
624
- -- requirement) is an UNDEFINED_TERM concern, NOT drift \u2014 scoped distinctly by
625
- -- the Wave-D diagnostics. term.key='TERM' keeps the VIEW scoped to the reserved
626
- -- glossary domain. Present-and-empty until Wave C populates term_citations; the
627
- -- diagnostic (TERM_DRIFT) reads it in Wave E.
603
+ -- term_drift: one row per cites reference whose pinned_version is behind the
604
+ -- cited TERM's changed_at_version. INNER JOIN: an unresolved term_id is an
605
+ -- UNDEFINED_TERM concern, not drift.
628
606
  -- @spec CHCK-018
629
607
  CREATE VIEW IF NOT EXISTS term_drift AS
630
608
  SELECT
@@ -638,38 +616,21 @@ FROM term_citations tc
638
616
  JOIN requirements term ON term.id = tc.term_id
639
617
  WHERE term.key = 'TERM' AND term.changed_at_version > tc.pinned_version;
640
618
 
641
- -- provenance_matrix (PMAT-04 / data half of PMAT-01): a WIDENED projection
642
- -- over provenance \xD7 requirements \xD7 coverage, never materialized (mirrors the
643
- -- coverage VIEW pattern). The widening joins are ON req_id ONLY \u2014 NEVER on
644
- -- issue_id, which is OPAQUE (PROV-02/SC3). JOIN requirements r ON p.req_id =
645
- -- r.id carries req_status (the requirement's lifecycle status); LEFT JOIN
646
- -- coverage c ON c.req_id = p.req_id carries the backing-test columns. Because
647
- -- coverage is a per-(req,repo) matrix, the join fans out per repo \u2014 so we
648
- -- aggregate to ONE row per provenance link.
649
- --
650
- -- WR-01 (13-REVIEW review-fix): test_levels is re-aggregated at the
651
- -- INDIVIDUAL-level granularity via a correlated subquery over tags, NOT by
652
- -- GROUP_CONCAT(DISTINCT c.test_levels). The coverage VIEW already concatenates
653
- -- levels per (req,repo) into a single string (api -> "unit,integration",
654
- -- mobile -> "unit"); GROUP_CONCAT(DISTINCT ...) over those whole strings
655
- -- deduplicates STRINGS, not levels, so a level present in two repos emitted
656
- -- twice ("unit,integration,unit"). The subquery selects DISTINCT t2.level for
657
- -- this req across ALL repos (ORDER BY level for determinism), so each level
658
- -- appears exactly once. The correlation is ON req_id ONLY (t2.req_id =
659
- -- p.req_id) \u2014 the issue_id-opacity fence stays green (no issue_id key usage).
660
- -- CRITICAL FENCE CONSTRAINT
661
- -- (PROV-02/SC3): the CI issue_id-opacity fence forbids grouping by issue_id,
662
- -- so we GROUP BY the provenance PK p.id (which uniquely determines the link)
663
- -- and let issue_id ride as a bare projected column under SQLite's
664
- -- bare-column rule. issue_id is therefore PURELY projected \u2014 never a
665
- -- JOIN/GROUP-BY/PK/FK/UNIQUE/INDEX key.
619
+ -- provenance_matrix: provenance \xD7 requirements \xD7 coverage, never
620
+ -- materialized. Every join is on req_id, never on issue_id. coverage is a
621
+ -- per-(req, repo) matrix, so the join fans out per repo and is aggregated to
622
+ -- one row per provenance link, grouped by the provenance PK p.id; issue_id
623
+ -- rides as a bare projected column and is never a JOIN, GROUP BY, PK, FK,
624
+ -- UNIQUE, or INDEX key. test_levels is aggregated by a correlated subquery
625
+ -- over tags (DISTINCT level, ORDER BY level) so a level present in two repos
626
+ -- appears once.
666
627
  CREATE VIEW IF NOT EXISTS provenance_matrix AS
667
628
  SELECT
668
629
  p.req_id AS req_id,
669
630
  p.role AS role,
670
- p.issue_id AS issue_id, -- OPAQUE payload, projected only (PROV-02/SC3)
631
+ p.issue_id AS issue_id, -- opaque payload, projected only
671
632
  p.source_file AS source_file,
672
- p.line AS line, -- git pointer per PMAT-01
633
+ p.line AS line,
673
634
  r.status AS req_status,
674
635
  MAX(c.implemented) AS implemented,
675
636
  MAX(c.verified) AS verified,
@@ -1204,19 +1165,86 @@ function packageColumnName(root, dir) {
1204
1165
  function byName(a, b) {
1205
1166
  return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
1206
1167
  }
1207
- function column(name, path, pin, ignore) {
1168
+ function column(name, path, pin, node, ignore) {
1208
1169
  return {
1209
- name,
1210
- path,
1211
- pinned_spec_version: pin,
1212
- ...ignore && ignore.length > 0 ? { ignore } : {}
1170
+ repo: {
1171
+ name,
1172
+ path,
1173
+ pinned_spec_version: pin,
1174
+ ...ignore && ignore.length > 0 ? { ignore } : {}
1175
+ },
1176
+ node
1213
1177
  };
1214
1178
  }
1179
+ function packageKey(ecosystem, packageName) {
1180
+ return `${ecosystem ?? ""}\x00${packageName}`;
1181
+ }
1182
+ function providersOf(columns) {
1183
+ const providers = new Map;
1184
+ for (const c of columns) {
1185
+ if (c.node.packageName === undefined)
1186
+ continue;
1187
+ const key = packageKey(c.node.ecosystem, c.node.packageName);
1188
+ if (!providers.has(key))
1189
+ providers.set(key, c.repo.name);
1190
+ }
1191
+ return providers;
1192
+ }
1193
+ function dependenciesOf(c, providers) {
1194
+ const deps = new Set;
1195
+ for (const name of c.node.dependsOn) {
1196
+ const provider = providers.get(packageKey(c.node.ecosystem, name));
1197
+ if (provider !== undefined && provider !== c.repo.name)
1198
+ deps.add(provider);
1199
+ }
1200
+ return deps;
1201
+ }
1202
+ function dependencyGraph(columns) {
1203
+ const providers = providersOf(columns);
1204
+ const dependents = new Map;
1205
+ const waitingOn = new Map;
1206
+ for (const c of columns) {
1207
+ const deps = dependenciesOf(c, providers);
1208
+ waitingOn.set(c.repo.name, deps.size);
1209
+ for (const provider of deps) {
1210
+ dependents.set(provider, [...dependents.get(provider) ?? [], c.repo.name]);
1211
+ }
1212
+ }
1213
+ return { names: columns.map((c) => c.repo.name).sort(), dependents, waitingOn };
1214
+ }
1215
+ function dependencyDepths(columns) {
1216
+ const { names, dependents, waitingOn } = dependencyGraph(columns);
1217
+ const depth = new Map;
1218
+ const ready = names.filter((n) => waitingOn.get(n) === 0);
1219
+ for (const n of ready)
1220
+ depth.set(n, 0);
1221
+ for (let i = 0;i < ready.length; i++) {
1222
+ const provider = ready[i];
1223
+ const providerDepth = depth.get(provider) ?? 0;
1224
+ for (const consumer of dependents.get(provider) ?? []) {
1225
+ depth.set(consumer, Math.max(depth.get(consumer) ?? 0, providerDepth + 1));
1226
+ const left = (waitingOn.get(consumer) ?? 0) - 1;
1227
+ waitingOn.set(consumer, left);
1228
+ if (left === 0)
1229
+ ready.push(consumer);
1230
+ }
1231
+ }
1232
+ const cyclic = Math.max(-1, ...depth.values()) + 1;
1233
+ for (const n of names)
1234
+ if (!depth.has(n))
1235
+ depth.set(n, cyclic);
1236
+ return depth;
1237
+ }
1238
+ function toRepos(columns) {
1239
+ const depth = dependencyDepths(columns);
1240
+ return columns.map((c) => ({ ...c.repo, dependency_depth: depth.get(c.repo.name) ?? 0 })).sort(byName);
1241
+ }
1215
1242
  function selfMember(absPlatform, platformVersion) {
1216
1243
  return {
1217
1244
  name: basename(absPlatform),
1218
1245
  path: absPlatform,
1219
1246
  pinned_spec_version: platformVersion,
1247
+ dependency_depth: 0,
1220
1248
  selfMember: true
1221
1249
  };
1222
1250
  }
@@ -1238,14 +1266,45 @@ async function packageColumns(repoPath, prefix, repo, parentPin) {
1238
1266
  const path = join3(repoPath, pkg.path);
1239
1267
  const configPath = join3(path, MEMBER_CONFIG_FILENAME);
1240
1268
  if (!existsSync(configPath)) {
1241
- out.push(column(name, path, parentPin));
1269
+ out.push(column(name, path, parentPin, pkg));
1242
1270
  continue;
1243
1271
  }
1244
1272
  const cfg = await readRepoConfig(configPath);
1245
- out.push(column(name, path, extractPin(cfg.specs), cfg.ignore));
1273
+ out.push(column(name, path, extractPin(cfg.specs), pkg, cfg.ignore));
1246
1274
  }
1247
1275
  return out;
1248
1276
  }
1277
+ function packageDirs(repo) {
1278
+ const dirs = repo.packages.map((p) => p.path);
1279
+ return dirs.some((d) => d === "" || d === ".") ? null : dirs;
1280
+ }
1281
+ function loneRootColumn(absPlatform, repo, platformVersion) {
1282
+ const dirs = packageDirs(repo);
1283
+ const { dependency_depth: _depth, ...self } = selfMember(absPlatform, platformVersion);
1284
+ if (dirs === null || self.name === CANONICAL_SPECS_DIR)
1285
+ return [];
1286
+ return [{ repo: { ...self, ignore: dirs }, node: repo }];
1287
+ }
1288
+ async function loneMembers(repo, absPlatform, platformVersion) {
1289
+ if (repo === undefined || repo.mode !== "monorepo" || repo.packages.length === 0) {
1290
+ return [selfMember(absPlatform, platformVersion)];
1291
+ }
1292
+ return toRepos([
1293
+ ...await packageColumns(absPlatform, "", repo, platformVersion),
1294
+ ...loneRootColumn(absPlatform, repo, platformVersion)
1295
+ ]);
1296
+ }
1297
+ async function memberColumns(repo, path, cfg) {
1298
+ const pin = extractPin(cfg.specs);
1299
+ if (repo.mode !== "monorepo" || repo.packages.length === 0) {
1300
+ return [column(repo.name, path, pin, repo, cfg.ignore)];
1301
+ }
1302
+ const packages = await packageColumns(path, `${repo.name}/`, repo, pin);
1303
+ const dirs = packageDirs(repo);
1304
+ if (dirs === null)
1305
+ return packages;
1306
+ return [...packages, column(repo.name, path, pin, repo, [...cfg.ignore ?? [], ...dirs])];
1307
+ }
1249
1308
  function describe(d, mapped) {
1250
1309
  const s = d.subject;
1251
1310
  switch (d.code) {
@@ -1278,15 +1337,14 @@ async function membersOf(reading, absPlatform, platformVersion) {
1278
1337
  const { mapped, located } = reading;
1279
1338
  const diagnostics2 = surfaced(mapped);
1280
1339
  if (mapped.mode !== "multi-repo") {
1281
- const repo = mapped.repos[0];
1282
- const members2 = repo !== undefined && repo.mode === "monorepo" && repo.packages.length > 0 ? await packageColumns(absPlatform, "", repo, platformVersion) : [selfMember(absPlatform, platformVersion)];
1283
- return { mode: mapped.mode, members: members2, unpinned: [], undeclared: [], diagnostics: diagnostics2 };
1340
+ const members = await loneMembers(mapped.repos[0], absPlatform, platformVersion);
1341
+ return { mode: mapped.mode, members, unpinned: [], undeclared: [], diagnostics: diagnostics2 };
1284
1342
  }
1285
1343
  if (!mapped.declared) {
1286
1344
  const undeclared2 = mapped.repos.map((r) => ({ name: r.name, path: join3(absPlatform, r.name) }));
1287
1345
  return { mode: "multi-repo", members: [], unpinned: [], undeclared: undeclared2, diagnostics: diagnostics2 };
1288
1346
  }
1289
- const members = [];
1347
+ const columns = [];
1290
1348
  const unpinned = [];
1291
1349
  for (const repo of mapped.repos) {
1292
1350
  const path = located.repos[repo.name];
@@ -1297,17 +1355,10 @@ async function membersOf(reading, absPlatform, platformVersion) {
1297
1355
  unpinned.push({ name: repo.name, path });
1298
1356
  continue;
1299
1357
  }
1300
- const cfg = await readRepoConfig(configPath);
1301
- const pin = extractPin(cfg.specs);
1302
- if (repo.mode === "monorepo" && repo.packages.length > 0) {
1303
- members.push(...await packageColumns(path, `${repo.name}/`, repo, pin));
1304
- } else {
1305
- members.push(column(repo.name, path, pin, cfg.ignore));
1306
- }
1358
+ columns.push(...await memberColumns(repo, path, await readRepoConfig(configPath)));
1307
1359
  }
1308
- members.sort(byName);
1309
1360
  const undeclared = mapped.diagnostics.filter((d) => d.code === "UNLISTED_REPO").map((d) => ({ name: d.subject, path: join3(absPlatform, d.subject) }));
1310
- return { mode: "multi-repo", members, unpinned, undeclared, diagnostics: diagnostics2 };
1361
+ return { mode: "multi-repo", members: toRepos(columns), unpinned, undeclared, diagnostics: diagnostics2 };
1311
1362
  }
1312
1363
  async function discoverRepos(platformDir) {
1313
1364
  const absPlatform = resolve2(platformDir);
@@ -1320,7 +1371,8 @@ async function discoverRepos(platformDir) {
1320
1371
  const canonical = {
1321
1372
  name: CANONICAL_SPECS_DIR,
1322
1373
  path: canonicalPath,
1323
- pinned_spec_version: platformVersion
1374
+ pinned_spec_version: platformVersion,
1375
+ dependency_depth: 0
1324
1376
  };
1325
1377
  const reading = readPlatformMap(absPlatform);
1326
1378
  const shape = reading === null ? loneSingleRepo(absPlatform, platformVersion) : await membersOf(reading, absPlatform, platformVersion);
@@ -1706,11 +1758,11 @@ SELECT
1706
1758
  ) AS via_other
1707
1759
  FROM repos
1708
1760
  WHERE repos.name != 'spec-engine'
1709
- ORDER BY repos.name
1761
+ ORDER BY repos.dependency_depth, repos.name
1710
1762
  `;
1711
1763
 
1712
1764
  // src/storage/sql/reads.ts
1713
- var LIST_RELATIONS_SQL, LIST_TERM_ALIASES_SQL = "SELECT term_id, name FROM term_aliases ORDER BY term_id, name", LIST_TERM_CITATIONS_SQL, LIST_PROVENANCE_SQL, LIST_PROVENANCE_MATRIX_SQL, PROVENANCE_BY_ISSUE_SQL, LIST_REPOS_SQL = "SELECT name, path, pinned_spec_version FROM repos ORDER BY name", GET_REPO_SQL = "SELECT name, path, pinned_spec_version FROM repos WHERE name = $name", LIST_DOMAINS_SQL = "SELECT key, owner, schema, spec_version, source_repo FROM domains ORDER BY key", GET_DOMAIN_SQL = "SELECT key, owner, schema, spec_version, source_repo FROM domains WHERE key = $key", LIST_TAGS_SQL = `SELECT id, req_id, repo, file, line, kind, level FROM tags
1765
+ var LIST_RELATIONS_SQL, LIST_TERM_ALIASES_SQL = "SELECT term_id, name FROM term_aliases ORDER BY term_id, name", LIST_TERM_CITATIONS_SQL, LIST_PROVENANCE_SQL, LIST_PROVENANCE_MATRIX_SQL, PROVENANCE_BY_ISSUE_SQL, LIST_REPOS_SQL = "SELECT name, path, pinned_spec_version, dependency_depth FROM repos ORDER BY name", GET_REPO_SQL = "SELECT name, path, pinned_spec_version, dependency_depth FROM repos WHERE name = $name", LIST_DOMAINS_SQL = "SELECT key, owner, schema, spec_version, source_repo FROM domains ORDER BY key", GET_DOMAIN_SQL = "SELECT key, owner, schema, spec_version, source_repo FROM domains WHERE key = $key", LIST_TAGS_SQL = `SELECT id, req_id, repo, file, line, kind, level FROM tags
1714
1766
  WHERE ($repo IS NULL OR repo = $repo)
1715
1767
  AND ($req_id IS NULL OR req_id = $req_id)
1716
1768
  AND ($file IS NULL OR file = $file)
@@ -1983,7 +2035,7 @@ class SqliteStorage {
1983
2035
  }
1984
2036
  #makeWriteHandle() {
1985
2037
  const db = this.#db;
1986
- const insRepo = db.prepare("INSERT OR REPLACE INTO repos (name, path, pinned_spec_version) VALUES ($name, $path, $pin)");
2038
+ const insRepo = db.prepare("INSERT OR REPLACE INTO repos (name, path, pinned_spec_version, dependency_depth) VALUES ($name, $path, $pin, $depth)");
1987
2039
  const insDomain = db.prepare("INSERT OR REPLACE INTO domains (key, owner, schema, spec_version, source_repo) " + "VALUES ($key, $owner, $schema, $ver, $source)");
1988
2040
  const insReq = db.prepare("INSERT OR REPLACE INTO requirements " + "(id, key, seq, status, superseded_by, text, why, source_file, line, spec_version, changed_at_version, superseded_at_version) " + "VALUES ($id, $key, $seq, $status, $sup, $text, $why, $file, $line, $sver, $cver, $saver)");
1989
2041
  const insTag = db.prepare("INSERT INTO tags (req_id, repo, file, line, kind, level) " + "VALUES ($req, $repo, $file, $line, $kind, $level)");
@@ -2013,7 +2065,8 @@ class SqliteStorage {
2013
2065
  insRepo.run({
2014
2066
  name: r.name,
2015
2067
  path: r.path,
2016
- pin: r.pinned_spec_version
2068
+ pin: r.pinned_spec_version,
2069
+ depth: r.dependency_depth
2017
2070
  });
2018
2071
  },
2019
2072
  upsertDomain(d) {
@@ -2136,11 +2189,7 @@ function poisonSchemaVersion(path, badVersion) {
2136
2189
  function poisonRepoRow(path, name) {
2137
2190
  const db = new Database(path);
2138
2191
  try {
2139
- db.run("INSERT INTO repos (name, path, pinned_spec_version) VALUES (?, ?, ?)", [
2140
- name,
2141
- "/dev/null",
2142
- 999
2143
- ]);
2192
+ db.run("INSERT INTO repos (name, path, pinned_spec_version, dependency_depth) VALUES (?, ?, ?, ?)", [name, "/dev/null", 999, 0]);
2144
2193
  } finally {
2145
2194
  db.close();
2146
2195
  }
@@ -2165,7 +2214,7 @@ function computeBuildId(storage2) {
2165
2214
  },
2166
2215
  {
2167
2216
  label: "repos",
2168
- sql: "SELECT name, path, pinned_spec_version FROM repos ORDER BY name"
2217
+ sql: "SELECT name, path, pinned_spec_version, dependency_depth FROM repos ORDER BY name"
2169
2218
  },
2170
2219
  {
2171
2220
  label: "domains",
@@ -2511,7 +2560,8 @@ function buildRows(input) {
2511
2560
  {
2512
2561
  name: canonical.name,
2513
2562
  path: canonical.path,
2514
- pinned_spec_version: platformVersion
2563
+ pinned_spec_version: platformVersion,
2564
+ dependency_depth: 0
2515
2565
  },
2516
2566
  ...members
2517
2567
  ], (r) => r.name);
@@ -3288,7 +3338,24 @@ import { resolve as resolve6 } from "path";
3288
3338
  function resolvePlatformDir(args) {
3289
3339
  return resolve6(args.platformDir ?? process.cwd());
3290
3340
  }
3291
- var platformDirArg, outArg, jsonArg, freshArg, noPromptArg;
3341
+ function listFlag(rawArgs, name, parsed) {
3342
+ const flag = `--${name}`;
3343
+ const raw = [];
3344
+ for (let i = 0;i < rawArgs.length; i++) {
3345
+ const arg = rawArgs[i];
3346
+ if (arg === flag && i + 1 < rawArgs.length)
3347
+ raw.push(rawArgs[++i]);
3348
+ else if (arg.startsWith(`${flag}=`))
3349
+ raw.push(arg.slice(flag.length + 1));
3350
+ }
3351
+ if (raw.length === 0) {
3352
+ if (parsed === undefined)
3353
+ return;
3354
+ raw.push(parsed);
3355
+ }
3356
+ return raw.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v !== "");
3357
+ }
3358
+ var platformDirArg, outArg, jsonArg, freshArg, noPromptArg, livesArg;
3292
3359
  var init__args = __esm(() => {
3293
3360
  init_constants();
3294
3361
  platformDirArg = {
@@ -3309,6 +3376,10 @@ var init__args = __esm(() => {
3309
3376
  type: "boolean",
3310
3377
  description: "Suppress the interactive onboarding prompt for siblings missing spec-engine.member.json (they surface as NO_SPEC_CONFIG warnings)"
3311
3378
  };
3379
+ livesArg = {
3380
+ type: "string",
3381
+ description: "Lives in (livesIn) paths: repeat the flag or comma-separate; an empty value clears the list"
3382
+ };
3312
3383
  });
3313
3384
 
3314
3385
  // src/commands/index.ts
@@ -5654,19 +5725,15 @@ var init_list = __esm(() => {
5654
5725
  });
5655
5726
 
5656
5727
  // src/propagation/format.ts
5657
- function sortPropagation(rows) {
5658
- return [...rows].sort((a, b) => a.repo.localeCompare(b.repo));
5659
- }
5660
5728
  function renderPropagation(rows, mode) {
5661
- const sorted = sortPropagation(rows);
5662
5729
  if (mode === "json") {
5663
- return JSON.stringify(sorted);
5730
+ return JSON.stringify(rows);
5664
5731
  }
5665
- if (sorted.length === 0)
5732
+ if (rows.length === 0)
5666
5733
  return "";
5667
5734
  const header = ["REPO", "STATE", "VIA", "DRIFT?"];
5668
5735
  const lines = [header];
5669
- for (const row of sorted) {
5736
+ for (const row of rows) {
5670
5737
  lines.push([row.repo, row.state, row.via_req_id ?? EMPTY_CELL2, row.drifted ? "yes" : "no"]);
5671
5738
  }
5672
5739
  const widths = header.map((_, col) => Math.max(...lines.map((row) => row[col]?.length ?? 0)));
@@ -6426,24 +6493,27 @@ __export(exports_req, {
6426
6493
  });
6427
6494
  import { createInterface as createInterface2 } from "readline";
6428
6495
  import { defineCommand as defineCommand12 } from "citty";
6429
- async function mintFromFlags(platformDir, key, textFlag, args) {
6496
+ async function mintFromFlags(platformDir, key, textFlag, args, rawArgs) {
6430
6497
  const statement = textFlag.trim();
6431
6498
  if (statement === "") {
6432
6499
  console.error("spec req: --text must be a non-empty Requirement");
6433
6500
  process.exit(EXIT.USAGE);
6434
6501
  }
6435
6502
  await printResolvedCharter(platformDir, key);
6436
- const lives = (args.lives ?? "").trim();
6437
6503
  await mintAndReport(platformDir, key, {
6438
6504
  statement,
6439
6505
  why: (args.why ?? "").trim(),
6440
- livesIn: lives ? [lives] : [],
6506
+ livesIn: listFlag(rawArgs, "lives", args.lives) ?? [],
6441
6507
  issue: (args.issue ?? "").trim() || undefined,
6508
+ status: statusOf(args),
6442
6509
  json: Boolean(args.json)
6443
6510
  });
6444
6511
  }
6445
- async function mintInteractively(platformDir, key, id) {
6446
- console.error(`Authoring ${id} \u2014 Active`);
6512
+ function statusOf(args) {
6513
+ return args.draft ? "draft" : "active";
6514
+ }
6515
+ async function mintInteractively(platformDir, key, id, status) {
6516
+ console.error(`Authoring ${id} \u2014 ${status === "draft" ? "Draft" : "Active"}`);
6447
6517
  await printResolvedCharter(platformDir, key);
6448
6518
  const requirement = (await askLine("Requirement: ")).trim();
6449
6519
  if (requirement === "") {
@@ -6452,12 +6522,13 @@ async function mintInteractively(platformDir, key, id) {
6452
6522
  return;
6453
6523
  }
6454
6524
  const why = (await askLine("Why it matters: ")).trim();
6455
- const lives = (await askLine("Lives in: ")).trim();
6525
+ const livesIn = listFlag([], "lives", await askLine("Lives in: ")) ?? [];
6456
6526
  await mintAndReport(platformDir, key, {
6457
6527
  statement: requirement,
6458
6528
  why,
6459
- livesIn: lives ? [lives] : [],
6529
+ livesIn,
6460
6530
  issue: undefined,
6531
+ status,
6461
6532
  json: false
6462
6533
  });
6463
6534
  }
@@ -6510,10 +6581,14 @@ var init_req = __esm(() => {
6510
6581
  description: "Non-interactive authoring: the Requirement field. When set, the entry appends with zero prompts."
6511
6582
  },
6512
6583
  why: { type: "string", description: "Why it matters field (only with --text)" },
6513
- lives: { type: "string", description: "Lives in field (only with --text)" },
6584
+ lives: livesArg,
6514
6585
  issue: {
6515
6586
  type: "string",
6516
6587
  description: "Originating ticket id, recorded as created-provenance on the entry (only with --text; opaque \u2014 never a requirement id)"
6588
+ },
6589
+ draft: {
6590
+ type: "boolean",
6591
+ description: "Author the entry as Draft instead of Active (promote it later with spec accept)"
6517
6592
  }
6518
6593
  };
6519
6594
  reqCommand = defineCommand12({
@@ -6522,7 +6597,7 @@ var init_req = __esm(() => {
6522
6597
  description: "Author a new requirement interactively (TTY) or print the next unused id (non-TTY)"
6523
6598
  },
6524
6599
  args: reqArgs,
6525
- async run({ args }) {
6600
+ async run({ args, rawArgs }) {
6526
6601
  const input = args.domainPrefix;
6527
6602
  const platformDir = resolvePlatformDir(args);
6528
6603
  try {
@@ -6536,7 +6611,7 @@ var init_req = __esm(() => {
6536
6611
  const { key, nextId: id } = allocated;
6537
6612
  const textFlag = args.text;
6538
6613
  if (textFlag !== undefined) {
6539
- await mintFromFlags(platformDir, key, textFlag, args);
6614
+ await mintFromFlags(platformDir, key, textFlag, args, rawArgs);
6540
6615
  return;
6541
6616
  }
6542
6617
  const hasFieldFlags = typeof args.why === "string" || typeof args.lives === "string" || typeof args.issue === "string";
@@ -6545,6 +6620,11 @@ var init_req = __esm(() => {
6545
6620
  process.exit(EXIT.USAGE);
6546
6621
  return;
6547
6622
  }
6623
+ if (args.draft && (args.json || !process.stdin.isTTY)) {
6624
+ console.error("spec req: --draft authors an entry \u2014 pass --text, or run on a TTY");
6625
+ process.exit(EXIT.USAGE);
6626
+ return;
6627
+ }
6548
6628
  if (args.json) {
6549
6629
  console.log(JSON.stringify({ domain: key, next_id: id }));
6550
6630
  return;
@@ -6553,7 +6633,7 @@ var init_req = __esm(() => {
6553
6633
  console.log(id);
6554
6634
  return;
6555
6635
  }
6556
- await mintInteractively(platformDir, key, id);
6636
+ await mintInteractively(platformDir, key, id, statusOf(args));
6557
6637
  }
6558
6638
  });
6559
6639
  });
@@ -7100,14 +7180,13 @@ async function resolveSuccessorText(args, platformDir, id) {
7100
7180
  function splitAliases(raw) {
7101
7181
  return raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
7102
7182
  }
7103
- function inputFromArgs(args, platformDir, id, statement) {
7183
+ function inputFromArgs(args, rawArgs, platformDir, id, statement) {
7104
7184
  const input = { platformDir, id, statement, noBump: Boolean(args.noBump) };
7105
7185
  if (typeof args.why === "string")
7106
7186
  input.why = args.why;
7107
- if (typeof args.lives === "string") {
7108
- const lives = args.lives.trim();
7109
- input.livesIn = lives === "" ? [] : [lives];
7110
- }
7187
+ const livesIn = listFlag(rawArgs, "lives", args.lives);
7188
+ if (livesIn !== undefined)
7189
+ input.livesIn = livesIn;
7111
7190
  if (typeof args.term === "string")
7112
7191
  input.term = args.term;
7113
7192
  if (typeof args.aliases === "string")
@@ -7144,7 +7223,7 @@ var init_supersede2 = __esm(() => {
7144
7223
  },
7145
7224
  lives: {
7146
7225
  type: "string",
7147
- description: "Successor's Lives in (default: copied from the old entry)"
7226
+ description: "Successor's Lives in: repeat or comma-separate; empty clears (default: copied from the old entry)"
7148
7227
  },
7149
7228
  term: {
7150
7229
  type: "string",
@@ -7173,7 +7252,7 @@ var init_supersede2 = __esm(() => {
7173
7252
  description: "Supersede a shipped requirement: flip it to superseded, mint the successor, bump specVersion, and emit the retag worklist."
7174
7253
  },
7175
7254
  args: supersedeArgs,
7176
- async run({ args }) {
7255
+ async run({ args, rawArgs }) {
7177
7256
  const id = args.id;
7178
7257
  if (!ID_RE.test(id)) {
7179
7258
  console.error(`spec supersede: id must be a requirement id (KEY-NNN); got ${id}`);
@@ -7186,7 +7265,7 @@ var init_supersede2 = __esm(() => {
7186
7265
  handleNotAPlatform(e);
7187
7266
  }
7188
7267
  const statement = await resolveSuccessorText(args, platformDir, id);
7189
- const result = await supersede(inputFromArgs(args, platformDir, id, statement), coldFreshTags(platformDir));
7268
+ const result = await supersede(inputFromArgs(args, rawArgs, platformDir, id, statement), coldFreshTags(platformDir));
7190
7269
  if (!result.ok)
7191
7270
  exitOnFailure("spec supersede", result);
7192
7271
  printWarnings("spec supersede", result.warnings);
@@ -7432,16 +7511,15 @@ __export(exports_move, {
7432
7511
  moveCommand: () => moveCommand
7433
7512
  });
7434
7513
  import { defineCommand as defineCommand17 } from "citty";
7435
- function inputFromArgs2(args, platformDir, id, targetKey) {
7514
+ function inputFromArgs2(args, rawArgs, platformDir, id, targetKey) {
7436
7515
  const input = { platformDir, id, targetKey, noBump: Boolean(args.noBump) };
7437
7516
  if (typeof args.text === "string")
7438
7517
  input.statement = args.text;
7439
7518
  if (typeof args.why === "string")
7440
7519
  input.why = args.why;
7441
- if (typeof args.lives === "string") {
7442
- const lives = args.lives.trim();
7443
- input.livesIn = lives === "" ? [] : [lives];
7444
- }
7520
+ const livesIn = listFlag(rawArgs, "lives", args.lives);
7521
+ if (livesIn !== undefined)
7522
+ input.livesIn = livesIn;
7445
7523
  return input;
7446
7524
  }
7447
7525
  var moveArgs, moveCommand;
@@ -7476,7 +7554,7 @@ var init_move2 = __esm(() => {
7476
7554
  },
7477
7555
  lives: {
7478
7556
  type: "string",
7479
- description: "Rewrite the successor's Lives in (default: copied from the source)"
7557
+ description: "Rewrite the successor's Lives in: repeat or comma-separate; empty clears (default: copied from the source)"
7480
7558
  },
7481
7559
  noBump: {
7482
7560
  type: "boolean",
@@ -7493,7 +7571,7 @@ var init_move2 = __esm(() => {
7493
7571
  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."
7494
7572
  },
7495
7573
  args: moveArgs,
7496
- async run({ args }) {
7574
+ async run({ args, rawArgs }) {
7497
7575
  const id = args.id;
7498
7576
  const rawTargetKey = args.newDomain;
7499
7577
  const platformDir = resolvePlatformDir(args);
@@ -7511,7 +7589,7 @@ var init_move2 = __esm(() => {
7511
7589
  } catch (e) {
7512
7590
  handleNotAPlatform(e);
7513
7591
  }
7514
- const result = await move(inputFromArgs2(args, platformDir, id, targetKey), coldFreshTags(platformDir));
7592
+ const result = await move(inputFromArgs2(args, rawArgs, platformDir, id, targetKey), coldFreshTags(platformDir));
7515
7593
  if (!result.ok)
7516
7594
  exitOnFailure("spec move", result);
7517
7595
  printWarnings("spec move", result.warnings);
@@ -7657,7 +7735,7 @@ function assertAmendFlags(args) {
7657
7735
  function splitAliases2(raw) {
7658
7736
  return raw === "" ? [] : raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
7659
7737
  }
7660
- function fieldsFromArgs(args) {
7738
+ function fieldsFromArgs(args, rawArgs) {
7661
7739
  assertAmendFlags(args);
7662
7740
  const fields = {};
7663
7741
  if (args.text !== undefined)
@@ -7666,10 +7744,9 @@ function fieldsFromArgs(args) {
7666
7744
  const v = args.why.trim();
7667
7745
  fields.why = v === "" ? null : v;
7668
7746
  }
7669
- if (args.lives !== undefined) {
7670
- const v = args.lives.trim();
7671
- fields.livesIn = v === "" ? [] : [v];
7672
- }
7747
+ const livesIn = listFlag(rawArgs, "lives", args.lives);
7748
+ if (livesIn !== undefined)
7749
+ fields.livesIn = livesIn;
7673
7750
  if (args.term !== undefined)
7674
7751
  fields.term = args.term.trim();
7675
7752
  if (args.issue !== undefined && args.issue.trim() !== "")
@@ -7696,7 +7773,7 @@ var init_amend2 = __esm(() => {
7696
7773
  platformDir: platformDirArg,
7697
7774
  text: { type: "string", description: "New Requirement (statement) field value" },
7698
7775
  why: { type: "string", description: "New Why it matters field value" },
7699
- lives: { type: "string", description: "New Lives in (livesIn) field value" },
7776
+ lives: livesArg,
7700
7777
  issue: {
7701
7778
  type: "string",
7702
7779
  description: "Ticket that motivated this amendment \u2014 recorded as amends-via provenance (opaque, never a requirement id)"
@@ -7714,7 +7791,7 @@ var init_amend2 = __esm(() => {
7714
7791
  description: "Revise an unshipped requirement's fields in place (same id, no specVersion bump). Supersede shipped truth instead."
7715
7792
  },
7716
7793
  args: amendArgs,
7717
- async run({ args }) {
7794
+ async run({ args, rawArgs }) {
7718
7795
  const id = args.id;
7719
7796
  if (!ID_RE.test(id)) {
7720
7797
  console.error(`spec amend: id must be a requirement id (KEY-NNN); got ${id}`);
@@ -7727,7 +7804,7 @@ var init_amend2 = __esm(() => {
7727
7804
  } catch (e) {
7728
7805
  handleNotAPlatform(e);
7729
7806
  }
7730
- const fields = fieldsFromArgs(args);
7807
+ const fields = fieldsFromArgs(args, rawArgs);
7731
7808
  const result = await amend({ platformDir, id, fields }, coldFreshTags(platformDir));
7732
7809
  if (!result.ok)
7733
7810
  exitOnFailure("spec amend", result);
@@ -7741,6 +7818,86 @@ var init_amend2 = __esm(() => {
7741
7818
  });
7742
7819
  });
7743
7820
 
7821
+ // src/operations/accept.ts
7822
+ async function accept(input) {
7823
+ const { platformDir, id } = input;
7824
+ const located = await locateEntry(platformDir, id);
7825
+ if (!located.ok)
7826
+ return located;
7827
+ const { specPath, relFile, domain: domain2, req } = located;
7828
+ if (req.status.toLowerCase() !== "draft") {
7829
+ return fail("conflict", `${id} is ${displayStatus(req.status)} \u2014 only a Draft entry accepts`);
7830
+ }
7831
+ req.status = "active";
7832
+ domain2.updated = localToday();
7833
+ const res = await validateAndWrite(specPath, domain2, relFile);
7834
+ if (!res.ok) {
7835
+ return fail("invalid_domain_file", res.diagnostics.map((d) => d.detail).join(`
7836
+ `), {
7837
+ diagnostics: res.diagnostics
7838
+ });
7839
+ }
7840
+ return { ok: true, id, file: relFile };
7841
+ }
7842
+ var init_accept = __esm(() => {
7843
+ init_src();
7844
+ init__envelope();
7845
+ init__result();
7846
+ });
7847
+
7848
+ // src/commands/accept.ts
7849
+ var exports_accept = {};
7850
+ __export(exports_accept, {
7851
+ acceptCommand: () => acceptCommand
7852
+ });
7853
+ import { defineCommand as defineCommand19 } from "citty";
7854
+ var acceptCommand;
7855
+ var init_accept2 = __esm(() => {
7856
+ init_constants();
7857
+ init_discover();
7858
+ init_accept();
7859
+ init_grammar3();
7860
+ init__args();
7861
+ init__shared();
7862
+ acceptCommand = defineCommand19({
7863
+ meta: {
7864
+ name: "accept",
7865
+ description: "Promote a Draft requirement to Active (same id, fields untouched)"
7866
+ },
7867
+ args: {
7868
+ id: {
7869
+ type: "positional",
7870
+ required: true,
7871
+ description: "Draft requirement id to accept (KEY-NNN)"
7872
+ },
7873
+ platformDir: platformDirArg,
7874
+ json: jsonArg
7875
+ },
7876
+ async run({ args }) {
7877
+ const id = args.id;
7878
+ if (!ID_RE.test(id)) {
7879
+ console.error(`spec accept: id must be a requirement id (KEY-NNN); got ${id}`);
7880
+ process.exit(EXIT.USAGE);
7881
+ return;
7882
+ }
7883
+ const platformDir = resolvePlatformDir(args);
7884
+ try {
7885
+ assertSpecPlatform(platformDir);
7886
+ } catch (e) {
7887
+ handleNotAPlatform(e);
7888
+ }
7889
+ const result = await accept({ platformDir, id });
7890
+ if (!result.ok)
7891
+ exitOnFailure("spec accept", result);
7892
+ if (args.json) {
7893
+ console.log(JSON.stringify({ id, file: result.file }));
7894
+ return;
7895
+ }
7896
+ console.log(`accepted ${id} in ${result.file} \u2014 now Active`);
7897
+ }
7898
+ });
7899
+ });
7900
+
7744
7901
  // src/server/authorPrompt.ts
7745
7902
  function renderAuthorPrompt(opts) {
7746
7903
  const charterBlock = opts.charter ? `
@@ -7916,6 +8073,19 @@ function buildMcpServer(platformDir) {
7916
8073
  const r = await deprecate({ platformDir, id: req_id, reason: trimmed }, coldFreshTags(platformDir));
7917
8074
  return r.ok ? jsonResult({ id: r.id, file: r.file, reason: r.reason, sites: r.sites }) : errorResult(r.detail);
7918
8075
  });
8076
+ server.registerTool("spec_accept", {
8077
+ title: "Accept a draft requirement",
8078
+ description: "Promote a Draft requirement to Active, leaving every other field untouched. The same JSON as `spec accept --json`.",
8079
+ inputSchema: {
8080
+ req_id: z3.string().describe("The Draft requirement id to accept (KEY-NNN)")
8081
+ }
8082
+ }, async ({ req_id }) => {
8083
+ if (!ID_RE.test(req_id)) {
8084
+ return errorResult(`req_id must be a requirement id (KEY-NNN); got ${req_id}`);
8085
+ }
8086
+ const r = await accept({ platformDir, id: req_id });
8087
+ return r.ok ? jsonResult({ id: r.id, file: r.file }) : errorResult(r.detail);
8088
+ });
7919
8089
  server.registerPrompt("author_requirements", {
7920
8090
  title: "Author requirements from a brief",
7921
8091
  description: "Turn a vague brief/ticket into well-formed Spec Engine requirements: one requirement per testable promise, placed by domain charter, deduped via spec query, drafted to the GUARD template + cold-read rubric, minted with spec req. The engine returns a static playbook template \u2014 your model runs it.",
@@ -7950,6 +8120,7 @@ var init_mcp = __esm(() => {
7950
8120
  init_src();
7951
8121
  init_domains();
7952
8122
  init__index();
8123
+ init_accept();
7953
8124
  init_check();
7954
8125
  init_deprecate();
7955
8126
  init_nextId();
@@ -7965,14 +8136,14 @@ __export(exports_mcp, {
7965
8136
  mcpCommand: () => mcpCommand
7966
8137
  });
7967
8138
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7968
- import { defineCommand as defineCommand19 } from "citty";
8139
+ import { defineCommand as defineCommand20 } from "citty";
7969
8140
  var mcpCommand;
7970
8141
  var init_mcp2 = __esm(() => {
7971
8142
  init_discover();
7972
8143
  init_mcp();
7973
8144
  init__args();
7974
8145
  init__shared();
7975
- mcpCommand = defineCommand19({
8146
+ mcpCommand = defineCommand20({
7976
8147
  meta: {
7977
8148
  name: "mcp",
7978
8149
  description: "Serve Spec Engine as an MCP (Model Context Protocol) server over stdio \u2014 agent-native query/resolve/check/report tools."
@@ -8001,7 +8172,7 @@ __export(exports_resolve, {
8001
8172
  });
8002
8173
  import { statSync as statSync5 } from "fs";
8003
8174
  import { join as join12, relative as relative2, resolve as resolve13, sep as sep3 } from "path";
8004
- import { defineCommand as defineCommand20 } from "citty";
8175
+ import { defineCommand as defineCommand21 } from "citty";
8005
8176
  function classifyArg(a) {
8006
8177
  if (a.startsWith("--")) {
8007
8178
  const eq = a.indexOf("=");
@@ -8187,7 +8358,7 @@ var init_resolve2 = __esm(() => {
8187
8358
  description: "Suppress interactive onboarding prompt for siblings missing spec-engine.member.json (defaults to NO_SPEC_CONFIG warning)"
8188
8359
  }
8189
8360
  };
8190
- resolveCommand = defineCommand20({
8361
+ resolveCommand = defineCommand21({
8191
8362
  meta: {
8192
8363
  name: "resolve",
8193
8364
  description: "Return the requirements tagged in the given files (RSLV-01). Accepts multiple positional file paths (e.g. `spec resolve api/src/renew.ts api/src/charge.ts`) and a comma-split fallback inside a single positional (`spec resolve a.ts,b.ts`). Paths may be platform-relative or absolute under the platform tree."
@@ -8352,7 +8523,7 @@ __export(exports_gate, {
8352
8523
  });
8353
8524
  import { existsSync as existsSync12 } from "fs";
8354
8525
  import { join as join13 } from "path";
8355
- import { defineCommand as defineCommand21 } from "citty";
8526
+ import { defineCommand as defineCommand22 } from "citty";
8356
8527
  function isEpipe(e) {
8357
8528
  if (e === null || typeof e !== "object" || !("code" in e))
8358
8529
  return false;
@@ -8415,7 +8586,7 @@ var init_gate2 = __esm(() => {
8415
8586
  description: "Suppress interactive onboarding prompt for siblings missing spec-engine.member.json (defaults to NO_SPEC_CONFIG warning)"
8416
8587
  }
8417
8588
  };
8418
- gateCommand = defineCommand21({
8589
+ gateCommand = defineCommand22({
8419
8590
  meta: {
8420
8591
  name: "gate",
8421
8592
  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."
@@ -8737,14 +8908,14 @@ var exports_guard = {};
8737
8908
  __export(exports_guard, {
8738
8909
  guardCommand: () => guardCommand
8739
8910
  });
8740
- import { defineCommand as defineCommand22 } from "citty";
8911
+ import { defineCommand as defineCommand23 } from "citty";
8741
8912
  var guardCommand;
8742
8913
  var init_guard2 = __esm(() => {
8743
8914
  init_constants();
8744
8915
  init_guard();
8745
8916
  init__args();
8746
8917
  init__shared();
8747
- guardCommand = defineCommand22({
8918
+ guardCommand = defineCommand23({
8748
8919
  meta: {
8749
8920
  name: "guard",
8750
8921
  description: "Loss detection: diff the requirement derivation at a git ref (default HEAD) against the working tree and block requirements about to be steamrolled."
@@ -11708,6 +11879,15 @@ function toLivesIn(v) {
11708
11879
  return [v];
11709
11880
  return [];
11710
11881
  }
11882
+ function mintFieldsFromBody(body) {
11883
+ return {
11884
+ statement: typeof body.statement === "string" ? body.statement : "",
11885
+ why: typeof body.why === "string" ? body.why : "",
11886
+ livesIn: toLivesIn(body.livesIn),
11887
+ issue: typeof body.issue === "string" && body.issue.trim() !== "" ? body.issue.trim() : undefined,
11888
+ status: body.status === "draft" ? "draft" : "active"
11889
+ };
11890
+ }
11711
11891
  function amendFieldsFromBody(body) {
11712
11892
  const fields = {};
11713
11893
  if (typeof body.statement === "string")
@@ -11792,14 +11972,7 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11792
11972
  return c.json({ error: "unknown domain key" }, 400);
11793
11973
  }
11794
11974
  return withWriteLock(async () => {
11795
- const r = await mint({
11796
- platformDir,
11797
- key,
11798
- statement: typeof body.statement === "string" ? body.statement : "",
11799
- why: typeof body.why === "string" ? body.why : "",
11800
- livesIn: toLivesIn(body.livesIn),
11801
- issue: typeof body.issue === "string" && body.issue.trim() !== "" ? body.issue.trim() : undefined
11802
- });
11975
+ const r = await mint({ platformDir, key, ...mintFieldsFromBody(body) });
11803
11976
  if (!r.ok)
11804
11977
  return failureResponse(c, r);
11805
11978
  await runIndex({ platformDir, storage: storage2 });
@@ -11910,6 +12083,30 @@ function mountApi(app, storage2, platformDir = process.cwd()) {
11910
12083
  return c.json({ ok: true, id: r.id, file: r.file, reason: r.reason, sites: r.sites }, 200);
11911
12084
  });
11912
12085
  }));
12086
+ app.post("/api/requirements/:id/accept", guarded(async function acceptRequirement(c) {
12087
+ if (!featureEnabled("editor")) {
12088
+ return c.json({ error: featureDisabledMessage("editor") }, 404);
12089
+ }
12090
+ const originReject = rejectCrossOrigin(c);
12091
+ if (originReject)
12092
+ return originReject;
12093
+ const headerReject = rejectBadWriteHeaders(c);
12094
+ if (headerReject)
12095
+ return headerReject;
12096
+ const parsed = await readJsonBody(c);
12097
+ if (!parsed.ok)
12098
+ return parsed.res;
12099
+ const id = c.req.param("id") ?? "";
12100
+ if (!ID_RE.test(id))
12101
+ return c.json({ error: "id must be a requirement id (KEY-NNN)" }, 400);
12102
+ return withWriteLock(async () => {
12103
+ const r = await accept({ platformDir, id });
12104
+ if (!r.ok)
12105
+ return failureResponse(c, r);
12106
+ await runIndex({ platformDir, storage: storage2 });
12107
+ return c.json({ ok: true, id: r.id, file: r.file }, 200);
12108
+ });
12109
+ }));
11913
12110
  app.get("/api/propagation/:id", guarded((c) => c.json(propagation2(storage2, c.req.param("id") ?? "").rows)));
11914
12111
  app.get("/api/query", guarded((c) => {
11915
12112
  if (!featureEnabled("query")) {
@@ -11990,6 +12187,7 @@ var init_api = __esm(() => {
11990
12187
  init_discover();
11991
12188
  init_pipeline();
11992
12189
  init__result();
12190
+ init_accept();
11993
12191
  init_amend();
11994
12192
  init_deprecate();
11995
12193
  init_mint();
@@ -12011,7 +12209,7 @@ __export(exports_serve, {
12011
12209
  });
12012
12210
  import { existsSync as existsSync13, mkdirSync as mkdirSync6 } from "fs";
12013
12211
  import { dirname as dirname8 } from "path";
12014
- import { defineCommand as defineCommand23 } from "citty";
12212
+ import { defineCommand as defineCommand24 } from "citty";
12015
12213
  import { Hono as Hono2 } from "hono";
12016
12214
  function composeServeApp(storage2, platformDir = process.cwd()) {
12017
12215
  const app = new Hono2;
@@ -12060,7 +12258,7 @@ var init_serve = __esm(() => {
12060
12258
  init_sqlite();
12061
12259
  init__args();
12062
12260
  init__shared();
12063
- serveCommand = defineCommand23({
12261
+ serveCommand = defineCommand24({
12064
12262
  meta: {
12065
12263
  name: "serve",
12066
12264
  description: "Run the local webapp. Binds 127.0.0.1 on --port (loopback only \u2014 no --host). --probe is the SERV-04 asset-embedding smoke."
@@ -12163,7 +12361,7 @@ __export(exports_docs, {
12163
12361
  import { existsSync as existsSync14, statSync as statSync6 } from "fs";
12164
12362
  import { join as join15, normalize, sep as sep4 } from "path";
12165
12363
  import { fileURLToPath } from "url";
12166
- import { defineCommand as defineCommand24 } from "citty";
12364
+ import { defineCommand as defineCommand25 } from "citty";
12167
12365
  function resolveDocsRootFrom(candidates) {
12168
12366
  for (const candidate of candidates) {
12169
12367
  if (existsSync14(join15(candidate, "index.html")))
@@ -12241,7 +12439,7 @@ function exitNoDocsRoot() {
12241
12439
  var docsCommand;
12242
12440
  var init_docs2 = __esm(() => {
12243
12441
  init_constants();
12244
- docsCommand = defineCommand24({
12442
+ docsCommand = defineCommand25({
12245
12443
  meta: {
12246
12444
  name: "docs",
12247
12445
  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."
@@ -12302,10 +12500,10 @@ __export(exports___smoke, {
12302
12500
  schemaSmokeCommand: () => schemaSmokeCommand,
12303
12501
  schemaMismatchSmokeCommand: () => schemaMismatchSmokeCommand
12304
12502
  });
12305
- import { defineCommand as defineCommand25 } from "citty";
12503
+ import { defineCommand as defineCommand26 } from "citty";
12306
12504
  var schemaSmokeCommand, schemaMismatchSmokeCommand;
12307
12505
  var init___smoke = __esm(() => {
12308
- schemaSmokeCommand = defineCommand25({
12506
+ schemaSmokeCommand = defineCommand26({
12309
12507
  meta: {
12310
12508
  name: "__schema-smoke",
12311
12509
  description: "(hidden CI) open a fresh DB, exec DDL, assert the schema shape",
@@ -12348,7 +12546,7 @@ var init___smoke = __esm(() => {
12348
12546
  console.log("__schema-smoke OK");
12349
12547
  }
12350
12548
  });
12351
- schemaMismatchSmokeCommand = defineCommand25({
12549
+ schemaMismatchSmokeCommand = defineCommand26({
12352
12550
  meta: {
12353
12551
  name: "__schema-mismatch-smoke",
12354
12552
  description: "(hidden CI) corrupt _schema_version, re-open, assert silent rebuild",
@@ -12374,8 +12572,8 @@ var init___smoke = __esm(() => {
12374
12572
 
12375
12573
  // src/cli.ts
12376
12574
  init_package();
12377
- import { defineCommand as defineCommand26, runMain, showUsage } from "citty";
12378
- var main = defineCommand26({
12575
+ import { defineCommand as defineCommand27, runMain, showUsage } from "citty";
12576
+ var main = defineCommand27({
12379
12577
  meta: {
12380
12578
  name: "spec",
12381
12579
  version: package_default.version,
@@ -12400,6 +12598,7 @@ var main = defineCommand26({
12400
12598
  deprecate: () => Promise.resolve().then(() => (init_deprecate2(), exports_deprecate)).then((m) => m.deprecateCommand),
12401
12599
  move: () => Promise.resolve().then(() => (init_move2(), exports_move)).then((m) => m.moveCommand),
12402
12600
  amend: () => Promise.resolve().then(() => (init_amend2(), exports_amend)).then((m) => m.amendCommand),
12601
+ accept: () => Promise.resolve().then(() => (init_accept2(), exports_accept)).then((m) => m.acceptCommand),
12403
12602
  mcp: () => Promise.resolve().then(() => (init_mcp2(), exports_mcp)).then((m) => m.mcpCommand),
12404
12603
  resolve: () => Promise.resolve().then(() => (init_resolve2(), exports_resolve)).then((m) => m.resolveCommand),
12405
12604
  gate: () => Promise.resolve().then(() => (init_gate2(), exports_gate)).then((m) => m.gateCommand),