@namewta/speculo 0.8.7 → 0.8.8

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 (35) 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 +53 -6
  5. package/template/canonical/canonical-specdev-grill-with-docs.md +8 -2
  6. package/template/canonical/canonical-specdev-orchestrate-implementation.md +2784 -0
  7. package/template/canonical/canonical-specdev-spec.md +10 -2
  8. package/template/canonical/canonical-specdev-tickets.md +45 -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/O-orchestrate-implementation/O-orchestrate-implementation.md +129 -0
  14. package/template/workflows/specdev/O-orchestrate-implementation/conflict-and-drift.md +18 -0
  15. package/template/workflows/specdev/O-orchestrate-implementation/execution-loop.md +30 -0
  16. package/template/workflows/specdev/O-orchestrate-implementation/implementation-evidence-template.md +39 -0
  17. package/template/workflows/specdev/O-orchestrate-implementation/implementation-map-template.md +50 -0
  18. package/template/workflows/specdev/O-orchestrate-implementation/implementation-plan-template.md +61 -0
  19. package/template/workflows/specdev/O-orchestrate-implementation/input-readiness.md +25 -0
  20. package/template/workflows/specdev/O-orchestrate-implementation/super-dag.md +27 -0
  21. package/template/workflows/specdev/README.md +17 -10
  22. package/template/workflows/specdev/W-wayfinder/W-wayfinder.md +1 -1
  23. package/template/workflows/specdev/W-wayfinder/local-tracker-contract.md +3 -4
  24. package/template/workflows/specdev/common/README.md +4 -1
  25. package/template/workflows/specdev/common/rules/artifact-contract.md +6 -2
  26. package/template/workflows/specdev/common/rules/change-completion.md +3 -0
  27. package/template/workflows/specdev/common/rules/deviation-control.md +2 -0
  28. package/template/workflows/specdev/common/rules/evidence-and-verification.md +2 -0
  29. package/template/workflows/specdev/common/rules/parent-implementation-orchestration.md +27 -0
  30. package/template/workflows/specdev/common/rules/path-ownership.md +3 -1
  31. package/template/workflows/specdev/common/schemas/implementation-map.schema.json +40 -0
  32. package/template/workflows/specdev/common/schemas/implementation-plan.schema.json +44 -0
  33. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +5 -3
  34. package/template/workflows/specdev/common/tools/README.md +2 -2
  35. package/template/workflows/specdev/common/tools/validate-specdev.mjs +484 -1
@@ -22,6 +22,8 @@ import { fileURLToPath } from "node:url";
22
22
  const DOMAIN_SCHEMA_VERSION = 3;
23
23
  const CONFIG_SCHEMA_VERSION = 5;
24
24
  const GOAL_PLAN_SCHEMA_VERSION = 6;
25
+ const IMPLEMENTATION_MAP_SCHEMA_VERSION = 1;
26
+ const IMPLEMENTATION_PLAN_SCHEMA_VERSION = 1;
25
27
  const CHANGE_STATUS_SCHEMA_VERSION = 6;
26
28
  const GLOBAL_STATUS_SCHEMA_VERSION = 5;
27
29
  const WORKFLOW_PREFIX = "{roots.workflows}/specdev/";
@@ -29,6 +31,7 @@ const STATE_PREFIX = "{roots.state}/specdev/";
29
31
  const STATE_ROOT_PREFIX = "{roots.state}/";
30
32
  const SKILLS_PREFIX = "{roots.skills}/";
31
33
  const COMMANDS_PREFIX = "{roots.commands}/";
34
+ const CHANGE_NAME = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
32
35
 
33
36
  const EXPECTED_WORKS = new Set([
34
37
  "A-archive-and-consolidate",
@@ -37,6 +40,7 @@ const EXPECTED_WORKS = new Set([
37
40
  "G-grill-with-docs",
38
41
  "I-implement",
39
42
  "I-init-setup",
43
+ "O-orchestrate-implementation",
40
44
  "P-goal-plan",
41
45
  "P-prototype",
42
46
  "R-review-architecture",
@@ -104,6 +108,7 @@ const VALID_STAGES = new Set([
104
108
  "review",
105
109
  "prototype",
106
110
  "wayfinder",
111
+ "orchestrate-implementation",
107
112
  "complete",
108
113
  ]);
109
114
  const REQUIRED_TICKET_KEYS = new Set([
@@ -156,6 +161,8 @@ const STATE_ARTIFACT_BASENAMES = new Set([
156
161
  "spec.md",
157
162
  "tickets-map.md",
158
163
  "goal-plan.md",
164
+ "implementation-map.md",
165
+ "implementation-plan.md",
159
166
  "ADR.md",
160
167
  "CONTEXT.md",
161
168
  "LOG.md",
@@ -168,6 +175,7 @@ const STATE_ARTIFACT_BASENAMES = new Set([
168
175
  "architecture-review.html",
169
176
  "wayfinder-map.md",
170
177
  "design-tree.json",
178
+ "implementation-orchestration.md",
171
179
  ]);
172
180
  const FORBIDDEN_OBSOLETE_BASENAMES = new Set([
173
181
  "source-issue.md",
@@ -674,6 +682,8 @@ function validateExecutionContractAssets(root) {
674
682
  const configSchemaPath = join(root, "common", "schemas", "config.schema.json");
675
683
  const goalPlanSchemaPath = join(root, "common", "schemas", "goal-plan.schema.json");
676
684
  const changeSchemaPath = join(root, "common", "schemas", "change-status.schema.json");
685
+ const implementationMapSchemaPath = join(root, "common", "schemas", "implementation-map.schema.json");
686
+ const implementationPlanSchemaPath = join(root, "common", "schemas", "implementation-plan.schema.json");
677
687
 
678
688
  for (const path of [
679
689
  configTemplatePath,
@@ -681,6 +691,8 @@ function validateExecutionContractAssets(root) {
681
691
  configSchemaPath,
682
692
  goalPlanSchemaPath,
683
693
  changeSchemaPath,
694
+ implementationMapSchemaPath,
695
+ implementationPlanSchemaPath,
684
696
  ]) {
685
697
  if (!isFile(path)) errors.push(`missing execution contract asset ${toPosix(relative(root, path))}`);
686
698
  }
@@ -753,6 +765,29 @@ function validateExecutionContractAssets(root) {
753
765
  ) {
754
766
  errors.push("change-status.schema.json must define the strict Ticket integration v6 contract");
755
767
  }
768
+
769
+ const implementationMapSchema = JSON.parse(readText(implementationMapSchemaPath));
770
+ if (
771
+ implementationMapSchema.$id !== "urn:speculo:specdev:implementation-map:v1" ||
772
+ implementationMapSchema.properties?.schema_version?.const !== IMPLEMENTATION_MAP_SCHEMA_VERSION ||
773
+ implementationMapSchema.properties?.members?.minItems !== 2 ||
774
+ implementationMapSchema.properties?.tasks?.minItems !== 1 ||
775
+ implementationMapSchema.additionalProperties !== false
776
+ ) {
777
+ errors.push("implementation-map.schema.json must define the strict parent implementation graph contract v1");
778
+ }
779
+ const implementationPlanSchema = JSON.parse(readText(implementationPlanSchemaPath));
780
+ if (
781
+ implementationPlanSchema.$id !== "urn:speculo:specdev:implementation-plan:v1" ||
782
+ implementationPlanSchema.properties?.schema_version?.const !== IMPLEMENTATION_PLAN_SCHEMA_VERSION ||
783
+ implementationPlanSchema.properties?.orchestration?.const !== "lead-directed" ||
784
+ implementationPlanSchema.properties?.implementation_agent_limit?.minimum !== 1 ||
785
+ implementationPlanSchema.properties?.integration_attempt_limit?.minimum !== 1 ||
786
+ !["current", "required"].every((value) => implementationPlanSchema.properties?.ticket_workspace_policy?.enum?.includes(value)) ||
787
+ implementationPlanSchema.additionalProperties !== false
788
+ ) {
789
+ errors.push("implementation-plan.schema.json must define the strict parent implementation projection contract v1");
790
+ }
756
791
  return errors;
757
792
  }
758
793
 
@@ -810,6 +845,13 @@ function capabilityChecks(root) {
810
845
  ],
811
846
  ],
812
847
  ],
848
+ [
849
+ "orchestrate-implementation",
850
+ [
851
+ join(root, "O-orchestrate-implementation", "O-orchestrate-implementation.md"),
852
+ ["Ready Spec", "Ready Tickets", "Implementation Map", "Implementation Plan", "lead-directed", "implementation_agent_limit", "serialization", "I-implement"],
853
+ ],
854
+ ],
813
855
  [
814
856
  "implement",
815
857
  [
@@ -897,6 +939,13 @@ function capabilityChecks(root) {
897
939
  "W-wayfinder/local-tracker-contract.md",
898
940
  "W-wayfinder/solution-comment-template.md",
899
941
  "R-review-architecture/architecture-report-contract.md",
942
+ "common/rules/parent-implementation-orchestration.md",
943
+ "common/schemas/implementation-map.schema.json",
944
+ "common/schemas/implementation-plan.schema.json",
945
+ "O-orchestrate-implementation/input-readiness.md",
946
+ "O-orchestrate-implementation/implementation-map-template.md",
947
+ "O-orchestrate-implementation/implementation-plan-template.md",
948
+ "O-orchestrate-implementation/implementation-evidence-template.md",
900
949
  ]) {
901
950
  if (!isFile(join(root, required))) errors.push(`missing architecture/wayfinding contract ${required}`);
902
951
  }
@@ -2117,6 +2166,438 @@ function validateGitEvidence(repoRoot, changeStatus, errors) {
2117
2166
  }
2118
2167
  }
2119
2168
 
2169
+ function validateParentImplementation(change, parentStatus, stage, errors, warnings) {
2170
+ const required = stage === "orchestrate-implementation";
2171
+ const mapPath = join(change, "implementation-map.md");
2172
+ const planPath = join(change, "implementation-plan.md");
2173
+ if (!required && !isFile(mapPath) && !isFile(planPath)) return null;
2174
+ if (!isFile(mapPath)) errors.push("orchestrate-implementation stage requires implementation-map.md");
2175
+ if (!isFile(planPath)) errors.push("orchestrate-implementation stage requires implementation-plan.md");
2176
+ if (!isFile(mapPath) || !isFile(planPath)) return null;
2177
+
2178
+ const parentName = basename(change);
2179
+ const map = parseFrontmatter(mapPath);
2180
+ const plan = parseFrontmatter(planPath);
2181
+ const mapKeys = ["schema_version", "artifact", "change", "status", "revision", "members", "tasks", "dependencies", "serializations"];
2182
+ 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"];
2183
+ for (const [label, meta, expected] of [
2184
+ ["implementation-map.md", map.meta, mapKeys],
2185
+ ["implementation-plan.md", plan.meta, planKeys],
2186
+ ]) {
2187
+ const missing = expected.filter((key) => !(key in meta));
2188
+ const unexpected = Object.keys(meta).filter((key) => !expected.includes(key));
2189
+ if (missing.length) errors.push(`${label}: missing keys ${JSON.stringify(missing)}`);
2190
+ if (unexpected.length) errors.push(`${label}: unexpected keys ${JSON.stringify(unexpected.sort())}`);
2191
+ }
2192
+ if (map.meta.schema_version !== IMPLEMENTATION_MAP_SCHEMA_VERSION || map.meta.artifact !== "implementation-map") {
2193
+ errors.push("implementation-map.md: artifact/schema_version must be implementation-map/1");
2194
+ }
2195
+ if (plan.meta.schema_version !== IMPLEMENTATION_PLAN_SCHEMA_VERSION || plan.meta.artifact !== "implementation-plan") {
2196
+ errors.push("implementation-plan.md: artifact/schema_version must be implementation-plan/1");
2197
+ }
2198
+ if (map.meta.change !== parentName || plan.meta.change !== parentName) {
2199
+ errors.push("parent implementation artifacts must name their containing change");
2200
+ }
2201
+ const artifactStatuses = new Set(["ready", "in_progress", "blocked", "completed"]);
2202
+ if (!artifactStatuses.has(map.meta.status)) errors.push(`implementation-map.md: invalid status ${map.meta.status}`);
2203
+ if (!artifactStatuses.has(plan.meta.status)) errors.push(`implementation-plan.md: invalid status ${plan.meta.status}`);
2204
+ if (!Number.isInteger(map.meta.revision) || map.meta.revision < 1) {
2205
+ errors.push("implementation-map.md: revision must be a positive integer");
2206
+ }
2207
+ if (plan.meta.source_map_revision !== map.meta.revision) {
2208
+ errors.push("implementation-plan.md: source_map_revision must equal Implementation Map revision");
2209
+ }
2210
+ if (plan.meta.orchestration !== "lead-directed" || typeof plan.meta.lead !== "string" || !plan.meta.lead.trim()) {
2211
+ errors.push("implementation-plan.md: lead-directed orchestration requires a recoverable Lead");
2212
+ }
2213
+ for (const key of ["implementation_agent_limit", "integration_attempt_limit"]) {
2214
+ if (!Number.isInteger(plan.meta[key]) || plan.meta[key] < 1) {
2215
+ errors.push(`implementation-plan.md: ${key} must be a positive integer`);
2216
+ }
2217
+ }
2218
+ const validStrategy =
2219
+ (plan.meta.ticket_workspace_policy === "current" && plan.meta.integration_gate === "direct-parent") ||
2220
+ (plan.meta.ticket_workspace_policy === "required" && plan.meta.integration_gate === "candidate-merge");
2221
+ if (!validStrategy) {
2222
+ errors.push("implementation-plan.md: workspace/integration strategy must be current/direct-parent or required/candidate-merge");
2223
+ }
2224
+ const readyStatuses = new Set(["ready", "in_progress"]);
2225
+ if (
2226
+ typeof plan.meta.ready_for_execution !== "boolean" ||
2227
+ (plan.meta.ready_for_execution === true) !== readyStatuses.has(plan.meta.status)
2228
+ ) {
2229
+ errors.push("implementation-plan.md: ready_for_execution must match status");
2230
+ }
2231
+ for (const heading of [
2232
+ "## 1. Members and Source Authority",
2233
+ "## 2. Composite Ticket Inventory",
2234
+ "## 3. Implementation Super-DAG",
2235
+ "## 4. Conflict and Serialization",
2236
+ "## 5. Contract and Path Coverage",
2237
+ "## 6. Revision Log",
2238
+ ]) {
2239
+ if (!map.body.includes(heading)) errors.push(`implementation-map.md: missing '${heading}'`);
2240
+ }
2241
+ for (const heading of [
2242
+ "## 1. Outcome and Authority",
2243
+ "## 2. Ready Frontier and Waves",
2244
+ "## 3. Workspace and Dispatch Contract",
2245
+ "## 4. Repository Integration Queue",
2246
+ "## 5. Gates and Aggregate Verification",
2247
+ "## 6. Conflict, Drift and Recovery",
2248
+ "## 7. Progress and Decisions",
2249
+ ]) {
2250
+ if (!plan.body.includes(heading)) errors.push(`implementation-plan.md: missing '${heading}'`);
2251
+ }
2252
+
2253
+ const members = requireList(map.meta, "members", "implementation-map.md", errors).map(String);
2254
+ const tasks = requireList(map.meta, "tasks", "implementation-map.md", errors).map(String);
2255
+ const dependencies = requireList(map.meta, "dependencies", "implementation-map.md", errors).map(String);
2256
+ const serializations = requireList(map.meta, "serializations", "implementation-map.md", errors).map(String);
2257
+ if (members.length < 2) errors.push("implementation-map.md: parent implementation requires at least two members");
2258
+ if (!tasks.length) errors.push("implementation-map.md: tasks must contain the child Ticket inventory");
2259
+ for (const [key, values] of [["members", members], ["tasks", tasks], ["dependencies", dependencies], ["serializations", serializations]]) {
2260
+ if (new Set(values).size !== values.length) errors.push(`implementation-map.md: ${key} must be unique`);
2261
+ }
2262
+ if (members.includes(parentName)) errors.push("implementation-map.md: parent change cannot include itself");
2263
+
2264
+ const changesRoot = dirname(change);
2265
+ const memberSet = new Set(members);
2266
+ const memberStatuses = new Map();
2267
+ const ticketByTask = new Map();
2268
+ const expectedTasks = new Set();
2269
+ const expectedInternalEdges = new Set();
2270
+ let activeImplementations = 0;
2271
+ let activeCurrentWriters = 0;
2272
+ const integratingByRef = new Map();
2273
+
2274
+ for (const member of members) {
2275
+ if (!CHANGE_NAME.test(member)) {
2276
+ errors.push(`implementation-map.md: invalid member change name ${member}`);
2277
+ continue;
2278
+ }
2279
+ const memberRoot = join(changesRoot, member);
2280
+ if (!isDirectory(memberRoot)) {
2281
+ errors.push(`implementation-map.md: member change does not exist: ${member}`);
2282
+ continue;
2283
+ }
2284
+ if (isFile(join(memberRoot, "implementation-map.md"))) {
2285
+ errors.push(`implementation-map.md: nested parent implementation is not supported in v1: ${member}`);
2286
+ }
2287
+
2288
+ const memberErrors = [];
2289
+ const memberWarnings = [];
2290
+ const status = validateChangeStatus(join(memberRoot, ".status.json"), member, memberErrors);
2291
+ if (status) {
2292
+ memberStatuses.set(member, status);
2293
+ if (status.change_status === "archived") memberErrors.push("archived change cannot be an implementation member");
2294
+ if (status.current_work !== null && status.current_work !== "specdev/implement") {
2295
+ memberErrors.push(`current_work=${status.current_work} conflicts with parent implementation ownership`);
2296
+ }
2297
+ for (const worktree of Array.isArray(status.worktrees) ? status.worktrees : []) {
2298
+ if (worktree?.status === "active") {
2299
+ activeImplementations += 1;
2300
+ if (worktree.workspace_ref === "current") activeCurrentWriters += 1;
2301
+ if (plan.meta.ticket_workspace_policy === "current" && worktree.workspace_ref !== "current") {
2302
+ memberErrors.push(`${worktree.ticket_id}: active workspace must follow parent current policy`);
2303
+ }
2304
+ if (plan.meta.ticket_workspace_policy === "required" && worktree.workspace_ref === "current") {
2305
+ memberErrors.push(`${worktree.ticket_id}: active workspace must follow parent required policy`);
2306
+ }
2307
+ }
2308
+ if (worktree?.status === "integrating") {
2309
+ const key = String(worktree.parent_branch ?? "<unknown-ref>");
2310
+ integratingByRef.set(key, (integratingByRef.get(key) ?? 0) + 1);
2311
+ }
2312
+ if (Number.isInteger(worktree?.integration?.attempts) && worktree.integration.attempts > plan.meta.integration_attempt_limit) {
2313
+ memberErrors.push(`${worktree.ticket_id}: integration attempts ${worktree.integration.attempts} exceed parent limit ${plan.meta.integration_attempt_limit}`);
2314
+ }
2315
+ }
2316
+ }
2317
+
2318
+ const specPath = join(memberRoot, "spec.md");
2319
+ const childSpec = isFile(specPath) ? validateSpec(specPath, memberErrors, memberWarnings) : null;
2320
+ if (!childSpec) memberErrors.push("Ready Spec is required before parent creation");
2321
+ else {
2322
+ if (childSpec.meta.change !== member) memberErrors.push("spec.md: change must equal member directory name");
2323
+ if (childSpec.meta.status !== "ready" || childSpec.meta.ready_for_tickets !== true) {
2324
+ memberErrors.push("Spec must have status=ready and ready_for_tickets=true");
2325
+ }
2326
+ }
2327
+
2328
+ const childMapPath = join(memberRoot, "tickets-map.md");
2329
+ const childMap = isFile(childMapPath) ? validateMap(childMapPath, memberErrors) : null;
2330
+ if (!childMap) memberErrors.push("Ready Tickets Map is required before parent creation");
2331
+ else {
2332
+ if (childMap.meta.change !== member) memberErrors.push("tickets-map.md: change must equal member directory name");
2333
+ if (!new Set(["ready", "in_progress", "completed"]).has(childMap.meta.status)) {
2334
+ memberErrors.push("Tickets Map status must be ready, in_progress, or completed");
2335
+ }
2336
+ }
2337
+
2338
+ const ticketRoot = join(memberRoot, "ticket");
2339
+ const ticketFiles = isDirectory(ticketRoot)
2340
+ ? readdirSync(ticketRoot).filter((name) => name.endsWith(".md")).sort()
2341
+ : [];
2342
+ if (!ticketFiles.length) memberErrors.push("Ready Tickets are required before parent creation");
2343
+ const childTickets = new Map();
2344
+ for (const name of ticketFiles) {
2345
+ const artifact = validateTicket(join(ticketRoot, name), memberErrors);
2346
+ if (!artifact) continue;
2347
+ const ticketId = String(artifact.meta.id);
2348
+ if (artifact.meta.change !== member) memberErrors.push(`${name}: change must equal member directory name`);
2349
+ if (childTickets.has(ticketId)) memberErrors.push(`duplicate Ticket id ${ticketId}`);
2350
+ childTickets.set(ticketId, artifact);
2351
+ const numericId = ticketId.replace(/^T-/, "");
2352
+ if (!name.startsWith(`${numericId}-`)) memberErrors.push(`${name}: filename prefix must match ${ticketId}`);
2353
+ if (childMap && !childMap.body.includes(ticketId)) memberErrors.push(`${name}: Ticket id is absent from Tickets Map`);
2354
+ if (childSpec) {
2355
+ for (const contractId of artifact.meta.contract_ids ?? []) {
2356
+ if (!childSpec.body.includes(String(contractId))) memberErrors.push(`${name}: contract ${contractId} not found in Spec`);
2357
+ }
2358
+ }
2359
+
2360
+ const taskId = `${member}::${ticketId}`;
2361
+ expectedTasks.add(taskId);
2362
+ ticketByTask.set(taskId, { artifact, member, memberRoot, status });
2363
+ const terminal = new Set(["done", "cancelled"]).has(artifact.meta.status);
2364
+ if (!terminal) {
2365
+ if (map.meta.status === "ready" && (artifact.meta.status !== "ready" || artifact.meta.ready !== true)) {
2366
+ memberErrors.push(`${ticketId}: parent creation requires status=ready and ready=true`);
2367
+ } else if (new Set(["ready", "in_progress", "review"]).has(artifact.meta.status) && artifact.meta.ready !== true) {
2368
+ memberErrors.push(`${ticketId}: executable Ticket must keep ready=true`);
2369
+ } else if (new Set(["blocked", "deviated"]).has(artifact.meta.status) && map.meta.status !== "blocked" && plan.meta.status !== "blocked") {
2370
+ memberErrors.push(`${ticketId}: blocked/deviated Ticket requires blocked parent artifacts`);
2371
+ } else if (artifact.meta.status === "draft") {
2372
+ memberErrors.push(`${ticketId}: draft Ticket cannot belong to a parent implementation`);
2373
+ }
2374
+ }
2375
+ }
2376
+
2377
+ for (const [ticketId, artifact] of childTickets) {
2378
+ for (const dependency of (artifact.meta.blocked_by ?? []).map(String)) {
2379
+ if (!childTickets.has(dependency)) memberErrors.push(`${ticketId}: blocked_by references missing ${dependency}`);
2380
+ expectedInternalEdges.add(`${member}::${ticketId} <- ${member}::${dependency}`);
2381
+ }
2382
+ }
2383
+ const childGraph = new Map([...childTickets].map(([ticketId, artifact]) => [ticketId, (artifact.meta.blocked_by ?? []).map(String)]));
2384
+ const childCycle = findCycle(childGraph);
2385
+ if (childCycle) memberErrors.push(`Ticket dependency cycle: ${childCycle.join(" -> ")}`);
2386
+
2387
+ if (childSpec) {
2388
+ const declared = new Set(childSpec.body.match(/\bAC-\d+\b/g) ?? []);
2389
+ const covered = new Set([...childTickets.values()].flatMap((artifact) => (artifact.meta.contract_ids ?? []).map(String)));
2390
+ const uncovered = [...declared].filter((id) => !covered.has(id) && !(childMap && new RegExp(`${escapeRegExp(id)}.*\\bdeferred\\b`, "i").test(childMap.body)));
2391
+ if (uncovered.length) memberErrors.push(`Spec acceptance contracts are not covered by Tickets: ${JSON.stringify(uncovered.sort())}`);
2392
+ }
2393
+ if (status?.change_status === "completed") {
2394
+ const unfinished = [...childTickets].filter(([, artifact]) => !new Set(["done", "cancelled"]).has(artifact.meta.status)).map(([id]) => id);
2395
+ if (unfinished.length) memberErrors.push(`completed member has unfinished Tickets: ${JSON.stringify(unfinished)}`);
2396
+ }
2397
+
2398
+ const goalPath = join(memberRoot, "goal-plan.md");
2399
+ if (isFile(goalPath)) {
2400
+ const goal = validateGoalPlan(goalPath, memberErrors);
2401
+ if (goal && (
2402
+ goal.meta.ticket_workspace_policy !== plan.meta.ticket_workspace_policy ||
2403
+ goal.meta.integration_gate !== plan.meta.integration_gate
2404
+ )) {
2405
+ memberErrors.push("Goal Plan workspace/integration strategy conflicts with parent Implementation Plan");
2406
+ }
2407
+ }
2408
+ errors.push(...memberErrors.map((message) => `${member}: ${message}`));
2409
+ warnings.push(...memberWarnings.map((message) => `${member}: ${message}`));
2410
+ }
2411
+
2412
+ const taskSet = new Set(tasks);
2413
+ for (const task of tasks) {
2414
+ if (!/^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*::T-\d{2,}$/.test(task)) {
2415
+ errors.push(`implementation-map.md: invalid composite task '${task}'`);
2416
+ }
2417
+ }
2418
+ const missingTasks = [...expectedTasks].filter((task) => !taskSet.has(task)).sort();
2419
+ const extraTasks = tasks.filter((task) => !expectedTasks.has(task)).sort();
2420
+ if (missingTasks.length || extraTasks.length) {
2421
+ errors.push(`implementation-map.md: tasks must exactly match child Tickets; missing=${JSON.stringify(missingTasks)} extra=${JSON.stringify(extraTasks)}`);
2422
+ }
2423
+
2424
+ const graph = new Map(tasks.map((task) => [task, []]));
2425
+ const dependencySet = new Set(dependencies);
2426
+ for (const edge of dependencies) {
2427
+ const match = /^([^ ]+::T-\d{2,}) <- ([^ ]+::T-\d{2,})$/.exec(edge);
2428
+ if (!match) {
2429
+ errors.push(`implementation-map.md: invalid dependency '${edge}'`);
2430
+ continue;
2431
+ }
2432
+ const [, dependent, prerequisite] = match;
2433
+ if (!taskSet.has(dependent) || !taskSet.has(prerequisite)) {
2434
+ errors.push(`implementation-map.md: dependency endpoints must be composite tasks: ${edge}`);
2435
+ } else if (dependent === prerequisite) {
2436
+ errors.push(`implementation-map.md: dependency cannot be self-referential: ${edge}`);
2437
+ } else {
2438
+ graph.get(dependent).push(prerequisite);
2439
+ const dependentMember = dependent.split("::")[0];
2440
+ const prerequisiteMember = prerequisite.split("::")[0];
2441
+ if (dependentMember === prerequisiteMember && !expectedInternalEdges.has(edge)) {
2442
+ errors.push(`implementation-map.md: same-member dependency must come from child blocked_by: ${edge}`);
2443
+ }
2444
+ }
2445
+ }
2446
+ const missingInternalEdges = [...expectedInternalEdges].filter((edge) => !dependencySet.has(edge)).sort();
2447
+ if (missingInternalEdges.length) {
2448
+ errors.push(`implementation-map.md: missing child Ticket dependencies ${JSON.stringify(missingInternalEdges)}`);
2449
+ }
2450
+ const cycle = findCycle(graph);
2451
+ if (cycle) errors.push(`implementation super-DAG cycle: ${cycle.join(" -> ")}`);
2452
+
2453
+ const serializationPairs = new Set();
2454
+ for (const edge of serializations) {
2455
+ const match = /^([^ ]+::T-\d{2,}) <> ([^ ]+::T-\d{2,})$/.exec(edge);
2456
+ if (!match) {
2457
+ errors.push(`implementation-map.md: invalid serialization '${edge}'`);
2458
+ continue;
2459
+ }
2460
+ const [, left, right] = match;
2461
+ if (!taskSet.has(left) || !taskSet.has(right)) {
2462
+ errors.push(`implementation-map.md: serialization endpoints must be composite tasks: ${edge}`);
2463
+ continue;
2464
+ }
2465
+ if (left === right) {
2466
+ errors.push(`implementation-map.md: serialization cannot be self-referential: ${edge}`);
2467
+ continue;
2468
+ }
2469
+ const key = [left, right].sort().join("\0");
2470
+ if (serializationPairs.has(key)) errors.push(`implementation-map.md: duplicate unordered serialization pair: ${edge}`);
2471
+ serializationPairs.add(key);
2472
+ }
2473
+
2474
+ const unfinishedTasks = [...ticketByTask]
2475
+ .filter(([, value]) => !new Set(["done", "cancelled"]).has(value.artifact.meta.status))
2476
+ .map(([task]) => task);
2477
+ for (let index = 0; index < unfinishedTasks.length; index += 1) {
2478
+ const leftId = unfinishedTasks[index];
2479
+ const left = ticketByTask.get(leftId).artifact;
2480
+ for (const rightId of unfinishedTasks.slice(index + 1)) {
2481
+ if (leftId.split("::")[0] === rightId.split("::")[0]) continue;
2482
+ if (
2483
+ transitivelyDepends(graph, leftId, rightId) ||
2484
+ transitivelyDepends(graph, rightId, leftId) ||
2485
+ serializationPairs.has([leftId, rightId].sort().join("\0"))
2486
+ ) continue;
2487
+ const right = ticketByTask.get(rightId).artifact;
2488
+ const overlaps = [];
2489
+ for (const leftPath of left.meta.writable_paths ?? []) {
2490
+ for (const rightPath of right.meta.writable_paths ?? []) {
2491
+ if (pathsOverlap(String(leftPath), String(rightPath))) overlaps.push([String(leftPath), String(rightPath)]);
2492
+ }
2493
+ }
2494
+ if (overlaps.length) {
2495
+ errors.push(`composite tasks ${leftId}/${rightId} have writable overlap without dependency or serialization: ${JSON.stringify(overlaps.slice(0, 3))}`);
2496
+ }
2497
+ }
2498
+ }
2499
+
2500
+ for (const entry of readdirSync(changesRoot, { withFileTypes: true })) {
2501
+ if (!entry.isDirectory() || entry.name === parentName) continue;
2502
+ const otherMapPath = join(changesRoot, entry.name, "implementation-map.md");
2503
+ if (!isFile(otherMapPath)) continue;
2504
+ const otherMap = parseFrontmatter(otherMapPath).meta;
2505
+ if (!Array.isArray(otherMap.members)) continue;
2506
+ let otherStatus = null;
2507
+ try {
2508
+ otherStatus = JSON.parse(readText(join(changesRoot, entry.name, ".status.json"))).change_status;
2509
+ } catch {
2510
+ // Invalid competing parents still own their members conservatively.
2511
+ }
2512
+ if (new Set(["completed", "archived"]).has(otherStatus)) continue;
2513
+ const overlap = otherMap.members.map(String).filter((member) => memberSet.has(member));
2514
+ if (overlap.length) errors.push(`member changes already belong to unfinished parent implementation ${entry.name}: ${JSON.stringify(overlap)}`);
2515
+ }
2516
+
2517
+ const config = findSpecdevConfig(change);
2518
+ const configuredAgents = positiveConfigLimit(config, "max_implementation_agents", 0);
2519
+ const configuredAttempts = positiveConfigLimit(config, "max_integration_attempts", 0);
2520
+ if (!config || config.schema_version !== CONFIG_SCHEMA_VERSION || configuredAgents === 0 || configuredAttempts === 0) {
2521
+ errors.push("implementation-plan.md: SpecDev config v5 with positive execution limits is required");
2522
+ } else {
2523
+ if (plan.meta.implementation_agent_limit > configuredAgents) {
2524
+ errors.push(`implementation-plan.md: implementation_agent_limit ${plan.meta.implementation_agent_limit} exceeds config max_implementation_agents ${configuredAgents}`);
2525
+ }
2526
+ if (plan.meta.integration_attempt_limit > configuredAttempts) {
2527
+ errors.push(`implementation-plan.md: integration_attempt_limit ${plan.meta.integration_attempt_limit} exceeds config max_integration_attempts ${configuredAttempts}`);
2528
+ }
2529
+ }
2530
+ if (activeImplementations > plan.meta.implementation_agent_limit) {
2531
+ errors.push(`parent implementation agent limit exceeded: ${activeImplementations} active for limit ${plan.meta.implementation_agent_limit}`);
2532
+ }
2533
+ if (plan.meta.ticket_workspace_policy === "current" && activeCurrentWriters > 1) {
2534
+ errors.push(`parent current policy permits only one active implementation writer; found ${activeCurrentWriters}`);
2535
+ }
2536
+ for (const [ref, count] of integratingByRef) {
2537
+ if (count > 1) errors.push(`repository/ref integration must be serialized for ${ref}; found ${count}`);
2538
+ }
2539
+
2540
+ if (required && parentStatus && new Set(["active", "blocked"]).has(parentStatus.change_status) && parentStatus.current_work !== "specdev/orchestrate-implementation") {
2541
+ errors.push("parent active/blocked status must keep current_work=specdev/orchestrate-implementation");
2542
+ }
2543
+ if (parentStatus?.change_status === "completed") {
2544
+ const incomplete = members.filter((member) => memberStatuses.get(member)?.change_status !== "completed");
2545
+ if (incomplete.length) errors.push(`parent implementation is completed while members remain incomplete: ${JSON.stringify(incomplete)}`);
2546
+ const unfinished = [...ticketByTask].filter(([, value]) => !new Set(["done", "cancelled"]).has(value.artifact.meta.status)).map(([task]) => task);
2547
+ if (unfinished.length) errors.push(`completed parent implementation has unfinished composite tasks: ${JSON.stringify(unfinished)}`);
2548
+ if (map.meta.status !== "completed" || plan.meta.status !== "completed") {
2549
+ errors.push("completed parent requires completed Implementation Map and Implementation Plan");
2550
+ }
2551
+ if (
2552
+ parentStatus.current_work !== null ||
2553
+ !Array.isArray(parentStatus.blockers) || parentStatus.blockers.length > 0 ||
2554
+ !Array.isArray(parentStatus.deviations) || parentStatus.deviations.length > 0
2555
+ ) {
2556
+ errors.push("completed parent requires null current_work and no blockers or deviations");
2557
+ }
2558
+ const activeMemberWork = [...memberStatuses]
2559
+ .flatMap(([member, status]) => (status.worktrees ?? []).map((worktree) => ({ member, worktree })))
2560
+ .filter(({ worktree }) => new Set(["planned", "active", "review", "integrating", "blocked"]).has(worktree?.status) || worktree?.integration?.status === "candidate")
2561
+ .map(({ member, worktree }) => `${member}::${worktree.ticket_id}`);
2562
+ if (activeMemberWork.length) {
2563
+ errors.push(`completed parent has active member worktrees or candidates: ${JSON.stringify(activeMemberWork)}`);
2564
+ }
2565
+ for (const [task, value] of ticketByTask) {
2566
+ if (value.artifact.meta.status !== "done") continue;
2567
+ if (!isFile(join(value.memberRoot, "evidence", `${value.artifact.meta.id}.md`))) {
2568
+ errors.push(`${task}: completed parent requires child Ticket Evidence`);
2569
+ }
2570
+ const worktree = value.status?.worktrees?.find((entry) => entry?.ticket_id === value.artifact.meta.id);
2571
+ if (!worktree || !new Set(["integrated", "removed"]).has(worktree.status)) {
2572
+ errors.push(`${task}: completed parent requires integrated or removed child workspace record`);
2573
+ }
2574
+ }
2575
+ const evidencePath = join(change, "evidence", "implementation-orchestration.md");
2576
+ if (!isFile(evidencePath)) {
2577
+ errors.push("completed parent requires evidence/implementation-orchestration.md");
2578
+ } else {
2579
+ const evidence = readText(evidencePath);
2580
+ for (const heading of [
2581
+ "## 1. Parent Plan and Final Revision",
2582
+ "## 2. Member and Ticket Completion",
2583
+ "## 3. Dependency and Serialization Audit",
2584
+ "## 4. Repository Integration Audit",
2585
+ "## 5. Aggregate Verification",
2586
+ "## 6. Contract, Drift and Deviation Audit",
2587
+ "## 7. Residual Risk and Boundary",
2588
+ ]) {
2589
+ if (!evidence.includes(heading)) errors.push(`evidence/implementation-orchestration.md: missing '${heading}'`);
2590
+ }
2591
+ }
2592
+ } else if (map.meta.status === "completed" || plan.meta.status === "completed") {
2593
+ errors.push("completed Implementation Map/Plan requires completed parent change status");
2594
+ }
2595
+ if (plan.meta.ready_for_execution === true && !new Set(["ready", "in_progress"]).has(map.meta.status)) {
2596
+ errors.push("ready Implementation Plan requires a ready or in_progress Implementation Map");
2597
+ }
2598
+ return { map, plan, members, tasks, memberStatuses, ticketByTask };
2599
+ }
2600
+
2120
2601
  function validateChange(change, stage = null, repoRoot = null) {
2121
2602
  const errors = [];
2122
2603
  const warnings = [];
@@ -2125,6 +2606,7 @@ function validateChange(change, stage = null, repoRoot = null) {
2125
2606
  }
2126
2607
 
2127
2608
  const changeStatus = validateChangeStatus(join(change, ".status.json"), basename(change), errors);
2609
+ validateParentImplementation(change, changeStatus, stage, errors, warnings);
2128
2610
  if (isFile(join(change, "source-issue.md"))) {
2129
2611
  errors.push("obsolete source-issue.md is forbidden; use source.md without compatibility fallback");
2130
2612
  }
@@ -2144,7 +2626,7 @@ function validateChange(change, stage = null, repoRoot = null) {
2144
2626
  validateReviews(change, stage === "review", errors);
2145
2627
  validatePrototypes(change, stage === "prototype", errors);
2146
2628
 
2147
- const specRequired = new Set(["spec", "tickets", "goal-plan", "implement", "complete"]).has(stage);
2629
+ const specRequired = new Set(["spec", "tickets", "goal-plan", "implement", "complete"]).has(stage) && !isFile(join(change, "implementation-map.md"));
2148
2630
  const specPath = join(change, "spec.md");
2149
2631
  const spec = isFile(specPath) || specRequired
2150
2632
  ? validateSpec(specPath, errors, warnings)
@@ -2400,6 +2882,7 @@ function validateChange(change, stage = null, repoRoot = null) {
2400
2882
  if (
2401
2883
  stage === "complete" &&
2402
2884
  !ticketFiles.length &&
2885
+ !isFile(join(change, "implementation-map.md")) &&
2403
2886
  !isFile(join(change, "evidence", "direct-spec.md"))
2404
2887
  ) {
2405
2888
  errors.push("complete stage without Tickets requires evidence/direct-spec.md");