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