@namewta/speculo 0.7.2 → 0.7.4

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 (55) hide show
  1. package/dist/src/migrations.js +755 -23
  2. package/dist/src/migrations.js.map +1 -1
  3. package/package.json +1 -1
  4. package/template/canonical/canonical-specdev-engineering-cognitive-mentor.md +200 -448
  5. package/template/canonical/canonical-specdev-goal-plan.md +744 -1108
  6. package/template/canonical/canonical-specdev-grill-with-docs.md +201 -449
  7. package/template/canonical/canonical-specdev-spec.md +232 -486
  8. package/template/canonical/canonical-specdev-tickets.md +411 -590
  9. package/template/canonical/canonical-specdev-wayfinder.md +199 -447
  10. package/template/skills/migrate-runtime-state/SKILL.md +6 -6
  11. package/template/skills/migrate-runtime-state/references/migration-contract.md +9 -3
  12. package/template/skills/migrate-runtime-state/scripts/migrate-runtime-state.mjs +404 -33
  13. package/template/workflows/specdev/I-implement/I-implement.md +98 -143
  14. package/template/workflows/specdev/I-implement/evidence-template.md +66 -48
  15. package/template/workflows/specdev/I-implement/execution-preflight.md +31 -21
  16. package/template/workflows/specdev/I-implement/merge-conflict-protocol.md +12 -12
  17. package/template/workflows/specdev/I-init-setup/I-init-setup.md +4 -5
  18. package/template/workflows/specdev/I-init-setup/change-status-template.json +14 -1
  19. package/template/workflows/specdev/I-init-setup/config-template.json +7 -6
  20. package/template/workflows/specdev/I-init-setup/status-template.json +1 -1
  21. package/template/workflows/specdev/INDEX.md +11 -8
  22. package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +76 -102
  23. package/template/workflows/specdev/P-goal-plan/completion-control.md +26 -44
  24. package/template/workflows/specdev/P-goal-plan/goal-plan-template.md +46 -37
  25. package/template/workflows/specdev/P-goal-plan/lead-orchestration.md +34 -0
  26. package/template/workflows/specdev/P-goal-plan/orchestration-protocol.md +31 -46
  27. package/template/workflows/specdev/P-goal-plan/planning-modes.md +67 -89
  28. package/template/workflows/specdev/P-prototype/ui-prototype.md +1 -1
  29. package/template/workflows/specdev/T-tickets/T-tickets.md +6 -3
  30. package/template/workflows/specdev/T-tickets/ticket-readiness.md +5 -3
  31. package/template/workflows/specdev/T-tickets/ticket-template.md +8 -1
  32. package/template/workflows/specdev/T-tickets/tickets-map-template.md +5 -4
  33. package/template/workflows/specdev/_state/status.json +1 -1
  34. package/template/workflows/specdev/common/README.md +2 -2
  35. package/template/workflows/specdev/common/rules/change-completion.md +17 -20
  36. package/template/workflows/specdev/common/rules/deviation-control.md +1 -1
  37. package/template/workflows/specdev/common/rules/evidence-and-verification.md +31 -37
  38. package/template/workflows/specdev/common/rules/path-ownership.md +21 -23
  39. package/template/workflows/specdev/common/rules/readiness-and-depth.md +1 -1
  40. package/template/workflows/specdev/common/schemas/change-status.schema.json +153 -363
  41. package/template/workflows/specdev/common/schemas/config.schema.json +17 -13
  42. package/template/workflows/specdev/common/schemas/goal-plan.schema.json +33 -16
  43. package/template/workflows/specdev/common/schemas/status.schema.json +7 -63
  44. package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +42 -21
  45. package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +36 -21
  46. package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +46 -18
  47. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +34 -31
  48. package/template/workflows/specdev/common/skills/subagent-delivery/references/external-web-subagent.md +10 -23
  49. package/template/workflows/specdev/common/skills/subagent-delivery/references/native-subagent.md +15 -25
  50. package/template/workflows/specdev/common/tools/README.md +2 -1
  51. package/template/workflows/specdev/common/tools/validate-specdev.mjs +591 -219
  52. package/template/workflows/specdev/I-implement/delegated-evidence-template.md +0 -12
  53. package/template/workflows/specdev/P-goal-plan/delegated-execution-template.md +0 -35
  54. package/template/workflows/specdev/P-goal-plan/delegated-execution.md +0 -59
  55. package/template/workflows/specdev/P-goal-plan/workspace-execution-template.md +0 -24
@@ -15,13 +15,18 @@ import {
15
15
  readdirSync,
16
16
  statSync,
17
17
  } from "node:fs";
18
+ import { execFileSync } from "node:child_process";
18
19
  import { dirname, basename, extname, join, relative, resolve, sep } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
20
21
 
21
- const SCHEMA_VERSION = 3;
22
- const GLOBAL_STATUS_SCHEMA_VERSION = 4;
22
+ const DOMAIN_SCHEMA_VERSION = 3;
23
+ const CONFIG_SCHEMA_VERSION = 5;
24
+ const GOAL_PLAN_SCHEMA_VERSION = 6;
25
+ const CHANGE_STATUS_SCHEMA_VERSION = 6;
26
+ const GLOBAL_STATUS_SCHEMA_VERSION = 5;
23
27
  const WORKFLOW_PREFIX = "{roots.workflows}/specdev/";
24
28
  const STATE_PREFIX = "{roots.state}/specdev/";
29
+ const STATE_ROOT_PREFIX = "{roots.state}/";
25
30
  const SKILLS_PREFIX = "{roots.skills}/";
26
31
  const COMMANDS_PREFIX = "{roots.commands}/";
27
32
 
@@ -55,14 +60,11 @@ const VALID_TICKET_STATUS = new Set([
55
60
  const VALID_DEPTH = new Set(["lite", "standard", "deep"]);
56
61
  const VALID_RISK = new Set(["low", "medium", "high", "critical"]);
57
62
  const VALID_PLAN_MODES = new Set([
58
- "coordination",
59
63
  "migration",
60
64
  "high-assurance",
61
65
  "reference-conformance",
62
66
  "release-coordination",
63
67
  ]);
64
- const VALID_COORDINATION_MODES = new Set(["single-session", "lead-team"]);
65
- const VALID_WORKSPACE_STRATEGIES = new Set(["current", "worktree", "mixed"]);
66
68
  const VALID_WORKTREE_STATUS = new Set([
67
69
  "planned",
68
70
  "active",
@@ -72,10 +74,10 @@ const VALID_WORKTREE_STATUS = new Set([
72
74
  "removed",
73
75
  "blocked",
74
76
  ]);
75
- const VALID_TERMINAL_ACTION = new Set(["integrate", "retain"]);
76
- const VALID_INTEGRATION_STATUS = new Set(["pending", "running", "passed", "blocked"]);
77
- const VALID_INTEGRATION_METHOD = new Set([null, "fast-forward", "merge-commit"]);
77
+ const VALID_INTEGRATION_STATUS = new Set(["pending", "candidate", "passed", "failed", "stale"]);
78
+ const VALID_INTEGRATION_METHOD = new Set([null, "direct-parent", "fast-forward", "merge-commit"]);
78
79
  const VALID_INTEGRATION_VERIFICATION = new Set(["pending", "passed", "failed"]);
80
+ const VALID_E2E_STATUS = new Set(["not-required", "pending", "passed", "failed"]);
79
81
  const VALID_DESIGN_TREE_STATUS = new Set(["active", "consensus", "blocked"]);
80
82
  const VALID_DESIGN_NODE_STATUS = new Set(["open", "answered", "deferred", "rejected"]);
81
83
  const VALID_WAYFINDER_LABEL = new Set([
@@ -142,39 +144,14 @@ const REQUIRED_GOAL_PLAN_SECTIONS = new Set([
142
144
  "## 5. Constraints, Risk and Recovery",
143
145
  "## 6. Progress and Decisions",
144
146
  ]);
145
- const DELEGATED_GOAL_PLAN_TRIGGERS = [
146
- "## Delegated Execution Addendum",
147
- "### Delivery Contract",
148
- "### Per-Ticket Dispatch Packets",
149
- "### Candidate Delivery Return and Lead Acceptance",
150
- "Lead / Provider",
151
- "Lead:",
152
- "Lead:",
153
- "Worker:",
154
- "Worker:",
155
- "Subagent",
156
- "subagent",
157
- "execution_model",
158
- "execution model: direct",
159
- "max_correction_rounds",
160
- ];
161
- const REQUIRED_DELEGATED_GOAL_PLAN_MARKERS = [
162
- "## Delegated Execution Addendum",
163
- "### Delivery Contract",
164
- "### Per-Ticket Dispatch Packets",
165
- "### Candidate Delivery Return and Lead Acceptance",
166
- "native-subagent / external-web-subagent",
167
- "Lead / Provider",
168
- "#### Dispatch:",
169
- ];
170
- const REQUIRED_WORKSPACE_GOAL_PLAN_MARKERS = [
171
- "## Isolated Workspace Addendum",
172
- "### Workspace Decision",
173
- "### Per-Ticket Workspace Allocation",
174
- "### Local Integration Authorization",
175
- "Integration owner",
176
- "Parent branch",
177
- "Terminal action",
147
+ const REQUIRED_LEAD_GOAL_PLAN_MARKERS = [
148
+ "### Lead Orchestration",
149
+ "Implementation subagents",
150
+ "Read-only agents",
151
+ "execution-time dynamic",
152
+ "### Ticket Workspace and Integration",
153
+ "### Authorization Matrix",
154
+ "Implementation commit",
178
155
  ];
179
156
  const STATE_ARTIFACT_BASENAMES = new Set([
180
157
  "spec.md",
@@ -195,6 +172,10 @@ const STATE_ARTIFACT_BASENAMES = new Set([
195
172
  ]);
196
173
  const FORBIDDEN_OBSOLETE_BASENAMES = new Set([
197
174
  "source-issue.md",
175
+ "delegated-execution.md",
176
+ "delegated-execution-template.md",
177
+ "workspace-execution-template.md",
178
+ "delegated-evidence-template.md",
198
179
  "input-validation.md",
199
180
  "vision-sections.md",
200
181
  "execution-sections.md",
@@ -263,6 +244,28 @@ function parseScalar(raw) {
263
244
  return value;
264
245
  }
265
246
 
247
+ function findSpecdevConfig(change) {
248
+ let current = resolve(change);
249
+ while (true) {
250
+ const candidate = join(current, ".speculo", "specdev", "config.json");
251
+ if (isFile(candidate)) {
252
+ try {
253
+ return JSON.parse(readText(candidate));
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+ const parent = dirname(current);
259
+ if (parent === current) return null;
260
+ current = parent;
261
+ }
262
+ }
263
+
264
+ function positiveConfigLimit(config, key, fallback) {
265
+ const value = config?.execution?.[key];
266
+ return Number.isInteger(value) && value >= 1 ? value : fallback;
267
+ }
268
+
266
269
  function parseFrontmatter(path) {
267
270
  const text = readText(path);
268
271
  const lines = text.split(/\r?\n/);
@@ -415,6 +418,19 @@ function validatePathValue(value, { allowProject = true } = {}) {
415
418
  }
416
419
  return null;
417
420
  }
421
+ if (value.startsWith(STATE_ROOT_PREFIX)) {
422
+ const suffix = value.slice(STATE_ROOT_PREFIX.length);
423
+ if (
424
+ !suffix ||
425
+ suffix.startsWith("/") ||
426
+ suffix.startsWith("./") ||
427
+ suffix.startsWith("../") ||
428
+ suffix.includes("/../")
429
+ ) {
430
+ return "state-root Path is not canonical";
431
+ }
432
+ return null;
433
+ }
418
434
  for (const [prefix, name] of [[SKILLS_PREFIX, "skills"], [COMMANDS_PREFIX, "commands"]]) {
419
435
  if (value.startsWith(prefix)) {
420
436
  const suffix = value.slice(prefix.length);
@@ -616,7 +632,7 @@ function validateGlobalStatusAssets(root) {
616
632
  !Array.isArray(data.active) ||
617
633
  !Array.isArray(data.archived)
618
634
  ) {
619
- errors.push(`${toPosix(relative(root, path))}: invalid SpecDev global status v4 seed`);
635
+ errors.push(`${toPosix(relative(root, path))}: invalid SpecDev global status v5 seed`);
620
636
  }
621
637
  }
622
638
 
@@ -627,11 +643,11 @@ function validateGlobalStatusAssets(root) {
627
643
  }
628
644
  const schema = JSON.parse(readText(schemaPath));
629
645
  if (
630
- schema.$id !== "urn:speculo:specdev:status:v4" ||
646
+ schema.$id !== "urn:speculo:specdev:status:v5" ||
631
647
  schema.properties?.schema_version?.const !== GLOBAL_STATUS_SCHEMA_VERSION ||
632
648
  schema.additionalProperties !== false
633
649
  ) {
634
- errors.push("status.schema.json must define the strict SpecDev global status v4 contract");
650
+ errors.push("status.schema.json must define the strict SpecDev global status v5 index contract");
635
651
  }
636
652
  for (const removed of ["work_history", "completed", "result"]) {
637
653
  if (JSON.stringify(schema).includes(`\"${removed}\"`)) {
@@ -641,6 +657,94 @@ function validateGlobalStatusAssets(root) {
641
657
  return errors;
642
658
  }
643
659
 
660
+ function validateExecutionContractAssets(root) {
661
+ const errors = [];
662
+ const configTemplatePath = join(root, "I-init-setup", "config-template.json");
663
+ const changeTemplatePath = join(root, "I-init-setup", "change-status-template.json");
664
+ const configSchemaPath = join(root, "common", "schemas", "config.schema.json");
665
+ const goalPlanSchemaPath = join(root, "common", "schemas", "goal-plan.schema.json");
666
+ const changeSchemaPath = join(root, "common", "schemas", "change-status.schema.json");
667
+
668
+ for (const path of [
669
+ configTemplatePath,
670
+ changeTemplatePath,
671
+ configSchemaPath,
672
+ goalPlanSchemaPath,
673
+ changeSchemaPath,
674
+ ]) {
675
+ if (!isFile(path)) errors.push(`missing execution contract asset ${toPosix(relative(root, path))}`);
676
+ }
677
+ if (errors.length) return errors;
678
+
679
+ const configTemplate = JSON.parse(readText(configTemplatePath));
680
+ if (
681
+ configTemplate.schema_version !== CONFIG_SCHEMA_VERSION ||
682
+ !Number.isInteger(configTemplate.execution?.max_implementation_agents) ||
683
+ configTemplate.execution.max_implementation_agents < 1 ||
684
+ !Number.isInteger(configTemplate.execution?.max_integration_attempts) ||
685
+ configTemplate.execution.max_integration_attempts < 1 ||
686
+ !Number.isInteger(configTemplate.planning?.ui_prototype_default_variants) ||
687
+ !Number.isInteger(configTemplate.planning?.ui_prototype_max_variants) ||
688
+ configTemplate.planning.ui_prototype_default_variants < 1 ||
689
+ configTemplate.planning.ui_prototype_max_variants < configTemplate.planning.ui_prototype_default_variants
690
+ ) {
691
+ errors.push("config-template.json must define SpecDev config v5 with positive execution limits and valid prototype variant bounds");
692
+ }
693
+ for (const obsolete of ["auto_commit", "worktree_for_parallel", "max_parallel"]) {
694
+ if (JSON.stringify(configTemplate).includes(`\"${obsolete}\"`)) {
695
+ errors.push(`config-template.json still contains obsolete execution field ${obsolete}`);
696
+ }
697
+ }
698
+
699
+ const configSchema = JSON.parse(readText(configSchemaPath));
700
+ const limit = configSchema.properties?.execution?.properties?.max_implementation_agents;
701
+ if (
702
+ configSchema.$id !== "urn:speculo:specdev:config:v5" ||
703
+ configSchema.properties?.schema_version?.const !== CONFIG_SCHEMA_VERSION ||
704
+ limit?.minimum !== 1 ||
705
+ configSchema.properties?.execution?.properties?.max_integration_attempts?.minimum !== 1 ||
706
+ configSchema.additionalProperties !== false
707
+ ) {
708
+ errors.push("config.schema.json must define the strict SpecDev config v5 execution contract");
709
+ }
710
+
711
+ const goalPlanSchema = JSON.parse(readText(goalPlanSchemaPath));
712
+ if (
713
+ goalPlanSchema.$id !== "urn:speculo:specdev:goal-plan:v6" ||
714
+ goalPlanSchema.properties?.schema_version?.const !== GOAL_PLAN_SCHEMA_VERSION ||
715
+ goalPlanSchema.properties?.orchestration?.const !== "lead-directed" ||
716
+ goalPlanSchema.properties?.implementation_agent_limit?.minimum !== 1 ||
717
+ goalPlanSchema.properties?.integration_attempt_limit?.minimum !== 1 ||
718
+ !["current", "required"].every((value) => goalPlanSchema.properties?.ticket_workspace_policy?.enum?.includes(value)) ||
719
+ !["direct-parent", "candidate-merge"].every((value) => goalPlanSchema.properties?.integration_gate?.enum?.includes(value)) ||
720
+ goalPlanSchema.additionalProperties !== false
721
+ ) {
722
+ errors.push("goal-plan.schema.json must define the strict Lead-directed Goal Plan v6 workspace strategy contract");
723
+ }
724
+
725
+ const changeTemplate = JSON.parse(readText(changeTemplatePath));
726
+ if (
727
+ changeTemplate.schema_version !== CHANGE_STATUS_SCHEMA_VERSION ||
728
+ changeTemplate.artifact !== "change-status" ||
729
+ !Array.isArray(changeTemplate.worktrees)
730
+ ) {
731
+ errors.push("change-status-template.json must define the SpecDev change-status v6 seed");
732
+ }
733
+
734
+ const changeSchema = JSON.parse(readText(changeSchemaPath));
735
+ const worktreeRequired = new Set(changeSchema.$defs?.worktree?.required ?? []);
736
+ const integrationRequired = new Set(changeSchema.$defs?.integration?.required ?? []);
737
+ if (
738
+ changeSchema.$id !== "urn:speculo:specdev:change-status:v6" ||
739
+ !["implementation_owner", "integration_owner", "source_checkpoint", "integration"].every((key) => worktreeRequired.has(key)) ||
740
+ !["parent_ref", "candidate_sha", "candidate_tree_sha", "candidate_branch", "candidate_workspace_ref", "full_suite", "e2e", "promotion_status"].every((key) => integrationRequired.has(key)) ||
741
+ changeSchema.additionalProperties !== false
742
+ ) {
743
+ errors.push("change-status.schema.json must define the strict Ticket integration v6 contract");
744
+ }
745
+ return errors;
746
+ }
747
+
644
748
  function capabilityChecks(root) {
645
749
  const checks = new Map([
646
750
  [
@@ -683,15 +787,14 @@ function capabilityChecks(root) {
683
787
  join(root, "P-goal-plan", "P-goal-plan.md"),
684
788
  [
685
789
  "Outcome",
686
- "Authority",
790
+ "权威来源",
687
791
  "DAG",
688
792
  "Gate",
689
793
  "Wave",
690
- "coordination_mode",
691
- "workspace_strategy",
692
- "single-session",
693
- "lead-team",
694
- "delegated-execution.md",
794
+ "lead-directed",
795
+ "implementation_agent_limit",
796
+ "candidate-merge",
797
+ "lead-orchestration.md",
695
798
  "Definition of Done",
696
799
  ],
697
800
  ],
@@ -700,7 +803,7 @@ function capabilityChecks(root) {
700
803
  "implement",
701
804
  [
702
805
  join(root, "I-implement", "I-implement.md"),
703
- ["codebase-design", "design-it-twice", "TDD", "双轴审查", "Evidence", "delegated execution", "subagent-delivery", "提交"],
806
+ ["codebase-design", "design-it-twice", "TDD", "双轴审查", "Evidence", "parent-candidate", "subagent-delivery", "实现 commit"],
704
807
  ],
705
808
  ],
706
809
  [
@@ -792,64 +895,32 @@ function capabilityChecks(root) {
792
895
  errors.push("Wayfinder map template must query open Tickets instead of caching them");
793
896
  }
794
897
 
795
- const ordinaryGoalPlanTemplate = join(root, "P-goal-plan", "goal-plan-template.md");
796
- if (isFile(ordinaryGoalPlanTemplate)) {
797
- const text = readText(ordinaryGoalPlanTemplate);
898
+ const goalPlanTemplate = join(root, "P-goal-plan", "goal-plan-template.md");
899
+ if (!isFile(goalPlanTemplate)) {
900
+ errors.push("missing Goal Plan template");
901
+ } else {
902
+ const text = readText(goalPlanTemplate);
798
903
  for (const required of [
799
- "coordination_mode: single-session",
800
- "workspace_strategy: current",
801
- "### Execution Topology",
904
+ "schema_version: 6",
905
+ "orchestration: lead-directed",
906
+ "implementation_agent_limit: 3",
907
+ "integration_attempt_limit: 3",
908
+ "ticket_workspace_policy: current",
909
+ "integration_gate: direct-parent",
910
+ ...REQUIRED_LEAD_GOAL_PLAN_MARKERS,
911
+ "Local direct-parent verification and parent update",
802
912
  ]) {
803
- if (!text.includes(required)) errors.push(`ordinary Goal Plan template lost marker ${required}`);
804
- }
805
- for (const forbidden of [
806
- "Lead",
807
- "Subagent",
808
- "subagent",
809
- "Provider",
810
- "Delivery Contract",
811
- "Dispatch Packet",
812
- "Worker",
813
- "max_correction_rounds",
814
- "execution_model",
815
- "worktree",
816
- "Ticket branch",
817
- "merge commit",
818
- ]) {
819
- if (text.includes(forbidden)) {
820
- errors.push(`ordinary Goal Plan template contains delegated marker ${forbidden}`);
821
- }
913
+ if (!text.includes(required)) errors.push(`Goal Plan template lost marker ${required}`);
822
914
  }
823
915
  }
824
-
825
- const workspaceTemplate = join(root, "P-goal-plan", "workspace-execution-template.md");
826
- if (!isFile(workspaceTemplate)) {
827
- errors.push("missing isolated workspace Goal Plan template");
828
- } else {
829
- const text = readText(workspaceTemplate);
830
- const missing = REQUIRED_WORKSPACE_GOAL_PLAN_MARKERS.filter((marker) => !text.includes(marker));
831
- if (missing.length) errors.push(`isolated workspace Goal Plan template lost markers: ${JSON.stringify(missing)}`);
916
+ for (const obsolete of [
917
+ "P-goal-plan/delegated-execution.md",
918
+ "P-goal-plan/delegated-execution-template.md",
919
+ "P-goal-plan/workspace-execution-template.md",
920
+ "I-implement/delegated-evidence-template.md",
921
+ ]) {
922
+ if (isFile(join(root, obsolete))) errors.push(`obsolete topology asset must be removed: ${obsolete}`);
832
923
  }
833
-
834
- const delegatedTemplate = join(root, "P-goal-plan", "delegated-execution-template.md");
835
- if (!isFile(delegatedTemplate)) {
836
- errors.push("missing delegated Goal Plan template");
837
- } else {
838
- const text = readText(delegatedTemplate);
839
- const missing = REQUIRED_DELEGATED_GOAL_PLAN_MARKERS.filter((marker) => !text.includes(marker));
840
- if (missing.length) errors.push(`delegated Goal Plan template lost markers: ${JSON.stringify(missing)}`);
841
- }
842
-
843
- const sampleCore = [...REQUIRED_GOAL_PLAN_SECTIONS].join("\n");
844
- const partialErrors = [];
845
- validateDelegatedGoalPlan(`${sampleCore}\n## Delegated Execution Addendum\n### Delivery Contract`, "partial delegated self-check", partialErrors);
846
- if (!partialErrors.length) errors.push("partial delegated Goal Plan self-check did not fail");
847
- const strayRoleErrors = [];
848
- validateDelegatedGoalPlan(`${sampleCore}\nLead: owner`, "stray delegated role self-check", strayRoleErrors);
849
- if (!strayRoleErrors.length) errors.push("stray delegated role self-check did not fail");
850
- const completeErrors = [];
851
- validateDelegatedGoalPlan(`${sampleCore}\n${REQUIRED_DELEGATED_GOAL_PLAN_MARKERS.join("\n")}`, "complete delegated self-check", completeErrors);
852
- if (completeErrors.length) errors.push(...completeErrors);
853
924
  return errors;
854
925
  }
855
926
 
@@ -919,6 +990,7 @@ function selfCheck(root) {
919
990
 
920
991
  errors.push(...validateJsonFiles(root));
921
992
  errors.push(...validateGlobalStatusAssets(root));
993
+ errors.push(...validateExecutionContractAssets(root));
922
994
  const references = validateDocumentReferences(root);
923
995
  errors.push(...references.errors);
924
996
  warnings.push(...references.warnings);
@@ -931,6 +1003,10 @@ function selfCheck(root) {
931
1003
  "P-goal-plan/governance-sections.md",
932
1004
  "P-goal-plan/lead-orchestration-protocol.md",
933
1005
  "P-goal-plan/quick-reference-table.md",
1006
+ "P-goal-plan/delegated-execution.md",
1007
+ "P-goal-plan/delegated-execution-template.md",
1008
+ "P-goal-plan/workspace-execution-template.md",
1009
+ "I-implement/delegated-evidence-template.md",
934
1010
  "schemas",
935
1011
  "tools",
936
1012
  "skills",
@@ -1156,8 +1232,8 @@ function validateSpec(path, errors, warnings) {
1156
1232
  }
1157
1233
  const { meta, body } = parseFrontmatter(path);
1158
1234
  const label = basename(path);
1159
- if (meta.artifact !== "spec" || meta.schema_version !== SCHEMA_VERSION) {
1160
- errors.push(`${label}: artifact/schema_version must be spec/${SCHEMA_VERSION}`);
1235
+ if (meta.artifact !== "spec" || meta.schema_version !== DOMAIN_SCHEMA_VERSION) {
1236
+ errors.push(`${label}: artifact/schema_version must be spec/${DOMAIN_SCHEMA_VERSION}`);
1161
1237
  }
1162
1238
  const sources = requireList(meta, "sources", label, errors);
1163
1239
  if (!sources.length) errors.push(`${label}: sources must contain at least one source`);
@@ -1185,8 +1261,8 @@ function validateMap(path, errors) {
1185
1261
  return null;
1186
1262
  }
1187
1263
  const { meta, body } = parseFrontmatter(path);
1188
- if (meta.artifact !== "tickets-map" || meta.schema_version !== SCHEMA_VERSION) {
1189
- errors.push(`${basename(path)}: artifact/schema_version must be tickets-map/${SCHEMA_VERSION}`);
1264
+ if (meta.artifact !== "tickets-map" || meta.schema_version !== DOMAIN_SCHEMA_VERSION) {
1265
+ errors.push(`${basename(path)}: artifact/schema_version must be tickets-map/${DOMAIN_SCHEMA_VERSION}`);
1190
1266
  }
1191
1267
  for (const heading of [
1192
1268
  "## 2. 执行清单",
@@ -1199,71 +1275,86 @@ function validateMap(path, errors) {
1199
1275
  return { path, meta, body };
1200
1276
  }
1201
1277
 
1202
- function validateDelegatedGoalPlan(body, label, errors) {
1203
- if (!DELEGATED_GOAL_PLAN_TRIGGERS.some((marker) => body.includes(marker))) return;
1204
- const missing = REQUIRED_DELEGATED_GOAL_PLAN_MARKERS.filter((marker) => !body.includes(marker));
1205
- if (missing.length) {
1206
- errors.push(`${label}: incomplete delegated execution addendum; missing ${JSON.stringify(missing)}`);
1207
- }
1208
- }
1209
-
1210
- function validateWorkspaceGoalPlan(body, label, errors, required = false) {
1211
- const hasWorkspaceAddendum = body.includes("## Isolated Workspace Addendum");
1212
- if (!required && !hasWorkspaceAddendum) return;
1213
- const missing = REQUIRED_WORKSPACE_GOAL_PLAN_MARKERS.filter((marker) => !body.includes(marker));
1214
- if (missing.length) {
1215
- errors.push(`${label}: incomplete isolated workspace addendum; missing ${JSON.stringify(missing)}`);
1216
- }
1217
- }
1218
-
1219
1278
  function validateGoalPlan(path, errors) {
1220
1279
  if (!isFile(path)) return null;
1221
1280
  const { meta, body } = parseFrontmatter(path);
1222
- if (meta.artifact !== "goal-plan" || meta.schema_version !== SCHEMA_VERSION) {
1223
- errors.push(`${basename(path)}: artifact/schema_version must be goal-plan/${SCHEMA_VERSION}`);
1281
+ const required = [
1282
+ "schema_version",
1283
+ "artifact",
1284
+ "change",
1285
+ "status",
1286
+ "modes",
1287
+ "orchestration",
1288
+ "lead",
1289
+ "implementation_agent_limit",
1290
+ "integration_attempt_limit",
1291
+ "ticket_workspace_policy",
1292
+ "integration_gate",
1293
+ "ready_for_execution",
1294
+ ];
1295
+ const missing = required.filter((key) => !(key in meta));
1296
+ if (missing.length) errors.push(`${basename(path)}: missing keys ${JSON.stringify(missing.sort())}`);
1297
+ const unexpected = Object.keys(meta).filter((key) => !required.includes(key));
1298
+ if (unexpected.length) errors.push(`${basename(path)}: unexpected keys ${JSON.stringify(unexpected.sort())}`);
1299
+ if (meta.artifact !== "goal-plan" || meta.schema_version !== GOAL_PLAN_SCHEMA_VERSION) {
1300
+ errors.push(`${basename(path)}: artifact/schema_version must be goal-plan/${GOAL_PLAN_SCHEMA_VERSION}`);
1224
1301
  }
1225
1302
  const modes = requireList(meta, "modes", basename(path), errors);
1226
1303
  const invalidModes = modes.filter((mode) => !VALID_PLAN_MODES.has(mode));
1227
1304
  if (invalidModes.length) {
1228
1305
  errors.push(`${basename(path)}: invalid modes ${JSON.stringify(invalidModes)}`);
1229
1306
  }
1230
- const coordinationMode = meta.coordination_mode;
1231
- const workspaceStrategy = meta.workspace_strategy;
1232
- if ((coordinationMode === undefined) !== (workspaceStrategy === undefined)) {
1233
- errors.push(`${basename(path)}: coordination_mode and workspace_strategy must appear together`);
1307
+ if (meta.orchestration !== "lead-directed") {
1308
+ errors.push(`${basename(path)}: orchestration must be lead-directed`);
1234
1309
  }
1235
- if (coordinationMode !== undefined && !VALID_COORDINATION_MODES.has(coordinationMode)) {
1236
- errors.push(`${basename(path)}: invalid coordination_mode ${coordinationMode}`);
1310
+ if (typeof meta.lead !== "string" || !meta.lead.trim()) {
1311
+ errors.push(`${basename(path)}: lead must be a non-empty recoverable locator`);
1237
1312
  }
1238
- if (workspaceStrategy !== undefined && !VALID_WORKSPACE_STRATEGIES.has(workspaceStrategy)) {
1239
- errors.push(`${basename(path)}: invalid workspace_strategy ${workspaceStrategy}`);
1313
+ if (!Number.isInteger(meta.implementation_agent_limit) || meta.implementation_agent_limit < 1) {
1314
+ errors.push(`${basename(path)}: implementation_agent_limit must be a positive integer`);
1240
1315
  }
1241
-
1242
- const hasDelegatedMarkers = DELEGATED_GOAL_PLAN_TRIGGERS.some((marker) => body.includes(marker));
1243
- if (coordinationMode === "lead-team" && !hasDelegatedMarkers) {
1244
- errors.push(`${basename(path)}: lead-team requires a complete delegated execution addendum`);
1245
- } else if (coordinationMode === "single-session" && hasDelegatedMarkers) {
1246
- errors.push(`${basename(path)}: single-session cannot contain delegated execution markers`);
1316
+ if (!Number.isInteger(meta.integration_attempt_limit) || meta.integration_attempt_limit < 1) {
1317
+ errors.push(`${basename(path)}: integration_attempt_limit must be a positive integer`);
1247
1318
  }
1248
- validateDelegatedGoalPlan(body, basename(path), errors);
1249
-
1250
- const hasWorkspaceAddendum = body.includes("## Isolated Workspace Addendum");
1251
- if (new Set(["worktree", "mixed"]).has(workspaceStrategy)) {
1252
- validateWorkspaceGoalPlan(body, basename(path), errors, true);
1253
- } else if (workspaceStrategy === "current" && hasWorkspaceAddendum) {
1254
- errors.push(`${basename(path)}: current workspace_strategy cannot contain an isolated workspace addendum`);
1255
- } else if (workspaceStrategy === undefined) {
1256
- validateWorkspaceGoalPlan(body, basename(path), errors);
1319
+ if (!new Set(["current", "required"]).has(meta.ticket_workspace_policy)) {
1320
+ errors.push(`${basename(path)}: ticket_workspace_policy must be current or required`);
1321
+ }
1322
+ if (!new Set(["direct-parent", "candidate-merge"]).has(meta.integration_gate)) {
1323
+ errors.push(`${basename(path)}: integration_gate must be direct-parent or candidate-merge`);
1324
+ }
1325
+ if (
1326
+ (meta.ticket_workspace_policy === "current" && meta.integration_gate !== "direct-parent") ||
1327
+ (meta.ticket_workspace_policy === "required" && meta.integration_gate !== "candidate-merge")
1328
+ ) {
1329
+ errors.push(`${basename(path)}: ticket_workspace_policy and integration_gate must use current/direct-parent or required/candidate-merge`);
1330
+ }
1331
+ for (const obsolete of ["coordination_mode", "workspace_strategy", "terminal_action"]) {
1332
+ if (obsolete in meta) errors.push(`${basename(path)}: obsolete Goal Plan field ${obsolete} is not allowed`);
1333
+ }
1334
+ for (const obsolete of ["## Delegated Execution Addendum", "## Isolated Workspace Addendum"]) {
1335
+ if (body.includes(obsolete)) errors.push(`${basename(path)}: obsolete Goal Plan addendum '${obsolete}' is not allowed`);
1336
+ }
1337
+ const validReadyStates = new Set(["ready", "in_progress"]);
1338
+ const validNotReadyStates = new Set(["draft", "blocked", "completed"]);
1339
+ if (
1340
+ (meta.ready_for_execution === true && !validReadyStates.has(meta.status)) ||
1341
+ (meta.ready_for_execution === false && !validNotReadyStates.has(meta.status))
1342
+ ) {
1343
+ errors.push(`${basename(path)}: ready_for_execution must match status`);
1257
1344
  }
1258
1345
  if (meta.ready_for_execution === true) {
1259
- if (!new Set(["ready", "in_progress", "completed"]).has(meta.status)) {
1260
- errors.push(`${basename(path)}: ready_for_execution conflicts with status`);
1261
- }
1262
1346
  for (const heading of REQUIRED_GOAL_PLAN_SECTIONS) {
1263
1347
  if (!body.includes(heading)) {
1264
1348
  errors.push(`${basename(path)}: ready Goal Plan missing '${heading}'`);
1265
1349
  }
1266
1350
  }
1351
+ const requiredMarkers = meta.ticket_workspace_policy === "current"
1352
+ ? [...REQUIRED_LEAD_GOAL_PLAN_MARKERS, "Local direct-parent verification and parent update"]
1353
+ : [...REQUIRED_LEAD_GOAL_PLAN_MARKERS, "source worktree 不运行 E2E", "Local candidate integration and parent update"];
1354
+ const missingLeadMarkers = requiredMarkers.filter((marker) => !body.includes(marker));
1355
+ if (missingLeadMarkers.length) {
1356
+ errors.push(`${basename(path)}: ready Goal Plan missing Lead/workspace markers ${JSON.stringify(missingLeadMarkers)}`);
1357
+ }
1267
1358
  const assumptions = sectionBody(body, "## Assumptions");
1268
1359
  if (assumptions && assumptions.includes("高影响") && !assumptions.includes("必须为 `false`")) {
1269
1360
  errors.push(`${basename(path)}: high-impact assumptions cannot remain in a ready Goal Plan`);
@@ -1272,6 +1363,27 @@ function validateGoalPlan(path, errors) {
1272
1363
  return { path, meta, body };
1273
1364
  }
1274
1365
 
1366
+ function validateGoalPlanRuntimeLimits(path, change, goalPlan, errors) {
1367
+ if (!goalPlan) return;
1368
+ const config = findSpecdevConfig(change);
1369
+ if (!config) {
1370
+ errors.push(`${basename(path)}: SpecDev config.json is required to validate execution limits`);
1371
+ return;
1372
+ }
1373
+ const configuredAgents = positiveConfigLimit(config, "max_implementation_agents", 0);
1374
+ const configuredAttempts = positiveConfigLimit(config, "max_integration_attempts", 0);
1375
+ if (config.schema_version !== CONFIG_SCHEMA_VERSION || configuredAgents === 0 || configuredAttempts === 0) {
1376
+ errors.push(`${basename(path)}: SpecDev config.json must use schema v${CONFIG_SCHEMA_VERSION} with positive execution limits`);
1377
+ return;
1378
+ }
1379
+ if (goalPlan.meta.implementation_agent_limit > configuredAgents) {
1380
+ errors.push(`${basename(path)}: implementation_agent_limit ${goalPlan.meta.implementation_agent_limit} exceeds config max_implementation_agents ${configuredAgents}`);
1381
+ }
1382
+ if (goalPlan.meta.integration_attempt_limit > configuredAttempts) {
1383
+ errors.push(`${basename(path)}: integration_attempt_limit ${goalPlan.meta.integration_attempt_limit} exceeds config max_integration_attempts ${configuredAttempts}`);
1384
+ }
1385
+ }
1386
+
1275
1387
  function validateDesignTree(path, change, errors) {
1276
1388
  if (!isFile(path)) return null;
1277
1389
  let data;
@@ -1449,8 +1561,8 @@ function validateTicket(path, errors) {
1449
1561
  errors.push(`${basename(path)}: missing frontmatter keys ${JSON.stringify(missing.sort())}`);
1450
1562
  return null;
1451
1563
  }
1452
- if (meta.artifact !== "ticket" || meta.schema_version !== SCHEMA_VERSION) {
1453
- errors.push(`${basename(path)}: artifact/schema_version must be ticket/${SCHEMA_VERSION}`);
1564
+ if (meta.artifact !== "ticket" || meta.schema_version !== DOMAIN_SCHEMA_VERSION) {
1565
+ errors.push(`${basename(path)}: artifact/schema_version must be ticket/${DOMAIN_SCHEMA_VERSION}`);
1454
1566
  }
1455
1567
  const ticketId = String(meta.id);
1456
1568
  if (!/^T-\d{2,}$/.test(ticketId)) errors.push(`${basename(path)}: invalid Ticket id ${ticketId}`);
@@ -1503,13 +1615,7 @@ function validateTicket(path, errors) {
1503
1615
  if (unresolved && !/^无[。.]?$/.test(unresolved.trim())) {
1504
1616
  errors.push(`${basename(path)}: Ready Ticket has unresolved questions`);
1505
1617
  }
1506
- if (
1507
- !lists.writable_paths.length &&
1508
- !body.includes("无代码变更") &&
1509
- !body.includes("仅文档")
1510
- ) {
1511
- errors.push(`${basename(path)}: Ready Ticket has no writable_paths and no no-code explanation`);
1512
- }
1618
+ if (!lists.writable_paths.length) errors.push(`${basename(path)}: Ready implementation Ticket requires writable_paths`);
1513
1619
  if (new Set(["standard", "deep"]).has(meta.planning_depth)) {
1514
1620
  for (const heading of ["## 5. 实现契约", "## 6. 执行路线"]) {
1515
1621
  if (!body.includes(heading)) {
@@ -1524,6 +1630,9 @@ function validateTicket(path, errors) {
1524
1630
  if (!verification || !verification.includes("|")) {
1525
1631
  errors.push(`${basename(path)}: Ready Ticket has no usable verification matrix`);
1526
1632
  }
1633
+ if (!verification.includes("E2E disposition") || !/(current-workspace|source-worktree|parent-candidate|direct-parent)/.test(verification)) {
1634
+ errors.push(`${basename(path)}: Ready Ticket must define E2E disposition and an explicit execution environment`);
1635
+ }
1527
1636
  const acceptance = sectionBody(body, "## 10. 验收标准").toLowerCase();
1528
1637
  if (!acceptance.includes("- [ ]") && !acceptance.includes("- [x]")) {
1529
1638
  errors.push(`${basename(path)}: Ready Ticket has no acceptance checklist`);
@@ -1550,6 +1659,10 @@ function validateChangeStatus(path, expectedChange, errors) {
1550
1659
  "change",
1551
1660
  "change_status",
1552
1661
  "current_work",
1662
+ "works_run",
1663
+ "claimed_investigations",
1664
+ "execution_authorization",
1665
+ "leadership",
1553
1666
  "created_at",
1554
1667
  "updated_at",
1555
1668
  "completed_at",
@@ -1557,11 +1670,14 @@ function validateChangeStatus(path, expectedChange, errors) {
1557
1670
  "archive_path",
1558
1671
  "blockers",
1559
1672
  "deviations",
1673
+ "worktrees",
1560
1674
  ];
1561
1675
  const missing = required.filter((key) => !(key in data));
1562
1676
  if (missing.length) errors.push(`${basename(path)}: missing keys ${JSON.stringify(missing.sort())}`);
1563
- if (data.schema_version !== SCHEMA_VERSION || data.artifact !== "change-status") {
1564
- errors.push(`${basename(path)}: artifact/schema_version must be change-status/${SCHEMA_VERSION}`);
1677
+ const unexpected = Object.keys(data).filter((key) => !required.includes(key));
1678
+ if (unexpected.length) errors.push(`${basename(path)}: unexpected keys ${JSON.stringify(unexpected.sort())}`);
1679
+ if (data.schema_version !== CHANGE_STATUS_SCHEMA_VERSION || data.artifact !== "change-status") {
1680
+ errors.push(`${basename(path)}: artifact/schema_version must be change-status/${CHANGE_STATUS_SCHEMA_VERSION}`);
1565
1681
  }
1566
1682
  if (data.change !== expectedChange) {
1567
1683
  errors.push(`${basename(path)}: change must equal directory name ${expectedChange}`);
@@ -1569,17 +1685,45 @@ function validateChangeStatus(path, expectedChange, errors) {
1569
1685
  if (!new Set(["active", "blocked", "completed", "archived"]).has(data.change_status)) {
1570
1686
  errors.push(`${basename(path)}: invalid change_status ${data.change_status}`);
1571
1687
  }
1572
- if (!Array.isArray(data.blockers) || !Array.isArray(data.deviations)) {
1573
- errors.push(`${basename(path)}: blockers and deviations must be arrays`);
1574
- }
1575
- if (data.worktrees !== undefined && !Array.isArray(data.worktrees)) {
1576
- errors.push(`${basename(path)}: worktrees must be an array when present`);
1688
+ if (!Array.isArray(data.blockers) || !Array.isArray(data.deviations) || !Array.isArray(data.worktrees)) {
1689
+ errors.push(`${basename(path)}: blockers, deviations, and worktrees must be arrays`);
1577
1690
  }
1691
+ const seenWorktreeTickets = new Set();
1578
1692
  for (const worktree of Array.isArray(data.worktrees) ? data.worktrees : []) {
1579
1693
  if (!worktree || typeof worktree !== "object" || Array.isArray(worktree)) {
1580
1694
  errors.push(`${basename(path)}: invalid worktree entry`);
1581
1695
  continue;
1582
1696
  }
1697
+ const requiredWorktreeKeys = [
1698
+ "ticket_id",
1699
+ "owner",
1700
+ "implementation_owner",
1701
+ "integration_owner",
1702
+ "provider",
1703
+ "base_sha",
1704
+ "parent_branch",
1705
+ "branch",
1706
+ "workspace_ref",
1707
+ "source_checkpoint",
1708
+ "integration",
1709
+ "status",
1710
+ "updated_at",
1711
+ ];
1712
+ const missingWorktreeKeys = requiredWorktreeKeys.filter((key) => !(key in worktree));
1713
+ if (missingWorktreeKeys.length) {
1714
+ errors.push(`${basename(path)}: worktree integration contract missing ${JSON.stringify(missingWorktreeKeys)}`);
1715
+ continue;
1716
+ }
1717
+ const unexpectedWorktreeKeys = Object.keys(worktree).filter((key) => !requiredWorktreeKeys.includes(key));
1718
+ if (unexpectedWorktreeKeys.length) {
1719
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} has unexpected keys ${JSON.stringify(unexpectedWorktreeKeys.sort())}`);
1720
+ }
1721
+ if (!/^T-[0-9]{2,}$/.test(String(worktree.ticket_id))) {
1722
+ errors.push(`${basename(path)}: invalid worktree ticket_id ${worktree.ticket_id}`);
1723
+ } else if (seenWorktreeTickets.has(worktree.ticket_id)) {
1724
+ errors.push(`${basename(path)}: duplicate worktree for ${worktree.ticket_id}`);
1725
+ }
1726
+ seenWorktreeTickets.add(worktree.ticket_id);
1583
1727
  const ref = worktree.workspace_ref;
1584
1728
  if (
1585
1729
  typeof ref !== "string" ||
@@ -1592,21 +1736,21 @@ function validateChangeStatus(path, expectedChange, errors) {
1592
1736
  if (!VALID_WORKTREE_STATUS.has(worktree.status)) {
1593
1737
  errors.push(`${basename(path)}: invalid worktree status ${worktree.status}`);
1594
1738
  }
1595
- if (worktree.provider === "git") {
1596
- const expectedRef = `specdev-worktree/${worktree.ticket_id}`;
1597
- if (ref !== expectedRef) {
1598
- errors.push(`${basename(path)}: git workspace_ref must equal ${expectedRef}`);
1599
- }
1739
+ const currentWorkspace = ref === "current";
1740
+ if (worktree.provider !== "git") {
1741
+ errors.push(`${basename(path)}: Ticket worktree ${worktree.ticket_id} provider must be git`);
1742
+ }
1743
+ const expectedRef = `specdev-worktree/${expectedChange}/${worktree.ticket_id}`;
1744
+ if (!currentWorkspace && ref !== expectedRef) {
1745
+ errors.push(`${basename(path)}: git workspace_ref must equal ${expectedRef}`);
1600
1746
  }
1601
1747
 
1602
- const hasIntegrationContract = worktree.terminal_action !== undefined;
1603
- if (!hasIntegrationContract) {
1604
- if (worktree.status === "integrating") {
1605
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integrating requires the complete integration contract`);
1606
- }
1607
- continue;
1748
+ if ("terminal_action" in worktree) {
1749
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} obsolete terminal_action is not allowed`);
1608
1750
  }
1609
1751
  const requiredIntegrationKeys = [
1752
+ "owner",
1753
+ "implementation_owner",
1610
1754
  "integration_owner",
1611
1755
  "parent_branch",
1612
1756
  "source_checkpoint",
@@ -1617,19 +1761,20 @@ function validateChangeStatus(path, expectedChange, errors) {
1617
1761
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration contract missing ${JSON.stringify(missingIntegrationKeys)}`);
1618
1762
  continue;
1619
1763
  }
1620
- if (!VALID_TERMINAL_ACTION.has(worktree.terminal_action)) {
1621
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} has invalid terminal_action ${worktree.terminal_action}`);
1622
- }
1623
- if (typeof worktree.integration_owner !== "string" || !worktree.integration_owner.trim()) {
1624
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration_owner is required`);
1764
+ for (const key of ["owner", "implementation_owner", "integration_owner", "base_sha", "parent_branch", "branch", "updated_at"]) {
1765
+ if (typeof worktree[key] !== "string" || !worktree[key].trim()) {
1766
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${key} is required`);
1767
+ }
1625
1768
  }
1626
1769
  if (typeof worktree.parent_branch !== "string" || !worktree.parent_branch.trim()) {
1627
1770
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} parent_branch is required`);
1628
- } else if (worktree.parent_branch === worktree.branch) {
1771
+ } else if (currentWorkspace && worktree.parent_branch !== worktree.branch) {
1772
+ errors.push(`${basename(path)}: current workspace ${worktree.ticket_id} branch must equal parent_branch`);
1773
+ } else if (!currentWorkspace && worktree.parent_branch === worktree.branch) {
1629
1774
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} parent_branch must differ from branch`);
1630
1775
  }
1631
1776
 
1632
- const sourceRequired = new Set(["review", "integrating", "integrated"]).has(worktree.status);
1777
+ const sourceRequired = new Set(["review", "integrating", "integrated", "removed"]).has(worktree.status);
1633
1778
  if (sourceRequired && (typeof worktree.source_checkpoint !== "string" || !worktree.source_checkpoint.trim())) {
1634
1779
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires source_checkpoint`);
1635
1780
  } else if (
@@ -1646,19 +1791,31 @@ function validateChangeStatus(path, expectedChange, errors) {
1646
1791
  }
1647
1792
  const requiredResultKeys = [
1648
1793
  "status",
1794
+ "parent_ref",
1649
1795
  "parent_before_sha",
1650
1796
  "source_sha",
1797
+ "candidate_sha",
1798
+ "candidate_tree_sha",
1799
+ "candidate_branch",
1800
+ "candidate_workspace_ref",
1651
1801
  "result_sha",
1652
1802
  "method",
1653
1803
  "conflict_paths",
1654
1804
  "verification",
1805
+ "full_suite",
1806
+ "e2e",
1655
1807
  "evidence",
1656
1808
  "attempts",
1809
+ "promotion_status",
1657
1810
  ];
1658
1811
  const missingResultKeys = requiredResultKeys.filter((key) => !(key in integration));
1659
1812
  if (missingResultKeys.length) {
1660
1813
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration missing ${JSON.stringify(missingResultKeys)}`);
1661
1814
  }
1815
+ const unexpectedResultKeys = Object.keys(integration).filter((key) => !requiredResultKeys.includes(key));
1816
+ if (unexpectedResultKeys.length) {
1817
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration has unexpected keys ${JSON.stringify(unexpectedResultKeys.sort())}`);
1818
+ }
1662
1819
  if (!VALID_INTEGRATION_STATUS.has(integration.status)) {
1663
1820
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} has invalid integration status ${integration.status}`);
1664
1821
  }
@@ -1680,14 +1837,53 @@ function validateChangeStatus(path, expectedChange, errors) {
1680
1837
  ) {
1681
1838
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration evidence must be a rooted Ticket Evidence path`);
1682
1839
  }
1683
- if (worktree.terminal_action === "retain" && new Set(["integrating", "integrated"]).has(worktree.status)) {
1684
- errors.push(`${basename(path)}: retain worktree ${worktree.ticket_id} cannot be ${worktree.status}`);
1840
+ if (!new Set(["pending", "applying", "applied", "failed", "stale"]).has(integration.promotion_status)) {
1841
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} has invalid promotion_status ${integration.promotion_status}`);
1685
1842
  }
1686
- const integrationLifecycleActive = new Set(["integrating", "integrated"]).has(worktree.status);
1687
- if (integrationLifecycleActive) {
1688
- if (worktree.terminal_action !== "integrate") {
1689
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires terminal_action=integrate`);
1843
+ for (const [name, suite] of [["full_suite", integration.full_suite], ["e2e", integration.e2e]]) {
1844
+ if (!suite || typeof suite !== "object" || Array.isArray(suite) || typeof suite.required !== "boolean" || !VALID_E2E_STATUS.has(suite.status)) {
1845
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${name} contract is invalid`);
1846
+ continue;
1847
+ }
1848
+ const unexpectedSuiteKeys = Object.keys(suite).filter((key) => !new Set(["required", "status", "reason", "evidence"]).has(key));
1849
+ if (unexpectedSuiteKeys.length) errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${name} has unexpected keys ${JSON.stringify(unexpectedSuiteKeys.sort())}`);
1850
+ if (suite.required === false && (suite.status !== "not-required" || typeof suite.reason !== "string" || !suite.reason.trim())) {
1851
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} non-required ${name} needs not-required status and reason`);
1852
+ }
1853
+ if (suite.required === true && suite.status === "not-required") {
1854
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} required ${name} cannot be not-required`);
1690
1855
  }
1856
+ }
1857
+ const e2e = integration.e2e;
1858
+ if (
1859
+ !e2e ||
1860
+ typeof e2e !== "object" ||
1861
+ Array.isArray(e2e) ||
1862
+ typeof e2e.required !== "boolean" ||
1863
+ !VALID_E2E_STATUS.has(e2e.status)
1864
+ ) {
1865
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration e2e contract is invalid`);
1866
+ } else {
1867
+ const unexpectedE2eKeys = Object.keys(e2e).filter((key) => !new Set(["required", "status", "reason", "evidence"]).has(key));
1868
+ if (unexpectedE2eKeys.length) {
1869
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} integration e2e has unexpected keys ${JSON.stringify(unexpectedE2eKeys.sort())}`);
1870
+ }
1871
+ if (e2e.required === false && e2e.status !== "not-required") {
1872
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} non-required E2E must use status=not-required`);
1873
+ }
1874
+ if (e2e.required === true && e2e.status === "not-required") {
1875
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} required E2E cannot be not-required`);
1876
+ }
1877
+ if (
1878
+ e2e.required === true &&
1879
+ e2e.status === "passed" &&
1880
+ (typeof e2e.evidence !== "string" || !e2e.evidence.trim())
1881
+ ) {
1882
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} passed required E2E needs evidence`);
1883
+ }
1884
+ }
1885
+ const integrationLifecycleActive = new Set(["integrating", "integrated", "removed"]).has(worktree.status);
1886
+ if (integrationLifecycleActive) {
1691
1887
  if (typeof integration.parent_before_sha !== "string" || !integration.parent_before_sha.trim()) {
1692
1888
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires parent_before_sha`);
1693
1889
  }
@@ -1696,36 +1892,69 @@ function validateChangeStatus(path, expectedChange, errors) {
1696
1892
  } else if (integration.source_sha !== worktree.source_checkpoint) {
1697
1893
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} source_sha must equal source_checkpoint`);
1698
1894
  }
1699
- if (!Number.isInteger(integration.attempts) || integration.attempts < 1) {
1700
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires attempts >= 1`);
1895
+ if (currentWorkspace) {
1896
+ if (integration.candidate_sha !== null || integration.candidate_tree_sha !== null || integration.candidate_branch !== null || integration.candidate_workspace_ref !== null) {
1897
+ errors.push(`${basename(path)}: current workspace ${worktree.ticket_id} cannot record candidate workspace fields`);
1898
+ }
1899
+ if (integration.method !== "direct-parent") {
1900
+ errors.push(`${basename(path)}: current workspace ${worktree.ticket_id} requires integration.method=direct-parent`);
1901
+ }
1902
+ } else {
1903
+ if (typeof integration.candidate_sha !== "string" || !integration.candidate_sha.trim()) {
1904
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires candidate_sha`);
1905
+ }
1906
+ const expectedCandidateBranch = `speculo/integration/${expectedChange}/${worktree.ticket_id}`;
1907
+ if (integration.candidate_branch !== expectedCandidateBranch) {
1908
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} candidate_branch must equal ${expectedCandidateBranch}`);
1909
+ }
1910
+ const expectedCandidateRef = `specdev-worktree/.integration/${expectedChange}/${worktree.ticket_id}`;
1911
+ if (integration.candidate_workspace_ref !== expectedCandidateRef) {
1912
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} candidate_workspace_ref must equal ${expectedCandidateRef}`);
1913
+ }
1914
+ if (!new Set(["fast-forward", "merge-commit"]).has(integration.method)) {
1915
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires an integration method`);
1916
+ }
1701
1917
  }
1918
+ if (!Number.isInteger(integration.attempts) || integration.attempts < 1) {
1919
+ errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires attempts >= 1`);
1920
+ }
1702
1921
  }
1703
- if (worktree.status === "integrating" && integration.status !== "running") {
1704
- errors.push(`${basename(path)}: integrating worktree ${worktree.ticket_id} requires integration.status=running`);
1922
+ if (worktree.status === "integrating" && integration.status !== "candidate") {
1923
+ errors.push(`${basename(path)}: integrating worktree ${worktree.ticket_id} requires integration.status=candidate`);
1705
1924
  }
1706
- if (worktree.status === "integrated") {
1925
+ if (new Set(["integrated", "removed"]).has(worktree.status)) {
1707
1926
  if (
1708
1927
  integration.status !== "passed" ||
1709
1928
  integration.verification !== "passed" ||
1710
- !new Set(["fast-forward", "merge-commit"]).has(integration.method) ||
1929
+ !(currentWorkspace ? integration.method === "direct-parent" : new Set(["fast-forward", "merge-commit"]).has(integration.method)) ||
1711
1930
  typeof integration.result_sha !== "string" ||
1712
- !integration.result_sha.trim()
1931
+ !integration.result_sha.trim() ||
1932
+ (currentWorkspace ? integration.result_sha !== worktree.source_checkpoint : integration.result_sha !== integration.candidate_sha) ||
1933
+ !e2e ||
1934
+ !new Set(["not-required", "passed"]).has(e2e.status)
1713
1935
  ) {
1714
- errors.push(`${basename(path)}: integrated worktree ${worktree.ticket_id} requires a passed result checkpoint`);
1936
+ errors.push(`${basename(path)}: ${worktree.status} worktree ${worktree.ticket_id} requires passed candidate/result/E2E state`);
1715
1937
  }
1716
- if (integration.method === "fast-forward") {
1938
+ if (currentWorkspace && integration.method === "direct-parent") {
1939
+ if (integration.candidate_sha !== null || integration.candidate_tree_sha !== null || integration.candidate_branch !== null || integration.candidate_workspace_ref !== null) {
1940
+ errors.push(`${basename(path)}: current workspace ${worktree.ticket_id} direct-parent cannot record candidate fields`);
1941
+ }
1717
1942
  if (integration.result_sha !== worktree.source_checkpoint) {
1718
- errors.push(`${basename(path)}: integrated worktree ${worktree.ticket_id} fast-forward result_sha must equal source_checkpoint`);
1943
+ errors.push(`${basename(path)}: current workspace ${worktree.status} ${worktree.ticket_id} result_sha must equal source_checkpoint`);
1944
+ }
1945
+ } else if (integration.method === "fast-forward") {
1946
+ if (integration.candidate_sha !== worktree.source_checkpoint) {
1947
+ errors.push(`${basename(path)}: ${worktree.status} worktree ${worktree.ticket_id} fast-forward candidate/result must equal source_checkpoint`);
1719
1948
  }
1720
1949
  if (Array.isArray(integration.conflict_paths) && integration.conflict_paths.length > 0) {
1721
- errors.push(`${basename(path)}: integrated worktree ${worktree.ticket_id} fast-forward cannot record conflict_paths`);
1950
+ errors.push(`${basename(path)}: ${worktree.status} worktree ${worktree.ticket_id} fast-forward cannot record conflict_paths`);
1722
1951
  }
1723
1952
  } else if (
1724
1953
  integration.method === "merge-commit" &&
1725
- typeof integration.result_sha === "string" &&
1726
- (integration.result_sha === worktree.source_checkpoint || integration.result_sha === integration.parent_before_sha)
1954
+ typeof integration.candidate_sha === "string" &&
1955
+ (integration.candidate_sha === worktree.source_checkpoint || integration.candidate_sha === integration.parent_before_sha)
1727
1956
  ) {
1728
- errors.push(`${basename(path)}: integrated worktree ${worktree.ticket_id} merge-commit result_sha must be a distinct checkpoint`);
1957
+ errors.push(`${basename(path)}: ${worktree.status} worktree ${worktree.ticket_id} merge-commit candidate must be a distinct checkpoint`);
1729
1958
  }
1730
1959
  }
1731
1960
  }
@@ -1741,7 +1970,96 @@ function validateChangeStatus(path, expectedChange, errors) {
1741
1970
  return data;
1742
1971
  }
1743
1972
 
1744
- function validateChange(change, stage = null) {
1973
+ function validateCurrentWorkspaceExecution(changeStatus, goalPlan, errors) {
1974
+ if (goalPlan?.meta.ticket_workspace_policy !== "current") return;
1975
+ const entries = Array.isArray(changeStatus?.worktrees) ? changeStatus.worktrees : [];
1976
+ const active = [];
1977
+ for (const worktree of entries) {
1978
+ if (worktree.workspace_ref !== "current") {
1979
+ errors.push(`${worktree.ticket_id}: current Goal Plan requires workspace_ref=current; source worktrees are not allowed`);
1980
+ }
1981
+ if (worktree.parent_branch !== worktree.branch) {
1982
+ errors.push(`${worktree.ticket_id}: current workspace execution must commit directly on parent_branch`);
1983
+ }
1984
+ if (new Set(["active", "review", "integrating", "blocked"]).has(worktree.status)) {
1985
+ active.push(worktree.ticket_id);
1986
+ }
1987
+ const integration = worktree.integration;
1988
+ if (integration && typeof integration === "object" && !Array.isArray(integration)) {
1989
+ if (["candidate_sha", "candidate_tree_sha", "candidate_branch", "candidate_workspace_ref"].some((key) => integration[key] !== null)) {
1990
+ errors.push(`${worktree.ticket_id}: current workspace execution cannot record candidate workspace fields`);
1991
+ }
1992
+ if (integration.method !== null && integration.method !== "direct-parent") {
1993
+ errors.push(`${worktree.ticket_id}: current workspace execution requires direct-parent integration`);
1994
+ }
1995
+ }
1996
+ }
1997
+ if (active.length > 1) {
1998
+ errors.push(`current Goal Plan requires strictly serial Ticket execution; active worktrees: ${active.join(", ")}`);
1999
+ }
2000
+ }
2001
+
2002
+ function gitOutput(repoRoot, args) {
2003
+ try {
2004
+ return execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
2005
+ } catch {
2006
+ return null;
2007
+ }
2008
+ }
2009
+
2010
+ function gitCommitExists(repoRoot, sha) {
2011
+ return typeof sha === "string" && gitSucceeds(repoRoot, ["cat-file", "-e", `${sha}^{commit}`]);
2012
+ }
2013
+
2014
+ function gitSucceeds(repoRoot, args) {
2015
+ try {
2016
+ execFileSync("git", ["-C", repoRoot, ...args], { stdio: ["ignore", "ignore", "ignore"] });
2017
+ return true;
2018
+ } catch {
2019
+ return false;
2020
+ }
2021
+ }
2022
+
2023
+ function validateGitEvidence(repoRoot, changeStatus, errors) {
2024
+ if (!repoRoot) return;
2025
+ const resolvedRoot = resolve(repoRoot);
2026
+ if (gitOutput(resolvedRoot, ["rev-parse", "--is-inside-work-tree"]) !== "true") {
2027
+ errors.push(`--repo is not a Git worktree: ${resolvedRoot}`);
2028
+ return;
2029
+ }
2030
+ const currentBranch = gitOutput(resolvedRoot, ["branch", "--show-current"]);
2031
+ for (const worktree of Array.isArray(changeStatus?.worktrees) ? changeStatus.worktrees : []) {
2032
+ const label = String(worktree.ticket_id ?? "worktree");
2033
+ for (const [name, sha] of [
2034
+ ["base_sha", worktree.base_sha],
2035
+ ["source_checkpoint", worktree.source_checkpoint],
2036
+ ["parent_before_sha", worktree.integration?.parent_before_sha],
2037
+ ["source_sha", worktree.integration?.source_sha],
2038
+ ["candidate_sha", worktree.integration?.candidate_sha],
2039
+ ["result_sha", worktree.integration?.result_sha],
2040
+ ]) {
2041
+ if (sha !== null && sha !== undefined && sha !== "" && !gitCommitExists(resolvedRoot, sha)) {
2042
+ errors.push(`${label}: ${name} is not a resolvable Git commit: ${sha}`);
2043
+ }
2044
+ }
2045
+ if (worktree.workspace_ref === "current") {
2046
+ if (currentBranch !== worktree.parent_branch) errors.push(`${label}: current branch ${currentBranch ?? "<detached>"} must equal ${worktree.parent_branch}`);
2047
+ if (new Set(["integrated", "removed"]).has(worktree.status) && worktree.integration?.result_sha && gitOutput(resolvedRoot, ["rev-parse", worktree.parent_branch]) !== worktree.integration.result_sha) {
2048
+ errors.push(`${label}: parent branch HEAD must equal recorded result_sha`);
2049
+ }
2050
+ if (worktree.integration?.source_sha && worktree.base_sha && !gitSucceeds(resolvedRoot, ["merge-base", "--is-ancestor", worktree.base_sha, worktree.integration.source_sha])) {
2051
+ errors.push(`${label}: source_sha must descend from base_sha`);
2052
+ }
2053
+ } else if (worktree.integration?.source_sha && worktree.base_sha && !gitSucceeds(resolvedRoot, ["merge-base", "--is-ancestor", worktree.base_sha, worktree.integration.source_sha])) {
2054
+ errors.push(`${label}: source_sha must descend from base_sha`);
2055
+ }
2056
+ if (new Set(["integrated", "removed"]).has(worktree.status) && gitOutput(resolvedRoot, ["status", "--porcelain"])) {
2057
+ errors.push(`${label}: repository is dirty while Ticket is recorded as ${worktree.status}`);
2058
+ }
2059
+ }
2060
+ }
2061
+
2062
+ function validateChange(change, stage = null, repoRoot = null) {
1745
2063
  const errors = [];
1746
2064
  const warnings = [];
1747
2065
  if (!isDirectory(change)) {
@@ -1837,6 +2155,38 @@ function validateChange(change, stage = null) {
1837
2155
  }
1838
2156
  }
1839
2157
 
2158
+ if (stage === "implement" && ticketMode && changeStatus) {
2159
+ const worktreesByTicket = new Map(
2160
+ (Array.isArray(changeStatus.worktrees) ? changeStatus.worktrees : [])
2161
+ .filter((entry) => entry && typeof entry === "object")
2162
+ .map((entry) => [String(entry.ticket_id), entry]),
2163
+ );
2164
+ for (const [ticketId, artifact] of tickets) {
2165
+ if (new Set(["draft", "cancelled"]).has(artifact.meta.status)) continue;
2166
+ if (!worktreesByTicket.has(ticketId)) {
2167
+ errors.push(`${ticketId}: Implement stage requires one Ticket workspace execution record`);
2168
+ }
2169
+ }
2170
+ for (const ticketId of worktreesByTicket.keys()) {
2171
+ if (!tickets.has(ticketId)) {
2172
+ errors.push(`${ticketId}: worktree record has no matching Ticket`);
2173
+ }
2174
+ }
2175
+ }
2176
+ if (changeStatus && goalPlan) {
2177
+ validateGoalPlanRuntimeLimits(goalPlan.path, change, goalPlan, errors);
2178
+ const attemptLimit = Number.isInteger(goalPlan.meta.integration_attempt_limit) ? goalPlan.meta.integration_attempt_limit : null;
2179
+ if (attemptLimit !== null) {
2180
+ for (const worktree of changeStatus.worktrees ?? []) {
2181
+ if (worktree?.integration && Number.isInteger(worktree.integration.attempts) && worktree.integration.attempts > attemptLimit) {
2182
+ errors.push(`${worktree.ticket_id}: integration attempts ${worktree.integration.attempts} exceed Goal Plan limit ${attemptLimit}`);
2183
+ }
2184
+ }
2185
+ }
2186
+ validateCurrentWorkspaceExecution(changeStatus, goalPlan, errors);
2187
+ }
2188
+ validateGitEvidence(repoRoot, changeStatus, errors);
2189
+
1840
2190
  if (spec) {
1841
2191
  const declaredContracts = new Set(spec.body.match(/\bAC-\d+\b/g) ?? []);
1842
2192
  const coveredContracts = new Set(
@@ -1922,19 +2272,35 @@ function validateChange(change, stage = null) {
1922
2272
  continue;
1923
2273
  }
1924
2274
  const text = readText(evidence);
1925
- for (const heading of [
1926
- "## 2. 修改范围",
1927
- "## 3. 验收与合同映射",
1928
- "## 4. 验证执行",
1929
- "## 5. 双轴审查",
1930
- "## 6. 路径所有权审计",
1931
- "## 7. 偏差与决策",
1932
- "## 8. 残余风险与后续",
1933
- ]) {
2275
+ const currentWorkspace = goalPlan?.meta.ticket_workspace_policy === "current";
2276
+ const requiredHeadings = [
2277
+ "## 2. Lead Dispatch And Candidate Return",
2278
+ "## 3. 修改范围与路径所有权",
2279
+ "## 4. 验收与合同映射",
2280
+ "## 5. Workspace Verification",
2281
+ "## 6. 双轴审查",
2282
+ "## 7. Integration Verification",
2283
+ "## 8. 偏差与决策",
2284
+ "## 9. 残余风险与交付定位",
2285
+ ];
2286
+ for (const heading of requiredHeadings) {
1934
2287
  if (!text.includes(heading)) {
1935
2288
  errors.push(`${toPosix(relative(change, evidence))}: missing '${heading}'`);
1936
2289
  }
1937
2290
  }
2291
+ const workspaceVerification = sectionBody(text, "## 5. Workspace Verification");
2292
+ if (!/(current-workspace|source-worktree)/.test(workspaceVerification)) {
2293
+ errors.push(`${toPosix(relative(change, evidence))}: Workspace Verification must name current-workspace or source-worktree`);
2294
+ }
2295
+ if (!currentWorkspace && /E2E[^\n]*(?:\bpass(?:ed)?\b|通过)/i.test(workspaceVerification) && !/不得|禁止|not-run/i.test(workspaceVerification)) {
2296
+ errors.push(`${toPosix(relative(change, evidence))}: E2E pass cannot come from source-worktree`);
2297
+ }
2298
+ const worktree = Array.isArray(changeStatus?.worktrees)
2299
+ ? changeStatus.worktrees.find((entry) => entry?.ticket_id === ticketId)
2300
+ : null;
2301
+ if (!worktree || !new Set(["integrated", "removed"]).has(worktree.status)) {
2302
+ errors.push(`${ticketId}: status is done but no integrated or removed Ticket worktree exists`);
2303
+ }
1938
2304
  }
1939
2305
 
1940
2306
  if (goalPlan?.meta.ready_for_execution === true) {
@@ -2014,13 +2380,14 @@ function printResults(errors, warnings) {
2014
2380
  }
2015
2381
 
2016
2382
  function usage() {
2017
- console.error("Usage: node validate-specdev.mjs [--stage <stage>] <change-directory> | --self-check");
2383
+ console.error("Usage: node validate-specdev.mjs [--stage <stage>] [--repo <project-root>] <change-directory> | --self-check");
2018
2384
  return 2;
2019
2385
  }
2020
2386
 
2021
2387
  function main(argv) {
2022
2388
  let selfCheckRequested = false;
2023
2389
  let stage = null;
2390
+ let repoRoot = null;
2024
2391
  const positional = [];
2025
2392
  for (let index = 0; index < argv.length; index += 1) {
2026
2393
  const arg = argv[index];
@@ -2031,6 +2398,11 @@ function main(argv) {
2031
2398
  index += 1;
2032
2399
  } else if (arg.startsWith("--stage=")) {
2033
2400
  stage = arg.slice("--stage=".length);
2401
+ } else if (arg === "--repo") {
2402
+ repoRoot = argv[index + 1] ?? null;
2403
+ index += 1;
2404
+ } else if (arg.startsWith("--repo=")) {
2405
+ repoRoot = arg.slice("--repo=".length);
2034
2406
  } else if (arg.startsWith("--")) {
2035
2407
  return usage();
2036
2408
  } else {
@@ -2046,7 +2418,7 @@ function main(argv) {
2046
2418
  return printResults(result.errors, result.warnings);
2047
2419
  }
2048
2420
  if (positional.length === 1) {
2049
- const result = validateChange(resolve(positional[0]), stage);
2421
+ const result = validateChange(resolve(positional[0]), stage, repoRoot);
2050
2422
  return printResults(result.errors, result.warnings);
2051
2423
  }
2052
2424
  return usage();