@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.
Files changed (36) hide show
  1. package/dist/src/structured.js +8 -4
  2. package/dist/src/structured.js.map +1 -1
  3. package/package.json +1 -1
  4. package/template/canonical/canonical-specdev-goal-plan.md +54 -6
  5. package/template/canonical/canonical-specdev-grill-with-docs.md +9 -2
  6. package/template/canonical/canonical-specdev-orchestrate-implementation.md +2785 -0
  7. package/template/canonical/canonical-specdev-spec.md +11 -2
  8. package/template/canonical/canonical-specdev-tickets.md +46 -3
  9. package/template/canonical/canonical-specdev-wayfinder.md +4 -4
  10. package/template/workflows/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md +1 -1
  11. package/template/workflows/specdev/I-implement/I-implement.md +10 -5
  12. package/template/workflows/specdev/I-implement/execution-preflight.md +2 -0
  13. package/template/workflows/specdev/L-learn-change/L-learn-change.md +99 -0
  14. package/template/workflows/specdev/O-orchestrate-implementation/O-orchestrate-implementation.md +129 -0
  15. package/template/workflows/specdev/O-orchestrate-implementation/conflict-and-drift.md +18 -0
  16. package/template/workflows/specdev/O-orchestrate-implementation/execution-loop.md +30 -0
  17. package/template/workflows/specdev/O-orchestrate-implementation/implementation-evidence-template.md +39 -0
  18. package/template/workflows/specdev/O-orchestrate-implementation/implementation-map-template.md +50 -0
  19. package/template/workflows/specdev/O-orchestrate-implementation/implementation-plan-template.md +61 -0
  20. package/template/workflows/specdev/O-orchestrate-implementation/input-readiness.md +25 -0
  21. package/template/workflows/specdev/O-orchestrate-implementation/super-dag.md +27 -0
  22. package/template/workflows/specdev/README.md +25 -12
  23. package/template/workflows/specdev/W-wayfinder/W-wayfinder.md +1 -1
  24. package/template/workflows/specdev/W-wayfinder/local-tracker-contract.md +3 -4
  25. package/template/workflows/specdev/common/README.md +4 -1
  26. package/template/workflows/specdev/common/rules/artifact-contract.md +7 -2
  27. package/template/workflows/specdev/common/rules/change-completion.md +3 -0
  28. package/template/workflows/specdev/common/rules/deviation-control.md +2 -0
  29. package/template/workflows/specdev/common/rules/evidence-and-verification.md +2 -0
  30. package/template/workflows/specdev/common/rules/parent-implementation-orchestration.md +27 -0
  31. package/template/workflows/specdev/common/rules/path-ownership.md +3 -1
  32. package/template/workflows/specdev/common/schemas/implementation-map.schema.json +40 -0
  33. package/template/workflows/specdev/common/schemas/implementation-plan.schema.json +44 -0
  34. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +5 -3
  35. package/template/workflows/specdev/common/tools/README.md +2 -2
  36. 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");