@planu/cli 4.3.4 → 4.3.6
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/CHANGELOG.md +11 -1
- package/dist/config/license-plans.json +2 -0
- package/dist/engine/ai-cost-estimator/core.d.ts +1 -1
- package/dist/engine/ai-cost-estimator/core.js +2 -2
- package/dist/engine/ai-cost-estimator/spec-loader.d.ts +2 -2
- package/dist/engine/ai-cost-estimator/spec-loader.js +17 -46
- package/dist/engine/ai-cost-estimator/token-estimator.d.ts +1 -1
- package/dist/engine/ai-cost-estimator/token-estimator.js +1 -1
- package/dist/engine/code-graph-configurator.d.ts +1 -1
- package/dist/engine/code-graph-configurator.js +7 -0
- package/dist/engine/context-intelligence/compression-guards.d.ts +8 -0
- package/dist/engine/context-intelligence/compression-guards.js +74 -0
- package/dist/engine/context-intelligence/context-graph-provider.d.ts +9 -0
- package/dist/engine/context-intelligence/context-graph-provider.js +98 -0
- package/dist/engine/context-intelligence/eval-harness.d.ts +8 -0
- package/dist/engine/context-intelligence/eval-harness.js +45 -0
- package/dist/engine/context-intelligence/impact-map.d.ts +6 -0
- package/dist/engine/context-intelligence/impact-map.js +47 -0
- package/dist/engine/context-intelligence/index.d.ts +7 -0
- package/dist/engine/context-intelligence/index.js +6 -0
- package/dist/engine/context-intelligence/safe-context-compressor.d.ts +3 -0
- package/dist/engine/context-intelligence/safe-context-compressor.js +75 -0
- package/dist/engine/dashboard/data-loader.js +9 -11
- package/dist/engine/dashboard/templates-project.d.ts +1 -1
- package/dist/engine/dashboard/templates-project.js +6 -4
- package/dist/engine/docs-site-generator/index.js +2 -11
- package/dist/engine/drift-monitor.js +13 -11
- package/dist/engine/qa-gate.js +6 -1
- package/dist/engine/readiness-checker.js +3 -3
- package/dist/engine/spec-conflict-graph.d.ts +1 -1
- package/dist/engine/spec-conflict-graph.js +2 -3
- package/dist/engine/spec-format/read-technical-section.d.ts +1 -7
- package/dist/engine/spec-format/read-technical-section.js +4 -30
- package/dist/engine/spec-registry/scorer.d.ts +1 -1
- package/dist/engine/spec-registry/scorer.js +3 -4
- package/dist/engine/validator/extractors.js +4 -2
- package/dist/engine/validator.d.ts +1 -1
- package/dist/engine/validator.js +40 -2
- package/dist/tools/code-graph-handler.js +4 -0
- package/dist/tools/create-spec/post-creation.d.ts +2 -0
- package/dist/tools/create-spec/post-creation.js +5 -0
- package/dist/tools/create-spec.js +2 -2
- package/dist/tools/license-gate.js +6 -1
- package/dist/tools/output-integrity-guard.d.ts +11 -0
- package/dist/tools/output-integrity-guard.js +53 -0
- package/dist/tools/register-sdd-tools.d.ts +1 -1
- package/dist/tools/register-sdd-tools.js +2 -0
- package/dist/tools/safe-handler.js +11 -9
- package/dist/tools/tool-registry/group-infra.js +26 -0
- package/dist/tools/update-status/dod-gates.js +1 -1
- package/dist/types/code-graph-integration.d.ts +2 -2
- package/dist/types/context-intelligence.d.ts +61 -0
- package/dist/types/context-intelligence.js +2 -0
- package/dist/types/qa-gate.d.ts +1 -1
- package/package.json +9 -9
|
@@ -3,7 +3,7 @@ import type { DashboardProjectSummary, DashboardUsageStats, ProjectHealthMetrics
|
|
|
3
3
|
export declare function renderHomePage(projects: DashboardProjectSummary[], usage: DashboardUsageStats): string;
|
|
4
4
|
/** Render the project detail page — spec cards + kanban board + health panel */
|
|
5
5
|
export declare function renderProjectPage(project: DashboardProjectSummary, metrics: ProjectHealthMetrics, usage: DashboardUsageStats): string;
|
|
6
|
-
/** Render the spec detail page —
|
|
6
|
+
/** Render the spec detail page — canonical spec.md sections */
|
|
7
7
|
export declare function renderSpecPage(projectId: string, projectName: string, specId: string, hu: string | null, ficha: string | null, progress: string | null): string;
|
|
8
8
|
/** Render a 404 not found page */
|
|
9
9
|
export declare function render404Page(path: string): string;
|
|
@@ -226,15 +226,17 @@ export function renderProjectPage(project, metrics, usage) {
|
|
|
226
226
|
`;
|
|
227
227
|
return renderLayout(project.name, body, getKanbanStyles(), getKanbanScript());
|
|
228
228
|
}
|
|
229
|
-
/** Render the spec detail page —
|
|
229
|
+
/** Render the spec detail page — canonical spec.md sections */
|
|
230
230
|
export function renderSpecPage(projectId, projectName, specId, hu, ficha, progress) {
|
|
231
|
-
const huHtml = hu !== null
|
|
231
|
+
const huHtml = hu !== null
|
|
232
|
+
? markdownToHtml(hu)
|
|
233
|
+
: '<p style="color:var(--text-muted)">spec.md no encontrado</p>';
|
|
232
234
|
const fichaHtml = ficha !== null
|
|
233
235
|
? markdownToHtml(ficha)
|
|
234
|
-
: '<p style="color:var(--text-muted)"
|
|
236
|
+
: '<p style="color:var(--text-muted)">## Technical no encontrado en spec.md</p>';
|
|
235
237
|
const progressHtml = progress !== null
|
|
236
238
|
? markdownToHtml(progress)
|
|
237
|
-
: '<p style="color:var(--text-muted)"
|
|
239
|
+
: '<p style="color:var(--text-muted)">## Progress no encontrado en spec.md</p>';
|
|
238
240
|
const body = `
|
|
239
241
|
<div class="breadcrumb">
|
|
240
242
|
<a href="/">Inicio</a> ›
|
|
@@ -7,6 +7,7 @@ import { renderSpecPage, renderSpecIndex } from './spec-renderer.js';
|
|
|
7
7
|
import { renderToolsCatalog } from './tools-renderer.js';
|
|
8
8
|
import { renderDiagramsPage } from './diagrams-renderer.js';
|
|
9
9
|
import { stripFrontmatter } from '../frontmatter-parser.js';
|
|
10
|
+
import { extractSectionBody } from '../spec-format/read-technical-section.js';
|
|
10
11
|
export async function generateDocsSite(config) {
|
|
11
12
|
const { projectPath, outputDir, title = 'Planu Documentation' } = config;
|
|
12
13
|
const [specs, metrics] = await Promise.all([
|
|
@@ -33,7 +34,6 @@ export async function generateDocsSite(config) {
|
|
|
33
34
|
});
|
|
34
35
|
for (const spec of specs) {
|
|
35
36
|
let specContent = '';
|
|
36
|
-
let techContent = '';
|
|
37
37
|
try {
|
|
38
38
|
const raw = await readFile(spec.specPath, 'utf-8');
|
|
39
39
|
specContent = stripFrontmatter(raw);
|
|
@@ -41,16 +41,7 @@ export async function generateDocsSite(config) {
|
|
|
41
41
|
catch {
|
|
42
42
|
/* missing */
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
const techPath = spec.specPath
|
|
46
|
-
.replace(/spec\.md$/, 'technical.md')
|
|
47
|
-
.replace(/HU\.md$/, 'FICHA-TECNICA.md');
|
|
48
|
-
const rawTech = await readFile(techPath, 'utf-8');
|
|
49
|
-
techContent = stripFrontmatter(rawTech);
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
/* missing */
|
|
53
|
-
}
|
|
44
|
+
const techContent = extractSectionBody(specContent, 'Technical');
|
|
54
45
|
pages.push({
|
|
55
46
|
path: `specs/${spec.slug}.html`,
|
|
56
47
|
title: spec.title,
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import { readFile, stat } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fastScanProjectMetadata, isNativeActive, fastDetectDriftParallel, } from './core-bridge.js';
|
|
6
|
+
import { readSpecTechnicalSection } from './spec-format/read-technical-section.js';
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// Config builder
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
@@ -107,14 +108,14 @@ const TEMPORAL_DRIFT_DAYS = 30;
|
|
|
107
108
|
* Note: score represents HEALTH (100 = no drift). Callers may invert if needed.
|
|
108
109
|
*/
|
|
109
110
|
export async function computeDriftScore(projectPath, specId, spec, technicalPath, metadataMap) {
|
|
110
|
-
const
|
|
111
|
+
const resolvedSpec = resolveSpecPath(projectPath, specId, technicalPath);
|
|
111
112
|
let content = '';
|
|
112
|
-
if (
|
|
113
|
+
if (resolvedSpec !== null) {
|
|
113
114
|
try {
|
|
114
|
-
content = await
|
|
115
|
+
content = await readSpecTechnicalSection({ specPath: resolvedSpec });
|
|
115
116
|
}
|
|
116
117
|
catch {
|
|
117
|
-
// cannot read
|
|
118
|
+
// cannot read spec.md technical section — treat all layers as fully healthy
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
const filePaths = extractFilePaths(content, projectPath);
|
|
@@ -311,13 +312,14 @@ async function computeAcCoverageScore(criteria, filePaths) {
|
|
|
311
312
|
// ---------------------------------------------------------------------------
|
|
312
313
|
// Internal helpers
|
|
313
314
|
// ---------------------------------------------------------------------------
|
|
314
|
-
/** Resolve the path to a spec
|
|
315
|
-
function
|
|
316
|
-
/* v8 ignore next
|
|
315
|
+
/** Resolve the path to a canonical spec.md file. */
|
|
316
|
+
function resolveSpecPath(projectPath, specId, technicalPath) {
|
|
317
|
+
/* v8 ignore next 5 */
|
|
317
318
|
if (technicalPath !== undefined && fs.existsSync(technicalPath)) {
|
|
318
|
-
|
|
319
|
+
const specPathFromLegacyHint = path.join(path.dirname(technicalPath), 'spec.md');
|
|
320
|
+
return fs.existsSync(specPathFromLegacyHint) ? specPathFromLegacyHint : null;
|
|
319
321
|
}
|
|
320
|
-
// Convention: planu/specs/<specId>-<slug>/
|
|
322
|
+
// Convention: planu/specs/<specId>-<slug>/spec.md
|
|
321
323
|
const specsDir = path.join(projectPath, 'planu', 'specs');
|
|
322
324
|
if (!fs.existsSync(specsDir)) {
|
|
323
325
|
return null;
|
|
@@ -334,10 +336,10 @@ function resolveTechnicalPath(projectPath, specId, technicalPath) {
|
|
|
334
336
|
if (match === undefined) {
|
|
335
337
|
return null;
|
|
336
338
|
}
|
|
337
|
-
const candidate = path.join(specsDir, match, '
|
|
339
|
+
const candidate = path.join(specsDir, match, 'spec.md');
|
|
338
340
|
return fs.existsSync(candidate) ? candidate : null;
|
|
339
341
|
}
|
|
340
|
-
/** Extract implementation file paths from
|
|
342
|
+
/** Extract implementation file paths from a spec's inline ## Technical content. */
|
|
341
343
|
function extractFilePaths(content, projectPath) {
|
|
342
344
|
const FILE_PATTERN = /\b([\w./-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|cs))\b/g;
|
|
343
345
|
const found = new Set();
|
package/dist/engine/qa-gate.js
CHANGED
|
@@ -26,7 +26,12 @@ export function runQaGate(spec, projectPath) {
|
|
|
26
26
|
const coverageThreshold = extractCoverageThreshold(spec);
|
|
27
27
|
const checks = [];
|
|
28
28
|
checks.push(runCheck('typecheck', 'pnpm', ['typecheck'], projectPath));
|
|
29
|
-
|
|
29
|
+
if (coverageThreshold === null) {
|
|
30
|
+
checks.push(runCheck('test', 'pnpm', ['test'], projectPath));
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
checks.push(runCheck('test-coverage', 'pnpm', ['test:coverage'], projectPath));
|
|
34
|
+
}
|
|
30
35
|
const passed = checks.every((c) => c.passed);
|
|
31
36
|
return Promise.resolve({
|
|
32
37
|
specId: spec.id,
|
|
@@ -155,7 +155,7 @@ function scoreFilesIdentified(spec, fichaContent) {
|
|
|
155
155
|
}
|
|
156
156
|
else {
|
|
157
157
|
points = 0;
|
|
158
|
-
warnings.push('No
|
|
158
|
+
warnings.push('No inline ## Technical section found in spec.md — files to create/modify are not identified.');
|
|
159
159
|
}
|
|
160
160
|
return { points, blockers, warnings };
|
|
161
161
|
}
|
|
@@ -180,7 +180,7 @@ function buildRecommendations(score, huPoints, criteriaPoints, filesPoints, deps
|
|
|
180
180
|
recs.push('Add more acceptance criteria (aim for 5+ testable criteria)');
|
|
181
181
|
}
|
|
182
182
|
if (filesPoints < 20) {
|
|
183
|
-
recs.push('
|
|
183
|
+
recs.push('Add an inline ## Technical section to spec.md with key files');
|
|
184
184
|
}
|
|
185
185
|
if (depsPoints < 30) {
|
|
186
186
|
recs.push('Verify all spec dependencies are in "done" status');
|
|
@@ -360,7 +360,7 @@ export function checkReadinessInternal(body) {
|
|
|
360
360
|
// Vague criteria detection (re-uses existing logic)
|
|
361
361
|
const criteriaResult = scoreCriteria(criteriaLines.length > 0 ? criteriaLines : [], vagueWords);
|
|
362
362
|
warnings.push(...criteriaResult.warnings);
|
|
363
|
-
// Technical files presence (proxy for
|
|
363
|
+
// Technical files presence (proxy for inline ## Technical — check src/ paths in body)
|
|
364
364
|
const hasTechnicalFiles = /\bsrc\/[^\s]+\.[jt]s\b/.test(bodyContent);
|
|
365
365
|
const technicalScore = hasTechnicalFiles ? 30 : 0;
|
|
366
366
|
if (!hasTechnicalFiles && criteriaCount > 0) {
|
|
@@ -2,7 +2,7 @@ import type { SpecConflictSafety, SpecConflictCheckResult, ConflictGraphEntry, S
|
|
|
2
2
|
export type { SpecConflictSafety, SpecConflictCheckResult, ConflictGraphEntry, SpecConflictGraph };
|
|
3
3
|
/**
|
|
4
4
|
* SPEC-657: Update the conflict graph entry for a single spec.
|
|
5
|
-
* Called whenever a spec's
|
|
5
|
+
* Called whenever a spec's inline ## Technical section changes.
|
|
6
6
|
*/
|
|
7
7
|
export declare function updateConflictGraphEntry(projectPath: string, specId: string): Promise<void>;
|
|
8
8
|
/**
|
|
@@ -39,8 +39,7 @@ async function readTechnicalFiles(projectPath, specId) {
|
|
|
39
39
|
return [];
|
|
40
40
|
}
|
|
41
41
|
const specPath = join(specsDir, folder, 'spec.md');
|
|
42
|
-
const
|
|
43
|
-
const content = await readSpecTechnicalSection({ specPath, technicalPath });
|
|
42
|
+
const content = await readSpecTechnicalSection({ specPath });
|
|
44
43
|
if (content.length === 0) {
|
|
45
44
|
return [];
|
|
46
45
|
}
|
|
@@ -62,7 +61,7 @@ async function saveGraph(projectPath, graph) {
|
|
|
62
61
|
}
|
|
63
62
|
/**
|
|
64
63
|
* SPEC-657: Update the conflict graph entry for a single spec.
|
|
65
|
-
* Called whenever a spec's
|
|
64
|
+
* Called whenever a spec's inline ## Technical section changes.
|
|
66
65
|
*/
|
|
67
66
|
export async function updateConflictGraphEntry(projectPath, specId) {
|
|
68
67
|
const files = await readTechnicalFiles(projectPath, specId);
|
|
@@ -2,14 +2,8 @@ import type { Spec } from '../../types/index.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Read the `## Technical` section body from a spec's unified `spec.md`.
|
|
4
4
|
* Returns "" when the spec file is unreadable or the section is missing.
|
|
5
|
-
*
|
|
6
|
-
* The legacy `spec.technicalPath` is consulted as a transitional fallback:
|
|
7
|
-
* if the spec.md does not yet contain a `## Technical` section but a
|
|
8
|
-
* standalone `technical.md` still exists on disk (pre-migration data),
|
|
9
|
-
* we read that file instead and strip its YAML frontmatter so callers
|
|
10
|
-
* receive the body content uniformly.
|
|
11
5
|
*/
|
|
12
|
-
export declare function readSpecTechnicalSection(spec: Pick<Spec, 'specPath'
|
|
6
|
+
export declare function readSpecTechnicalSection(spec: Pick<Spec, 'specPath'>): Promise<string>;
|
|
13
7
|
/**
|
|
14
8
|
* Extract the body of a top-level (`## `) section from a markdown document.
|
|
15
9
|
* Returns the content AFTER the heading line, up to the next top-level
|
|
@@ -1,35 +1,16 @@
|
|
|
1
1
|
// engine/spec-format/read-technical-section.ts — SPEC-1010 PR-B
|
|
2
2
|
//
|
|
3
3
|
// SSR back-migration (SPEC-752, v2.4.0) folded `technical.md` into the
|
|
4
|
-
// unified `spec.md` as a `## Technical` section.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// is the single source of truth for "give me the technical body for
|
|
8
|
-
// this spec": it extracts the `## Technical` section from `spec.md`
|
|
9
|
-
// and falls back to the legacy `technical.md` only when the unified
|
|
10
|
-
// section is empty (transitional safety net for specs not yet auto-
|
|
11
|
-
// migrated).
|
|
4
|
+
// unified `spec.md` as a `## Technical` section. This helper is the
|
|
5
|
+
// single source of truth for "give me the technical body for this spec":
|
|
6
|
+
// it extracts only the canonical inline `## Technical` section.
|
|
12
7
|
import { readFile } from 'node:fs/promises';
|
|
13
8
|
/**
|
|
14
9
|
* Read the `## Technical` section body from a spec's unified `spec.md`.
|
|
15
10
|
* Returns "" when the spec file is unreadable or the section is missing.
|
|
16
|
-
*
|
|
17
|
-
* The legacy `spec.technicalPath` is consulted as a transitional fallback:
|
|
18
|
-
* if the spec.md does not yet contain a `## Technical` section but a
|
|
19
|
-
* standalone `technical.md` still exists on disk (pre-migration data),
|
|
20
|
-
* we read that file instead and strip its YAML frontmatter so callers
|
|
21
|
-
* receive the body content uniformly.
|
|
22
11
|
*/
|
|
23
12
|
export async function readSpecTechnicalSection(spec) {
|
|
24
|
-
|
|
25
|
-
if (fromUnified.length > 0) {
|
|
26
|
-
return fromUnified;
|
|
27
|
-
}
|
|
28
|
-
if (!spec.technicalPath) {
|
|
29
|
-
return '';
|
|
30
|
-
}
|
|
31
|
-
const legacy = await readFile(spec.technicalPath, 'utf-8').catch(() => '');
|
|
32
|
-
return stripFrontmatter(legacy).trim();
|
|
13
|
+
return extractFromUnified(spec.specPath);
|
|
33
14
|
}
|
|
34
15
|
async function extractFromUnified(specPath) {
|
|
35
16
|
const content = await readFile(specPath, 'utf-8').catch(() => '');
|
|
@@ -85,13 +66,6 @@ function maskFencedBlocks(body) {
|
|
|
85
66
|
return block.replace(/[^\n]/g, ' ');
|
|
86
67
|
});
|
|
87
68
|
}
|
|
88
|
-
/**
|
|
89
|
-
* Strip a leading YAML frontmatter block (between `---` fences at the top
|
|
90
|
-
* of the document). No-op if frontmatter is absent.
|
|
91
|
-
*/
|
|
92
|
-
function stripFrontmatter(content) {
|
|
93
|
-
return content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
94
|
-
}
|
|
95
69
|
function escapeRegex(input) {
|
|
96
70
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
97
71
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ScoreBreakdown } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Calculate a completeness score (0-100) for a spec directory.
|
|
4
|
-
* Reads spec.md
|
|
4
|
+
* Reads canonical spec.md, evaluates 6 quality categories.
|
|
5
5
|
*/
|
|
6
6
|
export declare function calculateCompletenessScore(specDir: string): Promise<{
|
|
7
7
|
score: number;
|
|
@@ -81,7 +81,7 @@ function scoreKeyFiles(specContent, technicalContent) {
|
|
|
81
81
|
/* v8 ignore next -- files header present but no file-like entries */
|
|
82
82
|
return 0;
|
|
83
83
|
}
|
|
84
|
-
/** Score technical
|
|
84
|
+
/** Score inline technical content based on existence and length. */
|
|
85
85
|
function scoreTechnicalCompleteness(technicalContent) {
|
|
86
86
|
if (!technicalContent) {
|
|
87
87
|
return 0;
|
|
@@ -126,14 +126,13 @@ function scoreFrontmatter(specContent) {
|
|
|
126
126
|
// ---------------------------------------------------------------------------
|
|
127
127
|
/**
|
|
128
128
|
* Calculate a completeness score (0-100) for a spec directory.
|
|
129
|
-
* Reads spec.md
|
|
129
|
+
* Reads canonical spec.md, evaluates 6 quality categories.
|
|
130
130
|
*/
|
|
131
131
|
export async function calculateCompletenessScore(specDir) {
|
|
132
132
|
const specPath = join(specDir, 'spec.md');
|
|
133
|
-
const technicalPath = join(specDir, 'technical.md');
|
|
134
133
|
const [specContent, technicalContent] = await Promise.all([
|
|
135
134
|
readFileOrEmpty(specPath),
|
|
136
|
-
readSpecTechnicalSection({ specPath
|
|
135
|
+
readSpecTechnicalSection({ specPath }),
|
|
137
136
|
]);
|
|
138
137
|
const breakdown = {
|
|
139
138
|
acceptanceCriteria: scoreAcceptanceCriteria(specContent),
|
|
@@ -17,8 +17,10 @@ export async function extractCriteria(spec) {
|
|
|
17
17
|
const items = extractListItems(acSection);
|
|
18
18
|
criteria.push(...items);
|
|
19
19
|
}
|
|
20
|
-
// Also check for "Given/When/Then" patterns
|
|
21
|
-
|
|
20
|
+
// Also check for explicit "Given/When/Then" patterns at line start.
|
|
21
|
+
// Keep this anchored so narrative text containing "when" is not treated
|
|
22
|
+
// as an acceptance criterion.
|
|
23
|
+
const gwtMatches = huContent.matchAll(/^\s*(?:[-*+]\s+|\d+[.)]\s+)?(?:Given|Dado|When|Cuando|Then|Entonces)\s+(.+)$/gim);
|
|
22
24
|
for (const match of gwtMatches) {
|
|
23
25
|
/* v8 ignore next 3 -- defensive regex capture group guard */
|
|
24
26
|
if (match[1]) {
|
|
@@ -7,7 +7,7 @@ export { buildHolisticReportFromFlatScore } from './validator/holistic-report.js
|
|
|
7
7
|
export type { HolisticValidateReport } from '../types/analysis.js';
|
|
8
8
|
/**
|
|
9
9
|
* Validate a spec against the actual codebase.
|
|
10
|
-
* Reads the spec
|
|
10
|
+
* Reads the canonical spec.md, extracts criteria,
|
|
11
11
|
* then checks the filesystem/code for compliance.
|
|
12
12
|
*
|
|
13
13
|
* SPEC-730: The returned ValidateResult is enriched with `holisticReport`
|
package/dist/engine/validator.js
CHANGED
|
@@ -6,6 +6,7 @@ import { extractCriteria, extractVerifyBlocks } from './validator/extractors.js'
|
|
|
6
6
|
import { scanCodeForSpec, checkCriterion, classifyDriftSeverity, quickQualityCheck, } from './validator/analyzer.js';
|
|
7
7
|
import { resolveApplicableDimensions } from './validator/scope-resolver.js';
|
|
8
8
|
import { buildHolisticReportFromFlatScore } from './validator/holistic-report.js';
|
|
9
|
+
import { readEvidenceArtifacts } from './evidence-gates/artifact-reader.js';
|
|
9
10
|
// Re-export the full public API so external callers never need to know
|
|
10
11
|
// about the sub-module layout.
|
|
11
12
|
export { generateDoR, generateDoD } from './validator/dor-dod.js';
|
|
@@ -13,9 +14,43 @@ export { generateChecklist } from './validator/checklist.js';
|
|
|
13
14
|
// SPEC-730: export scope-resolver and holistic-report for consumers
|
|
14
15
|
export { resolveApplicableDimensions, normaliseScore } from './validator/scope-resolver.js';
|
|
15
16
|
export { buildHolisticReportFromFlatScore } from './validator/holistic-report.js';
|
|
17
|
+
function normalizeCriterionForEvidence(value) {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/^-\s*\[[ xX]\]\s*/i, '')
|
|
20
|
+
.replace(/\s+/g, ' ')
|
|
21
|
+
.trim()
|
|
22
|
+
.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
function rowHasValidationEvidence(row) {
|
|
25
|
+
const hasEvidence = [
|
|
26
|
+
row.testEvidence?.length,
|
|
27
|
+
row.contractEvidence?.length,
|
|
28
|
+
row.manualEvidence?.trim(),
|
|
29
|
+
row.validationEvidence?.trim(),
|
|
30
|
+
row.reviewerEvidence?.trim(),
|
|
31
|
+
].some((value) => Boolean(value));
|
|
32
|
+
return row.changedFiles.length > 0 && hasEvidence;
|
|
33
|
+
}
|
|
34
|
+
async function readTraceabilityEvidence(spec, projectPath) {
|
|
35
|
+
try {
|
|
36
|
+
const artifacts = await readEvidenceArtifacts({
|
|
37
|
+
spec,
|
|
38
|
+
projectId: spec.projectId,
|
|
39
|
+
specId: spec.id,
|
|
40
|
+
projectPath,
|
|
41
|
+
});
|
|
42
|
+
const rows = artifacts.traceabilityMatrix?.rows ?? [];
|
|
43
|
+
return new Map(rows
|
|
44
|
+
.filter(rowHasValidationEvidence)
|
|
45
|
+
.map((row) => [normalizeCriterionForEvidence(row.acceptanceCriterion), row]));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return new Map();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
16
51
|
/**
|
|
17
52
|
* Validate a spec against the actual codebase.
|
|
18
|
-
* Reads the spec
|
|
53
|
+
* Reads the canonical spec.md, extracts criteria,
|
|
19
54
|
* then checks the filesystem/code for compliance.
|
|
20
55
|
*
|
|
21
56
|
* SPEC-730: The returned ValidateResult is enriched with `holisticReport`
|
|
@@ -45,6 +80,7 @@ export async function validateSpec(spec, projectPath) {
|
|
|
45
80
|
};
|
|
46
81
|
}
|
|
47
82
|
const criteria = await extractCriteria(spec);
|
|
83
|
+
const traceabilityEvidence = await readTraceabilityEvidence(spec, projectPath);
|
|
48
84
|
const codeState = await scanCodeForSpec(spec, projectPath);
|
|
49
85
|
// Extract verify blocks from spec file (forgiving — empty map if file unreadable)
|
|
50
86
|
let verifyBlockMap = new Map();
|
|
@@ -61,7 +97,9 @@ export async function validateSpec(spec, projectPath) {
|
|
|
61
97
|
const qualityIssues = [];
|
|
62
98
|
for (const criterion of criteria) {
|
|
63
99
|
const verifyBlock = verifyBlockMap.get(criterion);
|
|
64
|
-
const
|
|
100
|
+
const evidenceRow = traceabilityEvidence.get(normalizeCriterionForEvidence(criterion));
|
|
101
|
+
const found = evidenceRow !== undefined ||
|
|
102
|
+
(await checkCriterion(criterion, projectPath, codeState, verifyBlock));
|
|
65
103
|
if (found) {
|
|
66
104
|
matches.push(criterion);
|
|
67
105
|
}
|
|
@@ -43,6 +43,10 @@ export async function handleConfigureCodeGraph(input) {
|
|
|
43
43
|
JSON.stringify({ [providerKey]: entry }, null, 2),
|
|
44
44
|
'```',
|
|
45
45
|
'',
|
|
46
|
+
providerKey === 'codegraph'
|
|
47
|
+
? 'Initialize the project index with `npx -y @colbymchenry/codegraph init -i` from the project root before relying on graph queries.'
|
|
48
|
+
: 'Initialize the provider index using the provider documentation before relying on graph queries.',
|
|
49
|
+
'',
|
|
46
50
|
'Restart Claude Code (or your MCP client) to activate the new server.',
|
|
47
51
|
];
|
|
48
52
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
@@ -11,6 +11,8 @@ export declare function checkContradictions(projectId: string, specId: string, d
|
|
|
11
11
|
export declare function fireSpecCreatedHook(projectId: string, spec: Spec, projectPath: string): void;
|
|
12
12
|
/** Generates post-creation suggestions based on project state and spec description. Best-effort: never throws. */
|
|
13
13
|
export declare function generatePostCreationSuggestions(projectPath: string, description: string, knowledge?: ProjectKnowledge): Promise<PostCreationSuggestion[]>;
|
|
14
|
+
/** Formats a structured post-creation suggestion for human-readable next steps. */
|
|
15
|
+
export declare function formatPostCreationSuggestion(suggestion: PostCreationSuggestion): string;
|
|
14
16
|
/**
|
|
15
17
|
* SPEC-781: Run heavy analysis (contradiction detection, complexity advice) in a
|
|
16
18
|
* fire-and-forget fashion after the spec has already been persisted synchronously.
|
|
@@ -162,6 +162,11 @@ export async function generatePostCreationSuggestions(projectPath, description,
|
|
|
162
162
|
});
|
|
163
163
|
return suggestions;
|
|
164
164
|
}
|
|
165
|
+
/** Formats a structured post-creation suggestion for human-readable next steps. */
|
|
166
|
+
export function formatPostCreationSuggestion(suggestion) {
|
|
167
|
+
const templateSuffix = suggestion.templateCategory !== undefined ? ` (${suggestion.templateCategory})` : '';
|
|
168
|
+
return `${suggestion.tool}${templateSuffix}: ${suggestion.reason}`;
|
|
169
|
+
}
|
|
165
170
|
/**
|
|
166
171
|
* SPEC-781: Run heavy analysis (contradiction detection, complexity advice) in a
|
|
167
172
|
* fire-and-forget fashion after the spec has already been persisted synchronously.
|
|
@@ -10,7 +10,7 @@ import { estimateSpec } from '../engine/estimator.js';
|
|
|
10
10
|
import { checkSpecReadiness } from '../engine/readiness-checker.js';
|
|
11
11
|
import { buildSpecContext, buildSplitResult } from './create-spec/spec-builder.js';
|
|
12
12
|
import { validateConstitution } from './create-spec/constitution-validator.js';
|
|
13
|
-
import { setupGitBranch, checkContradictions, fireSpecCreatedHook, generatePostCreationSuggestions, runAutopilotAsync, getAsyncAnalysisPath, } from './create-spec/post-creation.js';
|
|
13
|
+
import { setupGitBranch, checkContradictions, fireSpecCreatedHook, generatePostCreationSuggestions, formatPostCreationSuggestion, runAutopilotAsync, getAsyncAnalysisPath, } from './create-spec/post-creation.js';
|
|
14
14
|
import { notifyStoreChange } from '../engine/doc-generator/portal/regen-hook.js';
|
|
15
15
|
import { compactObj } from '../engine/compact-obj.js';
|
|
16
16
|
import { buildCreateSpecSummary } from '../engine/human-summary.js';
|
|
@@ -894,7 +894,7 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
894
894
|
}
|
|
895
895
|
// Append auto-pipeline output (SPEC-445)
|
|
896
896
|
lines.push(...formatPipelineLines(pipelineResult));
|
|
897
|
-
const allNextSteps = result.nextSteps ?? [];
|
|
897
|
+
const allNextSteps = (result.nextSteps ?? []).map((step) => (typeof step === 'string' ? step : formatPostCreationSuggestion(step)));
|
|
898
898
|
const markdownText = allNextSteps.length > 0
|
|
899
899
|
? addNextSteps(formatSuccess(ti('tools.create_spec.success', { id: spec.id, title: spec.title }), lines.join('\n')), allNextSteps)
|
|
900
900
|
: formatSuccess(ti('tools.create_spec.success', { id: spec.id, title: spec.title }), lines.join('\n'));
|
|
@@ -88,7 +88,12 @@ export function getTierSync() {
|
|
|
88
88
|
// ---------------------------------------------------------------------------
|
|
89
89
|
const sessionTierCache = new Map();
|
|
90
90
|
const SESSION_TIER_TTL_MS = 10 * 60 * 1000; // 10 min — longer since online validation is expensive
|
|
91
|
-
const RATE_LIMIT_EXEMPT_TOOLS = new Set([
|
|
91
|
+
const RATE_LIMIT_EXEMPT_TOOLS = new Set([
|
|
92
|
+
'planu_status',
|
|
93
|
+
'update_status_batch',
|
|
94
|
+
'configure_code_graph',
|
|
95
|
+
'code_graph_status',
|
|
96
|
+
]);
|
|
92
97
|
export function shouldBypassDailyRateLimit(toolName) {
|
|
93
98
|
return RATE_LIMIT_EXEMPT_TOOLS.has(toolName);
|
|
94
99
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolResult } from '../types/index.js';
|
|
2
|
+
type MalformedOutputCode = 'OBJECT_OBJECT' | 'OBJECT_PROMISE';
|
|
3
|
+
interface MalformedOutputIssue {
|
|
4
|
+
code: MalformedOutputCode;
|
|
5
|
+
pattern: string;
|
|
6
|
+
contentIndex: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function findMalformedToolOutput(result: ToolResult): MalformedOutputIssue | null;
|
|
9
|
+
export declare function guardToolResultOutput(toolName: string | undefined, result: ToolResult): ToolResult;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=output-integrity-guard.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const MALFORMED_TEXT_PATTERNS = [
|
|
2
|
+
{ code: 'OBJECT_OBJECT', pattern: /\[object Object\]/, label: '[object Object]' },
|
|
3
|
+
{ code: 'OBJECT_PROMISE', pattern: /\[object Promise\]/, label: '[object Promise]' },
|
|
4
|
+
];
|
|
5
|
+
export function findMalformedToolOutput(result) {
|
|
6
|
+
for (let index = 0; index < result.content.length; index++) {
|
|
7
|
+
const item = result.content[index];
|
|
8
|
+
const text = item?.text ?? '';
|
|
9
|
+
for (const rule of MALFORMED_TEXT_PATTERNS) {
|
|
10
|
+
if (rule.pattern.test(text)) {
|
|
11
|
+
return {
|
|
12
|
+
code: rule.code,
|
|
13
|
+
pattern: rule.label,
|
|
14
|
+
contentIndex: index,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
export function guardToolResultOutput(toolName, result) {
|
|
22
|
+
const issue = findMalformedToolOutput(result);
|
|
23
|
+
if (issue === null) {
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
const name = toolName ?? 'unknown';
|
|
27
|
+
const structuredKeys = Object.keys(result.structuredContent ?? {});
|
|
28
|
+
return {
|
|
29
|
+
content: [
|
|
30
|
+
{
|
|
31
|
+
type: 'text',
|
|
32
|
+
text: [
|
|
33
|
+
'❌ **Output integrity guard blocked malformed response**',
|
|
34
|
+
'',
|
|
35
|
+
`Tool \`${name}\` produced non-human-readable text (${issue.code}).`,
|
|
36
|
+
'The operation may have completed before the response was blocked.',
|
|
37
|
+
'Run `planu_status` or retry the tool after the formatting bug is fixed.',
|
|
38
|
+
].join('\n'),
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
isError: true,
|
|
42
|
+
structuredContent: {
|
|
43
|
+
error: 'OUTPUT_INTEGRITY_GUARD',
|
|
44
|
+
toolName: name,
|
|
45
|
+
pattern: issue.code,
|
|
46
|
+
contentIndex: issue.contentIndex,
|
|
47
|
+
operationMayHaveCompleted: true,
|
|
48
|
+
originalStructuredContentKeys: structuredKeys,
|
|
49
|
+
fixHint: 'A Planu tool attempted to render a structured object as human text. Format the object explicitly before returning content[].text.',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=output-integrity-guard.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
export declare const OFFICIAL_SDD_TOOL_NAMES: readonly ["planu_status", "facilitate", "init_project", "clarify_requirements", "create_spec", "challenge_spec", "check_readiness", "update_status", "update_status_batch", "package_handoff", "validate", "reconcile_spec", "create_rule", "create_skill", "skill_search"];
|
|
2
|
+
export declare const OFFICIAL_SDD_TOOL_NAMES: readonly ["planu_status", "facilitate", "init_project", "clarify_requirements", "create_spec", "challenge_spec", "check_readiness", "update_status", "update_status_batch", "package_handoff", "validate", "reconcile_spec", "create_rule", "create_skill", "skill_search", "configure_code_graph", "code_graph_status"];
|
|
3
3
|
export declare function registerSddTools(server: McpServer): void;
|
|
4
4
|
//# sourceMappingURL=register-sdd-tools.d.ts.map
|
|
@@ -17,6 +17,7 @@ import { consumeUpdateBanner } from '../engine/update-notifier.js';
|
|
|
17
17
|
import { getOrCreateInstallationId } from '../engine/telemetry/telemetry-store.js';
|
|
18
18
|
import { compressToolOutput } from './output-compressor.js';
|
|
19
19
|
import { recordToolTokens as recordLlmTokens } from '../engine/llm-runtime/index.js';
|
|
20
|
+
import { guardToolResultOutput } from './output-integrity-guard.js';
|
|
20
21
|
// SPEC-902: Structured error contract
|
|
21
22
|
import { wrapError, isStructuredError } from '../engine/errors/error-wrapper.js';
|
|
22
23
|
import { getErrorRecoveryRegistry } from '../engine/errors/registry-loader.js';
|
|
@@ -279,26 +280,27 @@ function safeWithTelemetry(toolName, handler) {
|
|
|
279
280
|
content: [...withDrift.content, { type: 'text', text: updateBanner }],
|
|
280
281
|
}
|
|
281
282
|
: withDrift;
|
|
283
|
+
const guardedResult = guardToolResultOutput(toolName, finalResult);
|
|
282
284
|
// Report validation errors (isError:true returned by handler logic, not exceptions)
|
|
283
|
-
if (toolName !== undefined &&
|
|
284
|
-
const firstContent =
|
|
285
|
+
if (toolName !== undefined && guardedResult.isError === true) {
|
|
286
|
+
const firstContent = guardedResult.content[0];
|
|
285
287
|
const errText = firstContent?.type === 'text' ? firstContent.text : 'unknown error';
|
|
286
288
|
reportToolValidationError(toolName, errText);
|
|
287
289
|
}
|
|
288
290
|
// Record token usage for successful calls (fire-and-forget, only when projectPath available)
|
|
289
|
-
if (toolName !== undefined && projectPath !== undefined &&
|
|
290
|
-
const outputText = extractOutputText(
|
|
291
|
+
if (toolName !== undefined && projectPath !== undefined && guardedResult.isError !== true) {
|
|
292
|
+
const outputText = extractOutputText(guardedResult);
|
|
291
293
|
recordToolTokens(toolName, projectPath, args, outputText, SESSION_ID);
|
|
292
294
|
}
|
|
293
295
|
// SPEC-460: Track real token estimates in LLM runtime (session-scoped, measured)
|
|
294
296
|
if (toolName !== undefined) {
|
|
295
297
|
const inputText = JSON.stringify(args);
|
|
296
|
-
const outputText = extractOutputText(
|
|
298
|
+
const outputText = extractOutputText(guardedResult);
|
|
297
299
|
recordLlmTokens(toolName, inputText, outputText);
|
|
298
300
|
}
|
|
299
301
|
// Emit tool_used telemetry for successful calls (fire-and-forget)
|
|
300
302
|
/* v8 ignore start */
|
|
301
|
-
if (toolName !== undefined &&
|
|
303
|
+
if (toolName !== undefined && guardedResult.isError !== true && isErrorReportingEnabled()) {
|
|
302
304
|
getOrCreateInstallationId()
|
|
303
305
|
.then((installationId) => {
|
|
304
306
|
sendTelemetryEvent({
|
|
@@ -331,7 +333,7 @@ function safeWithTelemetry(toolName, handler) {
|
|
|
331
333
|
const partial = buildAuditEntry({
|
|
332
334
|
toolName,
|
|
333
335
|
args,
|
|
334
|
-
outputType:
|
|
336
|
+
outputType: guardedResult.isError === true ? 'error' : 'success',
|
|
335
337
|
durationMs: Date.now() - startTime,
|
|
336
338
|
prevHash,
|
|
337
339
|
});
|
|
@@ -344,9 +346,9 @@ function safeWithTelemetry(toolName, handler) {
|
|
|
344
346
|
/* v8 ignore stop */
|
|
345
347
|
// SPEC-563: Auto session checkpoint — fire-and-forget, non-blocking
|
|
346
348
|
if (toolName !== undefined) {
|
|
347
|
-
maybeWriteCheckpoint(toolName,
|
|
349
|
+
maybeWriteCheckpoint(toolName, guardedResult.isError !== true, projectPath);
|
|
348
350
|
}
|
|
349
|
-
return
|
|
351
|
+
return guardedResult;
|
|
350
352
|
}
|
|
351
353
|
catch (error) {
|
|
352
354
|
const message = error instanceof Error ? error.message : String(error);
|