@namewta/speculo 2.0.1 → 2.0.3

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 (95) hide show
  1. package/README.md +42 -29
  2. package/dist/src/agent-skills.d.ts +43 -17
  3. package/dist/src/agent-skills.js +95 -28
  4. package/dist/src/agent-skills.js.map +1 -1
  5. package/dist/src/cli.js +43 -27
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/index.d.ts +20 -6
  8. package/dist/src/index.js +36 -11
  9. package/dist/src/index.js.map +1 -1
  10. package/dist/src/install-ui.d.ts +61 -0
  11. package/dist/src/install-ui.js +268 -0
  12. package/dist/src/install-ui.js.map +1 -0
  13. package/dist/src/interaction.d.ts +5 -0
  14. package/dist/src/interaction.js +10 -0
  15. package/dist/src/interaction.js.map +1 -0
  16. package/dist/src/transaction.d.ts +2 -0
  17. package/dist/src/transaction.js +16 -0
  18. package/dist/src/transaction.js.map +1 -1
  19. package/dist/src/workflows.js +2 -2
  20. package/dist/src/workflows.js.map +1 -1
  21. package/package.json +2 -2
  22. package/template/canonical/canonical-specdev-goal-plan.md +1747 -662
  23. package/template/canonical/canonical-specdev-grill-with-docs.md +1065 -233
  24. package/template/canonical/canonical-specdev-spec.md +1066 -278
  25. package/template/canonical/canonical-specdev-tickets.md +926 -184
  26. package/template/canonical/canonical-specdev-wayfinder.md +1060 -222
  27. package/template/commands/handoff/COMMAND.md +10 -0
  28. package/template/references/coordination/coordination-contract.md +12 -10
  29. package/template/references/coordination/modes/cli.md +6 -6
  30. package/template/references/coordination/modes/herdr.md +18 -3
  31. package/template/references/coordination/modes/orca.md +32 -9
  32. package/template/references/coordination/result-and-recovery.md +7 -4
  33. package/template/references/coordination/session-files.md +93 -0
  34. package/template/references/coordination/task-brief.md +10 -8
  35. package/template/workflows/specdev/SKILL.md +2 -2
  36. package/template/workflows/specdev/common/rules/adr-format.md +5 -11
  37. package/template/workflows/specdev/common/rules/artifact-contract.md +2 -2
  38. package/template/workflows/specdev/common/rules/change-completion.md +7 -2
  39. package/template/workflows/specdev/common/rules/domain-runtime.md +2 -2
  40. package/template/workflows/specdev/common/rules/evidence-and-verification.md +2 -2
  41. package/template/workflows/specdev/common/rules/execution-policy.md +28 -0
  42. package/template/workflows/specdev/common/rules/operating-governance.md +2 -2
  43. package/template/workflows/specdev/common/rules/path-ownership.md +3 -3
  44. package/template/workflows/specdev/common/rules/path-reference-contract.md +2 -0
  45. package/template/workflows/specdev/common/rules/planning-principles.md +1 -1
  46. package/template/workflows/specdev/common/rules/readiness-and-depth.md +5 -5
  47. package/template/workflows/specdev/common/rules/workflow-routing.md +3 -3
  48. package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +2 -0
  49. package/template/workflows/specdev/common/skills/code-review/SKILL.md +12 -7
  50. package/template/workflows/specdev/common/skills/code-review/references/result-reuse.md +3 -3
  51. package/template/workflows/specdev/common/skills/code-review/references/review-evidence.md +44 -0
  52. package/template/workflows/specdev/common/skills/code-review/references/reviewer-contracts.md +17 -5
  53. package/template/workflows/specdev/common/skills/code-review/references/working-tree-review.md +15 -0
  54. package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +11 -11
  55. package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +20 -12
  56. package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +14 -12
  57. package/template/workflows/specdev/common/tools/README.md +9 -1
  58. package/template/workflows/specdev/common/tools/change-artifacts.mjs +8 -11
  59. package/template/workflows/specdev/common/tools/plan-contract.mjs +13 -6
  60. package/template/workflows/specdev/common/tools/review-evidence.mjs +117 -0
  61. package/template/workflows/specdev/common/tools/review-scope.mjs +90 -5
  62. package/template/workflows/specdev/common/tools/tests/engineering.test.mjs +2 -2
  63. package/template/workflows/specdev/common/tools/tests/review-evidence.test.mjs +74 -0
  64. package/template/workflows/specdev/common/tools/tests/review-fixture.mjs +31 -0
  65. package/template/workflows/specdev/common/tools/tests/review-working-tree.test.mjs +59 -0
  66. package/template/workflows/specdev/common/tools/validate-specdev.mjs +210 -114
  67. package/template/workflows/specdev/manifest.json +4 -6
  68. package/template/workflows/specdev/specdev-c-code-review/SKILL.md +6 -6
  69. package/template/workflows/specdev/specdev-c-code-review/assets/code-review-template.md +5 -1
  70. package/template/workflows/specdev/specdev-c-code-review/references/source-files-review.md +2 -2
  71. package/template/workflows/specdev/specdev-i-implement/SKILL.md +6 -6
  72. package/template/workflows/specdev/specdev-i-implement/assets/evidence-template.md +16 -10
  73. package/template/workflows/specdev/specdev-i-implement/references/execution-preflight.md +8 -4
  74. package/template/workflows/specdev/specdev-i-implement/references/implementation-procedure.md +22 -18
  75. package/template/workflows/specdev/specdev-i-init-setup/SKILL.md +1 -1
  76. package/template/workflows/specdev/specdev-l-learn/SKILL.md +35 -0
  77. package/template/workflows/specdev/specdev-l-learn/references/learning-change.md +23 -0
  78. package/template/workflows/specdev/specdev-l-learn/references/lesson-format.md +61 -0
  79. package/template/workflows/specdev/specdev-p-goal-plan/SKILL.md +3 -3
  80. package/template/workflows/specdev/specdev-p-goal-plan/assets/goal-plan-template.md +4 -0
  81. package/template/workflows/specdev/specdev-p-goal-plan/assets/lightweight-goal-plan-template.md +2 -0
  82. package/template/workflows/specdev/specdev-p-goal-plan/references/completion-control.md +3 -0
  83. package/template/workflows/specdev/specdev-p-goal-plan/references/orchestration-protocol.md +1 -1
  84. package/template/workflows/specdev/specdev-p-goal-plan/references/planning-modes.md +2 -2
  85. package/template/workflows/specdev/specdev-p-goal-plan/references/single-change-plan.md +2 -2
  86. package/template/workflows/specdev/specdev-r-retro/SKILL.md +1 -1
  87. package/template/workflows/specdev/specdev-s-spec/SKILL.md +3 -3
  88. package/template/workflows/specdev/specdev-s-spec/assets/spec-template.md +15 -65
  89. package/template/workflows/specdev/specdev-t-tickets/SKILL.md +1 -1
  90. package/template/workflows/specdev/specdev-t-tickets/assets/ticket-template.md +19 -103
  91. package/template/workflows/specdev/specdev-t-tickets/assets/tickets-map-template.md +3 -1
  92. package/template/workflows/specdev/specdev-t-tickets/references/planning-procedure.md +5 -19
  93. package/template/workflows/specdev/specdev-t-tickets/references/ticket-readiness.md +5 -5
  94. package/template/workflows/specdev/specdev-l-learn-change/SKILL.md +0 -33
  95. package/template/workflows/specdev/specdev-l-learn-change/references/lesson-format.md +0 -51
@@ -28,9 +28,10 @@ import {
28
28
  validateGoalMap, validateInitiative, resolveProjectSource, readChangeExecution, readSpecification, resolveToolContext, validateExecutionPolicy, canonicalToolPath, resolveSnapshotContext, readLightweightGoal, validateAcceptanceEvidence,
29
29
  } from "./plan-contract.mjs";
30
30
 
31
- import { validateChangeClosure } from "./change-artifacts.mjs";
31
+ import { validateChangeClosure, readChangeOwner } from "./change-artifacts.mjs";
32
32
  import { validateRecord, validateRecords } from "./delivery-artifacts.mjs";
33
33
  import { reviewScopeCLI } from "./review-scope.mjs";
34
+ import { hasIndependentReview, validateIndependentReview } from "./review-evidence.mjs";
34
35
  import { ENGINEERING_KINDS, validateEngineeringArtifacts, validateEngineeringPackage } from "./engineering-evidence.mjs";
35
36
 
36
37
  const DOMAIN_SCHEMA_VERSION = 3;
@@ -55,7 +56,7 @@ const EXPECTED_WORKS = new Set([
55
56
  "specdev-g-grill-with-docs",
56
57
  "specdev-i-implement",
57
58
  "specdev-i-init-setup",
58
- "specdev-l-learn-change",
59
+ "specdev-l-learn",
59
60
  "specdev-p-goal-plan",
60
61
  "specdev-p-prototype",
61
62
  "specdev-r-review-architecture",
@@ -172,7 +173,7 @@ const VALID_STAGES = new Set([
172
173
  "tickets",
173
174
  "goal-plan",
174
175
  "implement",
175
- "learn-change",
176
+ "learn",
176
177
  "retro",
177
178
  "pr-delivery",
178
179
  "review",
@@ -202,15 +203,6 @@ const REQUIRED_TICKET_KEYS = new Set([
202
203
  "shared_paths",
203
204
  "shared_path_owners",
204
205
  ]);
205
- const REQUIRED_READY_TICKET_SECTIONS = new Set([
206
- "## 1. 战略与来源",
207
- "## 2. 决策状态",
208
- "## 3. 范围边界",
209
- "## 4. 要构建什么",
210
- "## 7. 路径访问契约",
211
- "## 8. 验证矩阵",
212
- "## 10. 验收标准",
213
- ]);
214
206
  const REQUIRED_GOAL_PLAN_SECTIONS = new Set([
215
207
  "## 1. Outcome and Authority",
216
208
  "## 2. Execution Graph",
@@ -334,6 +326,20 @@ function sectionBody(body, heading) {
334
326
  return (next ? tail.slice(0, next.index) : tail).trim();
335
327
  }
336
328
 
329
+ // Accept the published long templates and the concise headings without making
330
+ // numbering, repeated instructions or a fixed count of implementation steps a gate.
331
+ function semanticSection(body, names) {
332
+ const lines = runtime.markdownBodyLines(body);
333
+ const matches = lines.filter(line => {
334
+ const match = /^(#{2,3})\s+(?:\d+\.\s*)?(.+)$/.exec(line.text);
335
+ return match && names.includes(match[2]);
336
+ });
337
+ if (matches.length !== 1) return "";
338
+ const start = matches[0], level = /^#+/.exec(start.text)[0].length;
339
+ const end = lines.find(line => line.line > start.line && /^#{1,3}\s/.test(line.text) && /^#+/.exec(line.text)[0].length <= level);
340
+ return body.split(/\r?\n/).slice(start.line, end ? end.line-1 : undefined).join("\n").trim();
341
+ }
342
+
337
343
  function stripPathTag(value) {
338
344
  const match = /^<Path>([^<]+)<\/Path>$/.exec(String(value).trim());
339
345
  return match ? match[1].trim() : null;
@@ -874,10 +880,10 @@ function capabilityChecks(root) {
874
880
  ],
875
881
  ],
876
882
  [
877
- "learn-change",
883
+ "learn",
878
884
  [
879
- join(root, "specdev-l-learn-change", "SKILL.md"),
880
- ["开发完成后", "零专业背景", "$ARGUMENTS", "ASCII", "learning/index.md", "{number}_{topic}.md", "{roots.state}/learning/"],
885
+ join(root, "specdev-l-learn", "SKILL.md"),
886
+ ["需求参考", "零专业背景", "$ARGUMENTS", "ASCII", "learning-change.md", "learning/index.md", "{number}_{topic}.md", "{roots.state}/learning/"],
881
887
  ],
882
888
  ],
883
889
  [
@@ -1460,12 +1466,13 @@ function validateDiagnosis(path, expectedChange, errors) {
1460
1466
  return { path, meta, body };
1461
1467
  }
1462
1468
 
1463
- function validateReviews(change, required, errors) {
1469
+ function validateReviews(change, required, errors, repoRoot = null) {
1464
1470
  const root = join(change, "reviews");
1465
1471
  const paths = isDirectory(root)
1466
1472
  ? readdirSync(root).filter((name) => /^CR-\d{3,}\.md$/.test(name)).sort().map((name) => join(root, name))
1467
1473
  : [];
1468
1474
  if (required && !paths.length) errors.push("review stage requires a CR-###.md report");
1475
+ const latest = [...paths].sort((a,b) => Number(basename(a).match(/\d+/)[0])-Number(basename(b).match(/\d+/)[0])).at(-1);
1469
1476
  for (const path of paths) {
1470
1477
  const { meta, body } = parseFrontmatter(path);
1471
1478
  if (meta.schema_version !== 1 || meta.artifact !== "code-review") {
@@ -1483,6 +1490,12 @@ function validateReviews(change, required, errors) {
1483
1490
  for (const heading of ["## Fixed Input", "## 标准", "## 规范", "## Summary"]) {
1484
1491
  if (!body.includes(heading)) errors.push(`${basename(path)}: missing '${heading}'`);
1485
1492
  }
1493
+ // Historical CRs remain readable. Only the current review or an explicitly
1494
+ // supplied new record is checked against the independent execution contract.
1495
+ if (required && path === latest || hasIndependentReview(path)) {
1496
+ errors.push(...validateIndependentReview(change, toPosix(relative(change,path)), {repoRoot,requirePassed:meta.status === 'approved',expectedResults:{standards:meta.standards_result,specification:meta.specification_result},expectedHead:meta.head,checkCurrent:required && path === latest}));
1497
+ if (meta.status === 'approved' && (meta.standards_result !== 'pass' || meta.specification_result !== 'pass')) errors.push(`${basename(path)}: approved requires both review axes to pass`);
1498
+ }
1486
1499
  }
1487
1500
  return paths;
1488
1501
  }
@@ -1586,7 +1599,7 @@ function validateChangeLearning(change, required, errors) {
1586
1599
  const diagramName = /^\d{2,}_[\p{L}\p{N}_-]+\.md$/u;
1587
1600
 
1588
1601
  if (!existsSync(learningRoot)) {
1589
- if (required) errors.push("learn-change stage requires learning/index.md");
1602
+ if (required) errors.push("learn stage requires learning/index.md");
1590
1603
  return null;
1591
1604
  }
1592
1605
  if (lstatSync(learningRoot).isSymbolicLink() || !isDirectory(learningRoot)) {
@@ -1595,22 +1608,27 @@ function validateChangeLearning(change, required, errors) {
1595
1608
  }
1596
1609
 
1597
1610
  const diagramFiles = [];
1611
+ const diagramNumbers = new Set();
1598
1612
  for (const entry of readdirSync(learningRoot, { withFileTypes: true })) {
1599
1613
  if (entry.isSymbolicLink()) {
1600
1614
  errors.push(`learning/${entry.name}: learning artifacts must not be symlinks`);
1601
1615
  } else if (entry.isFile() && diagramName.test(entry.name)) {
1602
1616
  diagramFiles.push(entry.name);
1617
+ const number = Number(entry.name.split("_")[0]);
1618
+ if (diagramNumbers.has(number)) errors.push(`learning/${entry.name}: duplicate diagram number ${number}`);
1619
+ diagramNumbers.add(number);
1603
1620
  }
1604
1621
  }
1605
1622
  diagramFiles.sort();
1606
1623
 
1607
1624
  if (!isFile(indexPath)) {
1608
- errors.push("learn-change stage requires learning/index.md");
1625
+ errors.push("learn stage requires learning/index.md");
1609
1626
  return null;
1610
1627
  }
1611
1628
 
1612
1629
  const index = readText(indexPath);
1613
- if (!index.includes("# Change 学习图解索引")) {
1630
+ // Historical titles remain readable without rewriting existing learning records.
1631
+ if (!/^# (?:Change )?学习图解索引\s*$/m.test(index)) {
1614
1632
  errors.push("learning/index.md: missing index heading");
1615
1633
  }
1616
1634
  const entries = Array.from(
@@ -1687,18 +1705,12 @@ function validateSpec(path, errors, warnings) {
1687
1705
  const sources = requireList(meta, "sources", label, errors);
1688
1706
  if (!sources.length) errors.push(`${label}: sources must contain at least one source`);
1689
1707
  if (meta.ready_for_tickets === true) {
1690
- const unresolved = sectionBody(body, "### 未决问题");
1708
+ const unresolved = semanticSection(body, ["未决问题"]);
1691
1709
  if (unresolved && !/^无[。.]?$/.test(unresolved.trim())) {
1692
1710
  errors.push(`${label}: ready Spec has unresolved high-impact questions`);
1693
1711
  }
1694
- for (const heading of [
1695
- "## 1. 问题与目标",
1696
- "## 2. 解决方案与外部行为",
1697
- "## 4. 验收合同",
1698
- "## 5. 范围",
1699
- "## 9. 验证策略",
1700
- ]) {
1701
- if (!body.includes(heading)) errors.push(`${label}: ready Spec missing '${heading}'`);
1712
+ for (const heading of ["问题与目标", "解决方案与外部行为", "验收合同", "范围", "验证策略"]) {
1713
+ if (!semanticSection(body,[heading])) errors.push(`${label}: ready Spec missing '${heading}' content`);
1702
1714
  }
1703
1715
  }
1704
1716
  return { path, meta, body };
@@ -2176,32 +2188,22 @@ function validateTicket(path, errors) {
2176
2188
  if (!new Set(["ready", "in_progress", "review", "done"]).has(meta.status)) {
2177
2189
  errors.push(`${basename(path)}: ready=true conflicts with status=${meta.status}`);
2178
2190
  }
2179
- for (const heading of REQUIRED_READY_TICKET_SECTIONS) {
2180
- if (!body.includes(heading)) errors.push(`${basename(path)}: Ready Ticket missing '${heading}'`);
2191
+ for (const names of [["目标与来源","战略与来源"],["行为与范围","要构建什么"],["验收标准"],["验证方法","验证矩阵"]]) {
2192
+ if (!semanticSection(body,names)) errors.push(`${basename(path)}: Ready Ticket missing '${names[0]}' content`);
2181
2193
  }
2182
- const unresolved = sectionBody(body, "### 未决问题");
2194
+ const unresolved = semanticSection(body, ["未决问题"]);
2183
2195
  if (unresolved && !/^无[。.]?$/.test(unresolved.trim())) {
2184
2196
  errors.push(`${basename(path)}: Ready Ticket has unresolved questions`);
2185
2197
  }
2186
2198
  if (!lists.writable_paths.length) errors.push(`${basename(path)}: Ready implementation Ticket requires writable_paths`);
2187
- if (new Set(["standard", "deep"]).has(meta.planning_depth)) {
2188
- for (const heading of ["## 5. 实现契约", "## 6. 执行路线"]) {
2189
- if (!body.includes(heading)) {
2190
- errors.push(`${basename(path)}: ${meta.planning_depth} Ticket missing '${heading}'`);
2191
- }
2192
- }
2193
- }
2194
- if (meta.planning_depth === "deep" && !body.includes("## 9. 发布、迁移与恢复")) {
2195
- errors.push(`${basename(path)}: Deep Ticket missing release/migration/recovery`);
2196
- }
2197
- const verification = sectionBody(body, "## 8. 验证矩阵");
2199
+ const verification = semanticSection(body, ["验证方法","验证矩阵"]);
2198
2200
  if (!verification || !verification.includes("|")) {
2199
2201
  errors.push(`${basename(path)}: Ready Ticket has no usable verification matrix`);
2200
2202
  }
2201
2203
  if (!verification.includes("E2E disposition") || !/(current-workspace|source-worktree|parent-candidate|direct-parent)/.test(verification)) {
2202
2204
  errors.push(`${basename(path)}: Ready Ticket must define E2E disposition and an explicit execution environment`);
2203
2205
  }
2204
- const acceptance = sectionBody(body, "## 10. 验收标准").toLowerCase();
2206
+ const acceptance = semanticSection(body, ["验收标准"]).toLowerCase();
2205
2207
  if (!acceptance.includes("- [ ]") && !acceptance.includes("- [x]")) {
2206
2208
  errors.push(`${basename(path)}: Ready Ticket has no acceptance checklist`);
2207
2209
  }
@@ -2223,8 +2225,10 @@ function validateChangeExecution(change, errors, context) {
2223
2225
  errors.push(`${basename(path)}: invalid worktree entry`);
2224
2226
  continue;
2225
2227
  }
2228
+ const direct = worktree.purpose === "direct-spec";
2229
+ const unit = direct ? "direct-spec" : worktree.ticket_id;
2226
2230
  const requiredWorktreeKeys = [
2227
- "ticket_id",
2231
+ ...(direct ? ["purpose", "change", "integration_attempt_limit"] : ["ticket_id"]),
2228
2232
  "owner",
2229
2233
  "implementation_owner",
2230
2234
  "integration_owner",
@@ -2245,14 +2249,24 @@ function validateChangeExecution(change, errors, context) {
2245
2249
  }
2246
2250
  const unexpectedWorktreeKeys = Object.keys(worktree).filter((key) => !requiredWorktreeKeys.includes(key));
2247
2251
  if (unexpectedWorktreeKeys.length) {
2248
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} has unexpected keys ${JSON.stringify(unexpectedWorktreeKeys.sort())}`);
2252
+ errors.push(`${basename(path)}: worktree ${unit} has unexpected keys ${JSON.stringify(unexpectedWorktreeKeys.sort())}`);
2249
2253
  }
2250
- if (!/^T-[0-9]{2,}$/.test(String(worktree.ticket_id))) {
2251
- errors.push(`${basename(path)}: invalid worktree ticket_id ${worktree.ticket_id}`);
2252
- } else if (seenWorktreeTickets.has(worktree.ticket_id)) {
2253
- errors.push(`${basename(path)}: duplicate worktree for ${worktree.ticket_id}`);
2254
- }
2255
- seenWorktreeTickets.add(worktree.ticket_id);
2254
+ if (direct) {
2255
+ if (worktree.change !== expectedChange) errors.push("Direct Spec execution belongs to another change");
2256
+ try {
2257
+ if (readChangeOwner(context, expectedChange).owner !== worktree.owner) errors.push("Direct Spec execution owner differs from original Lead");
2258
+ } catch (error) { errors.push(error.message); }
2259
+ if (!Number.isInteger(worktree.integration_attempt_limit) || worktree.integration_attempt_limit < 1) errors.push("Direct Spec requires a positive integration_attempt_limit");
2260
+ if (worktree.integration?.attempts > worktree.integration_attempt_limit) errors.push("Direct Spec integration attempts exceed integration_attempt_limit");
2261
+ if (worktree.integration_owner !== worktree.owner) errors.push("Direct Spec integration owner must be Lead");
2262
+ if (worktree.status === "cancelled" && data.outcome !== "cancelled") errors.push("cancelled Direct Spec execution requires the original whole-change cancellation");
2263
+ if (worktree.workspace_ref !== "current" && worktree.branch !== `speculo/${expectedChange}/direct-spec`) errors.push("Direct Spec source branch must match its change");
2264
+ } else if (!/^T-[0-9]{2,}$/.test(String(unit))) {
2265
+ errors.push(`${basename(path)}: invalid worktree ticket_id ${unit}`);
2266
+ } else if (seenWorktreeTickets.has(unit)) {
2267
+ errors.push(`${basename(path)}: duplicate worktree for ${unit}`);
2268
+ }
2269
+ seenWorktreeTickets.add(unit);
2256
2270
  const ref = worktree.workspace_ref;
2257
2271
  if (
2258
2272
  typeof ref !== "string" ||
@@ -2262,20 +2276,20 @@ function validateChangeExecution(change, errors, context) {
2262
2276
  errors.push(`${basename(path)}: workspace_ref must be a portable non-absolute locator`);
2263
2277
  continue;
2264
2278
  }
2265
- if (!VALID_WORKTREE_STATUS.has(worktree.status)) {
2279
+ if (!VALID_WORKTREE_STATUS.has(worktree.status) && !(direct && worktree.status === "cancelled")) {
2266
2280
  errors.push(`${basename(path)}: invalid worktree status ${worktree.status}`);
2267
2281
  }
2268
2282
  const currentWorkspace = ref === "current";
2269
2283
  if (worktree.provider !== "git") {
2270
- errors.push(`${basename(path)}: Ticket worktree ${worktree.ticket_id} provider must be git`);
2284
+ errors.push(`${basename(path)}: Ticket worktree ${unit} provider must be git`);
2271
2285
  }
2272
- const expectedRef = `specdev-worktree/${expectedChange}/${worktree.ticket_id}`;
2286
+ const expectedRef = `specdev-worktree/${expectedChange}/${unit}`;
2273
2287
  if (!currentWorkspace && ref !== expectedRef) {
2274
2288
  errors.push(`${basename(path)}: git workspace_ref must equal ${expectedRef}`);
2275
2289
  }
2276
2290
 
2277
2291
  if ("terminal_action" in worktree) {
2278
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} obsolete terminal_action is not allowed`);
2292
+ errors.push(`${basename(path)}: worktree ${unit} obsolete terminal_action is not allowed`);
2279
2293
  }
2280
2294
  const requiredIntegrationKeys = [
2281
2295
  "owner",
@@ -2287,35 +2301,35 @@ function validateChangeExecution(change, errors, context) {
2287
2301
  ];
2288
2302
  const missingIntegrationKeys = requiredIntegrationKeys.filter((key) => !(key in worktree));
2289
2303
  if (missingIntegrationKeys.length) {
2290
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration contract missing ${JSON.stringify(missingIntegrationKeys)}`);
2304
+ errors.push(`${basename(path)}: worktree ${unit} integration contract missing ${JSON.stringify(missingIntegrationKeys)}`);
2291
2305
  continue;
2292
2306
  }
2293
2307
  for (const key of ["owner", "implementation_owner", "integration_owner", "base_sha", "parent_branch", "branch", "updated_at"]) {
2294
2308
  if (typeof worktree[key] !== "string" || !worktree[key].trim()) {
2295
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${key} is required`);
2309
+ errors.push(`${basename(path)}: worktree ${unit} ${key} is required`);
2296
2310
  }
2297
2311
  }
2298
2312
  if (typeof worktree.parent_branch !== "string" || !worktree.parent_branch.trim()) {
2299
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} parent_branch is required`);
2313
+ errors.push(`${basename(path)}: worktree ${unit} parent_branch is required`);
2300
2314
  } else if (currentWorkspace && worktree.parent_branch !== worktree.branch) {
2301
- errors.push(`${basename(path)}: current workspace ${worktree.ticket_id} branch must equal parent_branch`);
2315
+ errors.push(`${basename(path)}: current workspace ${unit} branch must equal parent_branch`);
2302
2316
  } else if (!currentWorkspace && worktree.parent_branch === worktree.branch) {
2303
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} parent_branch must differ from branch`);
2317
+ errors.push(`${basename(path)}: worktree ${unit} parent_branch must differ from branch`);
2304
2318
  }
2305
2319
 
2306
2320
  const sourceRequired = new Set(["review", "integrating", "integrated", "removed"]).has(worktree.status);
2307
2321
  if (sourceRequired && (typeof worktree.source_checkpoint !== "string" || !worktree.source_checkpoint.trim())) {
2308
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires source_checkpoint`);
2322
+ errors.push(`${basename(path)}: worktree ${unit} ${worktree.status} requires source_checkpoint`);
2309
2323
  } else if (
2310
2324
  worktree.source_checkpoint !== null &&
2311
2325
  (typeof worktree.source_checkpoint !== "string" || !worktree.source_checkpoint.trim())
2312
2326
  ) {
2313
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} source_checkpoint must be null or non-empty`);
2327
+ errors.push(`${basename(path)}: worktree ${unit} source_checkpoint must be null or non-empty`);
2314
2328
  }
2315
2329
 
2316
2330
  const integration = worktree.integration;
2317
2331
  if (!integration || typeof integration !== "object" || Array.isArray(integration)) {
2318
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration must be an object`);
2332
+ errors.push(`${basename(path)}: worktree ${unit} integration must be an object`);
2319
2333
  continue;
2320
2334
  }
2321
2335
  const requiredResultKeys = [
@@ -2339,49 +2353,48 @@ function validateChangeExecution(change, errors, context) {
2339
2353
  ];
2340
2354
  const missingResultKeys = requiredResultKeys.filter((key) => !(key in integration));
2341
2355
  if (missingResultKeys.length) {
2342
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration missing ${JSON.stringify(missingResultKeys)}`);
2356
+ errors.push(`${basename(path)}: worktree ${unit} integration missing ${JSON.stringify(missingResultKeys)}`);
2343
2357
  }
2344
2358
  const unexpectedResultKeys = Object.keys(integration).filter((key) => !requiredResultKeys.includes(key));
2345
2359
  if (unexpectedResultKeys.length) {
2346
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration has unexpected keys ${JSON.stringify(unexpectedResultKeys.sort())}`);
2360
+ errors.push(`${basename(path)}: worktree ${unit} integration has unexpected keys ${JSON.stringify(unexpectedResultKeys.sort())}`);
2347
2361
  }
2348
2362
  if (!VALID_INTEGRATION_STATUS.has(integration.status)) {
2349
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} has invalid integration status ${integration.status}`);
2363
+ errors.push(`${basename(path)}: worktree ${unit} has invalid integration status ${integration.status}`);
2350
2364
  }
2351
2365
  if (!VALID_INTEGRATION_METHOD.has(integration.method)) {
2352
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} has invalid integration method ${integration.method}`);
2366
+ errors.push(`${basename(path)}: worktree ${unit} has invalid integration method ${integration.method}`);
2353
2367
  }
2354
2368
  if (!VALID_INTEGRATION_VERIFICATION.has(integration.verification)) {
2355
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} has invalid integration verification ${integration.verification}`);
2369
+ errors.push(`${basename(path)}: worktree ${unit} has invalid integration verification ${integration.verification}`);
2356
2370
  }
2357
2371
  if (!Array.isArray(integration.conflict_paths)) {
2358
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} conflict_paths must be an array`);
2372
+ errors.push(`${basename(path)}: worktree ${unit} conflict_paths must be an array`);
2359
2373
  }
2360
2374
  if (!Number.isInteger(integration.attempts) || integration.attempts < 0) {
2361
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} attempts must be a non-negative integer`);
2375
+ errors.push(`${basename(path)}: worktree ${unit} attempts must be a non-negative integer`);
2362
2376
  }
2363
- if (
2364
- typeof integration.evidence !== "string" ||
2365
- !/^<Path>\{roots\.state\}\/specdev\/changes\/[^<]+\/evidence\/T-[0-9]{2,}\.md<\/Path>$/.test(integration.evidence)
2366
- ) {
2367
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration evidence must be a rooted Ticket Evidence path`);
2377
+ const expectedEvidence = `<Path>{roots.state}/specdev/changes/${expectedChange}/evidence/${unit}.md</Path>`;
2378
+ if (direct ? integration.evidence !== expectedEvidence : typeof integration.evidence !== "string" || !/^<Path>\{roots\.state\}\/specdev\/changes\/[^<]+\/evidence\/T-[0-9]{2,}\.md<\/Path>$/.test(integration.evidence)) {
2379
+ errors.push(`${basename(path)}: worktree ${unit} integration evidence must be its rooted Evidence path: ${expectedEvidence}`);
2368
2380
  }
2369
2381
  if (!new Set(["pending", "applying", "applied", "failed", "stale"]).has(integration.promotion_status)) {
2370
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} has invalid promotion_status ${integration.promotion_status}`);
2382
+ errors.push(`${basename(path)}: worktree ${unit} has invalid promotion_status ${integration.promotion_status}`);
2371
2383
  }
2372
2384
  for (const [name, suite] of [["full_suite", integration.full_suite], ["e2e", integration.e2e]]) {
2373
2385
  if (!suite || typeof suite !== "object" || Array.isArray(suite) || typeof suite.required !== "boolean" || !VALID_E2E_STATUS.has(suite.status)) {
2374
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${name} contract is invalid`);
2386
+ errors.push(`${basename(path)}: worktree ${unit} ${name} contract is invalid`);
2375
2387
  continue;
2376
2388
  }
2377
2389
  const unexpectedSuiteKeys = Object.keys(suite).filter((key) => !new Set(["required", "status", "reason", "evidence"]).has(key));
2378
- if (unexpectedSuiteKeys.length) errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${name} has unexpected keys ${JSON.stringify(unexpectedSuiteKeys.sort())}`);
2390
+ if (unexpectedSuiteKeys.length) errors.push(`${basename(path)}: worktree ${unit} ${name} has unexpected keys ${JSON.stringify(unexpectedSuiteKeys.sort())}`);
2379
2391
  if (suite.required === false && (suite.status !== "not-required" || typeof suite.reason !== "string" || !suite.reason.trim())) {
2380
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} non-required ${name} needs not-required status and reason`);
2392
+ errors.push(`${basename(path)}: worktree ${unit} non-required ${name} needs not-required status and reason`);
2381
2393
  }
2382
2394
  if (suite.required === true && suite.status === "not-required") {
2383
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} required ${name} cannot be not-required`);
2395
+ errors.push(`${basename(path)}: worktree ${unit} required ${name} cannot be not-required`);
2384
2396
  }
2397
+ if (direct && suite.required === true && suite.status === "passed" && (typeof suite.evidence !== "string" || !suite.evidence.trim())) errors.push(`Direct Spec passed ${name} requires actual evidence`);
2385
2398
  }
2386
2399
  const e2e = integration.e2e;
2387
2400
  if (
@@ -2391,65 +2404,65 @@ function validateChangeExecution(change, errors, context) {
2391
2404
  typeof e2e.required !== "boolean" ||
2392
2405
  !VALID_E2E_STATUS.has(e2e.status)
2393
2406
  ) {
2394
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration e2e contract is invalid`);
2407
+ errors.push(`${basename(path)}: worktree ${unit} integration e2e contract is invalid`);
2395
2408
  } else {
2396
2409
  const unexpectedE2eKeys = Object.keys(e2e).filter((key) => !new Set(["required", "status", "reason", "evidence"]).has(key));
2397
2410
  if (unexpectedE2eKeys.length) {
2398
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration e2e has unexpected keys ${JSON.stringify(unexpectedE2eKeys.sort())}`);
2411
+ errors.push(`${basename(path)}: worktree ${unit} integration e2e has unexpected keys ${JSON.stringify(unexpectedE2eKeys.sort())}`);
2399
2412
  }
2400
2413
  if (e2e.required === false && e2e.status !== "not-required") {
2401
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} non-required E2E must use status=not-required`);
2414
+ errors.push(`${basename(path)}: worktree ${unit} non-required E2E must use status=not-required`);
2402
2415
  }
2403
2416
  if (e2e.required === true && e2e.status === "not-required") {
2404
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} required E2E cannot be not-required`);
2417
+ errors.push(`${basename(path)}: worktree ${unit} required E2E cannot be not-required`);
2405
2418
  }
2406
2419
  if (
2407
2420
  e2e.required === true &&
2408
2421
  e2e.status === "passed" &&
2409
2422
  (typeof e2e.evidence !== "string" || !e2e.evidence.trim())
2410
2423
  ) {
2411
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} passed required E2E needs evidence`);
2424
+ errors.push(`${basename(path)}: worktree ${unit} passed required E2E needs evidence`);
2412
2425
  }
2413
2426
  }
2414
2427
  const integrationLifecycleActive = new Set(["integrating", "integrated", "removed"]).has(worktree.status);
2415
2428
  if (integrationLifecycleActive) {
2416
2429
  if (typeof integration.parent_before_sha !== "string" || !integration.parent_before_sha.trim()) {
2417
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires parent_before_sha`);
2430
+ errors.push(`${basename(path)}: worktree ${unit} ${worktree.status} requires parent_before_sha`);
2418
2431
  }
2419
2432
  if (typeof integration.source_sha !== "string" || !integration.source_sha.trim()) {
2420
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires source_sha`);
2433
+ errors.push(`${basename(path)}: worktree ${unit} ${worktree.status} requires source_sha`);
2421
2434
  } else if (integration.source_sha !== worktree.source_checkpoint) {
2422
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} source_sha must equal source_checkpoint`);
2435
+ errors.push(`${basename(path)}: worktree ${unit} source_sha must equal source_checkpoint`);
2423
2436
  }
2424
2437
  if (currentWorkspace) {
2425
2438
  if (integration.candidate_sha !== null || integration.candidate_tree_sha !== null || integration.candidate_branch !== null || integration.candidate_workspace_ref !== null) {
2426
- errors.push(`${basename(path)}: current workspace ${worktree.ticket_id} cannot record candidate workspace fields`);
2439
+ errors.push(`${basename(path)}: current workspace ${unit} cannot record candidate workspace fields`);
2427
2440
  }
2428
2441
  if (integration.method !== "direct-parent") {
2429
- errors.push(`${basename(path)}: current workspace ${worktree.ticket_id} requires integration.method=direct-parent`);
2442
+ errors.push(`${basename(path)}: current workspace ${unit} requires integration.method=direct-parent`);
2430
2443
  }
2431
2444
  } else {
2432
2445
  if (typeof integration.candidate_sha !== "string" || !integration.candidate_sha.trim()) {
2433
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires candidate_sha`);
2446
+ errors.push(`${basename(path)}: worktree ${unit} ${worktree.status} requires candidate_sha`);
2434
2447
  }
2435
- const expectedCandidateBranch = `speculo/integration/${expectedChange}/${worktree.ticket_id}`;
2448
+ const expectedCandidateBranch = `speculo/integration/${expectedChange}/${unit}`;
2436
2449
  if (integration.candidate_branch !== expectedCandidateBranch) {
2437
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} candidate_branch must equal ${expectedCandidateBranch}`);
2450
+ errors.push(`${basename(path)}: worktree ${unit} candidate_branch must equal ${expectedCandidateBranch}`);
2438
2451
  }
2439
- const expectedCandidateRef = `specdev-worktree/.integration/${expectedChange}/${worktree.ticket_id}`;
2452
+ const expectedCandidateRef = `specdev-worktree/.integration/${expectedChange}/${unit}`;
2440
2453
  if (integration.candidate_workspace_ref !== expectedCandidateRef) {
2441
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} candidate_workspace_ref must equal ${expectedCandidateRef}`);
2454
+ errors.push(`${basename(path)}: worktree ${unit} candidate_workspace_ref must equal ${expectedCandidateRef}`);
2442
2455
  }
2443
2456
  if (!new Set(["fast-forward", "merge-commit"]).has(integration.method)) {
2444
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires an integration method`);
2457
+ errors.push(`${basename(path)}: worktree ${unit} ${worktree.status} requires an integration method`);
2445
2458
  }
2446
2459
  }
2447
2460
  if (!Number.isInteger(integration.attempts) || integration.attempts < 1) {
2448
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires attempts >= 1`);
2461
+ errors.push(`${basename(path)}: worktree ${unit} ${worktree.status} requires attempts >= 1`);
2449
2462
  }
2450
2463
  }
2451
2464
  if (worktree.status === "integrating" && integration.status !== "candidate") {
2452
- errors.push(`${basename(path)}: integrating worktree ${worktree.ticket_id} requires integration.status=candidate`);
2465
+ errors.push(`${basename(path)}: integrating worktree ${unit} requires integration.status=candidate`);
2453
2466
  }
2454
2467
  if (new Set(["integrated", "removed"]).has(worktree.status)) {
2455
2468
  if (
@@ -2459,31 +2472,32 @@ function validateChangeExecution(change, errors, context) {
2459
2472
  typeof integration.result_sha !== "string" ||
2460
2473
  !integration.result_sha.trim() ||
2461
2474
  (currentWorkspace ? integration.result_sha !== worktree.source_checkpoint : integration.result_sha !== integration.candidate_sha) ||
2475
+ (direct && (integration.promotion_status !== "applied" || !new Set(["not-required", "passed"]).has(integration.full_suite?.status))) ||
2462
2476
  !e2e ||
2463
2477
  !new Set(["not-required", "passed"]).has(e2e.status)
2464
2478
  ) {
2465
- errors.push(`${basename(path)}: ${worktree.status} worktree ${worktree.ticket_id} requires passed candidate/result/E2E state`);
2479
+ errors.push(`${basename(path)}: ${worktree.status} worktree ${unit} requires passed candidate/result/E2E state`);
2466
2480
  }
2467
2481
  if (currentWorkspace && integration.method === "direct-parent") {
2468
2482
  if (integration.candidate_sha !== null || integration.candidate_tree_sha !== null || integration.candidate_branch !== null || integration.candidate_workspace_ref !== null) {
2469
- errors.push(`${basename(path)}: current workspace ${worktree.ticket_id} direct-parent cannot record candidate fields`);
2483
+ errors.push(`${basename(path)}: current workspace ${unit} direct-parent cannot record candidate fields`);
2470
2484
  }
2471
2485
  if (integration.result_sha !== worktree.source_checkpoint) {
2472
- errors.push(`${basename(path)}: current workspace ${worktree.status} ${worktree.ticket_id} result_sha must equal source_checkpoint`);
2486
+ errors.push(`${basename(path)}: current workspace ${worktree.status} ${unit} result_sha must equal source_checkpoint`);
2473
2487
  }
2474
2488
  } else if (integration.method === "fast-forward") {
2475
2489
  if (integration.candidate_sha !== worktree.source_checkpoint) {
2476
- errors.push(`${basename(path)}: ${worktree.status} worktree ${worktree.ticket_id} fast-forward candidate/result must equal source_checkpoint`);
2490
+ errors.push(`${basename(path)}: ${worktree.status} worktree ${unit} fast-forward candidate/result must equal source_checkpoint`);
2477
2491
  }
2478
2492
  if (Array.isArray(integration.conflict_paths) && integration.conflict_paths.length > 0) {
2479
- errors.push(`${basename(path)}: ${worktree.status} worktree ${worktree.ticket_id} fast-forward cannot record conflict_paths`);
2493
+ errors.push(`${basename(path)}: ${worktree.status} worktree ${unit} fast-forward cannot record conflict_paths`);
2480
2494
  }
2481
2495
  } else if (
2482
2496
  integration.method === "merge-commit" &&
2483
2497
  typeof integration.candidate_sha === "string" &&
2484
2498
  (integration.candidate_sha === worktree.source_checkpoint || integration.candidate_sha === integration.parent_before_sha)
2485
2499
  ) {
2486
- errors.push(`${basename(path)}: ${worktree.status} worktree ${worktree.ticket_id} merge-commit candidate must be a distinct checkpoint`);
2500
+ errors.push(`${basename(path)}: ${worktree.status} worktree ${unit} merge-commit candidate must be a distinct checkpoint`);
2487
2501
  }
2488
2502
  }
2489
2503
  }
@@ -2491,8 +2505,8 @@ function validateChangeExecution(change, errors, context) {
2491
2505
  }
2492
2506
 
2493
2507
  function validateCurrentWorkspaceExecution(changeStatus, goalPlan, errors) {
2494
- if (goalPlan?.meta.ticket_workspace_policy !== "current") return;
2495
- const entries = Array.isArray(changeStatus?.worktrees) ? changeStatus.worktrees : [];
2508
+ const allEntries = Array.isArray(changeStatus?.worktrees) ? changeStatus.worktrees : [];
2509
+ const entries = goalPlan?.meta.ticket_workspace_policy === "current" ? allEntries : allEntries.filter(entry => entry.workspace_ref === "current");
2496
2510
  const active = [];
2497
2511
  for (const worktree of entries) {
2498
2512
  if (worktree.workspace_ref !== "current") {
@@ -2502,7 +2516,7 @@ function validateCurrentWorkspaceExecution(changeStatus, goalPlan, errors) {
2502
2516
  errors.push(`${worktree.ticket_id}: current workspace execution must commit directly on parent_branch`);
2503
2517
  }
2504
2518
  if (new Set(["active", "review", "integrating", "blocked"]).has(worktree.status)) {
2505
- active.push(worktree.ticket_id);
2519
+ active.push(worktree.ticket_id ?? worktree.purpose);
2506
2520
  }
2507
2521
  const integration = worktree.integration;
2508
2522
  if (integration && typeof integration === "object" && !Array.isArray(integration)) {
@@ -2549,7 +2563,7 @@ function validateGitEvidence(repoRoot, changeStatus, errors) {
2549
2563
  }
2550
2564
  const currentBranch = gitOutput(resolvedRoot, ["branch", "--show-current"]);
2551
2565
  for (const worktree of Array.isArray(changeStatus?.worktrees) ? changeStatus.worktrees : []) {
2552
- const label = String(worktree.ticket_id ?? "worktree");
2566
+ const label = String(worktree.ticket_id ?? worktree.purpose ?? "worktree");
2553
2567
  for (const [name, sha] of [
2554
2568
  ["base_sha", worktree.base_sha],
2555
2569
  ["source_checkpoint", worktree.source_checkpoint],
@@ -2586,6 +2600,36 @@ function validateGitEvidence(repoRoot, changeStatus, errors) {
2586
2600
  if (gitOutput(candidate,["rev-parse","HEAD"]) !== worktree.integration.candidate_sha || gitOutput(candidate,["rev-parse","HEAD^{tree}"]) !== worktree.integration.candidate_tree_sha) errors.push(`${label}: actual candidate workspace differs from the recorded checkpoint/tree`);
2587
2601
  } catch(error) { errors.push(`${label}: actual candidate isolation is missing: ${error.message}`); }
2588
2602
  }
2603
+ if (worktree.purpose === "direct-spec") {
2604
+ const integration = worktree.integration;
2605
+ if (worktree.source_checkpoint && gitOutput(resolvedRoot, ["rev-parse", worktree.branch]) !== worktree.source_checkpoint && worktree.status !== "removed") errors.push(`${label}: source branch tip differs from source_checkpoint`);
2606
+ if (worktree.source_checkpoint && !gitSucceeds(resolvedRoot, ["merge-base", "--is-ancestor", worktree.base_sha, worktree.source_checkpoint])) errors.push(`${label}: source_checkpoint must descend from base_sha`);
2607
+ if (worktree.workspace_ref !== "current" && new Set(["review", "integrating", "integrated"]).has(worktree.status)) {
2608
+ try {
2609
+ const source = runtime.safeWorkPathSync(resolvedRoot, worktree.workspace_ref, {mustExist:true,type:"directory"});
2610
+ if (gitOutput(source, ["status", "--porcelain"])) errors.push(`${label}: source worktree is dirty`);
2611
+ } catch (error) { errors.push(`${label}: source worktree missing before cleanup: ${error.message}`); }
2612
+ }
2613
+ if (integration?.candidate_sha) {
2614
+ if (gitOutput(resolvedRoot, ["rev-parse", `${integration.candidate_sha}^{tree}`]) !== integration.candidate_tree_sha) errors.push(`${label}: candidate tree differs from recorded tree`);
2615
+ for (const sha of [integration.parent_before_sha, worktree.source_checkpoint]) {
2616
+ if (sha && !gitSucceeds(resolvedRoot, ["merge-base", "--is-ancestor", sha, integration.candidate_sha])) errors.push(`${label}: candidate must contain parent and source checkpoints`);
2617
+ }
2618
+ }
2619
+ const parent = gitOutput(resolvedRoot, ["rev-parse", worktree.parent_branch]);
2620
+ if (worktree.status === "integrating" && worktree.workspace_ref !== "current") {
2621
+ try {
2622
+ const candidate = runtime.safeWorkPathSync(resolvedRoot, integration.candidate_workspace_ref, {mustExist:true,type:"directory"});
2623
+ if (gitOutput(candidate, ["rev-parse", "--path-format=absolute", "--git-common-dir"]) !== gitOutput(resolvedRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"]) || gitOutput(candidate, ["branch", "--show-current"]) !== integration.candidate_branch || gitOutput(candidate, ["status", "--porcelain"])) errors.push(`${label}: candidate must be a clean worktree of the recorded repository and branch`);
2624
+ } catch (error) { errors.push(`${label}: candidate cannot be inspected: ${error.message}`); }
2625
+ }
2626
+ if (worktree.status === "integrating" && parent !== integration?.parent_before_sha) errors.push(`${label}: parent HEAD drifted; candidate must be marked stale and rebuilt`);
2627
+ if (new Set(["failed", "stale"]).has(integration?.status) && parent === integration.candidate_sha && parent !== integration.parent_before_sha) errors.push(`${label}: failed or stale candidate has already advanced the parent; reconcile before continuing`);
2628
+ if (new Set(["integrated", "removed"]).has(worktree.status)) {
2629
+ if (parent !== integration?.result_sha) errors.push(`${label}: parent branch HEAD must equal recorded result_sha`);
2630
+ if (!gitSucceeds(resolvedRoot, ["merge-base", "--is-ancestor", worktree.source_checkpoint, parent])) errors.push(`${label}: parent must contain source_checkpoint`);
2631
+ }
2632
+ }
2589
2633
  if (new Set(["integrated", "removed"]).has(worktree.status) && gitOutput(resolvedRoot, ["status", "--porcelain"])) {
2590
2634
  errors.push(`${label}: repository is dirty while Ticket is recorded as ${worktree.status}`);
2591
2635
  }
@@ -3052,7 +3096,7 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
3052
3096
  if(archive?.archive_state === 'moved') warnings.push('archive moved and checked, but is not sealed; finish the original A8 receipt');
3053
3097
  return { errors, warnings, ...(archive ? {archive} : {}) };
3054
3098
  }
3055
- if (isParentImplementation) validateParentImplementation(change, changeStatus, stage, errors, warnings, repoRoot, options);
3099
+ const parentImplementation = isParentImplementation ? validateParentImplementation(change, changeStatus, stage, errors, warnings, repoRoot, options) : null;
3056
3100
  errors.push(...validateInitiative(change));
3057
3101
  if (isFile(join(change, "source-issue.md"))) {
3058
3102
  errors.push("obsolete source-issue.md is forbidden; use source.md without compatibility fallback");
@@ -3080,10 +3124,10 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
3080
3124
  if (isFile(diagnosisPath) || stage === "diagnosis") {
3081
3125
  validateDiagnosis(diagnosisPath, basename(change), errors);
3082
3126
  }
3083
- validateReviews(change, stage === "review", errors);
3127
+ validateReviews(change, stage === "review", errors, workspace.context.projectRoot);
3084
3128
  validatePrototypes(change, stage === "prototype", errors, prototypeId, new Set(["spec", "tickets", "goal-plan", "implement", "review", "complete"]).has(stage), workspace.context);
3085
3129
  if (new Set(["tickets", "goal-plan", "implement", "review", "complete"]).has(stage)) runDesignCheck("--handoff", change, errors, workspace.context);
3086
- validateChangeLearning(change, stage === "learn-change", errors);
3130
+ validateChangeLearning(change, stage === "learn", errors);
3087
3131
  const retro = validateRecords(change, "retrospective", parseFrontmatter);
3088
3132
  const prs = validateRecords(change, "pull-request", parseFrontmatter);
3089
3133
  errors.push(...retro.errors, ...prs.errors);
@@ -3206,12 +3250,56 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
3206
3250
  }
3207
3251
  }
3208
3252
  }
3209
- validateCurrentWorkspaceExecution(changeStatus, goalPlan, errors);
3210
3253
  }
3254
+ validateCurrentWorkspaceExecution(changeStatus, goalPlan, errors);
3255
+ const directExecution = changeStatus?.worktrees?.find(entry => entry.purpose === "direct-spec");
3256
+ if (directExecution && workspace.config) errors.push(...validateExecutionPolicy({meta:{concurrent_implementation_limit:1,integration_attempt_limit:directExecution.integration_attempt_limit}},workspace.config).map(error => `Direct Spec: ${error}`));
3257
+ if (directExecution && (ticketMode || isParentImplementation)) errors.push("Direct Spec execution cannot coexist with Ticket or parent implementation artifacts");
3258
+ if (directExecution && (stage === "complete" || changeStatus.outcome === "completed") && !new Set(["integrated", "removed"]).has(directExecution.status)) errors.push("Direct Spec completion requires integrated or removed execution; active or failed candidates cannot complete");
3211
3259
  validateGitEvidence(stage === "complete" ? workspace.context.projectRoot : repoRoot, changeStatus, errors);
3212
3260
 
3261
+ if (stage === "implement" && spec && !ticketMode && (spec.meta.ready_for_tickets !== true || spec.meta.status !== "ready")) errors.push("Direct Spec implementation requires a ready Spec with ready_for_tickets=true");
3262
+ if (spec?.meta.ready_for_tickets === true && !/\bAC-\d+\b/.test(spec.body)) errors.push("ready Spec must define at least one AC-### acceptance contract");
3263
+
3213
3264
  if (stage === "complete" && spec && !ticketMode && !isParentImplementation && !retroOnly) errors.push(...validateAcceptanceEvidence(change,"evidence/direct-spec.md",[...new Set(spec.body.match(/\bAC-\d+\b/g) ?? [])]));
3214
3265
 
3266
+ // New completion/archival consumes actual review evidence. Historical records
3267
+ // remain readable outside these gates; archived validation returned earlier.
3268
+ let nonImplementation = false;
3269
+ if (stage === 'complete' && !spec && !ticketMode && !simpleGoal && !goalPlan && !isParentImplementation && !changeStatus?.worktrees?.length && !isDirectory(join(change,'evidence'))) {
3270
+ try {
3271
+ const owner = readChangeOwner(workspace.context,basename(change));
3272
+ if (owner.work === 'specdev/learn' && isFile(join(change,'learning','index.md'))) {
3273
+ validateChangeLearning(change,true,errors);
3274
+ nonImplementation = true;
3275
+ } else if (owner.work === 'specdev/triage' && source && triage?.meta.ready_for_implementation === false) nonImplementation = true;
3276
+ } catch(error) { errors.push(error.message); }
3277
+ }
3278
+ if (stage === 'complete' && changeStatus?.location.kind === 'active' && changeStatus?.outcome !== 'cancelled' && !retroOnly && !nonImplementation) {
3279
+ const units = [...tickets.values()].filter(ticket => ticket.meta.status !== 'cancelled');
3280
+ const finalArtifact = isParentImplementation ? 'evidence/implementation-orchestration.md'
3281
+ : units.length > 1 ? 'evidence/final-review.md'
3282
+ : units.length === 1 ? `evidence/${units[0].meta.id}.md`
3283
+ : lightweight ? 'README.md' : 'evidence/direct-spec.md';
3284
+ try {
3285
+ const head = execFileSync('git',['-C',workspace.context.projectRoot,'rev-parse','--verify','HEAD^{commit}'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
3286
+ const executions = [...(changeStatus.worktrees ?? []), ...[...(parentImplementation?.memberStatuses?.values() ?? [])].flatMap(status=>status.worktrees ?? [])];
3287
+ const owners = executions.flatMap(record => [record.implementation_owner, ...(record.integration?.conflict_paths?.length ? [record.integration_owner] : [])]).filter(value => typeof value === 'string' && /^[a-z][a-z0-9-]*:\S+$/i.test(value));
3288
+ const bases = [...new Set(executions.map(record=>record.base_sha).filter(value=>/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(value ?? '')))];
3289
+ const expectedBase = bases.length > 1 ? execFileSync('git',['-C',workspace.context.projectRoot,'merge-base','--octopus',...bases],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim() : bases[0] ?? null;
3290
+ const sourceLocator = path => {
3291
+ const snapshot = workspace.context.readOnlySnapshot;
3292
+ const original = snapshot ? join(snapshot.sourceContext.specdevRoot,relative(workspace.context.specdevRoot,path)) : path;
3293
+ return toPosix(relative(workspace.context.projectRoot,original));
3294
+ };
3295
+ const specLocator = sourceLocator(spec ? spec.path : join(change,'README.md'));
3296
+ const requiredSpecifications = isParentImplementation
3297
+ ? [sourceLocator(join(change,'implementation-plan.md')),sourceLocator(join(change,'implementation-map.md')),...new Set([...(parentImplementation?.ticketByTask?.values() ?? [])].map(unit=>sourceLocator(join(unit.memberRoot,'spec.md'))))]
3298
+ : spec ? [specLocator] : [`${specLocator}#scope`,`${specLocator}#acceptance`];
3299
+ errors.push(...validateIndependentReview(change,finalArtifact,{repoRoot:workspace.context.projectRoot,expectedHead:head,expectedBase,requiredImplementers:owners,requiredSpecifications,checkCurrent:true,requireDelivery:true}));
3300
+ } catch(error) { errors.push(`independent-review: final delivery checkpoint unavailable: ${error.message}`); }
3301
+ }
3302
+
3215
3303
  if (spec && ticketMode) {
3216
3304
  const declaredContracts = new Set(spec.body.match(/\bAC-\d+\b/g) ?? []);
3217
3305
  const coveredContracts = new Set(
@@ -3297,7 +3385,15 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
3297
3385
  continue;
3298
3386
  }
3299
3387
  const text = readText(evidence);
3300
- const currentWorkspace = goalPlan?.meta.ticket_workspace_policy === "current";
3388
+ if (stage === 'complete' && changeStatus?.location.kind === 'active' || hasIndependentReview(evidence)) {
3389
+ const execution = changeStatus?.worktrees?.find(entry => entry.ticket_id === ticketId);
3390
+ errors.push(...validateIndependentReview(change, `evidence/${ticketId}.md`, {
3391
+ repoRoot:workspace.context.projectRoot,
3392
+ expectedHead:execution?.integration?.result_sha ?? execution?.source_checkpoint ?? null,
3393
+ checkCurrent:false,
3394
+ }));
3395
+ }
3396
+ const currentWorkspace = changeStatus?.worktrees?.find(entry => entry.ticket_id === ticketId)?.workspace_ref === "current";
3301
3397
  const requiredHeadings = [
3302
3398
  "## 2. Lead Dispatch And Candidate Return",
3303
3399
  "## 3. 修改范围与路径所有权",
@@ -3417,7 +3513,7 @@ function printResults(errors, warnings) {
3417
3513
  }
3418
3514
 
3419
3515
  function usage() {
3420
- console.error("Usage: node validate-specdev.mjs [--stage <stage>] [--sealed (archived only)] [--prototype-id UI-NNN|LOGIC-NNN] [--repo <project-root>] [--engineering <performance|documentation|incident>] <change-directory> | --review-scope --repo <project-root> --mode <baseline|range|commit|pr|source-files> [comparison options or repeated --file <relative-path>] | --self-check | --capture <capture.md> | --triage-run <TRI-NNN.md>");
3516
+ console.error("Usage: node validate-specdev.mjs [--stage <stage>] [--sealed (archived only)] [--prototype-id UI-NNN|LOGIC-NNN] [--repo <project-root>] [--engineering <performance|documentation|incident>] <change-directory> | --review-scope --repo <project-root> --mode <baseline|range|commit|pr|source-files|working-tree> [comparison options or repeated --file <relative-path>] | --self-check | --capture <capture.md> | --triage-run <TRI-NNN.md>");
3421
3517
  return 2;
3422
3518
  }
3423
3519