@namewta/speculo 0.8.7 → 0.8.9
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/structured.js +8 -4
- package/dist/src/structured.js.map +1 -1
- package/package.json +1 -1
- package/template/canonical/canonical-specdev-goal-plan.md +54 -6
- package/template/canonical/canonical-specdev-grill-with-docs.md +9 -2
- package/template/canonical/canonical-specdev-orchestrate-implementation.md +2785 -0
- package/template/canonical/canonical-specdev-spec.md +11 -2
- package/template/canonical/canonical-specdev-tickets.md +46 -3
- package/template/canonical/canonical-specdev-wayfinder.md +4 -4
- package/template/workflows/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md +1 -1
- package/template/workflows/specdev/I-implement/I-implement.md +10 -5
- package/template/workflows/specdev/I-implement/execution-preflight.md +2 -0
- package/template/workflows/specdev/L-learn-change/L-learn-change.md +99 -0
- package/template/workflows/specdev/O-orchestrate-implementation/O-orchestrate-implementation.md +129 -0
- package/template/workflows/specdev/O-orchestrate-implementation/conflict-and-drift.md +18 -0
- package/template/workflows/specdev/O-orchestrate-implementation/execution-loop.md +30 -0
- package/template/workflows/specdev/O-orchestrate-implementation/implementation-evidence-template.md +39 -0
- package/template/workflows/specdev/O-orchestrate-implementation/implementation-map-template.md +50 -0
- package/template/workflows/specdev/O-orchestrate-implementation/implementation-plan-template.md +61 -0
- package/template/workflows/specdev/O-orchestrate-implementation/input-readiness.md +25 -0
- package/template/workflows/specdev/O-orchestrate-implementation/super-dag.md +27 -0
- package/template/workflows/specdev/README.md +25 -12
- package/template/workflows/specdev/W-wayfinder/W-wayfinder.md +1 -1
- package/template/workflows/specdev/W-wayfinder/local-tracker-contract.md +3 -4
- package/template/workflows/specdev/common/README.md +4 -1
- package/template/workflows/specdev/common/rules/artifact-contract.md +7 -2
- package/template/workflows/specdev/common/rules/change-completion.md +3 -0
- package/template/workflows/specdev/common/rules/deviation-control.md +2 -0
- package/template/workflows/specdev/common/rules/evidence-and-verification.md +2 -0
- package/template/workflows/specdev/common/rules/parent-implementation-orchestration.md +27 -0
- package/template/workflows/specdev/common/rules/path-ownership.md +3 -1
- package/template/workflows/specdev/common/schemas/implementation-map.schema.json +40 -0
- package/template/workflows/specdev/common/schemas/implementation-plan.schema.json +44 -0
- package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +5 -3
- package/template/workflows/specdev/common/tools/README.md +2 -2
- package/template/workflows/specdev/common/tools/validate-specdev.mjs +589 -1
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
existsSync,
|
|
14
|
+
lstatSync,
|
|
14
15
|
readFileSync,
|
|
15
16
|
readdirSync,
|
|
16
17
|
statSync,
|
|
@@ -22,6 +23,8 @@ import { fileURLToPath } from "node:url";
|
|
|
22
23
|
const DOMAIN_SCHEMA_VERSION = 3;
|
|
23
24
|
const CONFIG_SCHEMA_VERSION = 5;
|
|
24
25
|
const GOAL_PLAN_SCHEMA_VERSION = 6;
|
|
26
|
+
const IMPLEMENTATION_MAP_SCHEMA_VERSION = 1;
|
|
27
|
+
const IMPLEMENTATION_PLAN_SCHEMA_VERSION = 1;
|
|
25
28
|
const CHANGE_STATUS_SCHEMA_VERSION = 6;
|
|
26
29
|
const GLOBAL_STATUS_SCHEMA_VERSION = 5;
|
|
27
30
|
const WORKFLOW_PREFIX = "{roots.workflows}/specdev/";
|
|
@@ -29,6 +32,7 @@ const STATE_PREFIX = "{roots.state}/specdev/";
|
|
|
29
32
|
const STATE_ROOT_PREFIX = "{roots.state}/";
|
|
30
33
|
const SKILLS_PREFIX = "{roots.skills}/";
|
|
31
34
|
const COMMANDS_PREFIX = "{roots.commands}/";
|
|
35
|
+
const CHANGE_NAME = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
32
36
|
|
|
33
37
|
const EXPECTED_WORKS = new Set([
|
|
34
38
|
"A-archive-and-consolidate",
|
|
@@ -37,6 +41,8 @@ const EXPECTED_WORKS = new Set([
|
|
|
37
41
|
"G-grill-with-docs",
|
|
38
42
|
"I-implement",
|
|
39
43
|
"I-init-setup",
|
|
44
|
+
"L-learn-change",
|
|
45
|
+
"O-orchestrate-implementation",
|
|
40
46
|
"P-goal-plan",
|
|
41
47
|
"P-prototype",
|
|
42
48
|
"R-review-architecture",
|
|
@@ -101,9 +107,11 @@ const VALID_STAGES = new Set([
|
|
|
101
107
|
"tickets",
|
|
102
108
|
"goal-plan",
|
|
103
109
|
"implement",
|
|
110
|
+
"learn-change",
|
|
104
111
|
"review",
|
|
105
112
|
"prototype",
|
|
106
113
|
"wayfinder",
|
|
114
|
+
"orchestrate-implementation",
|
|
107
115
|
"complete",
|
|
108
116
|
]);
|
|
109
117
|
const REQUIRED_TICKET_KEYS = new Set([
|
|
@@ -156,6 +164,8 @@ const STATE_ARTIFACT_BASENAMES = new Set([
|
|
|
156
164
|
"spec.md",
|
|
157
165
|
"tickets-map.md",
|
|
158
166
|
"goal-plan.md",
|
|
167
|
+
"implementation-map.md",
|
|
168
|
+
"implementation-plan.md",
|
|
159
169
|
"ADR.md",
|
|
160
170
|
"CONTEXT.md",
|
|
161
171
|
"LOG.md",
|
|
@@ -168,6 +178,7 @@ const STATE_ARTIFACT_BASENAMES = new Set([
|
|
|
168
178
|
"architecture-review.html",
|
|
169
179
|
"wayfinder-map.md",
|
|
170
180
|
"design-tree.json",
|
|
181
|
+
"implementation-orchestration.md",
|
|
171
182
|
]);
|
|
172
183
|
const FORBIDDEN_OBSOLETE_BASENAMES = new Set([
|
|
173
184
|
"source-issue.md",
|
|
@@ -674,6 +685,8 @@ function validateExecutionContractAssets(root) {
|
|
|
674
685
|
const configSchemaPath = join(root, "common", "schemas", "config.schema.json");
|
|
675
686
|
const goalPlanSchemaPath = join(root, "common", "schemas", "goal-plan.schema.json");
|
|
676
687
|
const changeSchemaPath = join(root, "common", "schemas", "change-status.schema.json");
|
|
688
|
+
const implementationMapSchemaPath = join(root, "common", "schemas", "implementation-map.schema.json");
|
|
689
|
+
const implementationPlanSchemaPath = join(root, "common", "schemas", "implementation-plan.schema.json");
|
|
677
690
|
|
|
678
691
|
for (const path of [
|
|
679
692
|
configTemplatePath,
|
|
@@ -681,6 +694,8 @@ function validateExecutionContractAssets(root) {
|
|
|
681
694
|
configSchemaPath,
|
|
682
695
|
goalPlanSchemaPath,
|
|
683
696
|
changeSchemaPath,
|
|
697
|
+
implementationMapSchemaPath,
|
|
698
|
+
implementationPlanSchemaPath,
|
|
684
699
|
]) {
|
|
685
700
|
if (!isFile(path)) errors.push(`missing execution contract asset ${toPosix(relative(root, path))}`);
|
|
686
701
|
}
|
|
@@ -753,6 +768,29 @@ function validateExecutionContractAssets(root) {
|
|
|
753
768
|
) {
|
|
754
769
|
errors.push("change-status.schema.json must define the strict Ticket integration v6 contract");
|
|
755
770
|
}
|
|
771
|
+
|
|
772
|
+
const implementationMapSchema = JSON.parse(readText(implementationMapSchemaPath));
|
|
773
|
+
if (
|
|
774
|
+
implementationMapSchema.$id !== "urn:speculo:specdev:implementation-map:v1" ||
|
|
775
|
+
implementationMapSchema.properties?.schema_version?.const !== IMPLEMENTATION_MAP_SCHEMA_VERSION ||
|
|
776
|
+
implementationMapSchema.properties?.members?.minItems !== 2 ||
|
|
777
|
+
implementationMapSchema.properties?.tasks?.minItems !== 1 ||
|
|
778
|
+
implementationMapSchema.additionalProperties !== false
|
|
779
|
+
) {
|
|
780
|
+
errors.push("implementation-map.schema.json must define the strict parent implementation graph contract v1");
|
|
781
|
+
}
|
|
782
|
+
const implementationPlanSchema = JSON.parse(readText(implementationPlanSchemaPath));
|
|
783
|
+
if (
|
|
784
|
+
implementationPlanSchema.$id !== "urn:speculo:specdev:implementation-plan:v1" ||
|
|
785
|
+
implementationPlanSchema.properties?.schema_version?.const !== IMPLEMENTATION_PLAN_SCHEMA_VERSION ||
|
|
786
|
+
implementationPlanSchema.properties?.orchestration?.const !== "lead-directed" ||
|
|
787
|
+
implementationPlanSchema.properties?.implementation_agent_limit?.minimum !== 1 ||
|
|
788
|
+
implementationPlanSchema.properties?.integration_attempt_limit?.minimum !== 1 ||
|
|
789
|
+
!["current", "required"].every((value) => implementationPlanSchema.properties?.ticket_workspace_policy?.enum?.includes(value)) ||
|
|
790
|
+
implementationPlanSchema.additionalProperties !== false
|
|
791
|
+
) {
|
|
792
|
+
errors.push("implementation-plan.schema.json must define the strict parent implementation projection contract v1");
|
|
793
|
+
}
|
|
756
794
|
return errors;
|
|
757
795
|
}
|
|
758
796
|
|
|
@@ -810,6 +848,13 @@ function capabilityChecks(root) {
|
|
|
810
848
|
],
|
|
811
849
|
],
|
|
812
850
|
],
|
|
851
|
+
[
|
|
852
|
+
"orchestrate-implementation",
|
|
853
|
+
[
|
|
854
|
+
join(root, "O-orchestrate-implementation", "O-orchestrate-implementation.md"),
|
|
855
|
+
["Ready Spec", "Ready Tickets", "Implementation Map", "Implementation Plan", "lead-directed", "implementation_agent_limit", "serialization", "I-implement"],
|
|
856
|
+
],
|
|
857
|
+
],
|
|
813
858
|
[
|
|
814
859
|
"implement",
|
|
815
860
|
[
|
|
@@ -831,6 +876,13 @@ function capabilityChecks(root) {
|
|
|
831
876
|
["设计定向", "风格", "design-system.md", "comparison", "HTML/CSS/JS", "design-library/INDEX.md"],
|
|
832
877
|
],
|
|
833
878
|
],
|
|
879
|
+
[
|
|
880
|
+
"learn-change",
|
|
881
|
+
[
|
|
882
|
+
join(root, "L-learn-change", "L-learn-change.md"),
|
|
883
|
+
["开发完成后", "零专业背景", "$ARGUMENTS", "ASCII", "learning/index.md", "{number}_{topic}.md", "{roots.state}/learning/"],
|
|
884
|
+
],
|
|
885
|
+
],
|
|
834
886
|
[
|
|
835
887
|
"wayfinder",
|
|
836
888
|
[
|
|
@@ -897,6 +949,13 @@ function capabilityChecks(root) {
|
|
|
897
949
|
"W-wayfinder/local-tracker-contract.md",
|
|
898
950
|
"W-wayfinder/solution-comment-template.md",
|
|
899
951
|
"R-review-architecture/architecture-report-contract.md",
|
|
952
|
+
"common/rules/parent-implementation-orchestration.md",
|
|
953
|
+
"common/schemas/implementation-map.schema.json",
|
|
954
|
+
"common/schemas/implementation-plan.schema.json",
|
|
955
|
+
"O-orchestrate-implementation/input-readiness.md",
|
|
956
|
+
"O-orchestrate-implementation/implementation-map-template.md",
|
|
957
|
+
"O-orchestrate-implementation/implementation-plan-template.md",
|
|
958
|
+
"O-orchestrate-implementation/implementation-evidence-template.md",
|
|
900
959
|
]) {
|
|
901
960
|
if (!isFile(join(root, required))) errors.push(`missing architecture/wayfinding contract ${required}`);
|
|
902
961
|
}
|
|
@@ -1283,6 +1342,100 @@ function validatePrototypes(change, required, errors) {
|
|
|
1283
1342
|
return paths;
|
|
1284
1343
|
}
|
|
1285
1344
|
|
|
1345
|
+
function validateChangeLearning(change, required, errors) {
|
|
1346
|
+
const learningRoot = join(change, "learning");
|
|
1347
|
+
const indexPath = join(learningRoot, "index.md");
|
|
1348
|
+
const diagramName = /^\d{2,}_[\p{L}\p{N}_-]+\.md$/u;
|
|
1349
|
+
|
|
1350
|
+
if (!existsSync(learningRoot)) {
|
|
1351
|
+
if (required) errors.push("learn-change stage requires learning/index.md");
|
|
1352
|
+
return null;
|
|
1353
|
+
}
|
|
1354
|
+
if (lstatSync(learningRoot).isSymbolicLink() || !isDirectory(learningRoot)) {
|
|
1355
|
+
errors.push("learning/: change learning directory must be a real directory");
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const diagramFiles = [];
|
|
1360
|
+
for (const entry of readdirSync(learningRoot, { withFileTypes: true })) {
|
|
1361
|
+
if (entry.isSymbolicLink()) {
|
|
1362
|
+
errors.push(`learning/${entry.name}: learning artifacts must not be symlinks`);
|
|
1363
|
+
} else if (entry.isFile() && diagramName.test(entry.name)) {
|
|
1364
|
+
diagramFiles.push(entry.name);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
diagramFiles.sort();
|
|
1368
|
+
|
|
1369
|
+
if (!isFile(indexPath)) {
|
|
1370
|
+
errors.push("learn-change stage requires learning/index.md");
|
|
1371
|
+
return null;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const index = readText(indexPath);
|
|
1375
|
+
if (!index.includes("# Change 学习图解索引")) {
|
|
1376
|
+
errors.push("learning/index.md: missing index heading");
|
|
1377
|
+
}
|
|
1378
|
+
const entries = Array.from(
|
|
1379
|
+
index.matchAll(/^\|\s*(\d{2,})\s*\|\s*([^|\s]+\.md)\s*\|\s*([^|]+)\|\s*([^|]+)\|\s*$/gm),
|
|
1380
|
+
);
|
|
1381
|
+
if (!entries.length && required) {
|
|
1382
|
+
errors.push("learning/index.md: requires at least one diagram entry");
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const indexedFiles = new Set();
|
|
1386
|
+
let previousNumber = 0;
|
|
1387
|
+
for (const entry of entries) {
|
|
1388
|
+
const [, number, fileName, topic, summary] = entry;
|
|
1389
|
+
const numericNumber = Number(number);
|
|
1390
|
+
if (!diagramName.test(fileName)) {
|
|
1391
|
+
errors.push(`learning/index.md: invalid diagram filename '${fileName}'`);
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
if (numericNumber <= previousNumber) {
|
|
1395
|
+
errors.push("learning/index.md: diagram numbers must increase");
|
|
1396
|
+
}
|
|
1397
|
+
if (numericNumber !== previousNumber + 1) {
|
|
1398
|
+
errors.push("learning/index.md: diagram numbers must start at 01 and be continuous");
|
|
1399
|
+
}
|
|
1400
|
+
previousNumber = numericNumber;
|
|
1401
|
+
if (!fileName.startsWith(`${number}_`)) {
|
|
1402
|
+
errors.push(`learning/index.md: '${fileName}' must start with '${number}_'`);
|
|
1403
|
+
}
|
|
1404
|
+
if (!topic.trim() || !summary.trim()) {
|
|
1405
|
+
errors.push(`learning/index.md: '${fileName}' requires a topic and summary`);
|
|
1406
|
+
}
|
|
1407
|
+
indexedFiles.add(fileName);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
for (const fileName of diagramFiles) {
|
|
1411
|
+
if (!indexedFiles.has(fileName)) {
|
|
1412
|
+
errors.push(`learning/index.md: missing entry for '${fileName}'`);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
for (const fileName of indexedFiles) {
|
|
1416
|
+
if (!diagramFiles.includes(fileName)) {
|
|
1417
|
+
errors.push(`learning/index.md: '${fileName}' does not exist`);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
for (const fileName of diagramFiles) {
|
|
1422
|
+
const markdown = readText(join(learningRoot, fileName));
|
|
1423
|
+
for (const heading of ["## 先看全图", "## 一步一步看", "## 术语小词典", "## 你现在能复述什么"]) {
|
|
1424
|
+
if (!markdown.includes(heading)) errors.push(`learning/${fileName}: missing '${heading}'`);
|
|
1425
|
+
}
|
|
1426
|
+
if (!/```(?:text)?\s*[\s\S]*?(?:->|\||\+--)[\s\S]*?```/.test(markdown)) {
|
|
1427
|
+
errors.push(`learning/${fileName}: requires an ASCII diagram in a fenced code block`);
|
|
1428
|
+
}
|
|
1429
|
+
if (
|
|
1430
|
+
/<\/?(?:html|head|body|svg|canvas|img|picture)\b/i.test(markdown) ||
|
|
1431
|
+
/!\[[^\]]*\]\([^)]+\)/.test(markdown)
|
|
1432
|
+
) {
|
|
1433
|
+
errors.push(`learning/${fileName}: must be pure Markdown without HTML or image dependencies`);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
return indexPath;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1286
1439
|
function validateSpec(path, errors, warnings) {
|
|
1287
1440
|
if (!isFile(path)) {
|
|
1288
1441
|
warnings.push("Spec is missing; contract traceability cannot be fully checked");
|
|
@@ -2117,6 +2270,438 @@ function validateGitEvidence(repoRoot, changeStatus, errors) {
|
|
|
2117
2270
|
}
|
|
2118
2271
|
}
|
|
2119
2272
|
|
|
2273
|
+
function validateParentImplementation(change, parentStatus, stage, errors, warnings) {
|
|
2274
|
+
const required = stage === "orchestrate-implementation";
|
|
2275
|
+
const mapPath = join(change, "implementation-map.md");
|
|
2276
|
+
const planPath = join(change, "implementation-plan.md");
|
|
2277
|
+
if (!required && !isFile(mapPath) && !isFile(planPath)) return null;
|
|
2278
|
+
if (!isFile(mapPath)) errors.push("orchestrate-implementation stage requires implementation-map.md");
|
|
2279
|
+
if (!isFile(planPath)) errors.push("orchestrate-implementation stage requires implementation-plan.md");
|
|
2280
|
+
if (!isFile(mapPath) || !isFile(planPath)) return null;
|
|
2281
|
+
|
|
2282
|
+
const parentName = basename(change);
|
|
2283
|
+
const map = parseFrontmatter(mapPath);
|
|
2284
|
+
const plan = parseFrontmatter(planPath);
|
|
2285
|
+
const mapKeys = ["schema_version", "artifact", "change", "status", "revision", "members", "tasks", "dependencies", "serializations"];
|
|
2286
|
+
const planKeys = ["schema_version", "artifact", "change", "status", "source_map_revision", "orchestration", "lead", "implementation_agent_limit", "integration_attempt_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution"];
|
|
2287
|
+
for (const [label, meta, expected] of [
|
|
2288
|
+
["implementation-map.md", map.meta, mapKeys],
|
|
2289
|
+
["implementation-plan.md", plan.meta, planKeys],
|
|
2290
|
+
]) {
|
|
2291
|
+
const missing = expected.filter((key) => !(key in meta));
|
|
2292
|
+
const unexpected = Object.keys(meta).filter((key) => !expected.includes(key));
|
|
2293
|
+
if (missing.length) errors.push(`${label}: missing keys ${JSON.stringify(missing)}`);
|
|
2294
|
+
if (unexpected.length) errors.push(`${label}: unexpected keys ${JSON.stringify(unexpected.sort())}`);
|
|
2295
|
+
}
|
|
2296
|
+
if (map.meta.schema_version !== IMPLEMENTATION_MAP_SCHEMA_VERSION || map.meta.artifact !== "implementation-map") {
|
|
2297
|
+
errors.push("implementation-map.md: artifact/schema_version must be implementation-map/1");
|
|
2298
|
+
}
|
|
2299
|
+
if (plan.meta.schema_version !== IMPLEMENTATION_PLAN_SCHEMA_VERSION || plan.meta.artifact !== "implementation-plan") {
|
|
2300
|
+
errors.push("implementation-plan.md: artifact/schema_version must be implementation-plan/1");
|
|
2301
|
+
}
|
|
2302
|
+
if (map.meta.change !== parentName || plan.meta.change !== parentName) {
|
|
2303
|
+
errors.push("parent implementation artifacts must name their containing change");
|
|
2304
|
+
}
|
|
2305
|
+
const artifactStatuses = new Set(["ready", "in_progress", "blocked", "completed"]);
|
|
2306
|
+
if (!artifactStatuses.has(map.meta.status)) errors.push(`implementation-map.md: invalid status ${map.meta.status}`);
|
|
2307
|
+
if (!artifactStatuses.has(plan.meta.status)) errors.push(`implementation-plan.md: invalid status ${plan.meta.status}`);
|
|
2308
|
+
if (!Number.isInteger(map.meta.revision) || map.meta.revision < 1) {
|
|
2309
|
+
errors.push("implementation-map.md: revision must be a positive integer");
|
|
2310
|
+
}
|
|
2311
|
+
if (plan.meta.source_map_revision !== map.meta.revision) {
|
|
2312
|
+
errors.push("implementation-plan.md: source_map_revision must equal Implementation Map revision");
|
|
2313
|
+
}
|
|
2314
|
+
if (plan.meta.orchestration !== "lead-directed" || typeof plan.meta.lead !== "string" || !plan.meta.lead.trim()) {
|
|
2315
|
+
errors.push("implementation-plan.md: lead-directed orchestration requires a recoverable Lead");
|
|
2316
|
+
}
|
|
2317
|
+
for (const key of ["implementation_agent_limit", "integration_attempt_limit"]) {
|
|
2318
|
+
if (!Number.isInteger(plan.meta[key]) || plan.meta[key] < 1) {
|
|
2319
|
+
errors.push(`implementation-plan.md: ${key} must be a positive integer`);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
const validStrategy =
|
|
2323
|
+
(plan.meta.ticket_workspace_policy === "current" && plan.meta.integration_gate === "direct-parent") ||
|
|
2324
|
+
(plan.meta.ticket_workspace_policy === "required" && plan.meta.integration_gate === "candidate-merge");
|
|
2325
|
+
if (!validStrategy) {
|
|
2326
|
+
errors.push("implementation-plan.md: workspace/integration strategy must be current/direct-parent or required/candidate-merge");
|
|
2327
|
+
}
|
|
2328
|
+
const readyStatuses = new Set(["ready", "in_progress"]);
|
|
2329
|
+
if (
|
|
2330
|
+
typeof plan.meta.ready_for_execution !== "boolean" ||
|
|
2331
|
+
(plan.meta.ready_for_execution === true) !== readyStatuses.has(plan.meta.status)
|
|
2332
|
+
) {
|
|
2333
|
+
errors.push("implementation-plan.md: ready_for_execution must match status");
|
|
2334
|
+
}
|
|
2335
|
+
for (const heading of [
|
|
2336
|
+
"## 1. Members and Source Authority",
|
|
2337
|
+
"## 2. Composite Ticket Inventory",
|
|
2338
|
+
"## 3. Implementation Super-DAG",
|
|
2339
|
+
"## 4. Conflict and Serialization",
|
|
2340
|
+
"## 5. Contract and Path Coverage",
|
|
2341
|
+
"## 6. Revision Log",
|
|
2342
|
+
]) {
|
|
2343
|
+
if (!map.body.includes(heading)) errors.push(`implementation-map.md: missing '${heading}'`);
|
|
2344
|
+
}
|
|
2345
|
+
for (const heading of [
|
|
2346
|
+
"## 1. Outcome and Authority",
|
|
2347
|
+
"## 2. Ready Frontier and Waves",
|
|
2348
|
+
"## 3. Workspace and Dispatch Contract",
|
|
2349
|
+
"## 4. Repository Integration Queue",
|
|
2350
|
+
"## 5. Gates and Aggregate Verification",
|
|
2351
|
+
"## 6. Conflict, Drift and Recovery",
|
|
2352
|
+
"## 7. Progress and Decisions",
|
|
2353
|
+
]) {
|
|
2354
|
+
if (!plan.body.includes(heading)) errors.push(`implementation-plan.md: missing '${heading}'`);
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
const members = requireList(map.meta, "members", "implementation-map.md", errors).map(String);
|
|
2358
|
+
const tasks = requireList(map.meta, "tasks", "implementation-map.md", errors).map(String);
|
|
2359
|
+
const dependencies = requireList(map.meta, "dependencies", "implementation-map.md", errors).map(String);
|
|
2360
|
+
const serializations = requireList(map.meta, "serializations", "implementation-map.md", errors).map(String);
|
|
2361
|
+
if (members.length < 2) errors.push("implementation-map.md: parent implementation requires at least two members");
|
|
2362
|
+
if (!tasks.length) errors.push("implementation-map.md: tasks must contain the child Ticket inventory");
|
|
2363
|
+
for (const [key, values] of [["members", members], ["tasks", tasks], ["dependencies", dependencies], ["serializations", serializations]]) {
|
|
2364
|
+
if (new Set(values).size !== values.length) errors.push(`implementation-map.md: ${key} must be unique`);
|
|
2365
|
+
}
|
|
2366
|
+
if (members.includes(parentName)) errors.push("implementation-map.md: parent change cannot include itself");
|
|
2367
|
+
|
|
2368
|
+
const changesRoot = dirname(change);
|
|
2369
|
+
const memberSet = new Set(members);
|
|
2370
|
+
const memberStatuses = new Map();
|
|
2371
|
+
const ticketByTask = new Map();
|
|
2372
|
+
const expectedTasks = new Set();
|
|
2373
|
+
const expectedInternalEdges = new Set();
|
|
2374
|
+
let activeImplementations = 0;
|
|
2375
|
+
let activeCurrentWriters = 0;
|
|
2376
|
+
const integratingByRef = new Map();
|
|
2377
|
+
|
|
2378
|
+
for (const member of members) {
|
|
2379
|
+
if (!CHANGE_NAME.test(member)) {
|
|
2380
|
+
errors.push(`implementation-map.md: invalid member change name ${member}`);
|
|
2381
|
+
continue;
|
|
2382
|
+
}
|
|
2383
|
+
const memberRoot = join(changesRoot, member);
|
|
2384
|
+
if (!isDirectory(memberRoot)) {
|
|
2385
|
+
errors.push(`implementation-map.md: member change does not exist: ${member}`);
|
|
2386
|
+
continue;
|
|
2387
|
+
}
|
|
2388
|
+
if (isFile(join(memberRoot, "implementation-map.md"))) {
|
|
2389
|
+
errors.push(`implementation-map.md: nested parent implementation is not supported in v1: ${member}`);
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
const memberErrors = [];
|
|
2393
|
+
const memberWarnings = [];
|
|
2394
|
+
const status = validateChangeStatus(join(memberRoot, ".status.json"), member, memberErrors);
|
|
2395
|
+
if (status) {
|
|
2396
|
+
memberStatuses.set(member, status);
|
|
2397
|
+
if (status.change_status === "archived") memberErrors.push("archived change cannot be an implementation member");
|
|
2398
|
+
if (status.current_work !== null && status.current_work !== "specdev/implement") {
|
|
2399
|
+
memberErrors.push(`current_work=${status.current_work} conflicts with parent implementation ownership`);
|
|
2400
|
+
}
|
|
2401
|
+
for (const worktree of Array.isArray(status.worktrees) ? status.worktrees : []) {
|
|
2402
|
+
if (worktree?.status === "active") {
|
|
2403
|
+
activeImplementations += 1;
|
|
2404
|
+
if (worktree.workspace_ref === "current") activeCurrentWriters += 1;
|
|
2405
|
+
if (plan.meta.ticket_workspace_policy === "current" && worktree.workspace_ref !== "current") {
|
|
2406
|
+
memberErrors.push(`${worktree.ticket_id}: active workspace must follow parent current policy`);
|
|
2407
|
+
}
|
|
2408
|
+
if (plan.meta.ticket_workspace_policy === "required" && worktree.workspace_ref === "current") {
|
|
2409
|
+
memberErrors.push(`${worktree.ticket_id}: active workspace must follow parent required policy`);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
if (worktree?.status === "integrating") {
|
|
2413
|
+
const key = String(worktree.parent_branch ?? "<unknown-ref>");
|
|
2414
|
+
integratingByRef.set(key, (integratingByRef.get(key) ?? 0) + 1);
|
|
2415
|
+
}
|
|
2416
|
+
if (Number.isInteger(worktree?.integration?.attempts) && worktree.integration.attempts > plan.meta.integration_attempt_limit) {
|
|
2417
|
+
memberErrors.push(`${worktree.ticket_id}: integration attempts ${worktree.integration.attempts} exceed parent limit ${plan.meta.integration_attempt_limit}`);
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
const specPath = join(memberRoot, "spec.md");
|
|
2423
|
+
const childSpec = isFile(specPath) ? validateSpec(specPath, memberErrors, memberWarnings) : null;
|
|
2424
|
+
if (!childSpec) memberErrors.push("Ready Spec is required before parent creation");
|
|
2425
|
+
else {
|
|
2426
|
+
if (childSpec.meta.change !== member) memberErrors.push("spec.md: change must equal member directory name");
|
|
2427
|
+
if (childSpec.meta.status !== "ready" || childSpec.meta.ready_for_tickets !== true) {
|
|
2428
|
+
memberErrors.push("Spec must have status=ready and ready_for_tickets=true");
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const childMapPath = join(memberRoot, "tickets-map.md");
|
|
2433
|
+
const childMap = isFile(childMapPath) ? validateMap(childMapPath, memberErrors) : null;
|
|
2434
|
+
if (!childMap) memberErrors.push("Ready Tickets Map is required before parent creation");
|
|
2435
|
+
else {
|
|
2436
|
+
if (childMap.meta.change !== member) memberErrors.push("tickets-map.md: change must equal member directory name");
|
|
2437
|
+
if (!new Set(["ready", "in_progress", "completed"]).has(childMap.meta.status)) {
|
|
2438
|
+
memberErrors.push("Tickets Map status must be ready, in_progress, or completed");
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
const ticketRoot = join(memberRoot, "ticket");
|
|
2443
|
+
const ticketFiles = isDirectory(ticketRoot)
|
|
2444
|
+
? readdirSync(ticketRoot).filter((name) => name.endsWith(".md")).sort()
|
|
2445
|
+
: [];
|
|
2446
|
+
if (!ticketFiles.length) memberErrors.push("Ready Tickets are required before parent creation");
|
|
2447
|
+
const childTickets = new Map();
|
|
2448
|
+
for (const name of ticketFiles) {
|
|
2449
|
+
const artifact = validateTicket(join(ticketRoot, name), memberErrors);
|
|
2450
|
+
if (!artifact) continue;
|
|
2451
|
+
const ticketId = String(artifact.meta.id);
|
|
2452
|
+
if (artifact.meta.change !== member) memberErrors.push(`${name}: change must equal member directory name`);
|
|
2453
|
+
if (childTickets.has(ticketId)) memberErrors.push(`duplicate Ticket id ${ticketId}`);
|
|
2454
|
+
childTickets.set(ticketId, artifact);
|
|
2455
|
+
const numericId = ticketId.replace(/^T-/, "");
|
|
2456
|
+
if (!name.startsWith(`${numericId}-`)) memberErrors.push(`${name}: filename prefix must match ${ticketId}`);
|
|
2457
|
+
if (childMap && !childMap.body.includes(ticketId)) memberErrors.push(`${name}: Ticket id is absent from Tickets Map`);
|
|
2458
|
+
if (childSpec) {
|
|
2459
|
+
for (const contractId of artifact.meta.contract_ids ?? []) {
|
|
2460
|
+
if (!childSpec.body.includes(String(contractId))) memberErrors.push(`${name}: contract ${contractId} not found in Spec`);
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
const taskId = `${member}::${ticketId}`;
|
|
2465
|
+
expectedTasks.add(taskId);
|
|
2466
|
+
ticketByTask.set(taskId, { artifact, member, memberRoot, status });
|
|
2467
|
+
const terminal = new Set(["done", "cancelled"]).has(artifact.meta.status);
|
|
2468
|
+
if (!terminal) {
|
|
2469
|
+
if (map.meta.status === "ready" && (artifact.meta.status !== "ready" || artifact.meta.ready !== true)) {
|
|
2470
|
+
memberErrors.push(`${ticketId}: parent creation requires status=ready and ready=true`);
|
|
2471
|
+
} else if (new Set(["ready", "in_progress", "review"]).has(artifact.meta.status) && artifact.meta.ready !== true) {
|
|
2472
|
+
memberErrors.push(`${ticketId}: executable Ticket must keep ready=true`);
|
|
2473
|
+
} else if (new Set(["blocked", "deviated"]).has(artifact.meta.status) && map.meta.status !== "blocked" && plan.meta.status !== "blocked") {
|
|
2474
|
+
memberErrors.push(`${ticketId}: blocked/deviated Ticket requires blocked parent artifacts`);
|
|
2475
|
+
} else if (artifact.meta.status === "draft") {
|
|
2476
|
+
memberErrors.push(`${ticketId}: draft Ticket cannot belong to a parent implementation`);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
for (const [ticketId, artifact] of childTickets) {
|
|
2482
|
+
for (const dependency of (artifact.meta.blocked_by ?? []).map(String)) {
|
|
2483
|
+
if (!childTickets.has(dependency)) memberErrors.push(`${ticketId}: blocked_by references missing ${dependency}`);
|
|
2484
|
+
expectedInternalEdges.add(`${member}::${ticketId} <- ${member}::${dependency}`);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
const childGraph = new Map([...childTickets].map(([ticketId, artifact]) => [ticketId, (artifact.meta.blocked_by ?? []).map(String)]));
|
|
2488
|
+
const childCycle = findCycle(childGraph);
|
|
2489
|
+
if (childCycle) memberErrors.push(`Ticket dependency cycle: ${childCycle.join(" -> ")}`);
|
|
2490
|
+
|
|
2491
|
+
if (childSpec) {
|
|
2492
|
+
const declared = new Set(childSpec.body.match(/\bAC-\d+\b/g) ?? []);
|
|
2493
|
+
const covered = new Set([...childTickets.values()].flatMap((artifact) => (artifact.meta.contract_ids ?? []).map(String)));
|
|
2494
|
+
const uncovered = [...declared].filter((id) => !covered.has(id) && !(childMap && new RegExp(`${escapeRegExp(id)}.*\\bdeferred\\b`, "i").test(childMap.body)));
|
|
2495
|
+
if (uncovered.length) memberErrors.push(`Spec acceptance contracts are not covered by Tickets: ${JSON.stringify(uncovered.sort())}`);
|
|
2496
|
+
}
|
|
2497
|
+
if (status?.change_status === "completed") {
|
|
2498
|
+
const unfinished = [...childTickets].filter(([, artifact]) => !new Set(["done", "cancelled"]).has(artifact.meta.status)).map(([id]) => id);
|
|
2499
|
+
if (unfinished.length) memberErrors.push(`completed member has unfinished Tickets: ${JSON.stringify(unfinished)}`);
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
const goalPath = join(memberRoot, "goal-plan.md");
|
|
2503
|
+
if (isFile(goalPath)) {
|
|
2504
|
+
const goal = validateGoalPlan(goalPath, memberErrors);
|
|
2505
|
+
if (goal && (
|
|
2506
|
+
goal.meta.ticket_workspace_policy !== plan.meta.ticket_workspace_policy ||
|
|
2507
|
+
goal.meta.integration_gate !== plan.meta.integration_gate
|
|
2508
|
+
)) {
|
|
2509
|
+
memberErrors.push("Goal Plan workspace/integration strategy conflicts with parent Implementation Plan");
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
errors.push(...memberErrors.map((message) => `${member}: ${message}`));
|
|
2513
|
+
warnings.push(...memberWarnings.map((message) => `${member}: ${message}`));
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
const taskSet = new Set(tasks);
|
|
2517
|
+
for (const task of tasks) {
|
|
2518
|
+
if (!/^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*::T-\d{2,}$/.test(task)) {
|
|
2519
|
+
errors.push(`implementation-map.md: invalid composite task '${task}'`);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
const missingTasks = [...expectedTasks].filter((task) => !taskSet.has(task)).sort();
|
|
2523
|
+
const extraTasks = tasks.filter((task) => !expectedTasks.has(task)).sort();
|
|
2524
|
+
if (missingTasks.length || extraTasks.length) {
|
|
2525
|
+
errors.push(`implementation-map.md: tasks must exactly match child Tickets; missing=${JSON.stringify(missingTasks)} extra=${JSON.stringify(extraTasks)}`);
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
const graph = new Map(tasks.map((task) => [task, []]));
|
|
2529
|
+
const dependencySet = new Set(dependencies);
|
|
2530
|
+
for (const edge of dependencies) {
|
|
2531
|
+
const match = /^([^ ]+::T-\d{2,}) <- ([^ ]+::T-\d{2,})$/.exec(edge);
|
|
2532
|
+
if (!match) {
|
|
2533
|
+
errors.push(`implementation-map.md: invalid dependency '${edge}'`);
|
|
2534
|
+
continue;
|
|
2535
|
+
}
|
|
2536
|
+
const [, dependent, prerequisite] = match;
|
|
2537
|
+
if (!taskSet.has(dependent) || !taskSet.has(prerequisite)) {
|
|
2538
|
+
errors.push(`implementation-map.md: dependency endpoints must be composite tasks: ${edge}`);
|
|
2539
|
+
} else if (dependent === prerequisite) {
|
|
2540
|
+
errors.push(`implementation-map.md: dependency cannot be self-referential: ${edge}`);
|
|
2541
|
+
} else {
|
|
2542
|
+
graph.get(dependent).push(prerequisite);
|
|
2543
|
+
const dependentMember = dependent.split("::")[0];
|
|
2544
|
+
const prerequisiteMember = prerequisite.split("::")[0];
|
|
2545
|
+
if (dependentMember === prerequisiteMember && !expectedInternalEdges.has(edge)) {
|
|
2546
|
+
errors.push(`implementation-map.md: same-member dependency must come from child blocked_by: ${edge}`);
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
const missingInternalEdges = [...expectedInternalEdges].filter((edge) => !dependencySet.has(edge)).sort();
|
|
2551
|
+
if (missingInternalEdges.length) {
|
|
2552
|
+
errors.push(`implementation-map.md: missing child Ticket dependencies ${JSON.stringify(missingInternalEdges)}`);
|
|
2553
|
+
}
|
|
2554
|
+
const cycle = findCycle(graph);
|
|
2555
|
+
if (cycle) errors.push(`implementation super-DAG cycle: ${cycle.join(" -> ")}`);
|
|
2556
|
+
|
|
2557
|
+
const serializationPairs = new Set();
|
|
2558
|
+
for (const edge of serializations) {
|
|
2559
|
+
const match = /^([^ ]+::T-\d{2,}) <> ([^ ]+::T-\d{2,})$/.exec(edge);
|
|
2560
|
+
if (!match) {
|
|
2561
|
+
errors.push(`implementation-map.md: invalid serialization '${edge}'`);
|
|
2562
|
+
continue;
|
|
2563
|
+
}
|
|
2564
|
+
const [, left, right] = match;
|
|
2565
|
+
if (!taskSet.has(left) || !taskSet.has(right)) {
|
|
2566
|
+
errors.push(`implementation-map.md: serialization endpoints must be composite tasks: ${edge}`);
|
|
2567
|
+
continue;
|
|
2568
|
+
}
|
|
2569
|
+
if (left === right) {
|
|
2570
|
+
errors.push(`implementation-map.md: serialization cannot be self-referential: ${edge}`);
|
|
2571
|
+
continue;
|
|
2572
|
+
}
|
|
2573
|
+
const key = [left, right].sort().join("\0");
|
|
2574
|
+
if (serializationPairs.has(key)) errors.push(`implementation-map.md: duplicate unordered serialization pair: ${edge}`);
|
|
2575
|
+
serializationPairs.add(key);
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
const unfinishedTasks = [...ticketByTask]
|
|
2579
|
+
.filter(([, value]) => !new Set(["done", "cancelled"]).has(value.artifact.meta.status))
|
|
2580
|
+
.map(([task]) => task);
|
|
2581
|
+
for (let index = 0; index < unfinishedTasks.length; index += 1) {
|
|
2582
|
+
const leftId = unfinishedTasks[index];
|
|
2583
|
+
const left = ticketByTask.get(leftId).artifact;
|
|
2584
|
+
for (const rightId of unfinishedTasks.slice(index + 1)) {
|
|
2585
|
+
if (leftId.split("::")[0] === rightId.split("::")[0]) continue;
|
|
2586
|
+
if (
|
|
2587
|
+
transitivelyDepends(graph, leftId, rightId) ||
|
|
2588
|
+
transitivelyDepends(graph, rightId, leftId) ||
|
|
2589
|
+
serializationPairs.has([leftId, rightId].sort().join("\0"))
|
|
2590
|
+
) continue;
|
|
2591
|
+
const right = ticketByTask.get(rightId).artifact;
|
|
2592
|
+
const overlaps = [];
|
|
2593
|
+
for (const leftPath of left.meta.writable_paths ?? []) {
|
|
2594
|
+
for (const rightPath of right.meta.writable_paths ?? []) {
|
|
2595
|
+
if (pathsOverlap(String(leftPath), String(rightPath))) overlaps.push([String(leftPath), String(rightPath)]);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
if (overlaps.length) {
|
|
2599
|
+
errors.push(`composite tasks ${leftId}/${rightId} have writable overlap without dependency or serialization: ${JSON.stringify(overlaps.slice(0, 3))}`);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
for (const entry of readdirSync(changesRoot, { withFileTypes: true })) {
|
|
2605
|
+
if (!entry.isDirectory() || entry.name === parentName) continue;
|
|
2606
|
+
const otherMapPath = join(changesRoot, entry.name, "implementation-map.md");
|
|
2607
|
+
if (!isFile(otherMapPath)) continue;
|
|
2608
|
+
const otherMap = parseFrontmatter(otherMapPath).meta;
|
|
2609
|
+
if (!Array.isArray(otherMap.members)) continue;
|
|
2610
|
+
let otherStatus = null;
|
|
2611
|
+
try {
|
|
2612
|
+
otherStatus = JSON.parse(readText(join(changesRoot, entry.name, ".status.json"))).change_status;
|
|
2613
|
+
} catch {
|
|
2614
|
+
// Invalid competing parents still own their members conservatively.
|
|
2615
|
+
}
|
|
2616
|
+
if (new Set(["completed", "archived"]).has(otherStatus)) continue;
|
|
2617
|
+
const overlap = otherMap.members.map(String).filter((member) => memberSet.has(member));
|
|
2618
|
+
if (overlap.length) errors.push(`member changes already belong to unfinished parent implementation ${entry.name}: ${JSON.stringify(overlap)}`);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
const config = findSpecdevConfig(change);
|
|
2622
|
+
const configuredAgents = positiveConfigLimit(config, "max_implementation_agents", 0);
|
|
2623
|
+
const configuredAttempts = positiveConfigLimit(config, "max_integration_attempts", 0);
|
|
2624
|
+
if (!config || config.schema_version !== CONFIG_SCHEMA_VERSION || configuredAgents === 0 || configuredAttempts === 0) {
|
|
2625
|
+
errors.push("implementation-plan.md: SpecDev config v5 with positive execution limits is required");
|
|
2626
|
+
} else {
|
|
2627
|
+
if (plan.meta.implementation_agent_limit > configuredAgents) {
|
|
2628
|
+
errors.push(`implementation-plan.md: implementation_agent_limit ${plan.meta.implementation_agent_limit} exceeds config max_implementation_agents ${configuredAgents}`);
|
|
2629
|
+
}
|
|
2630
|
+
if (plan.meta.integration_attempt_limit > configuredAttempts) {
|
|
2631
|
+
errors.push(`implementation-plan.md: integration_attempt_limit ${plan.meta.integration_attempt_limit} exceeds config max_integration_attempts ${configuredAttempts}`);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
if (activeImplementations > plan.meta.implementation_agent_limit) {
|
|
2635
|
+
errors.push(`parent implementation agent limit exceeded: ${activeImplementations} active for limit ${plan.meta.implementation_agent_limit}`);
|
|
2636
|
+
}
|
|
2637
|
+
if (plan.meta.ticket_workspace_policy === "current" && activeCurrentWriters > 1) {
|
|
2638
|
+
errors.push(`parent current policy permits only one active implementation writer; found ${activeCurrentWriters}`);
|
|
2639
|
+
}
|
|
2640
|
+
for (const [ref, count] of integratingByRef) {
|
|
2641
|
+
if (count > 1) errors.push(`repository/ref integration must be serialized for ${ref}; found ${count}`);
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
if (required && parentStatus && new Set(["active", "blocked"]).has(parentStatus.change_status) && parentStatus.current_work !== "specdev/orchestrate-implementation") {
|
|
2645
|
+
errors.push("parent active/blocked status must keep current_work=specdev/orchestrate-implementation");
|
|
2646
|
+
}
|
|
2647
|
+
if (parentStatus?.change_status === "completed") {
|
|
2648
|
+
const incomplete = members.filter((member) => memberStatuses.get(member)?.change_status !== "completed");
|
|
2649
|
+
if (incomplete.length) errors.push(`parent implementation is completed while members remain incomplete: ${JSON.stringify(incomplete)}`);
|
|
2650
|
+
const unfinished = [...ticketByTask].filter(([, value]) => !new Set(["done", "cancelled"]).has(value.artifact.meta.status)).map(([task]) => task);
|
|
2651
|
+
if (unfinished.length) errors.push(`completed parent implementation has unfinished composite tasks: ${JSON.stringify(unfinished)}`);
|
|
2652
|
+
if (map.meta.status !== "completed" || plan.meta.status !== "completed") {
|
|
2653
|
+
errors.push("completed parent requires completed Implementation Map and Implementation Plan");
|
|
2654
|
+
}
|
|
2655
|
+
if (
|
|
2656
|
+
parentStatus.current_work !== null ||
|
|
2657
|
+
!Array.isArray(parentStatus.blockers) || parentStatus.blockers.length > 0 ||
|
|
2658
|
+
!Array.isArray(parentStatus.deviations) || parentStatus.deviations.length > 0
|
|
2659
|
+
) {
|
|
2660
|
+
errors.push("completed parent requires null current_work and no blockers or deviations");
|
|
2661
|
+
}
|
|
2662
|
+
const activeMemberWork = [...memberStatuses]
|
|
2663
|
+
.flatMap(([member, status]) => (status.worktrees ?? []).map((worktree) => ({ member, worktree })))
|
|
2664
|
+
.filter(({ worktree }) => new Set(["planned", "active", "review", "integrating", "blocked"]).has(worktree?.status) || worktree?.integration?.status === "candidate")
|
|
2665
|
+
.map(({ member, worktree }) => `${member}::${worktree.ticket_id}`);
|
|
2666
|
+
if (activeMemberWork.length) {
|
|
2667
|
+
errors.push(`completed parent has active member worktrees or candidates: ${JSON.stringify(activeMemberWork)}`);
|
|
2668
|
+
}
|
|
2669
|
+
for (const [task, value] of ticketByTask) {
|
|
2670
|
+
if (value.artifact.meta.status !== "done") continue;
|
|
2671
|
+
if (!isFile(join(value.memberRoot, "evidence", `${value.artifact.meta.id}.md`))) {
|
|
2672
|
+
errors.push(`${task}: completed parent requires child Ticket Evidence`);
|
|
2673
|
+
}
|
|
2674
|
+
const worktree = value.status?.worktrees?.find((entry) => entry?.ticket_id === value.artifact.meta.id);
|
|
2675
|
+
if (!worktree || !new Set(["integrated", "removed"]).has(worktree.status)) {
|
|
2676
|
+
errors.push(`${task}: completed parent requires integrated or removed child workspace record`);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
const evidencePath = join(change, "evidence", "implementation-orchestration.md");
|
|
2680
|
+
if (!isFile(evidencePath)) {
|
|
2681
|
+
errors.push("completed parent requires evidence/implementation-orchestration.md");
|
|
2682
|
+
} else {
|
|
2683
|
+
const evidence = readText(evidencePath);
|
|
2684
|
+
for (const heading of [
|
|
2685
|
+
"## 1. Parent Plan and Final Revision",
|
|
2686
|
+
"## 2. Member and Ticket Completion",
|
|
2687
|
+
"## 3. Dependency and Serialization Audit",
|
|
2688
|
+
"## 4. Repository Integration Audit",
|
|
2689
|
+
"## 5. Aggregate Verification",
|
|
2690
|
+
"## 6. Contract, Drift and Deviation Audit",
|
|
2691
|
+
"## 7. Residual Risk and Boundary",
|
|
2692
|
+
]) {
|
|
2693
|
+
if (!evidence.includes(heading)) errors.push(`evidence/implementation-orchestration.md: missing '${heading}'`);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
} else if (map.meta.status === "completed" || plan.meta.status === "completed") {
|
|
2697
|
+
errors.push("completed Implementation Map/Plan requires completed parent change status");
|
|
2698
|
+
}
|
|
2699
|
+
if (plan.meta.ready_for_execution === true && !new Set(["ready", "in_progress"]).has(map.meta.status)) {
|
|
2700
|
+
errors.push("ready Implementation Plan requires a ready or in_progress Implementation Map");
|
|
2701
|
+
}
|
|
2702
|
+
return { map, plan, members, tasks, memberStatuses, ticketByTask };
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2120
2705
|
function validateChange(change, stage = null, repoRoot = null) {
|
|
2121
2706
|
const errors = [];
|
|
2122
2707
|
const warnings = [];
|
|
@@ -2125,6 +2710,7 @@ function validateChange(change, stage = null, repoRoot = null) {
|
|
|
2125
2710
|
}
|
|
2126
2711
|
|
|
2127
2712
|
const changeStatus = validateChangeStatus(join(change, ".status.json"), basename(change), errors);
|
|
2713
|
+
validateParentImplementation(change, changeStatus, stage, errors, warnings);
|
|
2128
2714
|
if (isFile(join(change, "source-issue.md"))) {
|
|
2129
2715
|
errors.push("obsolete source-issue.md is forbidden; use source.md without compatibility fallback");
|
|
2130
2716
|
}
|
|
@@ -2143,8 +2729,9 @@ function validateChange(change, stage = null, repoRoot = null) {
|
|
|
2143
2729
|
}
|
|
2144
2730
|
validateReviews(change, stage === "review", errors);
|
|
2145
2731
|
validatePrototypes(change, stage === "prototype", errors);
|
|
2732
|
+
validateChangeLearning(change, stage === "learn-change", errors);
|
|
2146
2733
|
|
|
2147
|
-
const specRequired = new Set(["spec", "tickets", "goal-plan", "implement", "complete"]).has(stage);
|
|
2734
|
+
const specRequired = new Set(["spec", "tickets", "goal-plan", "implement", "complete"]).has(stage) && !isFile(join(change, "implementation-map.md"));
|
|
2148
2735
|
const specPath = join(change, "spec.md");
|
|
2149
2736
|
const spec = isFile(specPath) || specRequired
|
|
2150
2737
|
? validateSpec(specPath, errors, warnings)
|
|
@@ -2400,6 +2987,7 @@ function validateChange(change, stage = null, repoRoot = null) {
|
|
|
2400
2987
|
if (
|
|
2401
2988
|
stage === "complete" &&
|
|
2402
2989
|
!ticketFiles.length &&
|
|
2990
|
+
!isFile(join(change, "implementation-map.md")) &&
|
|
2403
2991
|
!isFile(join(change, "evidence", "direct-spec.md"))
|
|
2404
2992
|
) {
|
|
2405
2993
|
errors.push("complete stage without Tickets requires evidence/direct-spec.md");
|