@mcoda/core 0.1.18 → 0.1.20

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 (58) hide show
  1. package/dist/api/QaTasksApi.d.ts.map +1 -1
  2. package/dist/api/QaTasksApi.js +3 -0
  3. package/dist/prompts/PdrPrompts.d.ts.map +1 -1
  4. package/dist/prompts/PdrPrompts.js +22 -8
  5. package/dist/prompts/SdsPrompts.d.ts.map +1 -1
  6. package/dist/prompts/SdsPrompts.js +53 -34
  7. package/dist/services/backlog/BacklogService.d.ts.map +1 -1
  8. package/dist/services/backlog/BacklogService.js +3 -0
  9. package/dist/services/backlog/TaskOrderingService.d.ts +9 -0
  10. package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
  11. package/dist/services/backlog/TaskOrderingService.js +251 -35
  12. package/dist/services/docs/DocsService.d.ts.map +1 -1
  13. package/dist/services/docs/DocsService.js +487 -71
  14. package/dist/services/docs/review/gates/PdrFolderTreeGate.d.ts +7 -0
  15. package/dist/services/docs/review/gates/PdrFolderTreeGate.d.ts.map +1 -0
  16. package/dist/services/docs/review/gates/PdrFolderTreeGate.js +151 -0
  17. package/dist/services/docs/review/gates/PdrNoUnresolvedItemsGate.d.ts +7 -0
  18. package/dist/services/docs/review/gates/PdrNoUnresolvedItemsGate.d.ts.map +1 -0
  19. package/dist/services/docs/review/gates/PdrNoUnresolvedItemsGate.js +109 -0
  20. package/dist/services/docs/review/gates/PdrTechStackRationaleGate.d.ts +7 -0
  21. package/dist/services/docs/review/gates/PdrTechStackRationaleGate.d.ts.map +1 -0
  22. package/dist/services/docs/review/gates/PdrTechStackRationaleGate.js +128 -0
  23. package/dist/services/docs/review/gates/SdsFolderTreeGate.d.ts +7 -0
  24. package/dist/services/docs/review/gates/SdsFolderTreeGate.d.ts.map +1 -0
  25. package/dist/services/docs/review/gates/SdsFolderTreeGate.js +153 -0
  26. package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.d.ts +7 -0
  27. package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.d.ts.map +1 -0
  28. package/dist/services/docs/review/gates/SdsNoUnresolvedItemsGate.js +109 -0
  29. package/dist/services/docs/review/gates/SdsTechStackRationaleGate.d.ts +7 -0
  30. package/dist/services/docs/review/gates/SdsTechStackRationaleGate.d.ts.map +1 -0
  31. package/dist/services/docs/review/gates/SdsTechStackRationaleGate.js +128 -0
  32. package/dist/services/estimate/EstimateService.d.ts +2 -0
  33. package/dist/services/estimate/EstimateService.d.ts.map +1 -1
  34. package/dist/services/estimate/EstimateService.js +54 -0
  35. package/dist/services/execution/QaTasksService.d.ts +6 -0
  36. package/dist/services/execution/QaTasksService.d.ts.map +1 -1
  37. package/dist/services/execution/QaTasksService.js +278 -95
  38. package/dist/services/execution/TaskSelectionService.d.ts +3 -0
  39. package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
  40. package/dist/services/execution/TaskSelectionService.js +33 -0
  41. package/dist/services/execution/WorkOnTasksService.d.ts +4 -0
  42. package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
  43. package/dist/services/execution/WorkOnTasksService.js +146 -22
  44. package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
  45. package/dist/services/openapi/OpenApiService.js +43 -4
  46. package/dist/services/planning/CreateTasksService.d.ts +15 -0
  47. package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
  48. package/dist/services/planning/CreateTasksService.js +592 -81
  49. package/dist/services/planning/RefineTasksService.d.ts +1 -0
  50. package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
  51. package/dist/services/planning/RefineTasksService.js +88 -2
  52. package/dist/services/review/CodeReviewService.d.ts +6 -0
  53. package/dist/services/review/CodeReviewService.d.ts.map +1 -1
  54. package/dist/services/review/CodeReviewService.js +260 -41
  55. package/dist/services/shared/ProjectGuidance.d.ts +18 -2
  56. package/dist/services/shared/ProjectGuidance.d.ts.map +1 -1
  57. package/dist/services/shared/ProjectGuidance.js +535 -34
  58. package/package.json +6 -6
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { PathHelper } from "@mcoda/shared";
@@ -5,48 +6,226 @@ const QA_DOC_PATTERN = /(^|[\\/])(qa|e2e)([-_/]|$)/i;
5
6
  const MCODA_DOC_PATTERN = /(^|[\\/])\.mcoda([\\/]|$)/i;
6
7
  const SDS_DOC_PATTERN = /(^|[\\/])docs[\\/]+sds([\\/]|\.|$)/i;
7
8
  const FRONTMATTER_BLOCK = /^---[\s\S]*?\n---/;
9
+ const SDS_NAME_PATTERN = /(^|\/)(sds(?:[-_. ][a-z0-9]+)?|software[-_ ]design(?:[-_ ](?:spec|specification|outline|doc))?|design[-_ ]spec(?:ification)?)(\/|[-_.]|$)/i;
10
+ const SDS_PATH_HINT_PATTERN = /(^|\/)(docs\/sds|sds\/|software[-_ ]design|design[-_ ]spec|requirements|prd|pdr|rfp|architecture|solution[-_ ]design)/i;
11
+ const REQUIRED_GUIDANCE_SECTIONS = [
12
+ "Product Context",
13
+ "Architecture Notes",
14
+ "Coding Constraints",
15
+ "Testing Policy",
16
+ "Operational Notes",
17
+ ];
18
+ const PLACEHOLDER_PATTERNS = [
19
+ /\bdescribe the product domain\b/i,
20
+ /\blist important modules\b/i,
21
+ /\bdocument non-negotiable implementation rules\b/i,
22
+ /\bdefine required test levels\b/i,
23
+ /\badd deployment\/runtime constraints\b/i,
24
+ ];
25
+ const FRONTMATTER_TRUE_VALUES = new Set(["true", "1", "yes", "on"]);
26
+ const TREE_LIKE_PATTERN = /[├└│]|^\s*[./A-Za-z0-9_-]+\/\s*$/m;
27
+ const MARKDOWN_FILE_EXTENSIONS = new Set([".md", ".mdx", ".markdown"]);
28
+ const DIR_SCAN_EXCLUDES = new Set([
29
+ ".git",
30
+ ".svn",
31
+ ".hg",
32
+ ".mcoda",
33
+ "node_modules",
34
+ "vendor",
35
+ "dist",
36
+ "build",
37
+ "out",
38
+ "target",
39
+ ".next",
40
+ ".nuxt",
41
+ ".venv",
42
+ "venv",
43
+ "coverage",
44
+ ]);
8
45
  const DEFAULT_PROJECT_GUIDANCE_TEMPLATE = [
9
46
  "# Project Guidance",
10
47
  "",
11
- "This file is loaded by mcoda agents before task execution.",
12
- "Keep it concise and specific to this workspace.",
48
+ "This file is loaded by mcoda agents before task execution/review/QA.",
49
+ "SDS is the source of truth. Keep this guidance concrete and implementation-oriented.",
13
50
  "",
14
51
  "## Product Context",
15
- "- Describe the product domain and user-facing goals.",
52
+ "- Align scope and implementation decisions with the latest SDS.",
53
+ "- Prefer product behavior changes over test-only deltas for implementation tasks.",
16
54
  "",
17
55
  "## Architecture Notes",
18
- "- List important modules, boundaries, and integration points.",
56
+ "- Respect existing module/service boundaries and data contracts.",
57
+ "- Keep new interfaces and schemas backward-compatible unless SDS explicitly changes them.",
19
58
  "",
20
59
  "## Coding Constraints",
21
- "- Document non-negotiable implementation rules.",
60
+ "- Avoid hardcoded environment-specific values (ports, hosts, secrets, file paths).",
61
+ "- Reuse existing code patterns and adapters before adding new abstractions.",
22
62
  "",
23
63
  "## Testing Policy",
24
- "- Define required test levels and critical regression checks.",
64
+ "- Run targeted tests for touched behavior, then broader suites when required.",
65
+ "- Ensure implementation changes and tests evolve together.",
25
66
  "",
26
67
  "## Operational Notes",
27
- "- Add deployment/runtime constraints, known caveats, and troubleshooting hints.",
68
+ "- Preserve observability and runtime configuration conventions already used by the project.",
69
+ "- Document notable rollout risks and mitigation in task summaries.",
28
70
  "",
29
71
  ].join("\n");
30
- export const getDefaultProjectGuidanceTemplate = () => DEFAULT_PROJECT_GUIDANCE_TEMPLATE;
31
- export const resolveWorkspaceProjectGuidancePath = (workspaceRoot, mcodaDir) => {
32
- const resolvedMcodaDir = mcodaDir ?? PathHelper.getWorkspaceDir(workspaceRoot);
33
- return path.join(resolvedMcodaDir, "docs", "project-guidance.md");
72
+ const normalizePathSeparators = (value) => value.replace(/\\/g, "/");
73
+ const dedupe = (values) => {
74
+ const seen = new Set();
75
+ const output = [];
76
+ for (const value of values) {
77
+ const normalized = normalizePathSeparators(value);
78
+ if (seen.has(normalized))
79
+ continue;
80
+ seen.add(normalized);
81
+ output.push(value);
82
+ }
83
+ return output;
34
84
  };
35
- const guidanceCandidates = (workspaceRoot, mcodaDir) => {
36
- return [
37
- resolveWorkspaceProjectGuidancePath(workspaceRoot, mcodaDir),
38
- path.join(workspaceRoot, "docs", "project-guidance.md"),
39
- ];
85
+ const normalizeProjectKey = (value) => {
86
+ const trimmed = (value ?? "").trim();
87
+ if (!trimmed)
88
+ return undefined;
89
+ const normalized = trimmed
90
+ .toLowerCase()
91
+ .replace(/[^a-z0-9]+/g, "-")
92
+ .replace(/^-+|-+$/g, "");
93
+ return normalized || undefined;
40
94
  };
41
- export const isDocContextExcluded = (value, allowQaDocs = false) => {
42
- if (!value)
43
- return false;
44
- const normalized = value.replace(/\\/g, "/");
45
- if (MCODA_DOC_PATTERN.test(normalized))
46
- return true;
47
- if (!allowQaDocs && QA_DOC_PATTERN.test(normalized))
48
- return true;
49
- return false;
95
+ const sha256 = (content) => createHash("sha256").update(content).digest("hex");
96
+ const readTextFile = async (targetPath) => {
97
+ try {
98
+ return await fs.readFile(targetPath, "utf8");
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ };
104
+ const trimSection = (value, maxLines = 10) => {
105
+ const lines = value
106
+ .split(/\r?\n/)
107
+ .map((line) => line.trim())
108
+ .filter(Boolean);
109
+ if (lines.length === 0)
110
+ return "";
111
+ return lines.slice(0, maxLines).join("\n");
112
+ };
113
+ const toBullets = (value, maxItems = 6) => {
114
+ const lines = value
115
+ .split(/\r?\n/)
116
+ .map((line) => line.trim())
117
+ .filter(Boolean)
118
+ .filter((line) => !line.startsWith("```"));
119
+ const bullets = [];
120
+ for (const line of lines) {
121
+ let normalized = line.replace(/^[-*]\s+/, "").trim();
122
+ normalized = normalized.replace(/^\d+\.\s+/, "").trim();
123
+ if (!normalized)
124
+ continue;
125
+ if (normalized.length > 180)
126
+ normalized = `${normalized.slice(0, 177)}...`;
127
+ bullets.push(`- ${normalized}`);
128
+ if (bullets.length >= maxItems)
129
+ break;
130
+ }
131
+ return bullets;
132
+ };
133
+ const extractFencedTreeBlock = (content) => {
134
+ const fenceRegex = /```(?:text|md|markdown|tree)?\s*\n([\s\S]*?)\n```/gi;
135
+ for (const match of content.matchAll(fenceRegex)) {
136
+ const block = (match[1] ?? "").trim();
137
+ if (!block)
138
+ continue;
139
+ if (!TREE_LIKE_PATTERN.test(block))
140
+ continue;
141
+ const normalized = block.split(/\r?\n/).slice(0, 40).join("\n").trim();
142
+ if (normalized)
143
+ return normalized;
144
+ }
145
+ return undefined;
146
+ };
147
+ const parseHeadingSections = (content) => {
148
+ const lines = content.split(/\r?\n/);
149
+ const sections = [];
150
+ let currentHeading = "";
151
+ let currentBody = [];
152
+ const flush = () => {
153
+ if (!currentHeading)
154
+ return;
155
+ sections.push({ heading: currentHeading, body: currentBody.join("\n").trim() });
156
+ };
157
+ for (const line of lines) {
158
+ const match = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/);
159
+ if (match) {
160
+ flush();
161
+ currentHeading = match[1]?.trim() ?? "";
162
+ currentBody = [];
163
+ continue;
164
+ }
165
+ if (currentHeading)
166
+ currentBody.push(line);
167
+ }
168
+ flush();
169
+ return sections;
170
+ };
171
+ const findSectionByHeading = (sections, patterns) => {
172
+ for (const section of sections) {
173
+ const heading = section.heading.trim();
174
+ if (!heading)
175
+ continue;
176
+ if (patterns.some((pattern) => pattern.test(heading))) {
177
+ const body = trimSection(section.body, 14);
178
+ if (body)
179
+ return body;
180
+ }
181
+ }
182
+ return undefined;
183
+ };
184
+ const extractIntroParagraph = (content) => {
185
+ const stripped = content
186
+ .replace(FRONTMATTER_BLOCK, "")
187
+ .replace(/```[\s\S]*?```/g, "")
188
+ .split(/\r?\n/)
189
+ .map((line) => line.trim())
190
+ .filter(Boolean);
191
+ const first = stripped.find((line) => !line.startsWith("#"));
192
+ if (!first)
193
+ return undefined;
194
+ if (first.length <= 200)
195
+ return first;
196
+ return `${first.slice(0, 197)}...`;
197
+ };
198
+ const buildGuidanceFrontmatter = (metadata) => {
199
+ const lines = ["---", "mcoda_guidance: true"];
200
+ if (metadata.projectKey)
201
+ lines.push(`project_key: ${metadata.projectKey}`);
202
+ if (metadata.sdsSource)
203
+ lines.push(`sds_source: ${metadata.sdsSource}`);
204
+ if (metadata.sdsSha256)
205
+ lines.push(`sds_sha256: ${metadata.sdsSha256}`);
206
+ if (metadata.generatedAt)
207
+ lines.push(`generated_at: ${metadata.generatedAt}`);
208
+ lines.push("---");
209
+ return lines.join("\n");
210
+ };
211
+ const parseFrontmatterPairs = (frontmatter) => {
212
+ const lines = frontmatter
213
+ .split(/\r?\n/)
214
+ .slice(1, -1)
215
+ .map((line) => line.trim())
216
+ .filter(Boolean);
217
+ const pairs = {};
218
+ for (const line of lines) {
219
+ const idx = line.indexOf(":");
220
+ if (idx <= 0)
221
+ continue;
222
+ const key = line.slice(0, idx).trim().toLowerCase();
223
+ const value = line.slice(idx + 1).trim().replace(/^["']|["']$/g, "");
224
+ if (!key)
225
+ continue;
226
+ pairs[key] = value;
227
+ }
228
+ return pairs;
50
229
  };
51
230
  const extractFrontmatter = (content) => {
52
231
  if (!content)
@@ -57,6 +236,25 @@ const extractFrontmatter = (content) => {
57
236
  const match = trimmed.match(FRONTMATTER_BLOCK);
58
237
  return match ? match[0] : undefined;
59
238
  };
239
+ const parseGuidanceFrontmatter = (content) => {
240
+ if (!content)
241
+ return undefined;
242
+ const frontmatter = extractFrontmatter(content);
243
+ if (!frontmatter)
244
+ return undefined;
245
+ const pairs = parseFrontmatterPairs(frontmatter);
246
+ const markerValue = (pairs.mcoda_guidance ?? "").toLowerCase();
247
+ const mcodaGuidance = FRONTMATTER_TRUE_VALUES.has(markerValue);
248
+ if (!mcodaGuidance)
249
+ return undefined;
250
+ return {
251
+ mcodaGuidance,
252
+ projectKey: pairs.project_key || undefined,
253
+ sdsSource: pairs.sds_source || undefined,
254
+ sdsSha256: pairs.sds_sha256 || undefined,
255
+ generatedAt: pairs.generated_at || undefined,
256
+ };
257
+ };
60
258
  const hasSdsFrontmatter = (content) => {
61
259
  if (!content)
62
260
  return false;
@@ -71,6 +269,260 @@ const hasSdsFrontmatter = (content) => {
71
269
  return true;
72
270
  return false;
73
271
  };
272
+ const listMarkdownFiles = async (root, maxDepth = 5) => {
273
+ const output = [];
274
+ const walk = async (current, depth) => {
275
+ if (depth > maxDepth)
276
+ return;
277
+ let entries = [];
278
+ try {
279
+ entries = await fs.readdir(current, { withFileTypes: true });
280
+ }
281
+ catch {
282
+ return;
283
+ }
284
+ for (const entry of entries) {
285
+ const entryPath = path.join(current, entry.name);
286
+ const relative = normalizePathSeparators(path.relative(root, entryPath));
287
+ if (!relative)
288
+ continue;
289
+ if (entry.isDirectory()) {
290
+ if (DIR_SCAN_EXCLUDES.has(entry.name))
291
+ continue;
292
+ await walk(entryPath, depth + 1);
293
+ continue;
294
+ }
295
+ if (!entry.isFile())
296
+ continue;
297
+ const ext = path.extname(entry.name).toLowerCase();
298
+ if (!MARKDOWN_FILE_EXTENSIONS.has(ext))
299
+ continue;
300
+ output.push(relative);
301
+ }
302
+ };
303
+ await walk(root, 0);
304
+ return output;
305
+ };
306
+ const defaultSdsCandidates = (workspaceRoot, projectKey) => {
307
+ const slug = normalizeProjectKey(projectKey);
308
+ const candidates = [
309
+ "docs/sds.md",
310
+ "docs/sds/sds.md",
311
+ "docs/sds/index.md",
312
+ "docs/software-design-specification.md",
313
+ "sds.md",
314
+ ];
315
+ if (slug) {
316
+ candidates.unshift(`docs/sds/${slug}.md`);
317
+ candidates.unshift(`docs/${slug}-sds.md`);
318
+ }
319
+ return candidates.map((entry) => normalizePathSeparators(path.join(workspaceRoot, entry)));
320
+ };
321
+ const scoreSdsPath = (relativePath, projectKey) => {
322
+ const normalized = normalizePathSeparators(relativePath).toLowerCase();
323
+ const fileName = path.basename(normalized);
324
+ const project = normalizeProjectKey(projectKey);
325
+ let score = 0;
326
+ if (normalized === "docs/sds.md")
327
+ score += 120;
328
+ if (normalized === "docs/sds/sds.md")
329
+ score += 110;
330
+ if (normalized.startsWith("docs/sds/"))
331
+ score += 90;
332
+ if (SDS_NAME_PATTERN.test(fileName))
333
+ score += 40;
334
+ if (SDS_PATH_HINT_PATTERN.test(normalized))
335
+ score += 25;
336
+ if (project && normalized.includes(project))
337
+ score += 20;
338
+ return score;
339
+ };
340
+ const hasSdsContentSignals = (content, relativePath) => {
341
+ if (SDS_DOC_PATTERN.test(relativePath))
342
+ return true;
343
+ if (hasSdsFrontmatter(content))
344
+ return true;
345
+ if (/^\s*#\s*(software\s+design\s+specification|sds)\b/im.test(content))
346
+ return true;
347
+ if (/\bnon-functional requirements\b/i.test(content))
348
+ return true;
349
+ if (/\bfolder tree\b/i.test(content))
350
+ return true;
351
+ return false;
352
+ };
353
+ const findSdsContext = async (workspaceRoot, projectKey) => {
354
+ const preferred = defaultSdsCandidates(workspaceRoot, projectKey);
355
+ const docsRoot = path.join(workspaceRoot, "docs");
356
+ const markdownFiles = await listMarkdownFiles(docsRoot, 6).catch(() => []);
357
+ const fuzzyAbsolute = markdownFiles
358
+ .filter((file) => SDS_NAME_PATTERN.test(file) || SDS_PATH_HINT_PATTERN.test(file))
359
+ .map((file) => path.join(docsRoot, file));
360
+ const candidates = dedupe([...preferred, ...fuzzyAbsolute]);
361
+ let best;
362
+ for (const absolutePath of candidates) {
363
+ const content = await readTextFile(absolutePath);
364
+ if (!content)
365
+ continue;
366
+ const relativePath = normalizePathSeparators(path.relative(workspaceRoot, absolutePath));
367
+ let score = scoreSdsPath(relativePath, projectKey);
368
+ if (hasSdsContentSignals(content, relativePath))
369
+ score += 50;
370
+ if (score <= 0)
371
+ continue;
372
+ const context = {
373
+ absolutePath,
374
+ relativePath,
375
+ content,
376
+ hash: sha256(content),
377
+ };
378
+ if (!best || score > best.score) {
379
+ best = { context, score };
380
+ }
381
+ }
382
+ return best?.context;
383
+ };
384
+ const buildSdsDerivedTemplate = (sds, projectKey) => {
385
+ const sections = parseHeadingSections(sds.content);
386
+ const intro = extractIntroParagraph(sds.content);
387
+ const productContext = findSectionByHeading(sections, [/overview/i, /introduction/i, /scope/i, /goals?/i, /product/i]) ?? intro ?? "";
388
+ const architecture = findSectionByHeading(sections, [/architecture/i, /services?/i, /components?/i, /modules?/i]);
389
+ const constraints = findSectionByHeading(sections, [/constraints?/i, /security/i, /compliance/i, /standards?/i]);
390
+ const testing = findSectionByHeading(sections, [/testing/i, /\bqa\b/i, /verification/i, /validation/i]);
391
+ const operations = findSectionByHeading(sections, [/operations?/i, /deployment/i, /runbook/i, /observability/i, /monitoring/i]);
392
+ const folderSection = findSectionByHeading(sections, [/folder tree/i, /repo structure/i, /project structure/i]);
393
+ const treeBlock = extractFencedTreeBlock(folderSection ?? sds.content);
394
+ const generatedAt = new Date().toISOString();
395
+ const frontmatter = buildGuidanceFrontmatter({
396
+ projectKey: normalizeProjectKey(projectKey),
397
+ sdsSource: sds.relativePath,
398
+ sdsSha256: sds.hash,
399
+ generatedAt,
400
+ });
401
+ const productBullets = toBullets(productContext || `SDS source: ${sds.relativePath}`);
402
+ const architectureBullets = toBullets(architecture || "Follow the module and service boundaries specified in the SDS architecture section.");
403
+ const constraintsBullets = toBullets(constraints ||
404
+ "Do not add undocumented dependencies. Keep interfaces, schemas, and naming aligned with SDS and OpenAPI artifacts.");
405
+ const testingBullets = toBullets(testing ||
406
+ "For every implementation change, update tests that cover behavior and regression risk, then run the smallest relevant suite before broad test runs.");
407
+ const operationsBullets = toBullets(operations ||
408
+ "Keep runtime configuration environment-driven, avoid hardcoded ports/hosts, and preserve existing logging/telemetry conventions.");
409
+ const lines = [
410
+ frontmatter,
411
+ "",
412
+ "# Project Guidance",
413
+ "",
414
+ `This guidance is generated from SDS source \`${sds.relativePath}\` and is intended to keep implementation/review/QA aligned.`,
415
+ "",
416
+ "## Product Context",
417
+ ...(productBullets.length > 0 ? productBullets : [`- SDS source: ${sds.relativePath}`]),
418
+ "",
419
+ "## Architecture Notes",
420
+ ...(architectureBullets.length > 0
421
+ ? architectureBullets
422
+ : ["- Follow SDS-defined service boundaries and data contracts."]),
423
+ "",
424
+ "## Coding Constraints",
425
+ ...(constraintsBullets.length > 0
426
+ ? constraintsBullets
427
+ : ["- Match SDS/OpenAPI contracts and avoid introducing undocumented behavior."]),
428
+ "",
429
+ "## Testing Policy",
430
+ ...(testingBullets.length > 0 ? testingBullets : ["- Run targeted tests first, then broader suites where relevant."]),
431
+ "",
432
+ "## Operational Notes",
433
+ ...(operationsBullets.length > 0
434
+ ? operationsBullets
435
+ : ["- Keep runtime settings configurable and align with existing deployment/observability practices."]),
436
+ "",
437
+ "## Folder Tree Baseline",
438
+ treeBlock ? "```text" : `- Refer to folder tree defined in \`${sds.relativePath}\`.`,
439
+ ...(treeBlock ? [treeBlock, "```"] : []),
440
+ "",
441
+ ];
442
+ return lines.join("\n");
443
+ };
444
+ const validateGuidanceContent = (content) => {
445
+ const warnings = [];
446
+ for (const section of REQUIRED_GUIDANCE_SECTIONS) {
447
+ const matcher = new RegExp(`^##\\s+${section.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*$`, "im");
448
+ if (!matcher.test(content)) {
449
+ warnings.push(`missing_section:${section}`);
450
+ }
451
+ }
452
+ for (const placeholder of PLACEHOLDER_PATTERNS) {
453
+ if (placeholder.test(content)) {
454
+ warnings.push("placeholder_text_detected");
455
+ break;
456
+ }
457
+ }
458
+ return warnings;
459
+ };
460
+ const detectGuidanceStaleness = async (workspaceRoot, content) => {
461
+ const metadata = parseGuidanceFrontmatter(content);
462
+ if (!metadata) {
463
+ return { stale: false, warnings: [] };
464
+ }
465
+ const warnings = [];
466
+ if (!metadata.sdsSource || !metadata.sdsSha256) {
467
+ warnings.push("guidance_missing_sds_metadata");
468
+ return { stale: false, warnings, metadata };
469
+ }
470
+ const absoluteSource = path.isAbsolute(metadata.sdsSource)
471
+ ? metadata.sdsSource
472
+ : path.join(workspaceRoot, metadata.sdsSource);
473
+ const sourceContent = await readTextFile(absoluteSource);
474
+ if (!sourceContent) {
475
+ warnings.push(`guidance_sds_source_missing:${metadata.sdsSource}`);
476
+ return { stale: true, warnings, metadata };
477
+ }
478
+ const currentHash = sha256(sourceContent);
479
+ if (currentHash !== metadata.sdsSha256) {
480
+ warnings.push(`guidance_stale_sds_hash:${metadata.sdsSource}`);
481
+ return { stale: true, warnings, metadata };
482
+ }
483
+ return { stale: false, warnings, metadata };
484
+ };
485
+ const resolveGuidanceTemplate = async (workspaceRoot, options) => {
486
+ if (options.template && options.template.trim()) {
487
+ return { template: options.template.trim(), source: "custom" };
488
+ }
489
+ const sds = await findSdsContext(workspaceRoot, options.projectKey);
490
+ if (sds) {
491
+ return { template: buildSdsDerivedTemplate(sds, options.projectKey), source: "sds", sds };
492
+ }
493
+ return { template: getDefaultProjectGuidanceTemplate().trim(), source: "default" };
494
+ };
495
+ export const getDefaultProjectGuidanceTemplate = () => DEFAULT_PROJECT_GUIDANCE_TEMPLATE;
496
+ export const resolveWorkspaceProjectGuidancePath = (workspaceRoot, mcodaDir, projectKey) => {
497
+ const resolvedMcodaDir = mcodaDir ?? PathHelper.getWorkspaceDir(workspaceRoot);
498
+ const normalizedProject = normalizeProjectKey(projectKey);
499
+ if (normalizedProject) {
500
+ return path.join(resolvedMcodaDir, "docs", "projects", normalizedProject, "project-guidance.md");
501
+ }
502
+ return path.join(resolvedMcodaDir, "docs", "project-guidance.md");
503
+ };
504
+ const guidanceCandidates = (workspaceRoot, mcodaDir, projectKey) => {
505
+ const normalizedProject = normalizeProjectKey(projectKey);
506
+ const repoProjectPath = normalizedProject
507
+ ? path.join(workspaceRoot, "docs", "projects", normalizedProject, "project-guidance.md")
508
+ : undefined;
509
+ return dedupe([
510
+ resolveWorkspaceProjectGuidancePath(workspaceRoot, mcodaDir, normalizedProject),
511
+ resolveWorkspaceProjectGuidancePath(workspaceRoot, mcodaDir),
512
+ repoProjectPath,
513
+ path.join(workspaceRoot, "docs", "project-guidance.md"),
514
+ ].filter((entry) => Boolean(entry)));
515
+ };
516
+ export const isDocContextExcluded = (value, allowQaDocs = false) => {
517
+ if (!value)
518
+ return false;
519
+ const normalized = value.replace(/\\/g, "/");
520
+ if (MCODA_DOC_PATTERN.test(normalized))
521
+ return true;
522
+ if (!allowQaDocs && QA_DOC_PATTERN.test(normalized))
523
+ return true;
524
+ return false;
525
+ };
74
526
  export const normalizeDocType = (params) => {
75
527
  const raw = (params.docType ?? "DOC").trim();
76
528
  const normalizedType = raw ? raw.toUpperCase() : "DOC";
@@ -87,25 +539,43 @@ export const normalizeDocType = (params) => {
87
539
  const reason = [inSdsPath ? null : "path_not_sds", frontmatter ? null : "frontmatter_missing"].filter(Boolean).join("|");
88
540
  return { docType: "DOC", downgraded: true, reason: reason || "not_sds" };
89
541
  };
90
- export const loadProjectGuidance = async (workspaceRoot, mcodaDir) => {
91
- for (const candidate of guidanceCandidates(workspaceRoot, mcodaDir)) {
542
+ export const loadProjectGuidance = async (workspaceRoot, mcodaDir, options = {}) => {
543
+ const candidates = guidanceCandidates(workspaceRoot, mcodaDir, options.projectKey);
544
+ for (const candidate of candidates) {
92
545
  try {
93
546
  const content = (await fs.readFile(candidate, "utf8")).trim();
94
547
  if (!content)
95
548
  continue;
96
- return { content, source: candidate };
549
+ const validationWarnings = validateGuidanceContent(content);
550
+ const staleness = await detectGuidanceStaleness(workspaceRoot, content);
551
+ const metadata = staleness.metadata;
552
+ const warnings = [...validationWarnings, ...staleness.warnings];
553
+ return {
554
+ content,
555
+ source: candidate,
556
+ warnings: warnings.length > 0 ? warnings : undefined,
557
+ stale: staleness.stale,
558
+ projectKey: metadata?.projectKey,
559
+ sdsSource: metadata?.sdsSource,
560
+ sdsSha256: metadata?.sdsSha256,
561
+ generatedAt: metadata?.generatedAt,
562
+ };
97
563
  }
98
564
  catch {
99
565
  // ignore missing file
100
566
  }
101
567
  }
102
- console.warn(`[project-guidance] no project guidance found; searched: ${guidanceCandidates(workspaceRoot, mcodaDir).join(", ")}`);
568
+ console.warn(`[project-guidance] no project guidance found; searched: ${candidates.join(", ")}`);
103
569
  return null;
104
570
  };
105
571
  export const ensureProjectGuidance = async (workspaceRoot, options = {}) => {
106
- const targetPath = resolveWorkspaceProjectGuidancePath(workspaceRoot, options.mcodaDir);
572
+ const normalizedProject = normalizeProjectKey(options.projectKey);
573
+ const targetPath = resolveWorkspaceProjectGuidancePath(workspaceRoot, options.mcodaDir, normalizedProject);
107
574
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
108
575
  const force = Boolean(options.force);
576
+ const resolvedTemplate = await resolveGuidanceTemplate(workspaceRoot, options);
577
+ const template = resolvedTemplate.template.trim();
578
+ const payload = template.length > 0 ? `${template}\n` : "";
109
579
  let existed = false;
110
580
  try {
111
581
  await fs.access(targetPath);
@@ -118,15 +588,46 @@ export const ensureProjectGuidance = async (workspaceRoot, options = {}) => {
118
588
  try {
119
589
  const existing = (await fs.readFile(targetPath, "utf8")).trim();
120
590
  if (existing.length > 0) {
121
- return { path: targetPath, status: "existing" };
591
+ const validationWarnings = validateGuidanceContent(existing);
592
+ const staleness = await detectGuidanceStaleness(workspaceRoot, existing);
593
+ const warnings = [...validationWarnings, ...staleness.warnings];
594
+ const metadata = staleness.metadata;
595
+ if (staleness.stale && metadata?.mcodaGuidance) {
596
+ await fs.writeFile(targetPath, payload, "utf8");
597
+ return {
598
+ path: targetPath,
599
+ status: "overwritten",
600
+ warnings: warnings.length > 0 ? warnings : undefined,
601
+ stale: true,
602
+ source: resolvedTemplate.source,
603
+ projectKey: normalizedProject,
604
+ sdsSource: resolvedTemplate.sds?.relativePath,
605
+ sdsSha256: resolvedTemplate.sds?.hash,
606
+ };
607
+ }
608
+ return {
609
+ path: targetPath,
610
+ status: "existing",
611
+ warnings: warnings.length > 0 ? warnings : undefined,
612
+ stale: staleness.stale,
613
+ source: metadata?.mcodaGuidance ? "sds" : "custom",
614
+ projectKey: metadata?.projectKey,
615
+ sdsSource: metadata?.sdsSource,
616
+ sdsSha256: metadata?.sdsSha256,
617
+ };
122
618
  }
123
619
  }
124
620
  catch {
125
621
  // fall through and write template
126
622
  }
127
623
  }
128
- const template = (options.template ?? getDefaultProjectGuidanceTemplate()).trim();
129
- const payload = template.length > 0 ? `${template}\n` : "";
130
624
  await fs.writeFile(targetPath, payload, "utf8");
131
- return { path: targetPath, status: existed ? "overwritten" : "created" };
625
+ return {
626
+ path: targetPath,
627
+ status: existed ? "overwritten" : "created",
628
+ source: resolvedTemplate.source,
629
+ projectKey: normalizedProject,
630
+ sdsSource: resolvedTemplate.sds?.relativePath,
631
+ sdsSha256: resolvedTemplate.sds?.hash,
632
+ };
132
633
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcoda/core",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Core services and APIs for the mcoda CLI.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,11 +32,11 @@
32
32
  "dependencies": {
33
33
  "@apidevtools/swagger-parser": "^10.1.0",
34
34
  "yaml": "^2.4.2",
35
- "@mcoda/db": "0.1.18",
36
- "@mcoda/agents": "0.1.18",
37
- "@mcoda/integrations": "0.1.18",
38
- "@mcoda/generators": "0.1.18",
39
- "@mcoda/shared": "0.1.18"
35
+ "@mcoda/shared": "0.1.20",
36
+ "@mcoda/db": "0.1.20",
37
+ "@mcoda/agents": "0.1.20",
38
+ "@mcoda/generators": "0.1.20",
39
+ "@mcoda/integrations": "0.1.20"
40
40
  },
41
41
  "scripts": {
42
42
  "build": "tsc -p tsconfig.json",