@namewta/speculo 1.0.13 → 1.0.15

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 +3 -1
  2. package/package.json +1 -1
  3. package/template/canonical/canonical-specdev-goal-plan.md +9 -5
  4. package/template/canonical/canonical-specdev-grill-with-docs.md +6 -4
  5. package/template/canonical/canonical-specdev-spec.md +8 -4
  6. package/template/canonical/canonical-specdev-tickets.md +20 -4
  7. package/template/commands/handoff.md +1 -1
  8. package/template/commands/status.md +1 -1
  9. package/template/workflows/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md +3 -3
  10. package/template/workflows/specdev/I-implement/references/implementation-procedure.md +1 -1
  11. package/template/workflows/specdev/I-init-setup/tracking-template.md +1 -1
  12. package/template/workflows/specdev/INDEX.md +1 -1
  13. package/template/workflows/specdev/README.md +4 -4
  14. package/template/workflows/specdev/T-tickets/ticket-template.md +1 -0
  15. package/template/workflows/specdev/T-triage/T-triage.md +80 -15
  16. package/template/workflows/specdev/T-triage/capture-protocol.md +89 -0
  17. package/template/workflows/specdev/T-triage/capture-template.md +42 -0
  18. package/template/workflows/specdev/T-triage/close-comment-template.md +42 -0
  19. package/template/workflows/specdev/T-triage/intake-protocol.md +8 -2
  20. package/template/workflows/specdev/T-triage/issue-body-template.md +34 -0
  21. package/template/workflows/specdev/T-triage/issue-record-template.md +27 -0
  22. package/template/workflows/specdev/T-triage/publish-protocol.md +92 -0
  23. package/template/workflows/specdev/T-triage/publish-template.md +49 -0
  24. package/template/workflows/specdev/T-triage/reconcile-protocol.md +3 -3
  25. package/template/workflows/specdev/T-triage/references/classification-map.md +54 -0
  26. package/template/workflows/specdev/T-triage/references/public-projection.md +89 -0
  27. package/template/workflows/specdev/T-triage/tools/capture-status.mjs +125 -0
  28. package/template/workflows/specdev/T-triage/tools/publish-status.mjs +148 -0
  29. package/template/workflows/specdev/T-triage/triage-template.md +12 -1
  30. package/template/workflows/specdev/common/README.md +2 -0
  31. package/template/workflows/specdev/common/rules/artifact-contract.md +6 -4
  32. package/template/workflows/specdev/common/rules/change-completion.md +1 -1
  33. package/template/workflows/specdev/common/rules/evidence-and-verification.md +2 -0
  34. package/template/workflows/specdev/common/rules/workflow-routing.md +3 -1
  35. package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +1 -0
  36. package/template/workflows/specdev/common/schemas/capture.schema.json +15 -0
  37. package/template/workflows/specdev/common/schemas/publish.schema.json +21 -0
  38. package/template/workflows/specdev/common/schemas/ticket.schema.json +11 -0
  39. package/template/workflows/specdev/common/schemas/triage.schema.json +3 -1
  40. package/template/workflows/specdev/common/tools/README.md +7 -0
  41. package/template/workflows/specdev/common/tools/validate-specdev.mjs +304 -6
@@ -66,6 +66,57 @@ const VALID_TICKET_STATUS = new Set([
66
66
  "deviated",
67
67
  "cancelled",
68
68
  ]);
69
+ const VALID_TICKET_KIND = new Set([
70
+ "bug",
71
+ "feature",
72
+ "refactor",
73
+ "investigation",
74
+ "operations",
75
+ "documentation",
76
+ "review",
77
+ ]);
78
+ const VALID_TRIAGE_MODE = new Set(["intake", "reconcile", "publish"]);
79
+ const VALID_PUBLISH_ACTION = new Set([
80
+ "not-requested",
81
+ "pending",
82
+ "published",
83
+ "publish-failed",
84
+ "waived",
85
+ ]);
86
+ const VALID_PUBLISH_ROW_STATE = new Set([
87
+ "planned",
88
+ "created",
89
+ "commented",
90
+ "closed",
91
+ "skipped:cancelled",
92
+ "skipped:excluded",
93
+ "failed",
94
+ ]);
95
+ const FORBIDDEN_PUBLISH_LABELS = new Set([
96
+ "needs-triage",
97
+ "needs-info",
98
+ "ready-for-agent",
99
+ "ready-for-human",
100
+ "wontfix",
101
+ "duplicate",
102
+ "invalid",
103
+ "stale",
104
+ ]);
105
+ const COUNTED_PUBLISH_STATES = new Set(["closed", "created", "commented"]);
106
+ const VALID_CAPTURE_ROW_STATE = new Set([
107
+ "planned",
108
+ "open",
109
+ "skipped:duplicate",
110
+ "intaken",
111
+ "waived",
112
+ "failed",
113
+ ]);
114
+ const FORBIDDEN_CAPTURE_LABELS = new Set([
115
+ ...FORBIDDEN_PUBLISH_LABELS,
116
+ "specdev:published",
117
+ "specdev:change",
118
+ ]);
119
+ const COUNTED_CAPTURE_REMOTE_STATES = new Set(["open", "intaken"]);
69
120
  const VALID_DEPTH = new Set(["lite", "standard", "deep"]);
70
121
  const VALID_RISK = new Set(["low", "medium", "high", "critical"]);
71
122
  const VALID_PLAN_MODES = new Set([
@@ -175,6 +226,8 @@ const STATE_ARTIFACT_BASENAMES = new Set([
175
226
  "status.json",
176
227
  ".status.json",
177
228
  "triage.md",
229
+ "publish.md",
230
+ "capture.md",
178
231
  "diagnosis.md",
179
232
  "source.md",
180
233
  "architecture-review.md",
@@ -973,7 +1026,7 @@ function capabilityChecks(root) {
973
1026
  "triage",
974
1027
  [
975
1028
  join(root, "T-triage", "T-triage.md"),
976
- ["source.md", "intake", "reconcile", "唯一权威", "远程写入为零"],
1029
+ ["source.md", "intake", "reconcile", "publish", "publish.md", "specdev:published", "capture", "capture.md", "specdev:captured", "唯一权威", "远程写入为零"],
977
1030
  ],
978
1031
  ],
979
1032
  [
@@ -1061,7 +1114,7 @@ function capabilityChecks(root) {
1061
1114
  "archive",
1062
1115
  [
1063
1116
  join(root, "A-archive-and-consolidate", "A-archive-and-consolidate.md"),
1064
- ["archive-single", "dry-run", "external_action", "consolidate-from-code", "archive-and-consolidate"],
1117
+ ["archive-single", "dry-run", "external_action", "publish_action", "consolidate-from-code", "archive-and-consolidate"],
1065
1118
  ],
1066
1119
  ],
1067
1120
  [
@@ -1102,6 +1155,11 @@ function capabilityChecks(root) {
1102
1155
  "common/rules/parent-implementation-orchestration.md",
1103
1156
  "common/schemas/implementation-map.schema.json",
1104
1157
  "common/schemas/implementation-plan.schema.json",
1158
+ "common/schemas/capture.schema.json",
1159
+ "T-triage/capture-protocol.md",
1160
+ "T-triage/capture-template.md",
1161
+ "T-triage/issue-record-template.md",
1162
+ "T-triage/tools/capture-status.mjs",
1105
1163
  ]) {
1106
1164
  if (!isFile(join(root, required))) errors.push(`missing architecture/wayfinding contract ${required}`);
1107
1165
  }
@@ -1327,7 +1385,7 @@ function validateTriage(path, expectedChange, errors) {
1327
1385
  errors.push("triage.md: artifact/schema_version must be triage/1");
1328
1386
  }
1329
1387
  if (meta.change !== expectedChange) errors.push("triage.md: change must equal directory name");
1330
- if (!new Set(["intake", "reconcile"]).has(meta.mode)) errors.push(`triage.md: invalid mode ${meta.mode}`);
1388
+ if (!VALID_TRIAGE_MODE.has(meta.mode)) errors.push(`triage.md: invalid mode ${meta.mode}`);
1331
1389
  if (!VALID_RISK.has(meta.risk)) errors.push(`triage.md: invalid risk ${meta.risk}`);
1332
1390
  if (typeof meta.route !== "string" || !meta.route.startsWith("specdev/")) {
1333
1391
  errors.push("triage.md: route must be a specdev work id");
@@ -1338,15 +1396,223 @@ function validateTriage(path, expectedChange, errors) {
1338
1396
  if (!new Set(["not-applicable", "pending-close", "closed", "close-failed", "waived"]).has(meta.external_action)) {
1339
1397
  errors.push(`triage.md: invalid external_action ${meta.external_action}`);
1340
1398
  }
1399
+ const publishAction = meta.publish_action == null || meta.publish_action === ""
1400
+ ? "not-requested"
1401
+ : meta.publish_action;
1402
+ if (!VALID_PUBLISH_ACTION.has(publishAction)) {
1403
+ errors.push(`triage.md: invalid publish_action ${meta.publish_action}`);
1404
+ }
1405
+ meta.publish_action = publishAction;
1341
1406
  if (!String(meta.source ?? "").includes("/source.md</Path>")) {
1342
1407
  errors.push("triage.md: source must reference the local source.md artifact");
1343
1408
  }
1344
1409
  for (const heading of ["## 当前判定", "## 未知项", "## 路由", "## 外部动作"]) {
1345
1410
  if (!body.includes(heading)) errors.push(`triage.md: missing '${heading}'`);
1346
1411
  }
1412
+ if (publishAction !== "not-requested" && !body.includes("## 发布投影")) {
1413
+ errors.push("triage.md: missing '## 发布投影'");
1414
+ }
1347
1415
  return { path, meta, body };
1348
1416
  }
1349
1417
 
1418
+ function parsePublishLedger(body) {
1419
+ const rows = [];
1420
+ for (const line of body.split(/\r?\n/)) {
1421
+ if (!/^\|/.test(line) || /^\|\s*-+/.test(line) || /^\|\s*ticket\s*\|/i.test(line)) continue;
1422
+ const cells = line.split("|").map((cell) => cell.trim());
1423
+ const inner = cells.slice(1, cells.length - 1);
1424
+ if (inner.length < 8) continue;
1425
+ rows.push({
1426
+ ticket: inner[0],
1427
+ kind: inner[1],
1428
+ labels: inner[2],
1429
+ number: inner[3],
1430
+ url: inner[4],
1431
+ marker: inner[5],
1432
+ sha256: inner[6],
1433
+ state: inner[7],
1434
+ });
1435
+ }
1436
+ return rows;
1437
+ }
1438
+
1439
+ function validatePublish(path, expectedChange, triage, errors) {
1440
+ if (!isFile(path)) {
1441
+ errors.push("missing publish.md ledger");
1442
+ return null;
1443
+ }
1444
+ const { meta, body } = parseFrontmatter(path);
1445
+ const required = [
1446
+ "schema_version",
1447
+ "artifact",
1448
+ "change",
1449
+ "mode",
1450
+ "repo",
1451
+ "publish_action",
1452
+ "include_cancelled",
1453
+ "origin",
1454
+ "updated_at",
1455
+ ];
1456
+ const missing = required.filter((key) => !(key in meta));
1457
+ if (missing.length) errors.push(`publish.md: missing keys ${JSON.stringify(missing)}`);
1458
+ if (meta.schema_version !== 1 || meta.artifact !== "publish") {
1459
+ errors.push("publish.md: artifact/schema_version must be publish/1");
1460
+ }
1461
+ if (meta.change !== expectedChange) errors.push("publish.md: change must equal directory name");
1462
+ if (meta.mode !== "publish") errors.push(`publish.md: invalid mode ${meta.mode}`);
1463
+ if (!new Set(["pending", "published", "publish-failed", "waived"]).has(meta.publish_action)) {
1464
+ errors.push(`publish.md: invalid publish_action ${meta.publish_action}`);
1465
+ }
1466
+ if (!new Set(["local", "intake"]).has(meta.origin)) {
1467
+ errors.push(`publish.md: invalid origin ${meta.origin}`);
1468
+ }
1469
+ if (typeof meta.repo !== "string" || !/.+\/.+/.test(meta.repo)) {
1470
+ errors.push("publish.md: repo must be owner/repo");
1471
+ }
1472
+ for (const heading of ["## 发布计划", "## 账本", "## 计数", "## 重试"]) {
1473
+ if (!body.includes(heading)) errors.push(`publish.md: missing '${heading}'`);
1474
+ }
1475
+ const rows = parsePublishLedger(body);
1476
+ if (!rows.length) errors.push("publish.md: ledger table has no ticket rows");
1477
+ const mixed = triage && triage.meta.classification === "mixed";
1478
+ for (const row of rows) {
1479
+ if (!/^T-\d{2,}$/.test(row.ticket)) {
1480
+ errors.push(`publish.md: invalid ticket id ${row.ticket}`);
1481
+ }
1482
+ if (!VALID_PUBLISH_ROW_STATE.has(row.state)) {
1483
+ errors.push(`publish.md: ${row.ticket}: invalid state ${row.state}`);
1484
+ }
1485
+ const labels = row.labels.split(",").map((item) => item.trim()).filter(Boolean);
1486
+ for (const label of labels) {
1487
+ if (FORBIDDEN_PUBLISH_LABELS.has(label)) {
1488
+ errors.push(`publish.md: ${row.ticket}: forbidden label ${label}`);
1489
+ }
1490
+ }
1491
+ const skipped = row.state.startsWith("skipped:");
1492
+ if (!skipped && !VALID_TICKET_KIND.has(row.kind)) {
1493
+ errors.push(`publish.md: ${row.ticket}: invalid kind ${row.kind}`);
1494
+ }
1495
+ if (mixed && !skipped && !VALID_TICKET_KIND.has(row.kind)) {
1496
+ errors.push(`publish.md: mixed change requires kind on ${row.ticket}`);
1497
+ }
1498
+ if (COUNTED_PUBLISH_STATES.has(row.state) || row.state === "closed") {
1499
+ if (!/^\d+$/.test(row.number)) {
1500
+ errors.push(`publish.md: ${row.ticket}: ${row.state} row requires issue number`);
1501
+ }
1502
+ if (!String(row.url).startsWith("https://github.com/")) {
1503
+ errors.push(`publish.md: ${row.ticket}: ${row.state} row requires GitHub url`);
1504
+ }
1505
+ const expectedMarker = `specdev:${expectedChange}:${row.ticket}:published`;
1506
+ if (row.marker !== expectedMarker) {
1507
+ errors.push(`publish.md: ${row.ticket}: marker must be ${expectedMarker}`);
1508
+ }
1509
+ }
1510
+ if (row.state === "closed" || row.state === "created" || row.state === "commented") {
1511
+ if (!labels.includes("specdev:published")) {
1512
+ errors.push(`publish.md: ${row.ticket}: missing specdev:published`);
1513
+ }
1514
+ const originLabel = meta.origin === "intake" ? "origin:intake" : "origin:local";
1515
+ if (!labels.includes(originLabel)) {
1516
+ errors.push(`publish.md: ${row.ticket}: missing ${originLabel}`);
1517
+ }
1518
+ }
1519
+ }
1520
+ return { path, meta, body, rows };
1521
+ }
1522
+
1523
+ function parseCaptureLedger(body) {
1524
+ const rows = [];
1525
+ for (const line of body.split(/\r?\n/)) {
1526
+ if (!/^\|/.test(line) || /^\|\s*-+/.test(line) || /^\|\s*id\s*\|/i.test(line)) continue;
1527
+ const cells = line.split("|").map((cell) => cell.trim());
1528
+ const inner = cells.slice(1, cells.length - 1);
1529
+ if (inner.length < 9) continue;
1530
+ rows.push({
1531
+ id: inner[0],
1532
+ kind: inner[1],
1533
+ title: inner[2],
1534
+ labels: inner[3],
1535
+ number: inner[4],
1536
+ url: inner[5],
1537
+ marker: inner[6],
1538
+ sha256: inner[7],
1539
+ state: inner[8],
1540
+ });
1541
+ }
1542
+ return rows;
1543
+ }
1544
+
1545
+ function validateCapture(path, errors) {
1546
+ if (!isFile(path)) {
1547
+ errors.push("missing capture.md ledger");
1548
+ return null;
1549
+ }
1550
+ const { meta, body } = parseFrontmatter(path);
1551
+ const required = [
1552
+ "schema_version",
1553
+ "artifact",
1554
+ "mode",
1555
+ "repo",
1556
+ "updated_at",
1557
+ ];
1558
+ const missing = required.filter((key) => !(key in meta));
1559
+ if (missing.length) errors.push(`capture.md: missing keys ${JSON.stringify(missing)}`);
1560
+ if (meta.schema_version !== 1 || meta.artifact !== "capture-index") {
1561
+ errors.push("capture.md: artifact/schema_version must be capture-index/1");
1562
+ }
1563
+ if (meta.mode !== "capture") errors.push(`capture.md: invalid mode ${meta.mode}`);
1564
+ if (typeof meta.repo !== "string" || !/.+\/.+/.test(meta.repo)) {
1565
+ errors.push("capture.md: repo must be owner/repo");
1566
+ }
1567
+ for (const heading of ["## 捕获计划", "## 账本", "## 计数", "## 重试"]) {
1568
+ if (!body.includes(heading)) errors.push(`capture.md: missing '${heading}'`);
1569
+ }
1570
+ const rows = parseCaptureLedger(body);
1571
+ for (const row of rows) {
1572
+ if (!CHANGE_NAME.test(row.id)) {
1573
+ errors.push(`capture.md: invalid id ${row.id}`);
1574
+ }
1575
+ if (!VALID_CAPTURE_ROW_STATE.has(row.state)) {
1576
+ errors.push(`capture.md: ${row.id}: invalid state ${row.state}`);
1577
+ }
1578
+ const labels = row.labels.split(",").map((item) => item.trim()).filter(Boolean);
1579
+ for (const label of labels) {
1580
+ if (FORBIDDEN_CAPTURE_LABELS.has(label)) {
1581
+ errors.push(`capture.md: ${row.id}: forbidden label ${label}`);
1582
+ }
1583
+ }
1584
+ const skipped = row.state.startsWith("skipped:") || row.state === "waived" || row.state === "planned" || row.state === "failed";
1585
+ if (!skipped && !VALID_TICKET_KIND.has(row.kind)) {
1586
+ errors.push(`capture.md: ${row.id}: invalid kind ${row.kind}`);
1587
+ }
1588
+ if (COUNTED_CAPTURE_REMOTE_STATES.has(row.state)) {
1589
+ if (!VALID_TICKET_KIND.has(row.kind)) {
1590
+ errors.push(`capture.md: ${row.id}: invalid kind ${row.kind}`);
1591
+ }
1592
+ if (!/^\d+$/.test(row.number)) {
1593
+ errors.push(`capture.md: ${row.id}: ${row.state} row requires issue number`);
1594
+ }
1595
+ if (!String(row.url).startsWith("https://github.com/")) {
1596
+ errors.push(`capture.md: ${row.id}: ${row.state} row requires GitHub url`);
1597
+ }
1598
+ const expectedMarker = `specdev:capture:${row.id}`;
1599
+ if (row.marker !== expectedMarker) {
1600
+ errors.push(`capture.md: ${row.id}: marker must be ${expectedMarker}`);
1601
+ }
1602
+ if (!labels.includes("specdev:captured")) {
1603
+ errors.push(`capture.md: ${row.id}: missing specdev:captured`);
1604
+ }
1605
+ if (!labels.includes("origin:local")) {
1606
+ errors.push(`capture.md: ${row.id}: missing origin:local`);
1607
+ }
1608
+ if (labels.includes("origin:intake")) {
1609
+ errors.push(`capture.md: ${row.id}: capture origin must be local`);
1610
+ }
1611
+ }
1612
+ }
1613
+ return { path, meta, body, rows };
1614
+ }
1615
+
1350
1616
  function validateDiagnosis(path, expectedChange, errors) {
1351
1617
  if (!isFile(path)) {
1352
1618
  errors.push("missing diagnosis artifact");
@@ -2048,6 +2314,9 @@ function validateTicket(path, errors) {
2048
2314
  const ticketId = String(meta.id);
2049
2315
  if (!/^T-\d{2,}$/.test(ticketId)) errors.push(`${basename(path)}: invalid Ticket id ${ticketId}`);
2050
2316
  if (!VALID_TICKET_STATUS.has(meta.status)) errors.push(`${basename(path)}: invalid status ${meta.status}`);
2317
+ if (meta.kind != null && meta.kind !== "" && !VALID_TICKET_KIND.has(meta.kind)) {
2318
+ errors.push(`${basename(path)}: invalid kind ${meta.kind}`);
2319
+ }
2051
2320
  if (!VALID_DEPTH.has(meta.planning_depth)) {
2052
2321
  errors.push(`${basename(path)}: invalid planning_depth ${meta.planning_depth}`);
2053
2322
  }
@@ -3001,6 +3270,9 @@ function validateChange(change, stage = null, repoRoot = null) {
3001
3270
  if (isFile(join(change, "source-issue.md"))) {
3002
3271
  errors.push("obsolete source-issue.md is forbidden; use source.md without compatibility fallback");
3003
3272
  }
3273
+ if (isFile(join(change, "capture.md"))) {
3274
+ errors.push("capture.md is workspace-owned at specdev/capture.md; change-level copies are forbidden");
3275
+ }
3004
3276
  const sourceRequired = stage === "triage";
3005
3277
  const sourcePath = join(change, "source.md");
3006
3278
  const source = isFile(sourcePath) || sourceRequired
@@ -3010,6 +3282,13 @@ function validateChange(change, stage = null, repoRoot = null) {
3010
3282
  const triage = isFile(triagePath) || sourceRequired
3011
3283
  ? validateTriage(triagePath, basename(change), errors)
3012
3284
  : null;
3285
+ const publishPath = join(change, "publish.md");
3286
+ const publishRequired = Boolean(
3287
+ triage && new Set(["pending", "published", "publish-failed", "waived"]).has(triage.meta.publish_action),
3288
+ );
3289
+ if (isFile(publishPath) || publishRequired) {
3290
+ validatePublish(publishPath, basename(change), triage, errors);
3291
+ }
3013
3292
  const diagnosisPath = join(change, "diagnosis.md");
3014
3293
  if (isFile(diagnosisPath) || stage === "diagnosis") {
3015
3294
  validateDiagnosis(diagnosisPath, basename(change), errors);
@@ -3298,6 +3577,13 @@ function validateChange(change, stage = null, repoRoot = null) {
3298
3577
  ) {
3299
3578
  errors.push(`complete stage cannot archive external_action=${triage.meta.external_action}`);
3300
3579
  }
3580
+ if (
3581
+ stage === "complete" &&
3582
+ triage &&
3583
+ new Set(["pending", "publish-failed"]).has(triage.meta.publish_action)
3584
+ ) {
3585
+ errors.push(`complete stage cannot archive publish_action=${triage.meta.publish_action}`);
3586
+ }
3301
3587
 
3302
3588
  for (const path of walk(change).filter((item) => isFile(item) && extname(item) === ".md")) {
3303
3589
  const text = readText(path);
@@ -3325,7 +3611,7 @@ function printResults(errors, warnings) {
3325
3611
  }
3326
3612
 
3327
3613
  function usage() {
3328
- console.error("Usage: node validate-specdev.mjs [--stage <stage>] [--repo <project-root>] <change-directory> | --self-check");
3614
+ console.error("Usage: node validate-specdev.mjs [--stage <stage>] [--repo <project-root>] <change-directory> | --self-check | --capture <capture.md>");
3329
3615
  return 2;
3330
3616
  }
3331
3617
 
@@ -3333,6 +3619,7 @@ function main(argv) {
3333
3619
  let selfCheckRequested = false;
3334
3620
  let stage = null;
3335
3621
  let repoRoot = null;
3622
+ let capturePath = null;
3336
3623
  const positional = [];
3337
3624
  for (let index = 0; index < argv.length; index += 1) {
3338
3625
  const arg = argv[index];
@@ -3348,6 +3635,11 @@ function main(argv) {
3348
3635
  index += 1;
3349
3636
  } else if (arg.startsWith("--repo=")) {
3350
3637
  repoRoot = arg.slice("--repo=".length);
3638
+ } else if (arg === "--capture") {
3639
+ capturePath = argv[index + 1] ?? null;
3640
+ index += 1;
3641
+ } else if (arg.startsWith("--capture=")) {
3642
+ capturePath = arg.slice("--capture=".length);
3351
3643
  } else if (arg.startsWith("--")) {
3352
3644
  return usage();
3353
3645
  } else {
@@ -3356,12 +3648,18 @@ function main(argv) {
3356
3648
  }
3357
3649
  if (stage !== null && !VALID_STAGES.has(stage)) return usage();
3358
3650
  if (selfCheckRequested) {
3359
- if (positional.length || stage !== null) return usage();
3651
+ if (positional.length || stage !== null || capturePath) return usage();
3360
3652
  const scriptDirectory = dirname(fileURLToPath(import.meta.url));
3361
3653
  const root = resolve(scriptDirectory, "..", "..");
3362
3654
  const result = selfCheck(root);
3363
3655
  return printResults(result.errors, result.warnings);
3364
3656
  }
3657
+ if (capturePath) {
3658
+ if (positional.length || stage !== null || repoRoot) return usage();
3659
+ const errors = [];
3660
+ validateCapture(resolve(capturePath), errors);
3661
+ return printResults(errors, []);
3662
+ }
3365
3663
  if (positional.length === 1) {
3366
3664
  const result = validateChange(resolve(positional[0]), stage, repoRoot);
3367
3665
  return printResults(result.errors, result.warnings);
@@ -3369,7 +3667,7 @@ function main(argv) {
3369
3667
  return usage();
3370
3668
  }
3371
3669
 
3372
- export { parseFrontmatter, validateChange, validateTicket, validateMap, pathsOverlap, findCycle };
3670
+ export { parseFrontmatter, validateChange, validateTicket, validateMap, pathsOverlap, findCycle, validateCapture };
3373
3671
 
3374
3672
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
3375
3673
  try { process.exitCode = main(process.argv.slice(2)); }