@namewta/speculo 0.7.3 → 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 (43) hide show
  1. package/dist/src/migrations.js +190 -39
  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 +51 -15
  5. package/template/canonical/canonical-specdev-goal-plan.md +183 -116
  6. package/template/canonical/canonical-specdev-grill-with-docs.md +51 -15
  7. package/template/canonical/canonical-specdev-spec.md +61 -21
  8. package/template/canonical/canonical-specdev-tickets.md +83 -43
  9. package/template/canonical/canonical-specdev-wayfinder.md +51 -15
  10. package/template/skills/migrate-runtime-state/references/migration-contract.md +3 -3
  11. package/template/skills/migrate-runtime-state/scripts/migrate-runtime-state.mjs +94 -12
  12. package/template/workflows/specdev/I-implement/I-implement.md +27 -26
  13. package/template/workflows/specdev/I-implement/evidence-template.md +18 -12
  14. package/template/workflows/specdev/I-implement/execution-preflight.md +11 -9
  15. package/template/workflows/specdev/I-init-setup/I-init-setup.md +2 -2
  16. package/template/workflows/specdev/I-init-setup/change-status-template.json +3 -3
  17. package/template/workflows/specdev/I-init-setup/config-template.json +5 -2
  18. package/template/workflows/specdev/INDEX.md +6 -6
  19. package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +16 -16
  20. package/template/workflows/specdev/P-goal-plan/completion-control.md +8 -8
  21. package/template/workflows/specdev/P-goal-plan/goal-plan-template.md +20 -14
  22. package/template/workflows/specdev/P-goal-plan/lead-orchestration.md +5 -5
  23. package/template/workflows/specdev/P-goal-plan/orchestration-protocol.md +13 -13
  24. package/template/workflows/specdev/P-goal-plan/planning-modes.md +27 -15
  25. package/template/workflows/specdev/P-prototype/ui-prototype.md +1 -1
  26. package/template/workflows/specdev/T-tickets/T-tickets.md +4 -4
  27. package/template/workflows/specdev/T-tickets/ticket-readiness.md +3 -3
  28. package/template/workflows/specdev/T-tickets/ticket-template.md +6 -6
  29. package/template/workflows/specdev/T-tickets/tickets-map-template.md +2 -2
  30. package/template/workflows/specdev/common/README.md +1 -1
  31. package/template/workflows/specdev/common/rules/change-completion.md +2 -2
  32. package/template/workflows/specdev/common/rules/evidence-and-verification.md +10 -6
  33. package/template/workflows/specdev/common/rules/path-ownership.md +4 -4
  34. package/template/workflows/specdev/common/schemas/change-status.schema.json +31 -4
  35. package/template/workflows/specdev/common/schemas/config.schema.json +12 -6
  36. package/template/workflows/specdev/common/schemas/goal-plan.schema.json +15 -6
  37. package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +3 -3
  38. package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +1 -1
  39. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +4 -4
  40. package/template/workflows/specdev/common/skills/subagent-delivery/references/external-web-subagent.md +1 -1
  41. package/template/workflows/specdev/common/skills/subagent-delivery/references/native-subagent.md +4 -4
  42. package/template/workflows/specdev/common/tools/README.md +2 -1
  43. package/template/workflows/specdev/common/tools/validate-specdev.mjs +261 -66
@@ -15,13 +15,14 @@ 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
22
  const DOMAIN_SCHEMA_VERSION = 3;
22
- const CONFIG_SCHEMA_VERSION = 4;
23
- const GOAL_PLAN_SCHEMA_VERSION = 5;
24
- const CHANGE_STATUS_SCHEMA_VERSION = 5;
23
+ const CONFIG_SCHEMA_VERSION = 5;
24
+ const GOAL_PLAN_SCHEMA_VERSION = 6;
25
+ const CHANGE_STATUS_SCHEMA_VERSION = 6;
25
26
  const GLOBAL_STATUS_SCHEMA_VERSION = 5;
26
27
  const WORKFLOW_PREFIX = "{roots.workflows}/specdev/";
27
28
  const STATE_PREFIX = "{roots.state}/specdev/";
@@ -74,7 +75,7 @@ const VALID_WORKTREE_STATUS = new Set([
74
75
  "blocked",
75
76
  ]);
76
77
  const VALID_INTEGRATION_STATUS = new Set(["pending", "candidate", "passed", "failed", "stale"]);
77
- const VALID_INTEGRATION_METHOD = new Set([null, "fast-forward", "merge-commit"]);
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"]);
79
80
  const VALID_E2E_STATUS = new Set(["not-required", "pending", "passed", "failed"]);
80
81
  const VALID_DESIGN_TREE_STATUS = new Set(["active", "consensus", "blocked"]);
@@ -148,11 +149,9 @@ const REQUIRED_LEAD_GOAL_PLAN_MARKERS = [
148
149
  "Implementation subagents",
149
150
  "Read-only agents",
150
151
  "execution-time dynamic",
151
- "### Ticket Workspace and Candidate Integration",
152
- "source worktree 不运行 E2E",
152
+ "### Ticket Workspace and Integration",
153
153
  "### Authorization Matrix",
154
154
  "Implementation commit",
155
- "Local candidate integration and parent update",
156
155
  ];
157
156
  const STATE_ARTIFACT_BASENAMES = new Set([
158
157
  "spec.md",
@@ -245,6 +244,28 @@ function parseScalar(raw) {
245
244
  return value;
246
245
  }
247
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
+
248
269
  function parseFrontmatter(path) {
249
270
  const text = readText(path);
250
271
  const lines = text.split(/\r?\n/);
@@ -660,9 +681,14 @@ function validateExecutionContractAssets(root) {
660
681
  configTemplate.schema_version !== CONFIG_SCHEMA_VERSION ||
661
682
  !Number.isInteger(configTemplate.execution?.max_implementation_agents) ||
662
683
  configTemplate.execution.max_implementation_agents < 1 ||
663
- configTemplate.execution.max_implementation_agents > 3
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
664
690
  ) {
665
- errors.push("config-template.json must define SpecDev config v4 with max_implementation_agents from 1 to 3");
691
+ errors.push("config-template.json must define SpecDev config v5 with positive execution limits and valid prototype variant bounds");
666
692
  }
667
693
  for (const obsolete of ["auto_commit", "worktree_for_parallel", "max_parallel"]) {
668
694
  if (JSON.stringify(configTemplate).includes(`\"${obsolete}\"`)) {
@@ -673,26 +699,27 @@ function validateExecutionContractAssets(root) {
673
699
  const configSchema = JSON.parse(readText(configSchemaPath));
674
700
  const limit = configSchema.properties?.execution?.properties?.max_implementation_agents;
675
701
  if (
676
- configSchema.$id !== "urn:speculo:specdev:config:v4" ||
702
+ configSchema.$id !== "urn:speculo:specdev:config:v5" ||
677
703
  configSchema.properties?.schema_version?.const !== CONFIG_SCHEMA_VERSION ||
678
704
  limit?.minimum !== 1 ||
679
- limit?.maximum !== 3 ||
705
+ configSchema.properties?.execution?.properties?.max_integration_attempts?.minimum !== 1 ||
680
706
  configSchema.additionalProperties !== false
681
707
  ) {
682
- errors.push("config.schema.json must define the strict SpecDev config v4 execution contract");
708
+ errors.push("config.schema.json must define the strict SpecDev config v5 execution contract");
683
709
  }
684
710
 
685
711
  const goalPlanSchema = JSON.parse(readText(goalPlanSchemaPath));
686
712
  if (
687
- goalPlanSchema.$id !== "urn:speculo:specdev:goal-plan:v5" ||
713
+ goalPlanSchema.$id !== "urn:speculo:specdev:goal-plan:v6" ||
688
714
  goalPlanSchema.properties?.schema_version?.const !== GOAL_PLAN_SCHEMA_VERSION ||
689
715
  goalPlanSchema.properties?.orchestration?.const !== "lead-directed" ||
690
- goalPlanSchema.properties?.implementation_agent_limit?.maximum !== 3 ||
691
- goalPlanSchema.properties?.ticket_workspace_policy?.const !== "required" ||
692
- goalPlanSchema.properties?.integration_gate?.const !== "candidate-merge" ||
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)) ||
693
720
  goalPlanSchema.additionalProperties !== false
694
721
  ) {
695
- errors.push("goal-plan.schema.json must define the strict Lead-directed Goal Plan v5 contract");
722
+ errors.push("goal-plan.schema.json must define the strict Lead-directed Goal Plan v6 workspace strategy contract");
696
723
  }
697
724
 
698
725
  const changeTemplate = JSON.parse(readText(changeTemplatePath));
@@ -701,19 +728,19 @@ function validateExecutionContractAssets(root) {
701
728
  changeTemplate.artifact !== "change-status" ||
702
729
  !Array.isArray(changeTemplate.worktrees)
703
730
  ) {
704
- errors.push("change-status-template.json must define the SpecDev change-status v4 seed");
731
+ errors.push("change-status-template.json must define the SpecDev change-status v6 seed");
705
732
  }
706
733
 
707
734
  const changeSchema = JSON.parse(readText(changeSchemaPath));
708
735
  const worktreeRequired = new Set(changeSchema.$defs?.worktree?.required ?? []);
709
736
  const integrationRequired = new Set(changeSchema.$defs?.integration?.required ?? []);
710
737
  if (
711
- changeSchema.$id !== "urn:speculo:specdev:change-status:v5" ||
738
+ changeSchema.$id !== "urn:speculo:specdev:change-status:v6" ||
712
739
  !["implementation_owner", "integration_owner", "source_checkpoint", "integration"].every((key) => worktreeRequired.has(key)) ||
713
740
  !["parent_ref", "candidate_sha", "candidate_tree_sha", "candidate_branch", "candidate_workspace_ref", "full_suite", "e2e", "promotion_status"].every((key) => integrationRequired.has(key)) ||
714
741
  changeSchema.additionalProperties !== false
715
742
  ) {
716
- errors.push("change-status.schema.json must define the strict Ticket candidate-integration v5 contract");
743
+ errors.push("change-status.schema.json must define the strict Ticket integration v6 contract");
717
744
  }
718
745
  return errors;
719
746
  }
@@ -874,12 +901,14 @@ function capabilityChecks(root) {
874
901
  } else {
875
902
  const text = readText(goalPlanTemplate);
876
903
  for (const required of [
877
- "schema_version: 5",
904
+ "schema_version: 6",
878
905
  "orchestration: lead-directed",
879
906
  "implementation_agent_limit: 3",
880
- "ticket_workspace_policy: required",
881
- "integration_gate: candidate-merge",
907
+ "integration_attempt_limit: 3",
908
+ "ticket_workspace_policy: current",
909
+ "integration_gate: direct-parent",
882
910
  ...REQUIRED_LEAD_GOAL_PLAN_MARKERS,
911
+ "Local direct-parent verification and parent update",
883
912
  ]) {
884
913
  if (!text.includes(required)) errors.push(`Goal Plan template lost marker ${required}`);
885
914
  }
@@ -1256,8 +1285,9 @@ function validateGoalPlan(path, errors) {
1256
1285
  "status",
1257
1286
  "modes",
1258
1287
  "orchestration",
1259
- "lead",
1260
- "implementation_agent_limit",
1288
+ "lead",
1289
+ "implementation_agent_limit",
1290
+ "integration_attempt_limit",
1261
1291
  "ticket_workspace_policy",
1262
1292
  "integration_gate",
1263
1293
  "ready_for_execution",
@@ -1280,14 +1310,23 @@ function validateGoalPlan(path, errors) {
1280
1310
  if (typeof meta.lead !== "string" || !meta.lead.trim()) {
1281
1311
  errors.push(`${basename(path)}: lead must be a non-empty recoverable locator`);
1282
1312
  }
1283
- if (!Number.isInteger(meta.implementation_agent_limit) || meta.implementation_agent_limit < 1 || meta.implementation_agent_limit > 3) {
1284
- errors.push(`${basename(path)}: implementation_agent_limit must be an integer from 1 to 3`);
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`);
1315
+ }
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`);
1318
+ }
1319
+ if (!new Set(["current", "required"]).has(meta.ticket_workspace_policy)) {
1320
+ errors.push(`${basename(path)}: ticket_workspace_policy must be current or required`);
1285
1321
  }
1286
- if (meta.ticket_workspace_policy !== "required") {
1287
- errors.push(`${basename(path)}: ticket_workspace_policy must be required`);
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`);
1288
1324
  }
1289
- if (meta.integration_gate !== "candidate-merge") {
1290
- errors.push(`${basename(path)}: integration_gate must be candidate-merge`);
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`);
1291
1330
  }
1292
1331
  for (const obsolete of ["coordination_mode", "workspace_strategy", "terminal_action"]) {
1293
1332
  if (obsolete in meta) errors.push(`${basename(path)}: obsolete Goal Plan field ${obsolete} is not allowed`);
@@ -1309,9 +1348,12 @@ function validateGoalPlan(path, errors) {
1309
1348
  errors.push(`${basename(path)}: ready Goal Plan missing '${heading}'`);
1310
1349
  }
1311
1350
  }
1312
- const missingLeadMarkers = REQUIRED_LEAD_GOAL_PLAN_MARKERS.filter((marker) => !body.includes(marker));
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));
1313
1355
  if (missingLeadMarkers.length) {
1314
- errors.push(`${basename(path)}: ready Goal Plan missing Lead/candidate markers ${JSON.stringify(missingLeadMarkers)}`);
1356
+ errors.push(`${basename(path)}: ready Goal Plan missing Lead/workspace markers ${JSON.stringify(missingLeadMarkers)}`);
1315
1357
  }
1316
1358
  const assumptions = sectionBody(body, "## Assumptions");
1317
1359
  if (assumptions && assumptions.includes("高影响") && !assumptions.includes("必须为 `false`")) {
@@ -1321,6 +1363,27 @@ function validateGoalPlan(path, errors) {
1321
1363
  return { path, meta, body };
1322
1364
  }
1323
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
+
1324
1387
  function validateDesignTree(path, change, errors) {
1325
1388
  if (!isFile(path)) return null;
1326
1389
  let data;
@@ -1567,8 +1630,8 @@ function validateTicket(path, errors) {
1567
1630
  if (!verification || !verification.includes("|")) {
1568
1631
  errors.push(`${basename(path)}: Ready Ticket has no usable verification matrix`);
1569
1632
  }
1570
- if (!verification.includes("E2E disposition") || !verification.includes("parent-candidate")) {
1571
- errors.push(`${basename(path)}: Ready Ticket must define E2E disposition and parent-candidate owner/environment`);
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`);
1572
1635
  }
1573
1636
  const acceptance = sectionBody(body, "## 10. 验收标准").toLowerCase();
1574
1637
  if (!acceptance.includes("- [ ]") && !acceptance.includes("- [x]")) {
@@ -1673,11 +1736,12 @@ function validateChangeStatus(path, expectedChange, errors) {
1673
1736
  if (!VALID_WORKTREE_STATUS.has(worktree.status)) {
1674
1737
  errors.push(`${basename(path)}: invalid worktree status ${worktree.status}`);
1675
1738
  }
1739
+ const currentWorkspace = ref === "current";
1676
1740
  if (worktree.provider !== "git") {
1677
1741
  errors.push(`${basename(path)}: Ticket worktree ${worktree.ticket_id} provider must be git`);
1678
1742
  }
1679
1743
  const expectedRef = `specdev-worktree/${expectedChange}/${worktree.ticket_id}`;
1680
- if (ref !== expectedRef) {
1744
+ if (!currentWorkspace && ref !== expectedRef) {
1681
1745
  errors.push(`${basename(path)}: git workspace_ref must equal ${expectedRef}`);
1682
1746
  }
1683
1747
 
@@ -1704,7 +1768,9 @@ function validateChangeStatus(path, expectedChange, errors) {
1704
1768
  }
1705
1769
  if (typeof worktree.parent_branch !== "string" || !worktree.parent_branch.trim()) {
1706
1770
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} parent_branch is required`);
1707
- } 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) {
1708
1774
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} parent_branch must differ from branch`);
1709
1775
  }
1710
1776
 
@@ -1826,23 +1892,32 @@ function validateChangeStatus(path, expectedChange, errors) {
1826
1892
  } else if (integration.source_sha !== worktree.source_checkpoint) {
1827
1893
  errors.push(`${basename(path)}: worktree ${worktree.ticket_id} source_sha must equal source_checkpoint`);
1828
1894
  }
1829
- if (typeof integration.candidate_sha !== "string" || !integration.candidate_sha.trim()) {
1830
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires candidate_sha`);
1831
- }
1832
- const expectedCandidateBranch = `speculo/integration/${expectedChange}/${worktree.ticket_id}`;
1833
- if (integration.candidate_branch !== expectedCandidateBranch) {
1834
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} candidate_branch must equal ${expectedCandidateBranch}`);
1835
- }
1836
- const expectedCandidateRef = `specdev-worktree/.integration/${expectedChange}/${worktree.ticket_id}`;
1837
- if (integration.candidate_workspace_ref !== expectedCandidateRef) {
1838
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} candidate_workspace_ref must equal ${expectedCandidateRef}`);
1839
- }
1840
- if (!new Set(["fast-forward", "merge-commit"]).has(integration.method)) {
1841
- errors.push(`${basename(path)}: worktree ${worktree.ticket_id} ${worktree.status} requires an integration method`);
1842
- }
1843
- if (!Number.isInteger(integration.attempts) || integration.attempts < 1) {
1844
- 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
+ }
1845
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
+ }
1846
1921
  }
1847
1922
  if (worktree.status === "integrating" && integration.status !== "candidate") {
1848
1923
  errors.push(`${basename(path)}: integrating worktree ${worktree.ticket_id} requires integration.status=candidate`);
@@ -1851,16 +1926,23 @@ function validateChangeStatus(path, expectedChange, errors) {
1851
1926
  if (
1852
1927
  integration.status !== "passed" ||
1853
1928
  integration.verification !== "passed" ||
1854
- !new Set(["fast-forward", "merge-commit"]).has(integration.method) ||
1929
+ !(currentWorkspace ? integration.method === "direct-parent" : new Set(["fast-forward", "merge-commit"]).has(integration.method)) ||
1855
1930
  typeof integration.result_sha !== "string" ||
1856
1931
  !integration.result_sha.trim() ||
1857
- integration.result_sha !== integration.candidate_sha ||
1932
+ (currentWorkspace ? integration.result_sha !== worktree.source_checkpoint : integration.result_sha !== integration.candidate_sha) ||
1858
1933
  !e2e ||
1859
1934
  !new Set(["not-required", "passed"]).has(e2e.status)
1860
1935
  ) {
1861
1936
  errors.push(`${basename(path)}: ${worktree.status} worktree ${worktree.ticket_id} requires passed candidate/result/E2E state`);
1862
1937
  }
1863
- 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
+ }
1942
+ if (integration.result_sha !== worktree.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") {
1864
1946
  if (integration.candidate_sha !== worktree.source_checkpoint) {
1865
1947
  errors.push(`${basename(path)}: ${worktree.status} worktree ${worktree.ticket_id} fast-forward candidate/result must equal source_checkpoint`);
1866
1948
  }
@@ -1888,7 +1970,96 @@ function validateChangeStatus(path, expectedChange, errors) {
1888
1970
  return data;
1889
1971
  }
1890
1972
 
1891
- 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) {
1892
2063
  const errors = [];
1893
2064
  const warnings = [];
1894
2065
  if (!isDirectory(change)) {
@@ -1993,7 +2164,7 @@ function validateChange(change, stage = null) {
1993
2164
  for (const [ticketId, artifact] of tickets) {
1994
2165
  if (new Set(["draft", "cancelled"]).has(artifact.meta.status)) continue;
1995
2166
  if (!worktreesByTicket.has(ticketId)) {
1996
- errors.push(`${ticketId}: Implement stage requires one Ticket source worktree record`);
2167
+ errors.push(`${ticketId}: Implement stage requires one Ticket workspace execution record`);
1997
2168
  }
1998
2169
  }
1999
2170
  for (const ticketId of worktreesByTicket.keys()) {
@@ -2002,6 +2173,19 @@ function validateChange(change, stage = null) {
2002
2173
  }
2003
2174
  }
2004
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);
2005
2189
 
2006
2190
  if (spec) {
2007
2191
  const declaredContracts = new Set(spec.body.match(/\bAC-\d+\b/g) ?? []);
@@ -2088,22 +2272,27 @@ function validateChange(change, stage = null) {
2088
2272
  continue;
2089
2273
  }
2090
2274
  const text = readText(evidence);
2091
- for (const heading of [
2275
+ const currentWorkspace = goalPlan?.meta.ticket_workspace_policy === "current";
2276
+ const requiredHeadings = [
2092
2277
  "## 2. Lead Dispatch And Candidate Return",
2093
2278
  "## 3. 修改范围与路径所有权",
2094
2279
  "## 4. 验收与合同映射",
2095
- "## 5. Source-worktree 验证",
2280
+ "## 5. Workspace Verification",
2096
2281
  "## 6. 双轴审查",
2097
- "## 7. Parent-candidate 集成验证",
2282
+ "## 7. Integration Verification",
2098
2283
  "## 8. 偏差与决策",
2099
2284
  "## 9. 残余风险与交付定位",
2100
- ]) {
2285
+ ];
2286
+ for (const heading of requiredHeadings) {
2101
2287
  if (!text.includes(heading)) {
2102
2288
  errors.push(`${toPosix(relative(change, evidence))}: missing '${heading}'`);
2103
2289
  }
2104
2290
  }
2105
- const sourceVerification = sectionBody(text, "## 5. Source-worktree 验证");
2106
- if (/E2E[^\n]*(?:\bpass(?:ed)?\b|通过)/i.test(sourceVerification) && !/不得|禁止|not-run/i.test(sourceVerification)) {
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)) {
2107
2296
  errors.push(`${toPosix(relative(change, evidence))}: E2E pass cannot come from source-worktree`);
2108
2297
  }
2109
2298
  const worktree = Array.isArray(changeStatus?.worktrees)
@@ -2191,13 +2380,14 @@ function printResults(errors, warnings) {
2191
2380
  }
2192
2381
 
2193
2382
  function usage() {
2194
- 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");
2195
2384
  return 2;
2196
2385
  }
2197
2386
 
2198
2387
  function main(argv) {
2199
2388
  let selfCheckRequested = false;
2200
2389
  let stage = null;
2390
+ let repoRoot = null;
2201
2391
  const positional = [];
2202
2392
  for (let index = 0; index < argv.length; index += 1) {
2203
2393
  const arg = argv[index];
@@ -2208,6 +2398,11 @@ function main(argv) {
2208
2398
  index += 1;
2209
2399
  } else if (arg.startsWith("--stage=")) {
2210
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);
2211
2406
  } else if (arg.startsWith("--")) {
2212
2407
  return usage();
2213
2408
  } else {
@@ -2223,7 +2418,7 @@ function main(argv) {
2223
2418
  return printResults(result.errors, result.warnings);
2224
2419
  }
2225
2420
  if (positional.length === 1) {
2226
- const result = validateChange(resolve(positional[0]), stage);
2421
+ const result = validateChange(resolve(positional[0]), stage, repoRoot);
2227
2422
  return printResults(result.errors, result.warnings);
2228
2423
  }
2229
2424
  return usage();