@planu/cli 4.3.5 → 4.3.7

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 (39) hide show
  1. package/dist/engine/ai-cost-estimator/core.d.ts +1 -1
  2. package/dist/engine/ai-cost-estimator/core.js +2 -2
  3. package/dist/engine/ai-cost-estimator/spec-loader.d.ts +2 -2
  4. package/dist/engine/ai-cost-estimator/spec-loader.js +17 -46
  5. package/dist/engine/ai-cost-estimator/token-estimator.d.ts +1 -1
  6. package/dist/engine/ai-cost-estimator/token-estimator.js +1 -1
  7. package/dist/engine/context-intelligence/compression-guards.d.ts +8 -0
  8. package/dist/engine/context-intelligence/compression-guards.js +74 -0
  9. package/dist/engine/context-intelligence/context-graph-provider.d.ts +9 -0
  10. package/dist/engine/context-intelligence/context-graph-provider.js +98 -0
  11. package/dist/engine/context-intelligence/eval-harness.d.ts +8 -0
  12. package/dist/engine/context-intelligence/eval-harness.js +45 -0
  13. package/dist/engine/context-intelligence/impact-map.d.ts +6 -0
  14. package/dist/engine/context-intelligence/impact-map.js +47 -0
  15. package/dist/engine/context-intelligence/index.d.ts +7 -0
  16. package/dist/engine/context-intelligence/index.js +6 -0
  17. package/dist/engine/context-intelligence/safe-context-compressor.d.ts +3 -0
  18. package/dist/engine/context-intelligence/safe-context-compressor.js +75 -0
  19. package/dist/engine/dashboard/data-loader.js +9 -11
  20. package/dist/engine/dashboard/templates-project.d.ts +1 -1
  21. package/dist/engine/dashboard/templates-project.js +6 -4
  22. package/dist/engine/docs-site-generator/index.js +2 -11
  23. package/dist/engine/drift-monitor.js +13 -11
  24. package/dist/engine/qa-gate.js +6 -1
  25. package/dist/engine/readiness-checker.js +3 -3
  26. package/dist/engine/spec-conflict-graph.d.ts +1 -1
  27. package/dist/engine/spec-conflict-graph.js +2 -3
  28. package/dist/engine/spec-format/read-technical-section.d.ts +1 -7
  29. package/dist/engine/spec-format/read-technical-section.js +4 -30
  30. package/dist/engine/spec-registry/scorer.d.ts +1 -1
  31. package/dist/engine/spec-registry/scorer.js +3 -4
  32. package/dist/engine/validator/extractors.js +4 -2
  33. package/dist/engine/validator.d.ts +1 -1
  34. package/dist/engine/validator.js +40 -2
  35. package/dist/tools/update-status/dod-gates.js +1 -1
  36. package/dist/types/context-intelligence.d.ts +61 -0
  37. package/dist/types/context-intelligence.js +2 -0
  38. package/dist/types/qa-gate.d.ts +1 -1
  39. package/package.json +9 -9
@@ -1,7 +1,7 @@
1
1
  import type { AiCostEstimateResult, EstimateAiCostInput } from '../../types/index.js';
2
2
  /**
3
3
  * Estimates the AI cost for implementing a spec.
4
- * Reads the spec from planu/specs/ (or legacy docs/sdd/specs/), loads pricing from config JSON,
4
+ * Reads the canonical spec.md from planu/specs/, loads pricing from config JSON,
5
5
  * and returns a full AiCostEstimateResult with per-model breakdown.
6
6
  */
7
7
  export declare function estimateAiCost(input: EstimateAiCostInput): Promise<AiCostEstimateResult>;
@@ -34,13 +34,13 @@ function getComplexityLevel(acCount) {
34
34
  // ---------------------------------------------------------------------------
35
35
  /**
36
36
  * Estimates the AI cost for implementing a spec.
37
- * Reads the spec from planu/specs/ (or legacy docs/sdd/specs/), loads pricing from config JSON,
37
+ * Reads the canonical spec.md from planu/specs/, loads pricing from config JSON,
38
38
  * and returns a full AiCostEstimateResult with per-model breakdown.
39
39
  */
40
40
  export async function estimateAiCost(input) {
41
41
  const { specId, projectPath } = input;
42
42
  // 1. Load spec content
43
- const { content: specContent } = await loadSpecContent(specId);
43
+ const { content: specContent } = await loadSpecContent(specId, projectPath);
44
44
  const acCount = countAcceptanceCriteria(specContent);
45
45
  // 2. Load pricing config
46
46
  const config = await loadPricingConfig();
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Reads spec content from a sdd/specs directory.
2
+ * Reads canonical spec content from planu/specs.
3
3
  * Tries to find the spec directory by matching specId prefix.
4
4
  */
5
- export declare function loadSpecContent(specId: string): Promise<{
5
+ export declare function loadSpecContent(specId: string, projectPath?: string): Promise<{
6
6
  content: string;
7
7
  specDir: string;
8
8
  }>;
@@ -1,66 +1,37 @@
1
1
  // @crash-shield-ignore-file — config/cache reader for Planu-controlled JSON; writer is this codebase, shape guaranteed by build/seed.
2
2
  // engine/ai-cost-estimator/spec-loader.ts — Loads spec content and counts acceptance criteria.
3
3
  // Extracted from core.ts to keep file sizes within limits.
4
- import { readFile } from 'node:fs/promises';
4
+ import { readFile, readdir } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { stripFrontmatter } from '../frontmatter-parser.js';
7
7
  /**
8
- * Reads spec content from a sdd/specs directory.
8
+ * Reads canonical spec content from planu/specs.
9
9
  * Tries to find the spec directory by matching specId prefix.
10
10
  */
11
- export async function loadSpecContent(specId) {
12
- // Try modern location first, fallback to legacy
13
- let specsRoot = 'planu/specs';
11
+ export async function loadSpecContent(specId, projectPath = process.cwd()) {
12
+ const specsRoot = join(projectPath, 'planu', 'specs');
13
+ let topEntries;
14
14
  try {
15
- const { access: fsAccess } = await import('node:fs/promises');
16
- await fsAccess(specsRoot);
17
- }
18
- catch {
19
- specsRoot = 'docs/sdd/specs';
20
- }
21
- const { readdir } = await import('node:fs/promises');
22
- let specDir = null;
23
- try {
24
- const topEntries = await readdir(specsRoot);
25
- // Flat layout: planu/specs/SPEC-069-name/ (or legacy docs/sdd/specs/SPEC-069-name/)
26
- const flatMatch = topEntries.find((e) => e.startsWith(specId));
27
- if (flatMatch) {
28
- specDir = join(specsRoot, flatMatch);
29
- }
15
+ topEntries = await readdir(specsRoot);
30
16
  }
31
17
  catch {
32
18
  throw new Error(`Spec directory not found at "${specsRoot}". ` +
33
19
  'Make sure you are running from the project root.');
34
20
  }
35
- if (!specDir) {
36
- throw new Error(`Spec "${specId}" not found in "${specsRoot}". ` + 'Check that the spec directory exists.');
21
+ const flatMatch = topEntries.find((entry) => entry.startsWith(specId));
22
+ if (!flatMatch) {
23
+ throw new Error(`Spec "${specId}" not found in "${specsRoot}". Check that the spec directory exists.`);
37
24
  }
38
- const parts = [];
39
- // Try modern names first, fallback to legacy — only read one of each pair
40
- for (const [modern, legacy] of [
41
- ['spec.md', 'HU.md'],
42
- ['technical.md', 'FICHA-TECNICA.md'],
43
- ]) {
44
- let found = false;
45
- for (const filename of [modern, legacy]) {
46
- if (found) {
47
- break;
48
- }
49
- try {
50
- const raw = await readFile(join(specDir, filename), 'utf-8');
51
- parts.push(stripFrontmatter(raw));
52
- found = true;
53
- }
54
- catch {
55
- // File not present — try next
56
- }
57
- }
25
+ const specDir = join(specsRoot, flatMatch);
26
+ const specPath = join(specDir, 'spec.md');
27
+ try {
28
+ const raw = await readFile(specPath, 'utf-8');
29
+ return { content: stripFrontmatter(raw), specDir };
58
30
  }
59
- if (parts.length === 0) {
60
- throw new Error(`Spec "${specId}" has no spec files in "${specDir}". ` +
61
- 'Cannot estimate without spec content.');
31
+ catch {
32
+ throw new Error(`Spec "${specId}" has no canonical spec.md at "${specPath}". ` +
33
+ 'Run Planu migration/strict cleanup before estimating cost.');
62
34
  }
63
- return { content: parts.join('\n\n'), specDir };
64
35
  }
65
36
  /** Counts acceptance criteria (lines starting with "- [ ]" or "- [x]") in spec content. */
66
37
  export function countAcceptanceCriteria(specContent) {
@@ -6,7 +6,7 @@ import type { TokenEstimate, AiModelPricingConfig } from '../../types/index.js';
6
6
  export declare function detectProjectLanguage(projectPath: string): Promise<string>;
7
7
  /**
8
8
  * Estimates input tokens for an AI agent session based on three components:
9
- * 1. Spec content (HU.md + FICHA-TECNICA.md)
9
+ * 1. Spec content (canonical spec.md)
10
10
  * 2. Codebase context (if projectPath is provided)
11
11
  * 3. MCP tool definitions overhead (fixed constant)
12
12
  */
@@ -119,7 +119,7 @@ function isSourceFile(filename) {
119
119
  // ---------------------------------------------------------------------------
120
120
  /**
121
121
  * Estimates input tokens for an AI agent session based on three components:
122
- * 1. Spec content (HU.md + FICHA-TECNICA.md)
122
+ * 1. Spec content (canonical spec.md)
123
123
  * 2. Codebase context (if projectPath is provided)
124
124
  * 3. MCP tool definitions overhead (fixed constant)
125
125
  */
@@ -0,0 +1,8 @@
1
+ export declare function getSensitivePathRefusal(path: string): string | null;
2
+ export declare function collectPreservedFragments(content: string): Map<string, string[]>;
3
+ export declare function verifyPreservedFragments(original: string, candidate: string): {
4
+ ok: boolean;
5
+ checks: string[];
6
+ };
7
+ export declare function shouldBypassLeanMode(flow: string): boolean;
8
+ //# sourceMappingURL=compression-guards.d.ts.map
@@ -0,0 +1,74 @@
1
+ import { basename } from 'node:path';
2
+ const SENSITIVE_PATH_SEGMENTS = new Set([
3
+ '.ssh',
4
+ '.aws',
5
+ '.gnupg',
6
+ '.kube',
7
+ '.docker',
8
+ 'secrets',
9
+ 'credentials',
10
+ ]);
11
+ const SENSITIVE_FILE_PATTERNS = [
12
+ /^\.env(?:\.|$)/i,
13
+ /^\.npmrc$/i,
14
+ /(?:secret|credential|password|token|private[-_]?key|api[-_]?key)/i,
15
+ /\.(?:p12|pfx|pem|key)$/i,
16
+ ];
17
+ const PRESERVE_PATTERNS = [
18
+ { label: 'spec-id', pattern: /\bSPEC-\d+\b/g },
19
+ { label: 'acceptance-criteria', pattern: /^-\s*\[[ xX]\]\s+.+$/gm },
20
+ { label: 'fenced-code', pattern: /```[\s\S]*?```|~~~[\s\S]*?~~~/g },
21
+ { label: 'inline-code', pattern: /`[^`\n]+`/g },
22
+ { label: 'url', pattern: /\bhttps?:\/\/[^\s)]+/g },
23
+ { label: 'file-path', pattern: /\b[\w.-]+(?:\/[\w.-]+)+\.[A-Za-z0-9]+\b/g },
24
+ { label: 'command', pattern: /\b(?:pnpm|npm|bun|node|git|gh|npx|tsx|tsc)\s+[^\n]+/g },
25
+ { label: 'hash', pattern: /\b[a-f0-9]{32,64}\b/gi },
26
+ {
27
+ label: 'validation-failure',
28
+ pattern: /\b(?:error|failed|failure|blocked|validate|validation)\b[^\n]*/gi,
29
+ },
30
+ { label: 'approval-request', pattern: /\b(?:approve|approval|approved|forceApprove)\b[^\n]*/gi },
31
+ {
32
+ label: 'security-warning',
33
+ pattern: /\b(?:security|secret|credential|token|password|destructive)\b[^\n]*/gi,
34
+ },
35
+ ];
36
+ export function getSensitivePathRefusal(path) {
37
+ const normalized = path.replace(/\\/g, '/');
38
+ const segments = normalized.split('/').filter(Boolean);
39
+ if (segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()))) {
40
+ return `Refusing sensitive path segment in ${path}`;
41
+ }
42
+ const fileName = basename(normalized);
43
+ if (SENSITIVE_FILE_PATTERNS.some((pattern) => pattern.test(fileName))) {
44
+ return `Refusing sensitive file name ${path}`;
45
+ }
46
+ return null;
47
+ }
48
+ export function collectPreservedFragments(content) {
49
+ const fragments = new Map();
50
+ for (const { label, pattern } of PRESERVE_PATTERNS) {
51
+ const matches = [...content.matchAll(pattern)].map((match) => match[0]);
52
+ if (matches.length > 0) {
53
+ fragments.set(label, [...new Set(matches)]);
54
+ }
55
+ }
56
+ return fragments;
57
+ }
58
+ export function verifyPreservedFragments(original, candidate) {
59
+ const fragments = collectPreservedFragments(original);
60
+ const checks = [];
61
+ for (const [label, values] of fragments.entries()) {
62
+ for (const value of values) {
63
+ if (!candidate.includes(value)) {
64
+ return { ok: false, checks: [`missing:${label}:${value.slice(0, 80)}`] };
65
+ }
66
+ }
67
+ checks.push(label);
68
+ }
69
+ return { ok: true, checks };
70
+ }
71
+ export function shouldBypassLeanMode(flow) {
72
+ return /\b(security|validation|validate|approval|approved|release|destructive|delete|rollback|force)\b/i.test(flow);
73
+ }
74
+ //# sourceMappingURL=compression-guards.js.map
@@ -0,0 +1,9 @@
1
+ import type { ContextGraphInput, ContextGraphProvider, ContextImpactMap } from '../../types/context-intelligence.js';
2
+ export declare class PlanuContextGraphProvider implements ContextGraphProvider {
3
+ buildImpactMap(input: ContextGraphInput): Promise<ContextImpactMap>;
4
+ private resolveCandidateFiles;
5
+ private readSpecReferencedPaths;
6
+ private mapFile;
7
+ }
8
+ export declare function createContextGraphProvider(): ContextGraphProvider;
9
+ //# sourceMappingURL=context-graph-provider.d.ts.map
@@ -0,0 +1,98 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { glob } from 'glob';
4
+ import { emptyImpactMap, estimateImpactBudget, mergeImpactMaps } from './impact-map.js';
5
+ const SOURCE_EXTENSIONS = '{ts,tsx,js,jsx,mjs,cjs,py,go,rs,java,rb,cs}';
6
+ function normalizePath(path) {
7
+ return path.replace(/\\/g, '/');
8
+ }
9
+ function extractPaths(text) {
10
+ const matches = text.match(/\b(?:src|tests|packages|lib)\/[\w./-]+\.[A-Za-z0-9]+\b/g) ?? [];
11
+ return [...new Set(matches.map(normalizePath))];
12
+ }
13
+ function extractImports(content) {
14
+ const imports = new Set();
15
+ const patterns = [
16
+ /from\s+['"]([^'"]+)['"]/g,
17
+ /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
18
+ /require\(\s*['"]([^'"]+)['"]\s*\)/g,
19
+ ];
20
+ for (const pattern of patterns) {
21
+ for (const match of content.matchAll(pattern)) {
22
+ if (match[1]?.startsWith('.')) {
23
+ imports.add(match[1]);
24
+ }
25
+ }
26
+ }
27
+ return [...imports];
28
+ }
29
+ function extractSymbols(content, path) {
30
+ const symbols = [];
31
+ const lines = content.split('\n');
32
+ const pattern = /\b(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+([A-Za-z_][\w]*)/;
33
+ for (let index = 0; index < lines.length; index += 1) {
34
+ const match = pattern.exec(lines[index] ?? '');
35
+ if (match?.[1]) {
36
+ symbols.push({ name: match[1], path, line: index + 1, confidence: 0.75 });
37
+ }
38
+ }
39
+ return symbols;
40
+ }
41
+ export class PlanuContextGraphProvider {
42
+ async buildImpactMap(input) {
43
+ const candidates = await this.resolveCandidateFiles(input);
44
+ if (candidates.length === 0) {
45
+ return emptyImpactMap();
46
+ }
47
+ const maps = await Promise.all(candidates.map((path) => this.mapFile(input.projectPath, path)));
48
+ return mergeImpactMaps(maps, input.tokenBudget);
49
+ }
50
+ async resolveCandidateFiles(input) {
51
+ const fromChanged = (input.changedFiles ?? []).map(normalizePath);
52
+ const fromSpec = input.specId ? await this.readSpecReferencedPaths(input) : [];
53
+ const direct = [...new Set([...fromChanged, ...fromSpec])].filter(Boolean);
54
+ if (direct.length > 0) {
55
+ return direct.slice(0, estimateImpactBudget(input.tokenBudget) * 2);
56
+ }
57
+ const maxFiles = estimateImpactBudget(input.tokenBudget);
58
+ return (await glob(`**/*.${SOURCE_EXTENSIONS}`, {
59
+ cwd: input.projectPath,
60
+ nodir: true,
61
+ ignore: ['**/node_modules/**', '**/dist/**', '**/.git/**', '**/coverage/**'],
62
+ }))
63
+ .map(normalizePath)
64
+ .slice(0, maxFiles);
65
+ }
66
+ async readSpecReferencedPaths(input) {
67
+ const specFiles = await glob(`planu/specs/${input.specId}-*/spec.md`, {
68
+ cwd: input.projectPath,
69
+ nodir: true,
70
+ });
71
+ const specPath = specFiles[0];
72
+ if (!specPath) {
73
+ return [];
74
+ }
75
+ const content = await readFile(join(input.projectPath, specPath), 'utf-8').catch(() => '');
76
+ return extractPaths(content);
77
+ }
78
+ async mapFile(projectPath, path) {
79
+ const normalized = normalizePath(path);
80
+ const absolute = join(projectPath, normalized);
81
+ const content = await readFile(absolute, 'utf-8').catch(() => '');
82
+ const imports = extractImports(content);
83
+ return {
84
+ files: [{ path: normalized, reason: 'direct-context-candidate', confidence: 0.9 }],
85
+ symbols: extractSymbols(content, normalized),
86
+ edges: imports.map((target) => ({
87
+ from: normalized,
88
+ to: normalizePath(relative(projectPath, join(absolute, '..', target))),
89
+ type: 'import',
90
+ })),
91
+ truncated: false,
92
+ };
93
+ }
94
+ }
95
+ export function createContextGraphProvider() {
96
+ return new PlanuContextGraphProvider();
97
+ }
98
+ //# sourceMappingURL=context-graph-provider.js.map
@@ -0,0 +1,8 @@
1
+ import type { ContextEvalCase, ContextEvalMode, ContextEvalResult } from '../../types/context-intelligence.js';
2
+ export declare function evaluateContextModes(cases: ContextEvalCase[]): ContextEvalResult[];
3
+ export declare function summarizeContextEval(results: ContextEvalResult[]): {
4
+ cases: number;
5
+ averageSavingsVsBaselinePct: number;
6
+ bestModeCounts: Record<ContextEvalMode, number>;
7
+ };
8
+ //# sourceMappingURL=eval-harness.d.ts.map
@@ -0,0 +1,45 @@
1
+ function pctSaved(baseline, candidate) {
2
+ if (baseline <= 0) {
3
+ return 0;
4
+ }
5
+ return Math.max(0, Math.round(((baseline - candidate) / baseline) * 1000) / 10);
6
+ }
7
+ export function evaluateContextModes(cases) {
8
+ return cases.map((item) => {
9
+ const modes = {
10
+ baseline: item.baselineTokens,
11
+ 'spec-guided': item.specGuidedTokens,
12
+ 'graph-guided': item.graphGuidedTokens,
13
+ lean: item.leanTokens,
14
+ 'graph+lean': item.graphLeanTokens,
15
+ };
16
+ const bestMode = Object.entries(modes).sort((a, b) => a[1] - b[1])[0]?.[0] ??
17
+ 'baseline';
18
+ return {
19
+ id: item.id,
20
+ bestMode,
21
+ savingsVsBaselinePct: pctSaved(item.baselineTokens, modes[bestMode]),
22
+ modes,
23
+ };
24
+ });
25
+ }
26
+ export function summarizeContextEval(results) {
27
+ const bestModeCounts = {
28
+ baseline: 0,
29
+ 'spec-guided': 0,
30
+ 'graph-guided': 0,
31
+ lean: 0,
32
+ 'graph+lean': 0,
33
+ };
34
+ let totalSavings = 0;
35
+ for (const result of results) {
36
+ bestModeCounts[result.bestMode] += 1;
37
+ totalSavings += result.savingsVsBaselinePct;
38
+ }
39
+ return {
40
+ cases: results.length,
41
+ averageSavingsVsBaselinePct: results.length === 0 ? 0 : Math.round((totalSavings / results.length) * 10) / 10,
42
+ bestModeCounts,
43
+ };
44
+ }
45
+ //# sourceMappingURL=eval-harness.js.map
@@ -0,0 +1,6 @@
1
+ import type { ContextImpactMap } from '../../types/context-intelligence.js';
2
+ export declare function emptyImpactMap(): ContextImpactMap;
3
+ export declare function clampConfidence(value: number): number;
4
+ export declare function estimateImpactBudget(tokenBudget: number): number;
5
+ export declare function mergeImpactMaps(maps: ContextImpactMap[], tokenBudget: number): ContextImpactMap;
6
+ //# sourceMappingURL=impact-map.d.ts.map
@@ -0,0 +1,47 @@
1
+ export function emptyImpactMap() {
2
+ return { files: [], symbols: [], edges: [], truncated: false };
3
+ }
4
+ export function clampConfidence(value) {
5
+ return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
6
+ }
7
+ export function estimateImpactBudget(tokenBudget) {
8
+ if (tokenBudget <= 0) {
9
+ return 1;
10
+ }
11
+ return Math.max(1, Math.min(50, Math.floor(tokenBudget / 120)));
12
+ }
13
+ export function mergeImpactMaps(maps, tokenBudget) {
14
+ const maxFiles = estimateImpactBudget(tokenBudget);
15
+ const files = new Map();
16
+ const symbols = new Map();
17
+ const edges = new Map();
18
+ let sourceCount = 0;
19
+ for (const map of maps) {
20
+ for (const file of map.files) {
21
+ sourceCount += 1;
22
+ const existing = files.get(file.path);
23
+ if (!existing || existing.confidence < file.confidence) {
24
+ files.set(file.path, { ...file, confidence: clampConfidence(file.confidence) });
25
+ }
26
+ }
27
+ for (const symbol of map.symbols) {
28
+ symbols.set(`${symbol.path}:${symbol.name}:${String(symbol.line ?? '')}`, {
29
+ ...symbol,
30
+ confidence: clampConfidence(symbol.confidence),
31
+ });
32
+ }
33
+ for (const edge of map.edges) {
34
+ edges.set(`${edge.from}->${edge.to}:${edge.type}`, edge);
35
+ }
36
+ }
37
+ const rankedFiles = [...files.values()].sort((a, b) => b.confidence - a.confidence);
38
+ const selectedFiles = rankedFiles.slice(0, maxFiles);
39
+ const selectedPaths = new Set(selectedFiles.map((file) => file.path));
40
+ return {
41
+ files: selectedFiles,
42
+ symbols: [...symbols.values()].filter((symbol) => selectedPaths.has(symbol.path)),
43
+ edges: [...edges.values()].filter((edge) => selectedPaths.has(edge.from) || selectedPaths.has(edge.to)),
44
+ truncated: rankedFiles.length < sourceCount || rankedFiles.length > selectedFiles.length,
45
+ };
46
+ }
47
+ //# sourceMappingURL=impact-map.js.map
@@ -0,0 +1,7 @@
1
+ export { PlanuContextGraphProvider, createContextGraphProvider } from './context-graph-provider.js';
2
+ export { getSensitivePathRefusal, collectPreservedFragments, verifyPreservedFragments, shouldBypassLeanMode, } from './compression-guards.js';
3
+ export { compressSafeContext } from './safe-context-compressor.js';
4
+ export { evaluateContextModes, summarizeContextEval } from './eval-harness.js';
5
+ export { emptyImpactMap, estimateImpactBudget, mergeImpactMaps } from './impact-map.js';
6
+ export type { ContextEvalCase, ContextEvalMode, ContextEvalResult, ContextGraphInput, ContextGraphProvider, ContextImpactEdge, ContextImpactFile, ContextImpactMap, ContextImpactSymbol, SafeCompressionResult, SafeContextCompressionInput, } from '../../types/context-intelligence.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,6 @@
1
+ export { PlanuContextGraphProvider, createContextGraphProvider } from './context-graph-provider.js';
2
+ export { getSensitivePathRefusal, collectPreservedFragments, verifyPreservedFragments, shouldBypassLeanMode, } from './compression-guards.js';
3
+ export { compressSafeContext } from './safe-context-compressor.js';
4
+ export { evaluateContextModes, summarizeContextEval } from './eval-harness.js';
5
+ export { emptyImpactMap, estimateImpactBudget, mergeImpactMaps } from './impact-map.js';
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ import type { SafeCompressionResult, SafeContextCompressionInput } from '../../types/context-intelligence.js';
2
+ export declare function compressSafeContext(input: SafeContextCompressionInput): SafeCompressionResult;
3
+ //# sourceMappingURL=safe-context-compressor.d.ts.map
@@ -0,0 +1,75 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { getSensitivePathRefusal, verifyPreservedFragments, shouldBypassLeanMode, } from './compression-guards.js';
3
+ const DEFAULT_MAX_BYTES = 200_000;
4
+ function byteLength(value) {
5
+ return Buffer.byteLength(value, 'utf-8');
6
+ }
7
+ function compressParagraph(paragraph) {
8
+ if (paragraph.startsWith('```') ||
9
+ paragraph.startsWith('~~~') ||
10
+ paragraph.startsWith('- [') ||
11
+ /`[^`\n]+`|\bSPEC-\d+\b|\bhttps?:\/\//.test(paragraph)) {
12
+ return paragraph;
13
+ }
14
+ return paragraph
15
+ .split('\n')
16
+ .map((line) => line.replace(/\s+/g, ' ').trim())
17
+ .filter(Boolean)
18
+ .join(' ');
19
+ }
20
+ export function compressSafeContext(input) {
21
+ const originalBytes = byteLength(input.content);
22
+ if (input.sourcePath) {
23
+ const refusal = getSensitivePathRefusal(input.sourcePath);
24
+ if (refusal) {
25
+ return {
26
+ ok: false,
27
+ originalBytes,
28
+ compressedBytes: originalBytes,
29
+ refusedReason: refusal,
30
+ preservedChecks: [],
31
+ };
32
+ }
33
+ }
34
+ if (originalBytes > (input.maxBytes ?? DEFAULT_MAX_BYTES)) {
35
+ return {
36
+ ok: false,
37
+ originalBytes,
38
+ compressedBytes: originalBytes,
39
+ refusedReason: `Refusing content over ${String(input.maxBytes ?? DEFAULT_MAX_BYTES)} bytes`,
40
+ preservedChecks: [],
41
+ };
42
+ }
43
+ if (input.flow && shouldBypassLeanMode(input.flow)) {
44
+ return {
45
+ ok: true,
46
+ originalBytes,
47
+ compressedBytes: originalBytes,
48
+ preservedChecks: ['bypass-sensitive-flow'],
49
+ content: input.content,
50
+ };
51
+ }
52
+ const compressed = input.content
53
+ .split(/\n{2,}/)
54
+ .map((paragraph) => compressParagraph(paragraph.trim()))
55
+ .filter(Boolean)
56
+ .join('\n\n');
57
+ const preserved = verifyPreservedFragments(input.content, compressed);
58
+ if (!preserved.ok) {
59
+ return {
60
+ ok: false,
61
+ originalBytes,
62
+ compressedBytes: byteLength(compressed),
63
+ refusedReason: `Compression would drop preserved fragment: ${preserved.checks[0] ?? 'unknown'}`,
64
+ preservedChecks: preserved.checks,
65
+ };
66
+ }
67
+ return {
68
+ ok: true,
69
+ originalBytes,
70
+ compressedBytes: byteLength(compressed),
71
+ preservedChecks: preserved.checks,
72
+ content: compressed,
73
+ };
74
+ }
75
+ //# sourceMappingURL=safe-context-compressor.js.map
@@ -3,6 +3,7 @@
3
3
  import { readFile, readdir, stat } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { computeDashboardMetrics, computeHealthScore } from './kanban-logic.js';
6
+ import { extractSectionBody } from '../spec-format/read-technical-section.js';
6
7
  /** Root data directory for all project data */
7
8
  const DATA_DIR = join(process.cwd(), 'data');
8
9
  /** Safely read and parse a JSON file; returns null on any error */
@@ -190,11 +191,10 @@ export function computeHealthMetrics(project) {
190
191
  /** Read spec markdown files for the detail view */
191
192
  export async function loadSpecDetail(projectId, specId) {
192
193
  const specDir = join(DATA_DIR, 'projects', projectId, 'specs', specId);
193
- const [hu, ficha, progress] = await Promise.all([
194
- readTextFile(join(specDir, 'HU.md')),
195
- readTextFile(join(specDir, 'FICHA-TECNICA.md')),
196
- readTextFile(join(specDir, 'PROGRESS.md')),
197
- ]);
194
+ const specMd = await readTextFile(join(specDir, 'spec.md'));
195
+ const hu = specMd;
196
+ const ficha = specMd !== null ? extractSectionBody(specMd, 'Technical') || null : null;
197
+ const progress = specMd !== null ? extractSectionBody(specMd, 'Progress') || null : null;
198
198
  return { hu, ficha, progress };
199
199
  }
200
200
  /** Extract code links (file references) from a technical markdown */
@@ -257,12 +257,10 @@ export function parseFrontmatter(content) {
257
257
  /** Load enhanced spec detail with code links and frontmatter */
258
258
  export async function loadSpecDetailEnhanced(projectId, specId) {
259
259
  const specDir = join(DATA_DIR, 'projects', projectId, 'specs', specId);
260
- const [hu, ficha, progress, specMd] = await Promise.all([
261
- readTextFile(join(specDir, 'HU.md')),
262
- readTextFile(join(specDir, 'FICHA-TECNICA.md')),
263
- readTextFile(join(specDir, 'PROGRESS.md')),
264
- readTextFile(join(specDir, 'spec.md')),
265
- ]);
260
+ const specMd = await readTextFile(join(specDir, 'spec.md'));
261
+ const hu = specMd;
262
+ const ficha = specMd !== null ? extractSectionBody(specMd, 'Technical') || null : null;
263
+ const progress = specMd !== null ? extractSectionBody(specMd, 'Progress') || null : null;
266
264
  const codeLinks = ficha ? extractCodeLinks(ficha) : [];
267
265
  const frontmatter = specMd ? parseFrontmatter(specMd) : {};
268
266
  return { hu, ficha, progress, codeLinks, frontmatter };
@@ -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 — HU, FICHA, PROGRESS tabs */
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 — HU, FICHA, PROGRESS tabs */
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 ? markdownToHtml(hu) : '<p style="color:var(--text-muted)">HU.md no encontrado</p>';
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)">FICHA-TECNICA.md no encontrado</p>';
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)">PROGRESS.md no encontrado</p>';
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> &rsaquo;
@@ -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
- try {
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 resolvedTechnical = resolveTechnicalPath(projectPath, specId, technicalPath);
111
+ const resolvedSpec = resolveSpecPath(projectPath, specId, technicalPath);
111
112
  let content = '';
112
- if (resolvedTechnical !== null) {
113
+ if (resolvedSpec !== null) {
113
114
  try {
114
- content = await readFile(resolvedTechnical, 'utf8');
115
+ content = await readSpecTechnicalSection({ specPath: resolvedSpec });
115
116
  }
116
117
  catch {
117
- // cannot read technical.md — treat all layers as fully healthy
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's technical.md file. */
315
- function resolveTechnicalPath(projectPath, specId, technicalPath) {
316
- /* v8 ignore next 3 */
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
- return technicalPath;
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>/technical.md
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, 'technical.md');
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 technical.md content. */
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();
@@ -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
- checks.push(runCheck('test-coverage', 'pnpm', ['test:coverage'], projectPath));
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 FICHA-TECNICA.md found — files to create/modify are not identified. Run create_spec_tech first.');
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('Generate FICHA-TECNICA.md with create_spec_tech to identify key files');
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 technical.md — check src/ paths in body)
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 technical.md changes.
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 technicalPath = join(specsDir, folder, 'technical.md');
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 technical.md changes.
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' | 'technicalPath'>): Promise<string>;
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. The reader call sites
5
- // that previously read the standalone `technical.md` file silently
6
- // degrade to "" today because the file never gets written. This helper
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
- const fromUnified = await extractFromUnified(spec.specPath);
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 and technical.md, evaluates 6 quality categories.
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.md based on existence and length. */
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 and technical.md, evaluates 6 quality categories.
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, technicalPath }),
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
- const gwtMatches = huContent.matchAll(/(?:Given|Dado|When|Cuando|Then|Entonces)\s+(.+)/gi);
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 files (HU.md, FICHA-TECNICA.md), extracts criteria,
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`
@@ -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 files (HU.md, FICHA-TECNICA.md), extracts criteria,
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 found = await checkCriterion(criterion, projectPath, codeState, verifyBlock);
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
  }
@@ -525,7 +525,7 @@ export async function checkQaGate(specId, projectPath, force) {
525
525
  error: 'qa_gate_not_passed',
526
526
  code: 422,
527
527
  context: { specId, reason },
528
- fixHint: 'Run typecheck + test:coverage, record the result, then retry update_status(done). Or use force:true to bypass.',
528
+ fixHint: 'Run the QA gate and record the result: typecheck + test by default, or typecheck + test:coverage when the spec declares a coverage threshold. Then retry update_status(done). Or use force:true to bypass.',
529
529
  },
530
530
  };
531
531
  }
@@ -0,0 +1,61 @@
1
+ export interface ContextGraphInput {
2
+ projectPath: string;
3
+ specId?: string;
4
+ changedFiles?: string[];
5
+ tokenBudget: number;
6
+ }
7
+ export interface ContextImpactFile {
8
+ path: string;
9
+ reason: string;
10
+ confidence: number;
11
+ }
12
+ export interface ContextImpactSymbol {
13
+ name: string;
14
+ path: string;
15
+ line?: number;
16
+ confidence: number;
17
+ }
18
+ export interface ContextImpactEdge {
19
+ from: string;
20
+ to: string;
21
+ type: 'import' | 'call' | 'test' | 'spec';
22
+ }
23
+ export interface ContextImpactMap {
24
+ files: ContextImpactFile[];
25
+ symbols: ContextImpactSymbol[];
26
+ edges: ContextImpactEdge[];
27
+ truncated: boolean;
28
+ }
29
+ export interface ContextGraphProvider {
30
+ buildImpactMap(input: ContextGraphInput): Promise<ContextImpactMap>;
31
+ }
32
+ export interface SafeCompressionResult {
33
+ ok: boolean;
34
+ originalBytes: number;
35
+ compressedBytes: number;
36
+ refusedReason?: string;
37
+ preservedChecks: string[];
38
+ content?: string;
39
+ }
40
+ export interface SafeContextCompressionInput {
41
+ content: string;
42
+ sourcePath?: string;
43
+ maxBytes?: number;
44
+ flow?: string;
45
+ }
46
+ export interface ContextEvalCase {
47
+ id: string;
48
+ baselineTokens: number;
49
+ specGuidedTokens: number;
50
+ graphGuidedTokens: number;
51
+ leanTokens: number;
52
+ graphLeanTokens: number;
53
+ }
54
+ export type ContextEvalMode = 'baseline' | 'spec-guided' | 'graph-guided' | 'lean' | 'graph+lean';
55
+ export interface ContextEvalResult {
56
+ id: string;
57
+ bestMode: ContextEvalMode;
58
+ savingsVsBaselinePct: number;
59
+ modes: Record<ContextEvalMode, number>;
60
+ }
61
+ //# sourceMappingURL=context-intelligence.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=context-intelligence.js.map
@@ -1,4 +1,4 @@
1
- export type QaCheckName = 'typecheck' | 'test-coverage';
1
+ export type QaCheckName = 'typecheck' | 'test' | 'test-coverage';
2
2
  export interface QaCheckResult {
3
3
  name: QaCheckName;
4
4
  passed: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.3.5",
3
+ "version": "4.3.7",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,14 +32,14 @@
32
32
  "packageName": "@planu/core"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@planu/core-darwin-arm64": "4.3.5",
36
- "@planu/core-darwin-x64": "4.3.5",
37
- "@planu/core-linux-arm64-gnu": "4.3.5",
38
- "@planu/core-linux-arm64-musl": "4.3.5",
39
- "@planu/core-linux-x64-gnu": "4.3.5",
40
- "@planu/core-linux-x64-musl": "4.3.5",
41
- "@planu/core-win32-arm64-msvc": "4.3.5",
42
- "@planu/core-win32-x64-msvc": "4.3.5"
35
+ "@planu/core-darwin-arm64": "4.3.7",
36
+ "@planu/core-darwin-x64": "4.3.7",
37
+ "@planu/core-linux-arm64-gnu": "4.3.7",
38
+ "@planu/core-linux-arm64-musl": "4.3.7",
39
+ "@planu/core-linux-x64-gnu": "4.3.7",
40
+ "@planu/core-linux-x64-musl": "4.3.7",
41
+ "@planu/core-win32-arm64-msvc": "4.3.7",
42
+ "@planu/core-win32-x64-msvc": "4.3.7"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=24.0.0"