@mcoda/core 0.1.34 → 0.1.35

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 (26) hide show
  1. package/dist/api/AgentsApi.d.ts +4 -1
  2. package/dist/api/AgentsApi.d.ts.map +1 -1
  3. package/dist/api/AgentsApi.js +4 -1
  4. package/dist/services/docs/DocsService.d.ts +37 -0
  5. package/dist/services/docs/DocsService.d.ts.map +1 -1
  6. package/dist/services/docs/DocsService.js +537 -2
  7. package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -1
  8. package/dist/services/docs/review/gates/OpenQuestionsGate.js +13 -2
  9. package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.d.ts.map +1 -1
  10. package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.js +12 -1
  11. package/dist/services/planning/CreateTasksService.d.ts +20 -0
  12. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  13. package/dist/services/planning/CreateTasksService.js +772 -163
  14. package/dist/services/planning/SdsCoverageModel.d.ts +27 -0
  15. package/dist/services/planning/SdsCoverageModel.d.ts.map +1 -0
  16. package/dist/services/planning/SdsCoverageModel.js +138 -0
  17. package/dist/services/planning/SdsPreflightService.d.ts +2 -0
  18. package/dist/services/planning/SdsPreflightService.d.ts.map +1 -1
  19. package/dist/services/planning/SdsPreflightService.js +125 -31
  20. package/dist/services/planning/SdsStructureSignals.d.ts +24 -0
  21. package/dist/services/planning/SdsStructureSignals.d.ts.map +1 -0
  22. package/dist/services/planning/SdsStructureSignals.js +402 -0
  23. package/dist/services/planning/TaskSufficiencyService.d.ts +1 -0
  24. package/dist/services/planning/TaskSufficiencyService.d.ts.map +1 -1
  25. package/dist/services/planning/TaskSufficiencyService.js +218 -285
  26. package/package.json +6 -6
@@ -4,6 +4,8 @@ import { WorkspaceRepository } from "@mcoda/db";
4
4
  import { PathHelper } from "@mcoda/shared";
5
5
  import { JobService } from "../jobs/JobService.js";
6
6
  import { createEpicKeyGenerator, createStoryKeyGenerator, createTaskKeyGenerator } from "./KeyHelpers.js";
7
+ import { collectSdsCoverageSignalsFromDocs, evaluateSdsCoverage, normalizeCoverageAnchor, normalizeCoverageText, } from "./SdsCoverageModel.js";
8
+ import { collectSdsImplementationSignals, isStructuredFilePath, normalizeFolderEntry, normalizeHeadingCandidate, stripManagedSdsPreflightBlock, } from "./SdsStructureSignals.js";
7
9
  const DEFAULT_MAX_ITERATIONS = 5;
8
10
  const DEFAULT_MAX_TASKS_PER_ITERATION = 24;
9
11
  const DEFAULT_MIN_COVERAGE_RATIO = 1;
@@ -16,115 +18,20 @@ const REPORT_FILE_NAME = "task-sufficiency-report.json";
16
18
  const ignoredDirs = new Set([".git", "node_modules", "dist", "build", ".mcoda", ".docdex"]);
17
19
  const sdsFilenamePattern = /(sds|software[-_ ]design|system[-_ ]design|design[-_ ]spec)/i;
18
20
  const sdsContentPattern = /(software design specification|system design specification|^#\s*sds\b)/im;
19
- const nonImplementationHeadingPattern = /\b(software design specification|system design specification|\bsds\b|revision history|table of contents|purpose|scope|definitions?|abbreviations?|glossary|references?|appendix|document control|authors?)\b/i;
20
- const likelyImplementationHeadingPattern = /\b(architecture|entity|entities|service|services|module|modules|component|components|pipeline|workflow|api|endpoint|schema|model|feature|store|database|ingestion|training|inference|ui|frontend|backend|ops|observability|security|deployment|solver|integration|testing|validation|contract|index|mapping|registry|cache|queue|event|job|task|migration|controller|router|policy)\b/i;
21
- const repoRootSegments = new Set([
22
- "apps",
23
- "api",
24
- "backend",
25
- "config",
26
- "configs",
27
- "db",
28
- "deployment",
29
- "deployments",
30
- "docs",
31
- "frontend",
32
- "implementation",
33
- "infra",
34
- "internal",
35
- "packages",
36
- "scripts",
37
- "service",
38
- "services",
39
- "shared",
40
- "src",
41
- "test",
42
- "tests",
43
- "ui",
44
- "web",
45
- ]);
21
+ const supportRootSegments = new Set(["docs", "fixtures", "policies", "policy", "runbooks", "pdr", "rfp", "sds"]);
46
22
  const headingNoiseTokens = new Set(["and", "for", "from", "into", "the", "with"]);
47
- const coverageStopTokens = new Set([
48
- "about",
49
- "across",
50
- "after",
51
- "before",
52
- "between",
53
- "from",
54
- "into",
55
- "over",
56
- "under",
57
- "using",
58
- "with",
59
- "without",
60
- "into",
61
- "onto",
62
- "the",
63
- "and",
64
- "for",
65
- "of",
66
- "to",
67
- ]);
68
- const normalizeText = (value) => value
69
- .toLowerCase()
70
- .replace(/[`*_]/g, " ")
71
- .replace(/[^a-z0-9/\s.-]+/g, " ")
72
- .replace(/\s+/g, " ")
73
- .trim();
74
- const normalizeAnchor = (kind, value) => `${kind}:${normalizeText(value).replace(/\s+/g, " ").trim()}`;
23
+ const normalizeText = (value) => normalizeCoverageText(value);
24
+ const normalizeAnchor = normalizeCoverageAnchor;
75
25
  const unique = (items) => Array.from(new Set(items.filter(Boolean)));
26
+ const tokenizeCoverageSignal = (value) => unique(normalizeCoverageText(value)
27
+ .split(/\s+/)
28
+ .map((token) => token.replace(/[^a-z0-9._-]+/g, ""))
29
+ .filter((token) => token.length >= 3));
76
30
  const stripDecorators = (value) => value
77
31
  .replace(/[`*_]/g, " ")
78
32
  .replace(/^[\s>:\-[\]().]+/, "")
79
33
  .replace(/\s+/g, " ")
80
34
  .trim();
81
- const normalizeHeadingCandidate = (value) => {
82
- const cleaned = stripDecorators(value).replace(/^\d+(?:\.\d+)*\s+/, "").trim();
83
- return cleaned.length > 0 ? cleaned : stripDecorators(value);
84
- };
85
- const headingLooksImplementationRelevant = (heading) => {
86
- const normalized = normalizeHeadingCandidate(heading).toLowerCase();
87
- if (!normalized || normalized.length < 3)
88
- return false;
89
- if (nonImplementationHeadingPattern.test(normalized))
90
- return false;
91
- if (likelyImplementationHeadingPattern.test(normalized))
92
- return true;
93
- const sectionMatch = heading.trim().match(/^(\d+)(?:\.\d+)*(?:\s+|$)/);
94
- if (sectionMatch) {
95
- const major = Number.parseInt(sectionMatch[1] ?? "", 10);
96
- if (Number.isFinite(major) && major >= 3)
97
- return true;
98
- }
99
- const tokens = normalized
100
- .split(/\s+/)
101
- .map((token) => token.replace(/[^a-z0-9.-]+/g, ""))
102
- .filter((token) => token.length >= 4 && !headingNoiseTokens.has(token));
103
- return tokens.length >= 2;
104
- };
105
- const normalizeFolderEntry = (entry) => {
106
- const trimmed = stripDecorators(entry)
107
- .replace(/^\.?\//, "")
108
- .replace(/\/+$/, "")
109
- .replace(/\s+/g, "");
110
- if (!trimmed.includes("/"))
111
- return undefined;
112
- if (trimmed.includes("...") || trimmed.includes("*"))
113
- return undefined;
114
- return trimmed;
115
- };
116
- const folderEntryLooksRepoRelevant = (entry) => {
117
- const normalized = normalizeFolderEntry(entry);
118
- if (!normalized)
119
- return false;
120
- if (normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized))
121
- return false;
122
- const segments = normalized.split("/").filter(Boolean);
123
- if (segments.length < 2)
124
- return false;
125
- const root = segments[0].toLowerCase();
126
- return repoRootSegments.has(root);
127
- };
128
35
  const deriveSectionDomain = (heading) => {
129
36
  const normalized = normalizeHeadingCandidate(heading).toLowerCase();
130
37
  const tokens = normalized
@@ -142,130 +49,125 @@ const deriveFolderDomain = (entry) => {
142
49
  return "structure";
143
50
  return segments.length === 1 ? segments[0] : `${segments[0]}-${segments[1]}`;
144
51
  };
145
- const extractMarkdownHeadings = (content, limit) => {
146
- if (!content)
147
- return [];
148
- const lines = content.split(/\r?\n/);
149
- const headings = [];
150
- for (let index = 0; index < lines.length; index += 1) {
151
- const line = lines[index]?.trim() ?? "";
152
- if (!line)
153
- continue;
154
- const hashHeading = line.match(/^#{1,6}\s+(.+)$/);
155
- if (hashHeading) {
156
- const heading = hashHeading[1]?.replace(/#+$/, "").trim();
157
- if (heading)
158
- headings.push(heading);
159
- }
160
- else if (index + 1 < lines.length &&
161
- /^[=-]{3,}\s*$/.test((lines[index + 1] ?? "").trim()) &&
162
- !line.startsWith("-") &&
163
- !line.startsWith("*")) {
164
- headings.push(line);
165
- }
166
- else {
167
- const numberedHeading = line.match(/^(\d+(?:\.\d+)+)\s+(.+)$/);
168
- if (numberedHeading) {
169
- const heading = `${numberedHeading[1]} ${numberedHeading[2]}`.trim();
170
- if (/[a-z]/i.test(heading))
171
- headings.push(heading);
172
- }
173
- }
174
- if (headings.length >= limit)
175
- break;
52
+ const implementationRootWeight = (target) => {
53
+ const normalized = normalizeFolderEntry(target)?.toLowerCase() ?? normalizeText(target);
54
+ const segments = normalized.split("/").filter(Boolean);
55
+ const root = segments[0] ?? normalized;
56
+ let score = 55 + Math.min(segments.length, 4) * 6 + (isStructuredFilePath(normalized) ? 10 : 0);
57
+ if (supportRootSegments.has(root))
58
+ score -= 30;
59
+ if (segments.some((segment) => ["src", "app", "apps", "lib", "libs", "server", "api", "worker", "workers"].includes(segment))) {
60
+ score += 20;
61
+ }
62
+ if (segments.some((segment) => ["test", "tests", "spec", "specs", "integration", "acceptance", "e2e"].includes(segment))) {
63
+ score += 10;
64
+ }
65
+ if (segments.some((segment) => ["db", "data", "schema", "schemas", "migration", "migrations"].includes(segment))) {
66
+ score += 12;
176
67
  }
177
- return unique(headings).slice(0, limit);
68
+ if (segments.some((segment) => ["ops", "infra", "deploy", "deployment", "deployments", "script", "scripts"].includes(segment))) {
69
+ score += 12;
70
+ }
71
+ return score;
178
72
  };
179
- const extractFolderEntries = (content, limit) => {
180
- if (!content)
181
- return [];
182
- const candidates = [];
183
- const lines = content.split(/\r?\n/);
184
- for (const line of lines) {
185
- const trimmed = line.trim();
186
- if (!trimmed)
187
- continue;
188
- const matches = [...trimmed.matchAll(/[`'"]?([a-zA-Z0-9._-]+(?:\/[a-zA-Z0-9._-]+)+(?:\/[a-zA-Z0-9._-]+)*)[`'"]?/g)];
189
- for (const match of matches) {
190
- const raw = (match[1] ?? "").replace(/^\.?\//, "").replace(/\/+$/, "").trim();
191
- if (!raw || !raw.includes("/"))
192
- continue;
193
- candidates.push(raw);
194
- if (candidates.length >= limit)
195
- break;
196
- }
197
- if (candidates.length >= limit)
198
- break;
73
+ const inferImplementationTargets = (bundle, availablePaths, limit = 3) => {
74
+ const explicitTargets = bundle.values
75
+ .map((value) => normalizeFolderEntry(value))
76
+ .filter((value) => Boolean(value));
77
+ if (explicitTargets.length > 0) {
78
+ return unique(explicitTargets).slice(0, limit);
199
79
  }
200
- return unique(candidates).slice(0, limit);
80
+ const anchorTokens = tokenizeCoverageSignal(normalizeText([bundle.domain, ...bundle.values].join(" ")).replace(/[-/]+/g, " "));
81
+ const domainNeedle = bundle.domain.replace(/[-_]+/g, " ").trim();
82
+ const scored = availablePaths
83
+ .map((candidate) => normalizeFolderEntry(candidate))
84
+ .filter((candidate) => Boolean(candidate))
85
+ .filter((candidate) => bundle.kind === "folder" || !candidate.startsWith("docs/"))
86
+ .map((candidate) => {
87
+ const normalizedCandidate = normalizeText(candidate.replace(/\//g, " "));
88
+ const overlap = anchorTokens.filter((token) => normalizedCandidate.includes(token)).length;
89
+ const hasDomainMatch = domainNeedle.length > 0 && normalizedCandidate.includes(domainNeedle);
90
+ const hasEvidence = overlap > 0 || hasDomainMatch;
91
+ const score = implementationRootWeight(candidate) +
92
+ overlap * 20 +
93
+ (hasDomainMatch ? 15 : 0) -
94
+ (candidate.startsWith("docs/") ? 25 : 0);
95
+ return { candidate, score, hasEvidence };
96
+ })
97
+ .filter((entry) => entry.hasEvidence)
98
+ .sort((left, right) => right.score - left.score || left.candidate.localeCompare(right.candidate));
99
+ return unique(scored.filter((entry) => entry.score > 0).map((entry) => entry.candidate)).slice(0, limit);
201
100
  };
202
- const tokenizeCoverageSignal = (value) => unique(value
203
- .split(/\s+/)
204
- .map((token) => token.replace(/[^a-z0-9._-]+/g, ""))
205
- .filter((token) => token.length >= 3 && !coverageStopTokens.has(token)));
206
- const buildBigrams = (tokens) => {
207
- const bigrams = [];
208
- for (let index = 0; index < tokens.length - 1; index += 1) {
209
- const left = tokens[index];
210
- const right = tokens[index + 1];
211
- if (!left || !right)
212
- continue;
213
- bigrams.push(`${left} ${right}`);
101
+ const summarizeImplementationTargets = (targets) => {
102
+ if (targets.length === 0)
103
+ return "target implementation surfaces";
104
+ if (targets.length === 1)
105
+ return targets[0];
106
+ if (targets.length === 2)
107
+ return `${targets[0]}, ${targets[1]}`;
108
+ return `${targets[0]} (+${targets.length - 1} more)`;
109
+ };
110
+ const summarizeBundleScope = (bundle) => {
111
+ const normalizedValues = unique(bundle.values
112
+ .map((value) => bundle.kind === "folder" ? normalizeFolderEntry(value) ?? stripDecorators(value) : normalizeHeadingCandidate(value))
113
+ .filter(Boolean));
114
+ if (normalizedValues.length === 0)
115
+ return bundle.domain.replace(/[-_]+/g, " ").trim() || "coverage";
116
+ if (normalizedValues.length === 1)
117
+ return normalizedValues[0];
118
+ return `${normalizedValues[0]} (+${normalizedValues.length - 1} more)`;
119
+ };
120
+ const buildRemediationTitle = (bundle, implementationTargets) => {
121
+ const domainLabel = bundle.domain.replace(/[-_]+/g, " ").trim() || "coverage";
122
+ const scopeLabel = summarizeBundleScope(bundle);
123
+ if (implementationTargets.length === 0) {
124
+ return `Implement ${scopeLabel || domainLabel}`.slice(0, 180);
214
125
  }
215
- return unique(bigrams);
126
+ if (bundle.kind === "folder") {
127
+ return `Implement ${summarizeImplementationTargets(implementationTargets)}`.slice(0, 180);
128
+ }
129
+ return `Implement ${scopeLabel} in ${summarizeImplementationTargets(implementationTargets)}`.slice(0, 180);
216
130
  };
217
- const headingCovered = (corpus, heading) => {
218
- const normalized = normalizeText(normalizeHeadingCandidate(heading));
219
- if (!normalized)
220
- return true;
221
- if (corpus.includes(normalized))
222
- return true;
223
- const tokens = tokenizeCoverageSignal(normalized).slice(0, 10);
224
- if (tokens.length === 0)
225
- return true;
226
- const hitCount = tokens.filter((token) => corpus.includes(token)).length;
227
- const requiredHits = tokens.length <= 2
228
- ? tokens.length
229
- : tokens.length <= 4
230
- ? 2
231
- : Math.min(4, Math.ceil(tokens.length * 0.6));
232
- if (hitCount < requiredHits)
233
- return false;
234
- if (tokens.length >= 3) {
235
- const longestToken = tokens.reduce((longest, token) => (token.length > longest.length ? token : longest), "");
236
- if (longestToken.length >= 6 && !corpus.includes(longestToken))
237
- return false;
131
+ const summarizeAnchorBundle = (bundle) => {
132
+ if (bundle.normalizedAnchors.length === 0) {
133
+ return `${bundle.kind}:${summarizeBundleScope(bundle)}`;
238
134
  }
239
- const bigrams = buildBigrams(tokens);
240
- if (tokens.length >= 3 && bigrams.length > 0 && !bigrams.some((bigram) => corpus.includes(bigram))) {
241
- return false;
135
+ if (bundle.normalizedAnchors.length === 1) {
136
+ return bundle.normalizedAnchors[0];
242
137
  }
243
- return true;
138
+ return `${bundle.normalizedAnchors[0]} (+${bundle.normalizedAnchors.length - 1} more)`;
244
139
  };
245
- const folderEntryCovered = (corpus, entry) => {
246
- const normalizedEntry = normalizeFolderEntry(entry)?.toLowerCase().replace(/\/+/g, "/");
247
- if (!normalizedEntry)
248
- return true;
249
- const corpusTight = corpus.replace(/\s+/g, "");
250
- if (corpusTight.includes(normalizedEntry.replace(/\s+/g, "")))
251
- return true;
252
- const segments = normalizedEntry
253
- .split("/")
254
- .map((segment) => segment.trim().replace(/[^a-z0-9._-]+/g, ""))
255
- .filter(Boolean);
256
- if (segments.length === 0)
257
- return true;
258
- const tailSegments = unique(segments.slice(Math.max(0, segments.length - 3)));
259
- const hitCount = tailSegments.filter((segment) => corpus.includes(segment)).length;
260
- const requiredHits = tailSegments.length <= 1 ? 1 : Math.min(2, tailSegments.length);
261
- if (hitCount < requiredHits)
262
- return false;
263
- if (tailSegments.length >= 2) {
264
- const hasStrongTokenMatch = tailSegments.some((segment) => segment.length >= 5 && corpus.includes(segment));
265
- if (!hasStrongTokenMatch)
266
- return false;
140
+ const buildTargetedTestGuidance = (implementationTargets) => {
141
+ const guidance = new Set();
142
+ for (const target of implementationTargets) {
143
+ const segments = target
144
+ .toLowerCase()
145
+ .split("/")
146
+ .map((segment) => segment.trim())
147
+ .filter(Boolean);
148
+ if (segments.some((segment) => ["ui", "web", "frontend", "page", "pages", "screen", "screens", "component", "components"].includes(segment))) {
149
+ guidance.add("- Add/update unit, component, and flow coverage for the affected user-facing path.");
150
+ }
151
+ else if (segments.some((segment) => ["api", "server", "backend", "route", "routes", "controller", "controllers", "handler", "handlers"].includes(segment))) {
152
+ guidance.add("- Add/update unit and integration coverage for the affected service/API surface.");
153
+ }
154
+ else if (segments.some((segment) => ["cli", "cmd", "bin", "command", "commands"].includes(segment))) {
155
+ guidance.add("- Add/update command-level tests and end-to-end invocation coverage for the affected runtime flow.");
156
+ }
157
+ else if (segments.some((segment) => ["ops", "infra", "deploy", "deployment", "deployments", "script", "scripts"].includes(segment))) {
158
+ guidance.add("- Add/update operational smoke coverage or script validation for the affected deployment/runbook path.");
159
+ }
160
+ else if (segments.some((segment) => ["db", "data", "schema", "schemas", "migration", "migrations", "model", "models"].includes(segment))) {
161
+ guidance.add("- Add/update persistence, schema, and integration coverage for the affected implementation path.");
162
+ }
163
+ else if (segments.some((segment) => ["worker", "workers", "job", "jobs", "queue", "task", "tasks", "service", "services", "module", "modules"].includes(segment))) {
164
+ guidance.add("- Add/update unit and integration coverage for the affected implementation path.");
165
+ }
267
166
  }
268
- return true;
167
+ if (guidance.size === 0) {
168
+ guidance.add("- Add/update the smallest deterministic test scope that proves the affected implementation targets.");
169
+ }
170
+ return Array.from(guidance);
269
171
  };
270
172
  const readJsonSafe = (raw, fallback) => {
271
173
  if (typeof raw !== "string" || raw.trim().length === 0)
@@ -371,7 +273,10 @@ export class TaskSufficiencyService {
371
273
  const docs = [];
372
274
  for (const filePath of paths) {
373
275
  try {
374
- const content = await fs.readFile(filePath, "utf8");
276
+ const rawContent = await fs.readFile(filePath, "utf8");
277
+ const content = stripManagedSdsPreflightBlock(rawContent) ?? "";
278
+ if (!content)
279
+ continue;
375
280
  if (!sdsContentPattern.test(content) && !sdsFilenamePattern.test(path.basename(filePath)))
376
281
  continue;
377
282
  docs.push({ path: filePath, content });
@@ -433,33 +338,13 @@ export class TaskSufficiencyService {
433
338
  epicCount: epics.length,
434
339
  storyCount: stories.length,
435
340
  taskCount: tasks.length,
436
- corpus: normalizeText(corpusChunks.join("\n")).replace(/\s+/g, " ").trim(),
341
+ corpus: normalizeCoverageText(corpusChunks.join("\n")),
437
342
  existingAnchors,
438
343
  maxPriority: Number(maxPriorityRow?.max_priority ?? 0),
439
344
  };
440
345
  }
441
346
  evaluateCoverage(corpus, sectionHeadings, folderEntries, existingAnchors) {
442
- const missingSectionHeadings = sectionHeadings.filter((heading) => {
443
- const anchor = normalizeAnchor("section", heading);
444
- if (existingAnchors.has(anchor))
445
- return false;
446
- return !headingCovered(corpus, heading);
447
- });
448
- const missingFolderEntries = folderEntries.filter((entry) => {
449
- const anchor = normalizeAnchor("folder", entry);
450
- if (existingAnchors.has(anchor))
451
- return false;
452
- return !folderEntryCovered(corpus, entry);
453
- });
454
- const totalSignals = sectionHeadings.length + folderEntries.length;
455
- const coveredSignals = totalSignals - missingSectionHeadings.length - missingFolderEntries.length;
456
- const coverageRatio = totalSignals === 0 ? 1 : coveredSignals / totalSignals;
457
- return {
458
- coverageRatio: Number(coverageRatio.toFixed(4)),
459
- totalSignals,
460
- missingSectionHeadings,
461
- missingFolderEntries,
462
- };
347
+ return evaluateSdsCoverage(corpus, { sectionHeadings, folderEntries }, existingAnchors);
463
348
  }
464
349
  buildGapItems(coverage, existingAnchors, limit) {
465
350
  const items = [];
@@ -604,29 +489,26 @@ export class TaskSufficiencyService {
604
489
  const existingTaskKeys = await this.workspaceRepo.listTaskKeys(params.storyId);
605
490
  const taskKeyGen = createTaskKeyGenerator(params.storyKey, existingTaskKeys);
606
491
  const now = new Date().toISOString();
607
- const taskInserts = params.gapBundles.map((bundle, index) => {
608
- const target = bundle.values[0] ?? "SDS coverage gap";
492
+ const taskInserts = params.gapBundles.map(({ bundle, implementationTargets }, index) => {
493
+ if (implementationTargets.length === 0) {
494
+ throw new Error(`task-sufficiency-audit attempted to insert a remediation task without implementation targets (${summarizeAnchorBundle(bundle)}).`);
495
+ }
609
496
  const scopeCount = bundle.values.length;
610
497
  const domainLabel = bundle.domain.replace(/[-_]+/g, " ").trim() || "coverage";
611
- const titlePrefix = bundle.kind === "folder"
612
- ? "Implement SDS path"
613
- : bundle.kind === "mixed"
614
- ? "Implement SDS bundle"
615
- : "Implement SDS section";
616
- const title = scopeCount <= 1
617
- ? `${titlePrefix}: ${target}`.slice(0, 180)
618
- : `Implement SDS ${domainLabel} coverage bundle (${scopeCount} anchors)`.slice(0, 180);
498
+ const title = buildRemediationTitle(bundle, implementationTargets);
619
499
  const objective = bundle.kind === "folder"
620
500
  ? scopeCount <= 1
621
- ? `Create or update production code under the SDS folder-tree path \`${target}\`.`
501
+ ? `Create or update production code under the SDS path \`${bundle.values[0] ?? implementationTargets[0] ?? domainLabel}\`.`
622
502
  : `Create or update production code for ${scopeCount} related SDS folder-tree paths in the ${domainLabel} domain.`
623
503
  : bundle.kind === "mixed"
624
504
  ? `Implement a cohesive capability slice covering both SDS sections and folder targets in the ${domainLabel} domain.`
625
505
  : scopeCount <= 1
626
- ? `Implement the missing production functionality described by SDS section \`${target}\`.`
506
+ ? `Implement the missing production functionality described by SDS section \`${bundle.values[0] ?? domainLabel}\`.`
627
507
  : `Implement ${scopeCount} related SDS section requirements in the ${domainLabel} domain.`;
628
508
  const scopeLines = bundle.values.map((value) => `- ${value}`);
629
509
  const anchorLines = bundle.normalizedAnchors.map((anchor) => `- ${anchor}`);
510
+ const targetLines = implementationTargets.map((target) => `- ${target}`);
511
+ const testingLines = buildTargetedTestGuidance(implementationTargets);
630
512
  const description = [
631
513
  "## Objective",
632
514
  objective,
@@ -639,21 +521,25 @@ export class TaskSufficiencyService {
639
521
  "## Anchor Scope",
640
522
  ...scopeLines,
641
523
  "",
524
+ "## Concrete Implementation Targets",
525
+ ...targetLines,
526
+ "",
642
527
  "## Anchor Keys",
643
528
  ...anchorLines,
644
529
  "",
645
530
  "## Implementation Plan",
646
- "- Phase 1: add or update contracts/interfaces used by this scope.",
647
- "- Phase 2: implement production logic for each anchor item (no docs-only closure).",
648
- "- Phase 3: wire dependencies and startup/runtime integration points affected by this scope.",
531
+ "- Phase 1: update the listed implementation targets first; add missing files/scripts/tests only where the SDS requires them.",
532
+ "- Phase 2: implement production logic for each anchor item and keep the work mapped to the concrete targets above.",
533
+ "- Phase 3: wire dependencies, startup/runtime sequencing, and artifact generation affected by this scope.",
649
534
  "- Keep implementation traceable to anchor keys in commit and test evidence.",
650
535
  "",
651
536
  "## Testing",
652
- "- Add or update targeted unit/component/integration/API coverage for all listed anchors.",
653
- "- Execute the smallest deterministic test scope that proves the behavior.",
537
+ ...testingLines,
538
+ "- Execute the smallest deterministic test scope that proves the behavior for the listed targets.",
654
539
  "",
655
540
  "## Definition of Done",
656
541
  "- All anchor scope items in this bundle are represented in working code.",
542
+ "- All concrete implementation targets above are updated or explicitly confirmed unchanged with evidence.",
657
543
  "- Validation evidence exists and maps back to each anchor key.",
658
544
  ].join("\n");
659
545
  const storyPointsBase = bundle.kind === "folder" ? 1 : 2;
@@ -676,6 +562,7 @@ export class TaskSufficiencyService {
676
562
  domain: bundle.domain,
677
563
  scopeCount,
678
564
  values: bundle.values,
565
+ implementationTargets,
679
566
  anchor: bundle.normalizedAnchors[0],
680
567
  anchors: bundle.normalizedAnchors,
681
568
  iteration: params.iteration,
@@ -746,17 +633,11 @@ export class TaskSufficiencyService {
746
633
  throw new Error("task-sufficiency-audit requires an SDS document but none was found. Add docs/sds.md (or a fuzzy-match SDS doc) and retry.");
747
634
  }
748
635
  const warnings = [];
749
- const rawSectionHeadings = unique(sdsDocs.flatMap((doc) => extractMarkdownHeadings(doc.content, SDS_HEADING_LIMIT))).slice(0, SDS_HEADING_LIMIT);
750
- const rawFolderEntries = unique(sdsDocs.flatMap((doc) => extractFolderEntries(doc.content, SDS_FOLDER_LIMIT))).slice(0, SDS_FOLDER_LIMIT);
751
- const sectionHeadings = unique(rawSectionHeadings
752
- .map((heading) => normalizeHeadingCandidate(heading))
753
- .filter((heading) => headingLooksImplementationRelevant(heading))).slice(0, SDS_HEADING_LIMIT);
754
- const folderEntries = unique(rawFolderEntries
755
- .map((entry) => normalizeFolderEntry(entry))
756
- .filter((entry) => Boolean(entry))
757
- .filter((entry) => folderEntryLooksRepoRelevant(entry))).slice(0, SDS_FOLDER_LIMIT);
758
- const skippedHeadingSignals = Math.max(0, rawSectionHeadings.length - sectionHeadings.length);
759
- const skippedFolderSignals = Math.max(0, rawFolderEntries.length - folderEntries.length);
636
+ const coverageSignals = collectSdsCoverageSignalsFromDocs(sdsDocs, {
637
+ headingLimit: SDS_HEADING_LIMIT,
638
+ folderLimit: SDS_FOLDER_LIMIT,
639
+ });
640
+ const { rawSectionHeadings, rawFolderEntries, sectionHeadings, folderEntries, skippedHeadingSignals, skippedFolderSignals, } = coverageSignals;
760
641
  if (skippedHeadingSignals > 0 || skippedFolderSignals > 0) {
761
642
  warnings.push(`Filtered non-actionable SDS signals (headings=${skippedHeadingSignals}, folders=${skippedFolderSignals}) before remediation.`);
762
643
  }
@@ -828,6 +709,47 @@ export class TaskSufficiencyService {
828
709
  });
829
710
  break;
830
711
  }
712
+ const plannedGapBundles = gapBundles.map((bundle) => ({
713
+ bundle,
714
+ implementationTargets: inferImplementationTargets(bundle, folderEntries, 3),
715
+ }));
716
+ const actionableGapBundles = plannedGapBundles.filter((entry) => entry.implementationTargets.length > 0);
717
+ const unresolvedGapBundles = plannedGapBundles.filter((entry) => entry.implementationTargets.length === 0);
718
+ if (unresolvedGapBundles.length > 0) {
719
+ warnings.push(`Iteration ${iteration}: ${unresolvedGapBundles.length} SDS gap bundle(s) remain unresolved because no concrete implementation targets were inferred (${unresolvedGapBundles
720
+ .map((entry) => summarizeAnchorBundle(entry.bundle))
721
+ .join("; ")}).`);
722
+ }
723
+ if (actionableGapBundles.length === 0) {
724
+ warnings.push(`Iteration ${iteration}: unresolved SDS gaps remain but no executable remediation tasks could be generated.`);
725
+ iterations.push({
726
+ iteration,
727
+ coverageRatio: coverage.coverageRatio,
728
+ totalSignals: coverage.totalSignals,
729
+ missingSectionCount: coverage.missingSectionHeadings.length,
730
+ missingFolderCount: coverage.missingFolderEntries.length,
731
+ createdTaskKeys: [],
732
+ });
733
+ await this.jobService.writeCheckpoint(job.id, {
734
+ stage: "iteration",
735
+ timestamp: new Date().toISOString(),
736
+ details: {
737
+ iteration,
738
+ coverageRatio: coverage.coverageRatio,
739
+ totalSignals: coverage.totalSignals,
740
+ missingSectionCount: coverage.missingSectionHeadings.length,
741
+ missingFolderCount: coverage.missingFolderEntries.length,
742
+ action: "unresolved",
743
+ unresolvedGapItems: unresolvedGapBundles.map((entry) => ({
744
+ kind: entry.bundle.kind,
745
+ domain: entry.bundle.domain,
746
+ values: entry.bundle.values,
747
+ anchors: entry.bundle.normalizedAnchors,
748
+ })),
749
+ },
750
+ });
751
+ break;
752
+ }
831
753
  if (dryRun) {
832
754
  iterations.push({
833
755
  iteration,
@@ -847,10 +769,17 @@ export class TaskSufficiencyService {
847
769
  missingSectionCount: coverage.missingSectionHeadings.length,
848
770
  missingFolderCount: coverage.missingFolderEntries.length,
849
771
  action: "dry_run",
850
- proposedGapItems: gapBundles.map((bundle) => ({
851
- kind: bundle.kind,
852
- domain: bundle.domain,
853
- values: bundle.values,
772
+ proposedGapItems: actionableGapBundles.map((entry) => ({
773
+ kind: entry.bundle.kind,
774
+ domain: entry.bundle.domain,
775
+ values: entry.bundle.values,
776
+ implementationTargets: entry.implementationTargets,
777
+ })),
778
+ unresolvedGapItems: unresolvedGapBundles.map((entry) => ({
779
+ kind: entry.bundle.kind,
780
+ domain: entry.bundle.domain,
781
+ values: entry.bundle.values,
782
+ anchors: entry.bundle.normalizedAnchors,
854
783
  })),
855
784
  },
856
785
  });
@@ -863,7 +792,7 @@ export class TaskSufficiencyService {
863
792
  storyKey: target.storyKey,
864
793
  epicId: target.epicId,
865
794
  maxPriority: snapshot.maxPriority,
866
- gapBundles,
795
+ gapBundles: actionableGapBundles,
867
796
  iteration,
868
797
  jobId: job.id,
869
798
  commandRunId: commandRun.id,
@@ -891,7 +820,7 @@ export class TaskSufficiencyService {
891
820
  addedCount: createdTaskKeys.length,
892
821
  },
893
822
  });
894
- await this.jobService.appendLog(job.id, `Iteration ${iteration}: added ${createdTaskKeys.length} remediation task(s) from ${gapBundles.length} gap bundle(s): ${createdTaskKeys.join(", ")}\n`);
823
+ await this.jobService.appendLog(job.id, `Iteration ${iteration}: added ${createdTaskKeys.length} remediation task(s) from ${actionableGapBundles.length} actionable gap bundle(s): ${createdTaskKeys.join(", ")}\n`);
895
824
  }
896
825
  const finalSnapshot = await this.loadProjectSnapshot(request.projectKey);
897
826
  const finalCoverage = this.evaluateCoverage(finalSnapshot.corpus, sectionHeadings, folderEntries, finalSnapshot.existingAnchors);
@@ -914,18 +843,19 @@ export class TaskSufficiencyService {
914
843
  satisfied,
915
844
  totalTasksAdded,
916
845
  totalTasksUpdated,
917
- docs: sdsDocs.map((doc) => ({
918
- path: path.relative(request.workspace.workspaceRoot, doc.path),
919
- headingSignals: extractMarkdownHeadings(doc.content, SDS_HEADING_LIMIT)
920
- .map((heading) => normalizeHeadingCandidate(heading))
921
- .filter((heading) => headingLooksImplementationRelevant(heading)).length,
922
- folderSignals: extractFolderEntries(doc.content, SDS_FOLDER_LIMIT)
923
- .map((entry) => normalizeFolderEntry(entry))
924
- .filter((entry) => Boolean(entry))
925
- .filter((entry) => folderEntryLooksRepoRelevant(entry)).length,
926
- rawHeadingSignals: extractMarkdownHeadings(doc.content, SDS_HEADING_LIMIT).length,
927
- rawFolderSignals: extractFolderEntries(doc.content, SDS_FOLDER_LIMIT).length,
928
- })),
846
+ docs: sdsDocs.map((doc) => {
847
+ const signals = collectSdsImplementationSignals(doc.content, {
848
+ headingLimit: SDS_HEADING_LIMIT,
849
+ folderLimit: SDS_FOLDER_LIMIT,
850
+ });
851
+ return {
852
+ path: path.relative(request.workspace.workspaceRoot, doc.path),
853
+ headingSignals: signals.sectionHeadings.length,
854
+ folderSignals: signals.folderEntries.length,
855
+ rawHeadingSignals: signals.rawSectionHeadings.length,
856
+ rawFolderSignals: signals.rawFolderEntries.length,
857
+ };
858
+ }),
929
859
  finalCoverage: {
930
860
  coverageRatio: finalCoverage.coverageRatio,
931
861
  totalSignals: finalCoverage.totalSignals,
@@ -945,6 +875,7 @@ export class TaskSufficiencyService {
945
875
  satisfied,
946
876
  totalTasksAdded,
947
877
  totalTasksUpdated,
878
+ finalTotalSignals: finalCoverage.totalSignals,
948
879
  finalCoverageRatio: finalCoverage.coverageRatio,
949
880
  },
950
881
  });
@@ -959,6 +890,7 @@ export class TaskSufficiencyService {
959
890
  totalTasksUpdated,
960
891
  maxIterations,
961
892
  minCoverageRatio,
893
+ finalTotalSignals: finalCoverage.totalSignals,
962
894
  finalCoverageRatio: finalCoverage.coverageRatio,
963
895
  remainingSectionHeadings: finalCoverage.missingSectionHeadings,
964
896
  remainingFolderEntries: finalCoverage.missingFolderEntries,
@@ -981,6 +913,7 @@ export class TaskSufficiencyService {
981
913
  totalTasksUpdated,
982
914
  maxIterations,
983
915
  minCoverageRatio,
916
+ finalTotalSignals: finalCoverage.totalSignals,
984
917
  finalCoverageRatio: finalCoverage.coverageRatio,
985
918
  remainingSectionCount: finalCoverage.missingSectionHeadings.length,
986
919
  remainingFolderCount: finalCoverage.missingFolderEntries.length,