@mcoda/core 0.1.23 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
- package/dist/services/execution/TaskSelectionService.js +2 -4
- package/dist/services/planning/CreateTasksService.d.ts +8 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
- package/dist/services/planning/CreateTasksService.js +387 -47
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
- package/dist/services/planning/RefineTasksService.js +89 -15
- package/dist/services/planning/TaskSufficiencyService.d.ts +73 -0
- package/dist/services/planning/TaskSufficiencyService.d.ts.map +1 -0
- package/dist/services/planning/TaskSufficiencyService.js +704 -0
- package/package.json +6 -6
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { WorkspaceRepository } from "@mcoda/db";
|
|
4
|
+
import { PathHelper } from "@mcoda/shared";
|
|
5
|
+
import { JobService } from "../jobs/JobService.js";
|
|
6
|
+
import { createEpicKeyGenerator, createStoryKeyGenerator, createTaskKeyGenerator } from "./KeyHelpers.js";
|
|
7
|
+
const DEFAULT_MAX_ITERATIONS = 5;
|
|
8
|
+
const DEFAULT_MAX_TASKS_PER_ITERATION = 24;
|
|
9
|
+
const DEFAULT_MIN_COVERAGE_RATIO = 0.96;
|
|
10
|
+
const SDS_SCAN_MAX_FILES = 120;
|
|
11
|
+
const SDS_HEADING_LIMIT = 200;
|
|
12
|
+
const SDS_FOLDER_LIMIT = 240;
|
|
13
|
+
const REPORT_FILE_NAME = "task-sufficiency-report.json";
|
|
14
|
+
const ignoredDirs = new Set([".git", "node_modules", "dist", "build", ".mcoda", ".docdex"]);
|
|
15
|
+
const sdsFilenamePattern = /(sds|software[-_ ]design|system[-_ ]design|design[-_ ]spec)/i;
|
|
16
|
+
const sdsContentPattern = /(software design specification|system design specification|^#\s*sds\b)/im;
|
|
17
|
+
const normalizeText = (value) => value
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[`*_]/g, " ")
|
|
20
|
+
.replace(/[^a-z0-9/\s.-]+/g, " ")
|
|
21
|
+
.replace(/\s+/g, " ")
|
|
22
|
+
.trim();
|
|
23
|
+
const normalizeAnchor = (kind, value) => `${kind}:${normalizeText(value).replace(/\s+/g, " ").trim()}`;
|
|
24
|
+
const unique = (items) => Array.from(new Set(items.filter(Boolean)));
|
|
25
|
+
const extractMarkdownHeadings = (content, limit) => {
|
|
26
|
+
if (!content)
|
|
27
|
+
return [];
|
|
28
|
+
const matches = [...content.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*$/gm)];
|
|
29
|
+
const headings = [];
|
|
30
|
+
for (const match of matches) {
|
|
31
|
+
const heading = match[1]?.replace(/#+$/, "").trim();
|
|
32
|
+
if (!heading)
|
|
33
|
+
continue;
|
|
34
|
+
headings.push(heading);
|
|
35
|
+
if (headings.length >= limit)
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
return unique(headings).slice(0, limit);
|
|
39
|
+
};
|
|
40
|
+
const extractFolderEntries = (content, limit) => {
|
|
41
|
+
if (!content)
|
|
42
|
+
return [];
|
|
43
|
+
const candidates = [];
|
|
44
|
+
const lines = content.split(/\r?\n/);
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed)
|
|
48
|
+
continue;
|
|
49
|
+
const matches = [...trimmed.matchAll(/[`'"]?([a-zA-Z0-9._-]+(?:\/[a-zA-Z0-9._-]+)+(?:\/[a-zA-Z0-9._-]+)*)[`'"]?/g)];
|
|
50
|
+
for (const match of matches) {
|
|
51
|
+
const raw = (match[1] ?? "").replace(/^\.?\//, "").replace(/\/+$/, "").trim();
|
|
52
|
+
if (!raw || !raw.includes("/"))
|
|
53
|
+
continue;
|
|
54
|
+
candidates.push(raw);
|
|
55
|
+
if (candidates.length >= limit)
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (candidates.length >= limit)
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
return unique(candidates).slice(0, limit);
|
|
62
|
+
};
|
|
63
|
+
const headingCovered = (corpus, heading) => {
|
|
64
|
+
const normalized = normalizeText(heading);
|
|
65
|
+
if (!normalized)
|
|
66
|
+
return true;
|
|
67
|
+
if (corpus.includes(normalized))
|
|
68
|
+
return true;
|
|
69
|
+
const tokens = normalized
|
|
70
|
+
.split(/\s+/)
|
|
71
|
+
.filter((token) => token.length >= 4)
|
|
72
|
+
.slice(0, 8);
|
|
73
|
+
if (tokens.length === 0)
|
|
74
|
+
return true;
|
|
75
|
+
const hitCount = tokens.filter((token) => corpus.includes(token)).length;
|
|
76
|
+
const minHits = Math.min(2, tokens.length);
|
|
77
|
+
return hitCount >= minHits;
|
|
78
|
+
};
|
|
79
|
+
const folderEntryCovered = (corpus, entry) => {
|
|
80
|
+
const normalized = normalizeText(entry).replace(/\s+/g, "");
|
|
81
|
+
if (!normalized)
|
|
82
|
+
return true;
|
|
83
|
+
if (corpus.includes(normalized))
|
|
84
|
+
return true;
|
|
85
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
86
|
+
if (segments.length === 0)
|
|
87
|
+
return true;
|
|
88
|
+
const leaf = segments[segments.length - 1];
|
|
89
|
+
const parent = segments.length > 1 ? segments[segments.length - 2] : undefined;
|
|
90
|
+
if (leaf && corpus.includes(leaf)) {
|
|
91
|
+
if (!parent)
|
|
92
|
+
return true;
|
|
93
|
+
return corpus.includes(parent);
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
};
|
|
97
|
+
const readJsonSafe = (raw, fallback) => {
|
|
98
|
+
if (typeof raw !== "string" || raw.trim().length === 0)
|
|
99
|
+
return fallback;
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return fallback;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
export class TaskSufficiencyService {
|
|
108
|
+
constructor(workspace, deps, ownership = {}) {
|
|
109
|
+
this.workspace = workspace;
|
|
110
|
+
this.workspaceRepo = deps.workspaceRepo;
|
|
111
|
+
this.jobService = deps.jobService;
|
|
112
|
+
this.ownsWorkspaceRepo = ownership.ownsWorkspaceRepo === true;
|
|
113
|
+
this.ownsJobService = ownership.ownsJobService === true;
|
|
114
|
+
}
|
|
115
|
+
static async create(workspace) {
|
|
116
|
+
const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
|
|
117
|
+
const jobService = new JobService(workspace);
|
|
118
|
+
return new TaskSufficiencyService(workspace, { workspaceRepo, jobService }, { ownsWorkspaceRepo: true, ownsJobService: true });
|
|
119
|
+
}
|
|
120
|
+
async close() {
|
|
121
|
+
if (this.ownsWorkspaceRepo) {
|
|
122
|
+
await this.workspaceRepo.close();
|
|
123
|
+
}
|
|
124
|
+
if (this.ownsJobService) {
|
|
125
|
+
await this.jobService.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async discoverSdsPaths(workspaceRoot) {
|
|
129
|
+
const directCandidates = [
|
|
130
|
+
path.join(workspaceRoot, "docs", "sds.md"),
|
|
131
|
+
path.join(workspaceRoot, "docs", "sds", "sds.md"),
|
|
132
|
+
path.join(workspaceRoot, "docs", "software-design-specification.md"),
|
|
133
|
+
path.join(workspaceRoot, "sds.md"),
|
|
134
|
+
];
|
|
135
|
+
const found = new Set();
|
|
136
|
+
for (const candidate of directCandidates) {
|
|
137
|
+
try {
|
|
138
|
+
const stat = await fs.stat(candidate);
|
|
139
|
+
if (stat.isFile())
|
|
140
|
+
found.add(path.resolve(candidate));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// ignore missing direct candidate
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const roots = [path.join(workspaceRoot, "docs"), workspaceRoot];
|
|
147
|
+
for (const root of roots) {
|
|
148
|
+
const discovered = await this.walkSdsCandidates(root, root === workspaceRoot ? 3 : 5, SDS_SCAN_MAX_FILES);
|
|
149
|
+
discovered.forEach((entry) => found.add(entry));
|
|
150
|
+
if (found.size >= SDS_SCAN_MAX_FILES)
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
return Array.from(found).slice(0, SDS_SCAN_MAX_FILES);
|
|
154
|
+
}
|
|
155
|
+
async walkSdsCandidates(root, maxDepth, cap) {
|
|
156
|
+
const results = [];
|
|
157
|
+
const walk = async (dir, depth) => {
|
|
158
|
+
if (results.length >= cap || depth > maxDepth)
|
|
159
|
+
return;
|
|
160
|
+
let entries = [];
|
|
161
|
+
try {
|
|
162
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
if (results.length >= cap)
|
|
169
|
+
break;
|
|
170
|
+
if (entry.isDirectory()) {
|
|
171
|
+
if (ignoredDirs.has(entry.name))
|
|
172
|
+
continue;
|
|
173
|
+
await walk(path.join(dir, entry.name), depth + 1);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!entry.isFile())
|
|
177
|
+
continue;
|
|
178
|
+
const filePath = path.join(dir, entry.name);
|
|
179
|
+
if (!/\.(md|markdown|txt)$/i.test(entry.name))
|
|
180
|
+
continue;
|
|
181
|
+
if (!sdsFilenamePattern.test(entry.name)) {
|
|
182
|
+
try {
|
|
183
|
+
const sample = await fs.readFile(filePath, "utf8");
|
|
184
|
+
if (!sdsContentPattern.test(sample.slice(0, 30000)))
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
results.push(path.resolve(filePath));
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
await walk(root, 0);
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
async loadSdsSources(paths) {
|
|
198
|
+
const docs = [];
|
|
199
|
+
for (const filePath of paths) {
|
|
200
|
+
try {
|
|
201
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
202
|
+
if (!sdsContentPattern.test(content) && !sdsFilenamePattern.test(path.basename(filePath)))
|
|
203
|
+
continue;
|
|
204
|
+
docs.push({ path: filePath, content });
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// ignore unreadable source
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return docs;
|
|
211
|
+
}
|
|
212
|
+
async loadProjectSnapshot(projectKey) {
|
|
213
|
+
const project = await this.workspaceRepo.getProjectByKey(projectKey);
|
|
214
|
+
if (!project) {
|
|
215
|
+
throw new Error(`task-sufficiency-audit could not find project "${projectKey}". Run create-tasks first or pass a valid --project.`);
|
|
216
|
+
}
|
|
217
|
+
const db = this.workspaceRepo.getDb();
|
|
218
|
+
const [epics, stories, tasks, maxPriorityRow] = await Promise.all([
|
|
219
|
+
db.all(`SELECT id, key, title, description
|
|
220
|
+
FROM epics
|
|
221
|
+
WHERE project_id = ?
|
|
222
|
+
ORDER BY COALESCE(priority, 2147483647), datetime(created_at), key`, project.id),
|
|
223
|
+
db.all(`SELECT id, key, title, description, acceptance_criteria
|
|
224
|
+
FROM user_stories
|
|
225
|
+
WHERE project_id = ?
|
|
226
|
+
ORDER BY COALESCE(priority, 2147483647), datetime(created_at), key`, project.id),
|
|
227
|
+
db.all(`SELECT id, key, title, description, metadata_json
|
|
228
|
+
FROM tasks
|
|
229
|
+
WHERE project_id = ?
|
|
230
|
+
ORDER BY COALESCE(priority, 2147483647), datetime(created_at), key`, project.id),
|
|
231
|
+
db.get(`SELECT COALESCE(MAX(priority), 0) AS max_priority FROM tasks WHERE project_id = ?`, project.id),
|
|
232
|
+
]);
|
|
233
|
+
const existingAnchors = new Set();
|
|
234
|
+
const corpusChunks = [];
|
|
235
|
+
for (const epic of epics) {
|
|
236
|
+
corpusChunks.push(`${epic.title ?? ""} ${epic.description ?? ""}`);
|
|
237
|
+
}
|
|
238
|
+
for (const story of stories) {
|
|
239
|
+
corpusChunks.push(`${story.title ?? ""} ${story.description ?? ""} ${story.acceptance_criteria ?? ""}`);
|
|
240
|
+
}
|
|
241
|
+
for (const task of tasks) {
|
|
242
|
+
corpusChunks.push(`${task.title ?? ""} ${task.description ?? ""}`);
|
|
243
|
+
const metadata = readJsonSafe(task.metadata_json, null);
|
|
244
|
+
const rawAnchor = metadata?.sufficiencyAudit?.anchor;
|
|
245
|
+
if (typeof rawAnchor === "string" && rawAnchor.trim().length > 0) {
|
|
246
|
+
existingAnchors.add(rawAnchor.trim());
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
project,
|
|
251
|
+
epicCount: epics.length,
|
|
252
|
+
storyCount: stories.length,
|
|
253
|
+
taskCount: tasks.length,
|
|
254
|
+
corpus: normalizeText(corpusChunks.join("\n")).replace(/\s+/g, " ").trim(),
|
|
255
|
+
existingAnchors,
|
|
256
|
+
maxPriority: Number(maxPriorityRow?.max_priority ?? 0),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
evaluateCoverage(corpus, sectionHeadings, folderEntries, existingAnchors) {
|
|
260
|
+
const missingSectionHeadings = sectionHeadings.filter((heading) => {
|
|
261
|
+
const anchor = normalizeAnchor("section", heading);
|
|
262
|
+
if (existingAnchors.has(anchor))
|
|
263
|
+
return false;
|
|
264
|
+
return !headingCovered(corpus, heading);
|
|
265
|
+
});
|
|
266
|
+
const missingFolderEntries = folderEntries.filter((entry) => {
|
|
267
|
+
const anchor = normalizeAnchor("folder", entry);
|
|
268
|
+
if (existingAnchors.has(anchor))
|
|
269
|
+
return false;
|
|
270
|
+
return !folderEntryCovered(corpus, entry);
|
|
271
|
+
});
|
|
272
|
+
const totalSignals = sectionHeadings.length + folderEntries.length;
|
|
273
|
+
const coveredSignals = totalSignals - missingSectionHeadings.length - missingFolderEntries.length;
|
|
274
|
+
const coverageRatio = totalSignals === 0 ? 1 : coveredSignals / totalSignals;
|
|
275
|
+
return {
|
|
276
|
+
coverageRatio: Number(coverageRatio.toFixed(4)),
|
|
277
|
+
totalSignals,
|
|
278
|
+
missingSectionHeadings,
|
|
279
|
+
missingFolderEntries,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
buildGapItems(coverage, existingAnchors, limit) {
|
|
283
|
+
const items = [];
|
|
284
|
+
for (const heading of coverage.missingSectionHeadings) {
|
|
285
|
+
const normalizedAnchor = normalizeAnchor("section", heading);
|
|
286
|
+
if (existingAnchors.has(normalizedAnchor))
|
|
287
|
+
continue;
|
|
288
|
+
items.push({ kind: "section", value: heading, normalizedAnchor });
|
|
289
|
+
if (items.length >= limit)
|
|
290
|
+
return items;
|
|
291
|
+
}
|
|
292
|
+
for (const entry of coverage.missingFolderEntries) {
|
|
293
|
+
const normalizedAnchor = normalizeAnchor("folder", entry);
|
|
294
|
+
if (existingAnchors.has(normalizedAnchor))
|
|
295
|
+
continue;
|
|
296
|
+
items.push({ kind: "folder", value: entry, normalizedAnchor });
|
|
297
|
+
if (items.length >= limit)
|
|
298
|
+
return items;
|
|
299
|
+
}
|
|
300
|
+
return items;
|
|
301
|
+
}
|
|
302
|
+
async ensureTargetStory(project) {
|
|
303
|
+
const db = this.workspaceRepo.getDb();
|
|
304
|
+
const existingStory = await db.get(`SELECT us.id AS story_id, us.key AS story_key, us.epic_id AS epic_id, e.key AS epic_key
|
|
305
|
+
FROM user_stories us
|
|
306
|
+
JOIN epics e ON e.id = us.epic_id
|
|
307
|
+
WHERE us.project_id = ?
|
|
308
|
+
ORDER BY COALESCE(us.priority, 2147483647), datetime(us.created_at), us.key
|
|
309
|
+
LIMIT 1`, project.id);
|
|
310
|
+
if (existingStory) {
|
|
311
|
+
return {
|
|
312
|
+
epicId: existingStory.epic_id,
|
|
313
|
+
epicKey: existingStory.epic_key,
|
|
314
|
+
storyId: existingStory.story_id,
|
|
315
|
+
storyKey: existingStory.story_key,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
let epicId = "";
|
|
319
|
+
let epicKey = "";
|
|
320
|
+
const existingEpic = await db.get(`SELECT id, key
|
|
321
|
+
FROM epics
|
|
322
|
+
WHERE project_id = ?
|
|
323
|
+
ORDER BY COALESCE(priority, 2147483647), datetime(created_at), key
|
|
324
|
+
LIMIT 1`, project.id);
|
|
325
|
+
if (existingEpic) {
|
|
326
|
+
epicId = existingEpic.id;
|
|
327
|
+
epicKey = existingEpic.key;
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
const epicKeyGen = createEpicKeyGenerator(project.key, await this.workspaceRepo.listEpicKeys(project.id));
|
|
331
|
+
const insertedEpic = (await this.workspaceRepo.insertEpics([
|
|
332
|
+
{
|
|
333
|
+
projectId: project.id,
|
|
334
|
+
key: epicKeyGen("ops"),
|
|
335
|
+
title: "Backlog Sufficiency Alignment",
|
|
336
|
+
description: "Tracks generated backlog patches required to align SDS coverage and implementation readiness.",
|
|
337
|
+
storyPointsTotal: null,
|
|
338
|
+
priority: null,
|
|
339
|
+
metadata: {
|
|
340
|
+
source: "task-sufficiency-audit",
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
]))[0];
|
|
344
|
+
epicId = insertedEpic.id;
|
|
345
|
+
epicKey = insertedEpic.key;
|
|
346
|
+
}
|
|
347
|
+
const storyKeyGen = createStoryKeyGenerator(epicKey, await this.workspaceRepo.listStoryKeys(epicId));
|
|
348
|
+
const insertedStory = (await this.workspaceRepo.insertStories([
|
|
349
|
+
{
|
|
350
|
+
projectId: project.id,
|
|
351
|
+
epicId,
|
|
352
|
+
key: storyKeyGen(),
|
|
353
|
+
title: "Close SDS Coverage Gaps",
|
|
354
|
+
description: "Adds missing implementation tasks discovered by SDS-vs-backlog sufficiency auditing.",
|
|
355
|
+
acceptanceCriteria: "- SDS gaps are represented as executable backlog tasks.\n- Coverage report reaches configured minimum threshold.",
|
|
356
|
+
storyPointsTotal: null,
|
|
357
|
+
priority: null,
|
|
358
|
+
metadata: {
|
|
359
|
+
source: "task-sufficiency-audit",
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
]))[0];
|
|
363
|
+
return {
|
|
364
|
+
epicId,
|
|
365
|
+
epicKey,
|
|
366
|
+
storyId: insertedStory.id,
|
|
367
|
+
storyKey: insertedStory.key,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
async insertGapTasks(params) {
|
|
371
|
+
const existingTaskKeys = await this.workspaceRepo.listTaskKeys(params.storyId);
|
|
372
|
+
const taskKeyGen = createTaskKeyGenerator(params.storyKey, existingTaskKeys);
|
|
373
|
+
const now = new Date().toISOString();
|
|
374
|
+
const taskInserts = params.gapItems.map((gap, index) => {
|
|
375
|
+
const titlePrefix = gap.kind === "section" ? "Cover SDS section" : "Materialize SDS folder entry";
|
|
376
|
+
const title = `${titlePrefix}: ${gap.value}`.slice(0, 180);
|
|
377
|
+
const objective = gap.kind === "section"
|
|
378
|
+
? `Implement or update product code to satisfy the SDS section \"${gap.value}\".`
|
|
379
|
+
: `Create/update codebase artifacts required by SDS folder-tree entry \"${gap.value}\".`;
|
|
380
|
+
const description = [
|
|
381
|
+
`## Objective`,
|
|
382
|
+
objective,
|
|
383
|
+
``,
|
|
384
|
+
`## Context`,
|
|
385
|
+
`- Generated by task-sufficiency-audit iteration ${params.iteration}.`,
|
|
386
|
+
`- Anchor: ${gap.normalizedAnchor}`,
|
|
387
|
+
``,
|
|
388
|
+
`## Implementation Plan`,
|
|
389
|
+
`- Inspect SDS and current implementation for this anchor.`,
|
|
390
|
+
`- Add or update production code and wiring to satisfy the requirement.`,
|
|
391
|
+
`- Update impacted docs/contracts if the implementation surface changes.`,
|
|
392
|
+
``,
|
|
393
|
+
`## Testing`,
|
|
394
|
+
`- Add or update unit/component/integration tests for this anchor.`,
|
|
395
|
+
`- Ensure existing regression suites remain green.`,
|
|
396
|
+
``,
|
|
397
|
+
`## Definition of Done`,
|
|
398
|
+
`- Anchor requirement is fully represented in code.`,
|
|
399
|
+
`- Tests covering this scope pass.`,
|
|
400
|
+
].join("\n");
|
|
401
|
+
return {
|
|
402
|
+
projectId: params.project.id,
|
|
403
|
+
epicId: params.epicId,
|
|
404
|
+
userStoryId: params.storyId,
|
|
405
|
+
key: taskKeyGen(),
|
|
406
|
+
title,
|
|
407
|
+
description,
|
|
408
|
+
type: "feature",
|
|
409
|
+
status: "not_started",
|
|
410
|
+
storyPoints: 1,
|
|
411
|
+
priority: params.maxPriority + index + 1,
|
|
412
|
+
metadata: {
|
|
413
|
+
sufficiencyAudit: {
|
|
414
|
+
source: "task-sufficiency-audit",
|
|
415
|
+
kind: gap.kind,
|
|
416
|
+
value: gap.value,
|
|
417
|
+
anchor: gap.normalizedAnchor,
|
|
418
|
+
iteration: params.iteration,
|
|
419
|
+
generatedAt: now,
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
const rows = await this.workspaceRepo.insertTasks(taskInserts);
|
|
425
|
+
for (const row of rows) {
|
|
426
|
+
await this.workspaceRepo.createTaskRun({
|
|
427
|
+
taskId: row.id,
|
|
428
|
+
command: "task-sufficiency-audit",
|
|
429
|
+
status: "succeeded",
|
|
430
|
+
jobId: params.jobId,
|
|
431
|
+
commandRunId: params.commandRunId,
|
|
432
|
+
startedAt: now,
|
|
433
|
+
finishedAt: now,
|
|
434
|
+
runContext: {
|
|
435
|
+
key: row.key,
|
|
436
|
+
source: "task-sufficiency-audit",
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
const db = this.workspaceRepo.getDb();
|
|
441
|
+
const storyTotal = await db.get(`SELECT COALESCE(SUM(COALESCE(story_points, 0)), 0) AS total FROM tasks WHERE user_story_id = ?`, params.storyId);
|
|
442
|
+
const epicTotal = await db.get(`SELECT COALESCE(SUM(COALESCE(story_points, 0)), 0) AS total FROM tasks WHERE epic_id = ?`, params.epicId);
|
|
443
|
+
await this.workspaceRepo.updateStoryPointsTotal(params.storyId, Number(storyTotal?.total ?? 0));
|
|
444
|
+
await this.workspaceRepo.updateEpicStoryPointsTotal(params.epicId, Number(epicTotal?.total ?? 0));
|
|
445
|
+
return rows;
|
|
446
|
+
}
|
|
447
|
+
async writeReportArtifacts(projectKey, report) {
|
|
448
|
+
const baseDir = path.join(this.workspace.mcodaDir, "tasks", projectKey);
|
|
449
|
+
const historyDir = path.join(baseDir, "sufficiency-audit");
|
|
450
|
+
await fs.mkdir(baseDir, { recursive: true });
|
|
451
|
+
await fs.mkdir(historyDir, { recursive: true });
|
|
452
|
+
const reportPath = path.join(baseDir, REPORT_FILE_NAME);
|
|
453
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
454
|
+
const historyPath = path.join(historyDir, `${stamp}.json`);
|
|
455
|
+
const payload = JSON.stringify(report, null, 2);
|
|
456
|
+
await fs.writeFile(reportPath, payload, "utf8");
|
|
457
|
+
await fs.writeFile(historyPath, payload, "utf8");
|
|
458
|
+
return { reportPath, historyPath };
|
|
459
|
+
}
|
|
460
|
+
async runAudit(request) {
|
|
461
|
+
const maxIterations = Math.max(1, request.maxIterations ?? DEFAULT_MAX_ITERATIONS);
|
|
462
|
+
const maxTasksPerIteration = Math.max(1, request.maxTasksPerIteration ?? DEFAULT_MAX_TASKS_PER_ITERATION);
|
|
463
|
+
const minCoverageRatio = Math.min(1, Math.max(0, request.minCoverageRatio ?? DEFAULT_MIN_COVERAGE_RATIO));
|
|
464
|
+
const dryRun = request.dryRun === true;
|
|
465
|
+
const sourceCommand = request.sourceCommand?.trim() || undefined;
|
|
466
|
+
await PathHelper.ensureDir(this.workspace.mcodaDir);
|
|
467
|
+
const commandRun = await this.jobService.startCommandRun("task-sufficiency-audit", request.projectKey);
|
|
468
|
+
const job = await this.jobService.startJob("task_sufficiency_audit", commandRun.id, request.projectKey, {
|
|
469
|
+
commandName: "task-sufficiency-audit",
|
|
470
|
+
payload: {
|
|
471
|
+
projectKey: request.projectKey,
|
|
472
|
+
sourceCommand,
|
|
473
|
+
dryRun,
|
|
474
|
+
maxIterations,
|
|
475
|
+
maxTasksPerIteration,
|
|
476
|
+
minCoverageRatio,
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
try {
|
|
480
|
+
const sdsPaths = await this.discoverSdsPaths(request.workspace.workspaceRoot);
|
|
481
|
+
const sdsDocs = await this.loadSdsSources(sdsPaths);
|
|
482
|
+
if (sdsDocs.length === 0) {
|
|
483
|
+
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.");
|
|
484
|
+
}
|
|
485
|
+
const sectionHeadings = unique(sdsDocs.flatMap((doc) => extractMarkdownHeadings(doc.content, SDS_HEADING_LIMIT))).slice(0, SDS_HEADING_LIMIT);
|
|
486
|
+
const folderEntries = unique(sdsDocs.flatMap((doc) => extractFolderEntries(doc.content, SDS_FOLDER_LIMIT))).slice(0, SDS_FOLDER_LIMIT);
|
|
487
|
+
await this.jobService.writeCheckpoint(job.id, {
|
|
488
|
+
stage: "sds_loaded",
|
|
489
|
+
timestamp: new Date().toISOString(),
|
|
490
|
+
details: {
|
|
491
|
+
docCount: sdsDocs.length,
|
|
492
|
+
headingSignals: sectionHeadings.length,
|
|
493
|
+
folderSignals: folderEntries.length,
|
|
494
|
+
docs: sdsDocs.map((doc) => path.relative(request.workspace.workspaceRoot, doc.path)),
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
const warnings = [];
|
|
498
|
+
const iterations = [];
|
|
499
|
+
let totalTasksAdded = 0;
|
|
500
|
+
const totalTasksUpdated = 0;
|
|
501
|
+
let satisfied = false;
|
|
502
|
+
for (let iteration = 1; iteration <= maxIterations; iteration += 1) {
|
|
503
|
+
const snapshot = await this.loadProjectSnapshot(request.projectKey);
|
|
504
|
+
const coverage = this.evaluateCoverage(snapshot.corpus, sectionHeadings, folderEntries, snapshot.existingAnchors);
|
|
505
|
+
const shouldStop = coverage.coverageRatio >= minCoverageRatio ||
|
|
506
|
+
(coverage.missingSectionHeadings.length === 0 && coverage.missingFolderEntries.length === 0);
|
|
507
|
+
if (shouldStop) {
|
|
508
|
+
satisfied = true;
|
|
509
|
+
iterations.push({
|
|
510
|
+
iteration,
|
|
511
|
+
coverageRatio: coverage.coverageRatio,
|
|
512
|
+
totalSignals: coverage.totalSignals,
|
|
513
|
+
missingSectionCount: coverage.missingSectionHeadings.length,
|
|
514
|
+
missingFolderCount: coverage.missingFolderEntries.length,
|
|
515
|
+
createdTaskKeys: [],
|
|
516
|
+
});
|
|
517
|
+
await this.jobService.writeCheckpoint(job.id, {
|
|
518
|
+
stage: "iteration",
|
|
519
|
+
timestamp: new Date().toISOString(),
|
|
520
|
+
details: {
|
|
521
|
+
iteration,
|
|
522
|
+
coverageRatio: coverage.coverageRatio,
|
|
523
|
+
totalSignals: coverage.totalSignals,
|
|
524
|
+
missingSectionCount: coverage.missingSectionHeadings.length,
|
|
525
|
+
missingFolderCount: coverage.missingFolderEntries.length,
|
|
526
|
+
action: "complete",
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
const gapItems = this.buildGapItems(coverage, snapshot.existingAnchors, maxTasksPerIteration);
|
|
532
|
+
if (gapItems.length === 0) {
|
|
533
|
+
warnings.push(`Iteration ${iteration}: unresolved SDS gaps remain but no insertable gap items were identified.`);
|
|
534
|
+
iterations.push({
|
|
535
|
+
iteration,
|
|
536
|
+
coverageRatio: coverage.coverageRatio,
|
|
537
|
+
totalSignals: coverage.totalSignals,
|
|
538
|
+
missingSectionCount: coverage.missingSectionHeadings.length,
|
|
539
|
+
missingFolderCount: coverage.missingFolderEntries.length,
|
|
540
|
+
createdTaskKeys: [],
|
|
541
|
+
});
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
if (dryRun) {
|
|
545
|
+
iterations.push({
|
|
546
|
+
iteration,
|
|
547
|
+
coverageRatio: coverage.coverageRatio,
|
|
548
|
+
totalSignals: coverage.totalSignals,
|
|
549
|
+
missingSectionCount: coverage.missingSectionHeadings.length,
|
|
550
|
+
missingFolderCount: coverage.missingFolderEntries.length,
|
|
551
|
+
createdTaskKeys: [],
|
|
552
|
+
});
|
|
553
|
+
await this.jobService.writeCheckpoint(job.id, {
|
|
554
|
+
stage: "iteration",
|
|
555
|
+
timestamp: new Date().toISOString(),
|
|
556
|
+
details: {
|
|
557
|
+
iteration,
|
|
558
|
+
coverageRatio: coverage.coverageRatio,
|
|
559
|
+
totalSignals: coverage.totalSignals,
|
|
560
|
+
missingSectionCount: coverage.missingSectionHeadings.length,
|
|
561
|
+
missingFolderCount: coverage.missingFolderEntries.length,
|
|
562
|
+
action: "dry_run",
|
|
563
|
+
proposedGapItems: gapItems.map((item) => ({ kind: item.kind, value: item.value })),
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
const target = await this.ensureTargetStory(snapshot.project);
|
|
569
|
+
const inserted = await this.insertGapTasks({
|
|
570
|
+
project: snapshot.project,
|
|
571
|
+
storyId: target.storyId,
|
|
572
|
+
storyKey: target.storyKey,
|
|
573
|
+
epicId: target.epicId,
|
|
574
|
+
maxPriority: snapshot.maxPriority,
|
|
575
|
+
gapItems,
|
|
576
|
+
iteration,
|
|
577
|
+
jobId: job.id,
|
|
578
|
+
commandRunId: commandRun.id,
|
|
579
|
+
});
|
|
580
|
+
const createdTaskKeys = inserted.map((task) => task.key);
|
|
581
|
+
totalTasksAdded += createdTaskKeys.length;
|
|
582
|
+
iterations.push({
|
|
583
|
+
iteration,
|
|
584
|
+
coverageRatio: coverage.coverageRatio,
|
|
585
|
+
totalSignals: coverage.totalSignals,
|
|
586
|
+
missingSectionCount: coverage.missingSectionHeadings.length,
|
|
587
|
+
missingFolderCount: coverage.missingFolderEntries.length,
|
|
588
|
+
createdTaskKeys,
|
|
589
|
+
});
|
|
590
|
+
await this.jobService.writeCheckpoint(job.id, {
|
|
591
|
+
stage: "iteration",
|
|
592
|
+
timestamp: new Date().toISOString(),
|
|
593
|
+
details: {
|
|
594
|
+
iteration,
|
|
595
|
+
coverageRatio: coverage.coverageRatio,
|
|
596
|
+
totalSignals: coverage.totalSignals,
|
|
597
|
+
missingSectionCount: coverage.missingSectionHeadings.length,
|
|
598
|
+
missingFolderCount: coverage.missingFolderEntries.length,
|
|
599
|
+
createdTaskKeys,
|
|
600
|
+
addedCount: createdTaskKeys.length,
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
await this.jobService.appendLog(job.id, `Iteration ${iteration}: added ${createdTaskKeys.length} task(s): ${createdTaskKeys.join(", ")}\n`);
|
|
604
|
+
}
|
|
605
|
+
const finalSnapshot = await this.loadProjectSnapshot(request.projectKey);
|
|
606
|
+
const finalCoverage = this.evaluateCoverage(finalSnapshot.corpus, sectionHeadings, folderEntries, finalSnapshot.existingAnchors);
|
|
607
|
+
if (finalCoverage.coverageRatio >= minCoverageRatio ||
|
|
608
|
+
(finalCoverage.missingSectionHeadings.length === 0 && finalCoverage.missingFolderEntries.length === 0)) {
|
|
609
|
+
satisfied = true;
|
|
610
|
+
}
|
|
611
|
+
if (!satisfied) {
|
|
612
|
+
warnings.push(`Sufficiency target not reached (coverage=${finalCoverage.coverageRatio}, threshold=${minCoverageRatio}) after ${iterations.length} iteration(s).`);
|
|
613
|
+
}
|
|
614
|
+
const report = {
|
|
615
|
+
projectKey: request.projectKey,
|
|
616
|
+
sourceCommand,
|
|
617
|
+
generatedAt: new Date().toISOString(),
|
|
618
|
+
dryRun,
|
|
619
|
+
maxIterations,
|
|
620
|
+
maxTasksPerIteration,
|
|
621
|
+
minCoverageRatio,
|
|
622
|
+
satisfied,
|
|
623
|
+
totalTasksAdded,
|
|
624
|
+
totalTasksUpdated,
|
|
625
|
+
docs: sdsDocs.map((doc) => ({
|
|
626
|
+
path: path.relative(request.workspace.workspaceRoot, doc.path),
|
|
627
|
+
headingSignals: extractMarkdownHeadings(doc.content, SDS_HEADING_LIMIT).length,
|
|
628
|
+
folderSignals: extractFolderEntries(doc.content, SDS_FOLDER_LIMIT).length,
|
|
629
|
+
})),
|
|
630
|
+
finalCoverage: {
|
|
631
|
+
coverageRatio: finalCoverage.coverageRatio,
|
|
632
|
+
totalSignals: finalCoverage.totalSignals,
|
|
633
|
+
missingSectionHeadings: finalCoverage.missingSectionHeadings,
|
|
634
|
+
missingFolderEntries: finalCoverage.missingFolderEntries,
|
|
635
|
+
},
|
|
636
|
+
iterations,
|
|
637
|
+
warnings,
|
|
638
|
+
};
|
|
639
|
+
const { reportPath, historyPath } = await this.writeReportArtifacts(request.projectKey, report);
|
|
640
|
+
await this.jobService.writeCheckpoint(job.id, {
|
|
641
|
+
stage: "report_written",
|
|
642
|
+
timestamp: new Date().toISOString(),
|
|
643
|
+
details: {
|
|
644
|
+
reportPath,
|
|
645
|
+
historyPath,
|
|
646
|
+
satisfied,
|
|
647
|
+
totalTasksAdded,
|
|
648
|
+
totalTasksUpdated,
|
|
649
|
+
finalCoverageRatio: finalCoverage.coverageRatio,
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
const result = {
|
|
653
|
+
jobId: job.id,
|
|
654
|
+
commandRunId: commandRun.id,
|
|
655
|
+
projectKey: request.projectKey,
|
|
656
|
+
sourceCommand,
|
|
657
|
+
satisfied,
|
|
658
|
+
dryRun,
|
|
659
|
+
totalTasksAdded,
|
|
660
|
+
totalTasksUpdated,
|
|
661
|
+
maxIterations,
|
|
662
|
+
minCoverageRatio,
|
|
663
|
+
finalCoverageRatio: finalCoverage.coverageRatio,
|
|
664
|
+
remainingSectionHeadings: finalCoverage.missingSectionHeadings,
|
|
665
|
+
remainingFolderEntries: finalCoverage.missingFolderEntries,
|
|
666
|
+
remainingGaps: {
|
|
667
|
+
sections: finalCoverage.missingSectionHeadings.length,
|
|
668
|
+
folders: finalCoverage.missingFolderEntries.length,
|
|
669
|
+
total: finalCoverage.missingSectionHeadings.length + finalCoverage.missingFolderEntries.length,
|
|
670
|
+
},
|
|
671
|
+
iterations,
|
|
672
|
+
reportPath,
|
|
673
|
+
reportHistoryPath: historyPath,
|
|
674
|
+
warnings,
|
|
675
|
+
};
|
|
676
|
+
await this.jobService.updateJobStatus(job.id, "completed", {
|
|
677
|
+
payload: {
|
|
678
|
+
projectKey: request.projectKey,
|
|
679
|
+
satisfied,
|
|
680
|
+
dryRun,
|
|
681
|
+
totalTasksAdded,
|
|
682
|
+
totalTasksUpdated,
|
|
683
|
+
maxIterations,
|
|
684
|
+
minCoverageRatio,
|
|
685
|
+
finalCoverageRatio: finalCoverage.coverageRatio,
|
|
686
|
+
remainingSectionCount: finalCoverage.missingSectionHeadings.length,
|
|
687
|
+
remainingFolderCount: finalCoverage.missingFolderEntries.length,
|
|
688
|
+
reportPath,
|
|
689
|
+
reportHistoryPath: historyPath,
|
|
690
|
+
warnings,
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
await this.jobService.finishCommandRun(commandRun.id, "succeeded");
|
|
694
|
+
return result;
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
698
|
+
await this.jobService.appendLog(job.id, `task-sufficiency-audit failed: ${message}\n`);
|
|
699
|
+
await this.jobService.updateJobStatus(job.id, "failed", { errorSummary: message });
|
|
700
|
+
await this.jobService.finishCommandRun(commandRun.id, "failed", message);
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|