@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.
- package/dist/src/migrations.js +190 -39
- package/dist/src/migrations.js.map +1 -1
- package/package.json +1 -1
- package/template/canonical/canonical-specdev-engineering-cognitive-mentor.md +51 -15
- package/template/canonical/canonical-specdev-goal-plan.md +183 -116
- package/template/canonical/canonical-specdev-grill-with-docs.md +51 -15
- package/template/canonical/canonical-specdev-spec.md +61 -21
- package/template/canonical/canonical-specdev-tickets.md +83 -43
- package/template/canonical/canonical-specdev-wayfinder.md +51 -15
- package/template/skills/migrate-runtime-state/references/migration-contract.md +3 -3
- package/template/skills/migrate-runtime-state/scripts/migrate-runtime-state.mjs +94 -12
- package/template/workflows/specdev/I-implement/I-implement.md +27 -26
- package/template/workflows/specdev/I-implement/evidence-template.md +18 -12
- package/template/workflows/specdev/I-implement/execution-preflight.md +11 -9
- package/template/workflows/specdev/I-init-setup/I-init-setup.md +2 -2
- package/template/workflows/specdev/I-init-setup/change-status-template.json +3 -3
- package/template/workflows/specdev/I-init-setup/config-template.json +5 -2
- package/template/workflows/specdev/INDEX.md +6 -6
- package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +16 -16
- package/template/workflows/specdev/P-goal-plan/completion-control.md +8 -8
- package/template/workflows/specdev/P-goal-plan/goal-plan-template.md +20 -14
- package/template/workflows/specdev/P-goal-plan/lead-orchestration.md +5 -5
- package/template/workflows/specdev/P-goal-plan/orchestration-protocol.md +13 -13
- package/template/workflows/specdev/P-goal-plan/planning-modes.md +27 -15
- package/template/workflows/specdev/P-prototype/ui-prototype.md +1 -1
- package/template/workflows/specdev/T-tickets/T-tickets.md +4 -4
- package/template/workflows/specdev/T-tickets/ticket-readiness.md +3 -3
- package/template/workflows/specdev/T-tickets/ticket-template.md +6 -6
- package/template/workflows/specdev/T-tickets/tickets-map-template.md +2 -2
- package/template/workflows/specdev/common/README.md +1 -1
- package/template/workflows/specdev/common/rules/change-completion.md +2 -2
- package/template/workflows/specdev/common/rules/evidence-and-verification.md +10 -6
- package/template/workflows/specdev/common/rules/path-ownership.md +4 -4
- package/template/workflows/specdev/common/schemas/change-status.schema.json +31 -4
- package/template/workflows/specdev/common/schemas/config.schema.json +12 -6
- package/template/workflows/specdev/common/schemas/goal-plan.schema.json +15 -6
- package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +3 -3
- package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +1 -1
- package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +4 -4
- package/template/workflows/specdev/common/skills/subagent-delivery/references/external-web-subagent.md +1 -1
- package/template/workflows/specdev/common/skills/subagent-delivery/references/native-subagent.md +4 -4
- package/template/workflows/specdev/common/tools/README.md +2 -1
- 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 =
|
|
23
|
-
const GOAL_PLAN_SCHEMA_VERSION =
|
|
24
|
-
const CHANGE_STATUS_SCHEMA_VERSION =
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
702
|
+
configSchema.$id !== "urn:speculo:specdev:config:v5" ||
|
|
677
703
|
configSchema.properties?.schema_version?.const !== CONFIG_SCHEMA_VERSION ||
|
|
678
704
|
limit?.minimum !== 1 ||
|
|
679
|
-
|
|
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
|
|
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:
|
|
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?.
|
|
691
|
-
goalPlanSchema.properties?.
|
|
692
|
-
goalPlanSchema.properties?.
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
904
|
+
"schema_version: 6",
|
|
878
905
|
"orchestration: lead-directed",
|
|
879
906
|
"implementation_agent_limit: 3",
|
|
880
|
-
"
|
|
881
|
-
"
|
|
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
|
-
|
|
1260
|
-
|
|
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
|
|
1284
|
-
errors.push(`${basename(path)}: implementation_agent_limit must be
|
|
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 (
|
|
1287
|
-
errors.push(`${basename(path)}:
|
|
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 (
|
|
1290
|
-
|
|
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
|
|
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/
|
|
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") ||
|
|
1571
|
-
errors.push(`${basename(path)}: Ready Ticket must define E2E disposition and
|
|
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
|
|
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 (
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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 === "
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
2280
|
+
"## 5. Workspace Verification",
|
|
2096
2281
|
"## 6. 双轴审查",
|
|
2097
|
-
"## 7.
|
|
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
|
|
2106
|
-
if (
|
|
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();
|