@planu/cli 3.9.12 → 3.9.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/hook-templates/planu-spec-sanctity.sh +2 -3
- package/dist/engine/autopilot/bootstrap.js +0 -138
- package/dist/engine/housekeeping/ephemeral-artifacts-cleaner.d.ts +2 -2
- package/dist/engine/housekeeping/ephemeral-artifacts-cleaner.js +7 -28
- package/dist/engine/living-specs/index.d.ts +2 -2
- package/dist/engine/living-specs/index.js +10 -35
- package/dist/engine/progress-writer.d.ts +2 -2
- package/dist/engine/progress-writer.js +2 -2
- package/dist/engine/spec-format/lean-technical-generator.d.ts +1 -1
- package/dist/engine/spec-format/lean-technical-generator.js +2 -2
- package/dist/engine/spec-format/technical-md-populator.d.ts +1 -1
- package/dist/engine/spec-format/technical-md-populator.js +2 -2
- package/dist/tools/sync-spec-state-handler.js +7 -0
- package/dist/tools/update-status/index.js +1 -1
- package/dist/types/impact-detection.d.ts +6 -0
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.js +0 -1
- package/package.json +7 -7
- package/dist/engine/implementation-brief/convention-extractor.d.ts +0 -5
- package/dist/engine/implementation-brief/convention-extractor.js +0 -75
- package/dist/engine/implementation-brief/extension-points.d.ts +0 -2
- package/dist/engine/implementation-brief/extension-points.js +0 -32
- package/dist/engine/implementation-brief/generator.d.ts +0 -3
- package/dist/engine/implementation-brief/generator.js +0 -139
- package/dist/engine/implementation-brief/helpers-scanner.d.ts +0 -3
- package/dist/engine/implementation-brief/helpers-scanner.js +0 -163
- package/dist/engine/implementation-brief/test-pattern-matcher.d.ts +0 -3
- package/dist/engine/implementation-brief/test-pattern-matcher.js +0 -90
- package/dist/engine/risk-analyzer/risk-generator.d.ts +0 -6
- package/dist/engine/risk-analyzer/risk-generator.js +0 -94
- package/dist/types/implementation-brief.d.ts +0 -41
- package/dist/types/implementation-brief.js +0 -3
|
@@ -21,7 +21,6 @@ set -euo pipefail
|
|
|
21
21
|
ALLOWED_FILES=(
|
|
22
22
|
"spec.md"
|
|
23
23
|
"progress.json"
|
|
24
|
-
"implementation-brief.md"
|
|
25
24
|
)
|
|
26
25
|
|
|
27
26
|
# ---------------------------------------------------------------------------
|
|
@@ -104,9 +103,9 @@ EOF
|
|
|
104
103
|
Allowed files inside planu/specs/SPEC-*/ are:
|
|
105
104
|
- spec.md
|
|
106
105
|
- progress.json
|
|
107
|
-
- implementation-brief.md
|
|
108
106
|
|
|
109
|
-
Do NOT create technical.md, progress.md,
|
|
107
|
+
Do NOT create technical.md, progress.md, implementation-brief.md, prompt.md,
|
|
108
|
+
risk-register.md, PLAN.md, NOTES.md, ADRs, or other files here.
|
|
110
109
|
Keep implementation notes in conversation context or in src/.
|
|
111
110
|
EOF
|
|
112
111
|
exit 2
|
|
@@ -24,140 +24,6 @@ function registerSessionContextFreshnessListener() {
|
|
|
24
24
|
})();
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
28
|
-
* SPEC-586: Fire-and-forget implementation-brief generation + impact detector
|
|
29
|
-
* on spec:status:approved. Errors are silently logged and never block the parent tool.
|
|
30
|
-
*/
|
|
31
|
-
function registerImplementationReadyListener() {
|
|
32
|
-
onAutopilotEvent('spec:status:approved', (event) => {
|
|
33
|
-
if (!event.specId) {
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
const { specId, projectPath, projectId } = event;
|
|
37
|
-
void (async () => {
|
|
38
|
-
try {
|
|
39
|
-
const { glob } = await import('glob');
|
|
40
|
-
const [specFiles, techFiles] = await Promise.all([
|
|
41
|
-
glob(`planu/specs/${specId}-*/spec.md`, { cwd: projectPath, absolute: true }),
|
|
42
|
-
glob(`planu/specs/${specId}-*/technical.md`, { cwd: projectPath, absolute: true }),
|
|
43
|
-
]);
|
|
44
|
-
const specPath = specFiles[0];
|
|
45
|
-
const technicalPath = techFiles[0];
|
|
46
|
-
if (!specPath) {
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
const { readFile } = await import('node:fs/promises');
|
|
50
|
-
const specBody = await readFile(specPath, 'utf-8').catch(() => '');
|
|
51
|
-
const tagsMatch = /^tags:\s*\[([^\]]*)\]/m.exec(specBody);
|
|
52
|
-
const targetMatch = /^target:\s*(.+)$/m.exec(specBody);
|
|
53
|
-
const scopeMatch = /^scope:\s*(.+)$/m.exec(specBody);
|
|
54
|
-
const tags = tagsMatch?.[1] !== undefined
|
|
55
|
-
? tagsMatch[1].split(',').map((t) => t.trim().replace(/^["']|["']$/g, ''))
|
|
56
|
-
: [];
|
|
57
|
-
const target = targetMatch?.[1]?.trim() ?? 'backend';
|
|
58
|
-
const scope = scopeMatch?.[1]?.trim() ?? 'cross-module';
|
|
59
|
-
void projectId;
|
|
60
|
-
const { generateImplementationBrief } = await import('../implementation-brief/generator.js');
|
|
61
|
-
generateImplementationBrief({
|
|
62
|
-
specId,
|
|
63
|
-
specPath,
|
|
64
|
-
projectPath,
|
|
65
|
-
description: specBody,
|
|
66
|
-
tags,
|
|
67
|
-
target,
|
|
68
|
-
scope,
|
|
69
|
-
}).catch((err) => {
|
|
70
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
71
|
-
console.error(`[autopilot] implementation-brief failed for ${specId}: ${msg}`);
|
|
72
|
-
});
|
|
73
|
-
if (technicalPath) {
|
|
74
|
-
const { runImpactDetector } = await import('../impact-detector/index.js');
|
|
75
|
-
runImpactDetector({
|
|
76
|
-
specId,
|
|
77
|
-
specPath,
|
|
78
|
-
technicalPath,
|
|
79
|
-
projectPath,
|
|
80
|
-
}).catch((err) => {
|
|
81
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
82
|
-
console.error(`[autopilot] impact-detector failed for ${specId}: ${msg}`);
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
/* best-effort — never surface to user */
|
|
88
|
-
}
|
|
89
|
-
})();
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* SPEC-627: Fire-and-forget risk-register generation on spec:status:approved.
|
|
94
|
-
* Writes planu/specs/SPEC-XXX/risk-register.md from spec metadata.
|
|
95
|
-
*/
|
|
96
|
-
function registerRiskAnalyzerListener() {
|
|
97
|
-
onAutopilotEvent('spec:status:approved', (event) => {
|
|
98
|
-
if (!event.specId) {
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
const { specId, projectPath } = event;
|
|
102
|
-
void (async () => {
|
|
103
|
-
try {
|
|
104
|
-
const { glob } = await import('glob');
|
|
105
|
-
const specFiles = await glob(`planu/specs/${specId}-*/spec.md`, { cwd: projectPath, absolute: true });
|
|
106
|
-
const specPath = specFiles[0];
|
|
107
|
-
if (!specPath) {
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
const { readFile, writeFile } = await import('node:fs/promises');
|
|
111
|
-
const specBody = await readFile(specPath, 'utf-8');
|
|
112
|
-
const typeMatch = /^type:\s*(.+)$/m.exec(specBody);
|
|
113
|
-
const scopeMatch = /^scope:\s*(.+)$/m.exec(specBody);
|
|
114
|
-
const difficultyMatch = /^difficulty:\s*(\d+)/m.exec(specBody);
|
|
115
|
-
const riskMatch = /^risk:\s*(.+)$/m.exec(specBody);
|
|
116
|
-
const tagsMatch = /^tags:\s*\[([^\]]*)\]/m.exec(specBody);
|
|
117
|
-
const tags = tagsMatch?.[1] !== undefined
|
|
118
|
-
? tagsMatch[1].split(',').map((t) => t.trim().replace(/^["']|["']$/g, ''))
|
|
119
|
-
: [];
|
|
120
|
-
const { generateRiskRegister } = await import('../risk-analyzer/risk-generator.js');
|
|
121
|
-
const rawDifficulty = parseInt(difficultyMatch?.[1] ?? '2', 10);
|
|
122
|
-
const difficulty = (rawDifficulty >= 1 && rawDifficulty <= 5 ? rawDifficulty : 2);
|
|
123
|
-
const rawRisk = riskMatch?.[1]?.trim() ?? 'low';
|
|
124
|
-
const riskLevel = (['low', 'medium', 'high', 'critical'].includes(rawRisk) ? rawRisk : 'low');
|
|
125
|
-
const ctx = {
|
|
126
|
-
specId,
|
|
127
|
-
title: specId,
|
|
128
|
-
type: (typeMatch?.[1]?.trim() ?? 'feature'),
|
|
129
|
-
scope: (scopeMatch?.[1]?.trim() ?? 'feature'),
|
|
130
|
-
difficulty,
|
|
131
|
-
risk: riskLevel,
|
|
132
|
-
tags,
|
|
133
|
-
language: 'TypeScript',
|
|
134
|
-
framework: null,
|
|
135
|
-
dependencyCount: 0,
|
|
136
|
-
fileCount: 0,
|
|
137
|
-
};
|
|
138
|
-
const register = generateRiskRegister(ctx);
|
|
139
|
-
const lines = [
|
|
140
|
-
`# Risk Register — ${specId}`,
|
|
141
|
-
`_Generated ${new Date().toISOString()}_`,
|
|
142
|
-
'',
|
|
143
|
-
`**Aggregate score:** ${register.aggregateRiskScore}/100`,
|
|
144
|
-
'',
|
|
145
|
-
'## Risks',
|
|
146
|
-
];
|
|
147
|
-
for (const r of register.risks) {
|
|
148
|
-
lines.push(`- **${r.category}** (${r.probability}% probability, ${r.impactDays}d impact): ${r.description}`);
|
|
149
|
-
lines.push(` Mitigation: ${r.mitigationPlan}`);
|
|
150
|
-
}
|
|
151
|
-
const { dirname, join } = await import('node:path');
|
|
152
|
-
const registerPath = join(dirname(specPath), 'risk-register.md');
|
|
153
|
-
await writeFile(registerPath, lines.join('\n'), 'utf-8');
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
/* best-effort — never surface to user */
|
|
157
|
-
}
|
|
158
|
-
})();
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
27
|
/**
|
|
162
28
|
* SPEC-600: Fire-and-forget cascade on approved→implementing.
|
|
163
29
|
* Auto-recommends model + generates orchestration plan for complex specs.
|
|
@@ -260,10 +126,6 @@ export function bootstrapAutopilotHandlers() {
|
|
|
260
126
|
}
|
|
261
127
|
// SPEC-585: Register session-context freshness listener
|
|
262
128
|
registerSessionContextFreshnessListener();
|
|
263
|
-
// SPEC-586: Register implementation-brief + impact-detector on spec:status:approved
|
|
264
|
-
registerImplementationReadyListener();
|
|
265
|
-
// SPEC-627: Register risk-register generator on spec:status:approved
|
|
266
|
-
registerRiskAnalyzerListener();
|
|
267
129
|
// SPEC-600: Register cascade listener for implementing transition
|
|
268
130
|
// SPEC-649: registerDoneCascadeListener removed — optimize_context no longer auto-runs on done
|
|
269
131
|
registerImplementingCascadeListener();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { EphemeralArtifactsResult, CleanEphemeralArtifactsInput } from '../../types/housekeeping.js';
|
|
2
2
|
/**
|
|
3
|
-
* Scan all planu/specs/SPEC-* directories
|
|
4
|
-
*
|
|
3
|
+
* Scan all planu/specs/SPEC-* directories and delete (or report) obsolete
|
|
4
|
+
* sidecar artifact files.
|
|
5
5
|
*/
|
|
6
6
|
export declare function cleanEphemeralArtifacts(input: CleanEphemeralArtifactsInput): Promise<EphemeralArtifactsResult>;
|
|
7
7
|
//# sourceMappingURL=ephemeral-artifacts-cleaner.d.ts.map
|
|
@@ -1,25 +1,16 @@
|
|
|
1
1
|
// engine/housekeeping/ephemeral-artifacts-cleaner.ts — SPEC-762
|
|
2
|
-
// Removes
|
|
3
|
-
// prompt.md)
|
|
4
|
-
|
|
2
|
+
// Removes obsolete per-spec sidecar planning artifacts (risk-register.md,
|
|
3
|
+
// implementation-brief.md, prompt.md). Current Planu specs keep durable content
|
|
4
|
+
// in spec.md; these files are no longer generated or preserved.
|
|
5
|
+
import { access, readdir, unlink } from 'node:fs/promises';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// Constants
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
10
|
const EPHEMERAL_FILENAMES = ['risk-register.md', 'implementation-brief.md', 'prompt.md'];
|
|
10
|
-
const TERMINAL_STATUSES = new Set(['done', 'discarded']);
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
12
|
// Helpers
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
|
-
/** Extract the `status:` field from a spec.md YAML frontmatter block. */
|
|
15
|
-
function extractStatus(raw) {
|
|
16
|
-
const frontmatterMatch = /^---\r?\n([\s\S]*?)\r?\n---/m.exec(raw);
|
|
17
|
-
if (!frontmatterMatch?.[1]) {
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
const statusMatch = /^status:\s*(\S+)/m.exec(frontmatterMatch[1]);
|
|
21
|
-
return statusMatch?.[1] ?? null;
|
|
22
|
-
}
|
|
23
14
|
/** Return true when the directory name looks like a Planu spec folder. */
|
|
24
15
|
function isSpecDir(name) {
|
|
25
16
|
return /^SPEC-\d+/.test(name);
|
|
@@ -28,8 +19,8 @@ function isSpecDir(name) {
|
|
|
28
19
|
// Public API
|
|
29
20
|
// ---------------------------------------------------------------------------
|
|
30
21
|
/**
|
|
31
|
-
* Scan all planu/specs/SPEC-* directories
|
|
32
|
-
*
|
|
22
|
+
* Scan all planu/specs/SPEC-* directories and delete (or report) obsolete
|
|
23
|
+
* sidecar artifact files.
|
|
33
24
|
*/
|
|
34
25
|
export async function cleanEphemeralArtifacts(input) {
|
|
35
26
|
const specsRoot = join(input.projectPath, 'planu', 'specs');
|
|
@@ -50,22 +41,10 @@ export async function cleanEphemeralArtifacts(input) {
|
|
|
50
41
|
return result; // planu/specs/ doesn't exist — nothing to clean
|
|
51
42
|
}
|
|
52
43
|
for (const specDir of specDirs) {
|
|
53
|
-
const specMdPath = join(specDir, 'spec.md');
|
|
54
|
-
let status;
|
|
55
|
-
try {
|
|
56
|
-
const raw = await readFile(specMdPath, 'utf-8');
|
|
57
|
-
status = extractStatus(raw);
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
continue; // no spec.md — skip this directory
|
|
61
|
-
}
|
|
62
|
-
if (status === null || !TERMINAL_STATUSES.has(status)) {
|
|
63
|
-
continue; // spec is still active
|
|
64
|
-
}
|
|
65
44
|
for (const filename of EPHEMERAL_FILENAMES) {
|
|
66
45
|
const filePath = join(specDir, filename);
|
|
67
46
|
try {
|
|
68
|
-
await
|
|
47
|
+
await access(filePath); // probe existence
|
|
69
48
|
}
|
|
70
49
|
catch {
|
|
71
50
|
continue; // file doesn't exist — skip
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { LivingSpecReconcileResult } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Auto-reconcile a spec against its actual implementation files.
|
|
4
|
-
* Reads
|
|
5
|
-
*
|
|
4
|
+
* Reads spec.md for file paths and criteria, scans files, matches criteria,
|
|
5
|
+
* and writes the auto-reconcile summary into the inline ## Progress section.
|
|
6
6
|
* Never throws -- returns partial result on error.
|
|
7
7
|
*/
|
|
8
8
|
export declare function reconcileSpec(specId: string, projectPath: string): Promise<LivingSpecReconcileResult>;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// engine/living-specs/index.ts -- Main auto-reconcile entry point (SPEC-330)
|
|
2
|
-
import { readFile,
|
|
2
|
+
import { readFile, access } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { glob } from 'glob';
|
|
5
5
|
import { scanFile, extractFilePaths } from './file-scanner.js';
|
|
6
6
|
import { extractCriteriaLines, matchCriteria } from './criteria-matcher.js';
|
|
7
|
+
import { replaceSectionInSpec } from '../spec-format/replace-section.js';
|
|
7
8
|
/**
|
|
8
9
|
* Find the spec directory for a given specId within the project.
|
|
9
10
|
* Looks for planu/specs/{specId}-SLUG pattern via glob.
|
|
@@ -32,24 +33,12 @@ async function readSafe(filePath) {
|
|
|
32
33
|
function buildDriftFlags(fileResults) {
|
|
33
34
|
return fileResults.filter((f) => !f.exists).map((f) => 'DRIFT: ' + f.path + ' missing');
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
-
* Append or update the Auto-Reconcile section in progress.md.
|
|
37
|
-
* SPEC-461: Only updates if the file already exists (lean specs don't have progress.md).
|
|
38
|
-
*/
|
|
39
|
-
async function updateProgressMd(progressPath, result) {
|
|
40
|
-
// SPEC-461: Don't create progress.md for lean specs — only update if it already exists
|
|
41
|
-
try {
|
|
42
|
-
await access(progressPath);
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
return; // File doesn't exist — lean format spec, skip silently
|
|
46
|
-
}
|
|
47
|
-
const existing = await readSafe(progressPath);
|
|
36
|
+
function buildAutoReconcileProgressBody(result) {
|
|
48
37
|
const coveragePct = result.criteriaTotal > 0 ? Math.round((result.criteriaMet / result.criteriaTotal) * 100) : 0;
|
|
49
38
|
const driftSection = result.driftFlags.length > 0
|
|
50
39
|
? '\n### Drift Flags\n\n' + result.driftFlags.map((f) => '- ' + f).join('\n') + '\n'
|
|
51
40
|
: '';
|
|
52
|
-
|
|
41
|
+
return ('### Auto-Reconcile\n\n' +
|
|
53
42
|
'> Last reconciled: ' +
|
|
54
43
|
result.reconciledAt +
|
|
55
44
|
'\n\n' +
|
|
@@ -67,21 +56,12 @@ async function updateProgressMd(progressPath, result) {
|
|
|
67
56
|
String(coveragePct) +
|
|
68
57
|
'%)' +
|
|
69
58
|
driftSection +
|
|
70
|
-
'\n';
|
|
71
|
-
const AUTO_RECONCILE_MARKER = '## Auto-Reconcile';
|
|
72
|
-
let newContent;
|
|
73
|
-
if (existing.includes(AUTO_RECONCILE_MARKER)) {
|
|
74
|
-
newContent = existing.replace(/\n## Auto-Reconcile[\s\S]*?(?=\n## |\n$|$)/, section);
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
newContent = existing + section;
|
|
78
|
-
}
|
|
79
|
-
await writeFile(progressPath, newContent, 'utf-8');
|
|
59
|
+
'\n');
|
|
80
60
|
}
|
|
81
61
|
/**
|
|
82
62
|
* Auto-reconcile a spec against its actual implementation files.
|
|
83
|
-
* Reads
|
|
84
|
-
*
|
|
63
|
+
* Reads spec.md for file paths and criteria, scans files, matches criteria,
|
|
64
|
+
* and writes the auto-reconcile summary into the inline ## Progress section.
|
|
85
65
|
* Never throws -- returns partial result on error.
|
|
86
66
|
*/
|
|
87
67
|
export async function reconcileSpec(specId, projectPath) {
|
|
@@ -103,14 +83,9 @@ export async function reconcileSpec(specId, projectPath) {
|
|
|
103
83
|
empty.driftFlags.push('DRIFT: spec directory for ' + specId + ' not found');
|
|
104
84
|
return empty;
|
|
105
85
|
}
|
|
106
|
-
const technicalPath = join(specDir, 'technical.md');
|
|
107
86
|
const specMdPath = join(specDir, 'spec.md');
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
readSafe(technicalPath),
|
|
111
|
-
readSafe(specMdPath),
|
|
112
|
-
]);
|
|
113
|
-
const relativePaths = extractFilePaths(technicalContent);
|
|
87
|
+
const specMdContent = await readSafe(specMdPath);
|
|
88
|
+
const relativePaths = extractFilePaths(specMdContent);
|
|
114
89
|
const absolutePaths = relativePaths.map((p) => join(projectPath, p));
|
|
115
90
|
const fileResults = await Promise.all(absolutePaths.map((p) => scanFile(p)));
|
|
116
91
|
const fileResultsWithRelative = fileResults.map((fr, i) => ({
|
|
@@ -134,7 +109,7 @@ export async function reconcileSpec(specId, projectPath) {
|
|
|
134
109
|
fileResults: fileResultsWithRelative,
|
|
135
110
|
criteriaResults,
|
|
136
111
|
};
|
|
137
|
-
await
|
|
112
|
+
await replaceSectionInSpec(specMdPath, 'Progress', buildAutoReconcileProgressBody(result));
|
|
138
113
|
return result;
|
|
139
114
|
}
|
|
140
115
|
catch (err) {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { Spec } from '../types/index.js';
|
|
2
2
|
/**
|
|
3
|
-
* Generate initial
|
|
3
|
+
* Generate initial ## Progress content for a newly created spec.
|
|
4
4
|
*/
|
|
5
5
|
export declare function generateProgressContent(spec: Spec): string;
|
|
6
6
|
/**
|
|
7
|
-
* Generate
|
|
7
|
+
* Generate updated ## Progress content reflecting a status change.
|
|
8
8
|
*/
|
|
9
9
|
export declare function generateProgressUpdate(spec: Spec, previousStatus: string, existingContent: string): string;
|
|
10
10
|
//# sourceMappingURL=progress-writer.d.ts.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generate initial
|
|
2
|
+
* Generate initial ## Progress content for a newly created spec.
|
|
3
3
|
*/
|
|
4
4
|
export function generateProgressContent(spec) {
|
|
5
5
|
const sections = [
|
|
@@ -10,7 +10,7 @@ export function generateProgressContent(spec) {
|
|
|
10
10
|
return sections.map((s) => `## ${s.title}\n\n${s.content}`).join('\n\n---\n\n');
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* Generate
|
|
13
|
+
* Generate updated ## Progress content reflecting a status change.
|
|
14
14
|
*/
|
|
15
15
|
export function generateProgressUpdate(spec, previousStatus, existingContent) {
|
|
16
16
|
const timestamp = new Date().toISOString();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LeanFileEntry, LeanTechnicalInput } from '../../types/index.js';
|
|
2
2
|
export type { LeanFileEntry, LeanTechnicalInput };
|
|
3
|
-
/** Generate lean
|
|
3
|
+
/** Generate lean ## Technical content: YAML-like metadata + files section only. */
|
|
4
4
|
export declare function generateLeanTechnicalContent(input: LeanTechnicalInput): string;
|
|
5
5
|
//# sourceMappingURL=lean-technical-generator.d.ts.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// engine/spec-format/lean-technical-generator.ts — Generates lean
|
|
1
|
+
// engine/spec-format/lean-technical-generator.ts — Generates lean ## Technical content (SPEC-461)
|
|
2
2
|
// Output: ~15-20 lines. Only files section with create/modify/test and pending/done status.
|
|
3
|
-
/** Generate lean
|
|
3
|
+
/** Generate lean ## Technical content: YAML-like metadata + files section only. */
|
|
4
4
|
export function generateLeanTechnicalContent(input) {
|
|
5
5
|
const { specId, filesToCreate = [], filesToModify = [], filesToTest = [] } = input;
|
|
6
6
|
const lines = ['---', `spec: ${specId}`, '---', '', '## Files', ''];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LeanFileEntry } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
|
-
* Attempt to extract file paths from spec body for auto-populating
|
|
3
|
+
* Attempt to extract file paths from spec body for auto-populating ## Technical.
|
|
4
4
|
* Returns null when no structured file section is found (caller uses placeholder).
|
|
5
5
|
*/
|
|
6
6
|
export declare function extractFilesFromSpecBody(specBody: string, projectPath: string): Promise<{
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// engine/spec-format/technical-md-populator.ts — SPEC-586: Parse spec body to populate
|
|
1
|
+
// engine/spec-format/technical-md-populator.ts — SPEC-586: Parse spec body to populate ## Technical
|
|
2
2
|
import { stat } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
const FILE_PATH_RE = /\b(src|tests)\/[\w/-]+\.[a-z]+\b/g;
|
|
@@ -52,7 +52,7 @@ async function categorizeByExistence(paths, projectPath) {
|
|
|
52
52
|
return { create, modify, test: [] };
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
|
-
* Attempt to extract file paths from spec body for auto-populating
|
|
55
|
+
* Attempt to extract file paths from spec body for auto-populating ## Technical.
|
|
56
56
|
* Returns null when no structured file section is found (caller uses placeholder).
|
|
57
57
|
*/
|
|
58
58
|
export async function extractFilesFromSpecBody(specBody, projectPath) {
|
|
@@ -11,6 +11,7 @@ import { handleUpdateStatus } from './update-status/index.js';
|
|
|
11
11
|
import { parseFrontmatter } from '../engine/frontmatter-parser.js';
|
|
12
12
|
import { verifyTerminalFrontmatter } from '../engine/frontmatter-sha/index.js';
|
|
13
13
|
import { appendTransitionEvent } from '../storage/transition-log.js';
|
|
14
|
+
import { cleanEphemeralArtifacts } from '../engine/housekeeping/index.js';
|
|
14
15
|
import { readFile } from 'node:fs/promises';
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
16
17
|
// Sync helpers (exported for startup use)
|
|
@@ -155,6 +156,12 @@ export async function startupSync() {
|
|
|
155
156
|
catch (err) {
|
|
156
157
|
console.error(`[Planu] startupSync: failed to sync project ${project.path}:`, err);
|
|
157
158
|
}
|
|
159
|
+
try {
|
|
160
|
+
await cleanEphemeralArtifacts({ projectPath: project.path, dryRun: false });
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
console.error(`[Planu] startupSync: failed to clean spec artifacts ${project.path}:`, err);
|
|
164
|
+
}
|
|
158
165
|
}));
|
|
159
166
|
}
|
|
160
167
|
// ---------------------------------------------------------------------------
|
|
@@ -788,7 +788,7 @@ export async function handleUpdateStatus(params, server) {
|
|
|
788
788
|
}
|
|
789
789
|
// SPEC-694: Auto-orchestration plan for cross-module/architectural specs on approved
|
|
790
790
|
const orchestrationPlan = await resolveOrchestrationPlan(newStatus, spec.scope, specId, effectiveGatePath);
|
|
791
|
-
// Sync spec.md frontmatter and
|
|
791
|
+
// Sync spec.md frontmatter and inline ## Progress section.
|
|
792
792
|
// SPEC-698: capture warning so it surfaces in the tool response (was silent before)
|
|
793
793
|
const syncResult = await syncSpecFiles(updatedSpec, originalStatus, newStatus, effectiveGatePath);
|
|
794
794
|
const frontmatterSyncWarnings = syncResult.warning ? [syncResult.warning] : [];
|
|
@@ -13,6 +13,12 @@ export interface TestBreakHit {
|
|
|
13
13
|
matchedPattern: 'tool-count' | 'snapshot' | 'license-plans-sync' | 'streams-anchor';
|
|
14
14
|
linePreview: string;
|
|
15
15
|
}
|
|
16
|
+
export type AnticipatedBreakPattern = 'tool-count' | 'snapshot' | 'license-plans-sync' | 'streams-anchor' | 'other';
|
|
17
|
+
export interface AnticipatedBreak {
|
|
18
|
+
testPath: string;
|
|
19
|
+
pattern: AnticipatedBreakPattern;
|
|
20
|
+
remediation: string;
|
|
21
|
+
}
|
|
16
22
|
export interface ImpactDetectorInput {
|
|
17
23
|
specId: string;
|
|
18
24
|
specPath: string;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -235,7 +235,6 @@ export * from './storage.js';
|
|
|
235
235
|
export * from './observatory.js';
|
|
236
236
|
export * from './orphan-spec-refs.js';
|
|
237
237
|
export * from './session-safeguard.js';
|
|
238
|
-
export * from './implementation-brief.js';
|
|
239
238
|
export * from './impact-detection.js';
|
|
240
239
|
export * from './criteria-injection.js';
|
|
241
240
|
export * from './gemini.js';
|
package/dist/types/index.js
CHANGED
|
@@ -232,7 +232,6 @@ export * from './storage.js';
|
|
|
232
232
|
export * from './observatory.js';
|
|
233
233
|
export * from './orphan-spec-refs.js';
|
|
234
234
|
export * from './session-safeguard.js';
|
|
235
|
-
export * from './implementation-brief.js';
|
|
236
235
|
export * from './impact-detection.js';
|
|
237
236
|
export * from './criteria-injection.js';
|
|
238
237
|
export * from './gemini.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planu/cli",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.14",
|
|
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,12 +32,12 @@
|
|
|
32
32
|
"packageName": "@planu/core"
|
|
33
33
|
},
|
|
34
34
|
"optionalDependencies": {
|
|
35
|
-
"@planu/core-darwin-arm64": "3.9.
|
|
36
|
-
"@planu/core-darwin-x64": "3.9.
|
|
37
|
-
"@planu/core-linux-arm64-gnu": "3.9.
|
|
38
|
-
"@planu/core-linux-arm64-musl": "3.9.
|
|
39
|
-
"@planu/core-linux-x64-gnu": "3.9.
|
|
40
|
-
"@planu/core-linux-x64-musl": "3.9.
|
|
35
|
+
"@planu/core-darwin-arm64": "3.9.14",
|
|
36
|
+
"@planu/core-darwin-x64": "3.9.14",
|
|
37
|
+
"@planu/core-linux-arm64-gnu": "3.9.14",
|
|
38
|
+
"@planu/core-linux-arm64-musl": "3.9.14",
|
|
39
|
+
"@planu/core-linux-x64-gnu": "3.9.14",
|
|
40
|
+
"@planu/core-linux-x64-musl": "3.9.14"
|
|
41
41
|
},
|
|
42
42
|
"engines": {
|
|
43
43
|
"node": ">=24.0.0"
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
import type { ConventionExcerpt } from '../../types/index.js';
|
|
2
|
-
export declare function extractConventions(projectPath: string, tags: string[], target: string, scope: string): Promise<{
|
|
3
|
-
excerpts: ConventionExcerpt[];
|
|
4
|
-
}>;
|
|
5
|
-
//# sourceMappingURL=convention-extractor.d.ts.map
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
// engine/implementation-brief/convention-extractor.ts — SPEC-586: Filter .claude/rules/ by tags
|
|
2
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
const MAX_EXCERPT_CHARS = 400;
|
|
5
|
-
const MAX_RULES = 3;
|
|
6
|
-
const TAG_TO_RULE = {
|
|
7
|
-
backend: ['typescript-eslint.md', 'architecture.md'],
|
|
8
|
-
frontend: ['typescript-eslint.md'],
|
|
9
|
-
testing: ['testing.md'],
|
|
10
|
-
test: ['testing.md'],
|
|
11
|
-
git: ['git-workflow.md'],
|
|
12
|
-
autopilot: ['autopilot-first.md'],
|
|
13
|
-
spec: ['sdd-methodology.md'],
|
|
14
|
-
parallel: ['parallel-sessions.md'],
|
|
15
|
-
};
|
|
16
|
-
function rulesForTags(tags, target, scope) {
|
|
17
|
-
const selected = new Set();
|
|
18
|
-
const combined = [...tags, target, scope].map((t) => t.toLowerCase());
|
|
19
|
-
for (const term of combined) {
|
|
20
|
-
for (const [key, rules] of Object.entries(TAG_TO_RULE)) {
|
|
21
|
-
if (term.includes(key)) {
|
|
22
|
-
for (const r of rules) {
|
|
23
|
-
selected.add(r);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
if (combined.some((t) => t === 'backend' || t === 'fullstack')) {
|
|
29
|
-
selected.add('architecture.md');
|
|
30
|
-
}
|
|
31
|
-
return [...selected].slice(0, MAX_RULES);
|
|
32
|
-
}
|
|
33
|
-
async function readExcerpt(rulesDir, ruleFile) {
|
|
34
|
-
try {
|
|
35
|
-
const content = await readFile(join(rulesDir, ruleFile), 'utf-8');
|
|
36
|
-
const lines = content.split('\n');
|
|
37
|
-
let excerpt = '';
|
|
38
|
-
for (const line of lines) {
|
|
39
|
-
if (excerpt.length + line.length > MAX_EXCERPT_CHARS) {
|
|
40
|
-
break;
|
|
41
|
-
}
|
|
42
|
-
excerpt += line + '\n';
|
|
43
|
-
}
|
|
44
|
-
return excerpt.trim();
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return '';
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
async function listRuleFiles(rulesDir) {
|
|
51
|
-
try {
|
|
52
|
-
const entries = await readdir(rulesDir);
|
|
53
|
-
return entries.filter((e) => e.endsWith('.md'));
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
return [];
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
export async function extractConventions(projectPath, tags, target, scope) {
|
|
60
|
-
const rulesDir = join(projectPath, '.claude', 'rules');
|
|
61
|
-
const available = await listRuleFiles(rulesDir);
|
|
62
|
-
if (available.length === 0) {
|
|
63
|
-
return { excerpts: [] };
|
|
64
|
-
}
|
|
65
|
-
const wanted = rulesForTags(tags, target, scope).filter((r) => available.includes(r));
|
|
66
|
-
const excerpts = [];
|
|
67
|
-
for (const ruleFile of wanted) {
|
|
68
|
-
const excerpt = await readExcerpt(rulesDir, ruleFile);
|
|
69
|
-
if (excerpt) {
|
|
70
|
-
excerpts.push({ rule: ruleFile, excerpt });
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return { excerpts };
|
|
74
|
-
}
|
|
75
|
-
//# sourceMappingURL=convention-extractor.js.map
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
// engine/implementation-brief/extension-points.ts — SPEC-586: Enumerate plugin registry paths
|
|
2
|
-
import { stat } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
const REGISTRY_PATHS = [
|
|
5
|
-
'src/tools/create-spec/adapters',
|
|
6
|
-
'src/engine/detectors',
|
|
7
|
-
'src/tools/generate-tests/generators',
|
|
8
|
-
'src/config',
|
|
9
|
-
'src/engine/autopilot',
|
|
10
|
-
'src/engine/implementation-brief',
|
|
11
|
-
'src/engine/impact-detector',
|
|
12
|
-
];
|
|
13
|
-
async function exists(p) {
|
|
14
|
-
try {
|
|
15
|
-
await stat(p);
|
|
16
|
-
return true;
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
export async function enumerateExtensionPoints(projectPath) {
|
|
23
|
-
const found = [];
|
|
24
|
-
for (const rel of REGISTRY_PATHS) {
|
|
25
|
-
const full = join(projectPath, rel);
|
|
26
|
-
if (await exists(full)) {
|
|
27
|
-
found.push(rel);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return found;
|
|
31
|
-
}
|
|
32
|
-
//# sourceMappingURL=extension-points.js.map
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
// engine/implementation-brief/generator.ts — SPEC-586: Orchestrate implementation brief generation
|
|
2
|
-
import { writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
-
import { join, dirname } from 'node:path';
|
|
4
|
-
import { scanReusableHelpers } from './helpers-scanner.js';
|
|
5
|
-
import { findTestPatterns } from './test-pattern-matcher.js';
|
|
6
|
-
import { extractConventions } from './convention-extractor.js';
|
|
7
|
-
import { enumerateExtensionPoints } from './extension-points.js';
|
|
8
|
-
import { predictTestBreaks } from '../impact-detector/test-break-predictor.js';
|
|
9
|
-
const MAX_HELPERS = 10;
|
|
10
|
-
function renderBrief(brief) {
|
|
11
|
-
const lines = [];
|
|
12
|
-
lines.push(`# Implementation Brief — ${brief.specId}`);
|
|
13
|
-
lines.push(`_Generated ${brief.generatedAt}_`);
|
|
14
|
-
lines.push('');
|
|
15
|
-
lines.push('## Reusable helpers');
|
|
16
|
-
if (brief.reusableHelpers.length === 0) {
|
|
17
|
-
lines.push('_None found._');
|
|
18
|
-
}
|
|
19
|
-
else {
|
|
20
|
-
for (const h of brief.reusableHelpers) {
|
|
21
|
-
lines.push(`- **${h.symbol}** — \`${h.path}:${h.line}\` — ${h.purpose}`);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
lines.push('');
|
|
25
|
-
lines.push('## Test patterns');
|
|
26
|
-
if (brief.testPatterns.length === 0) {
|
|
27
|
-
lines.push('_No similar tests found._');
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
for (const t of brief.testPatterns) {
|
|
31
|
-
lines.push(`- \`${t.testPath}\` — ${t.whyRelevant}`);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
lines.push('');
|
|
35
|
-
lines.push('## Applicable conventions');
|
|
36
|
-
if (brief.conventions.excerpts.length === 0) {
|
|
37
|
-
lines.push('_No matching rules found._');
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
for (const e of brief.conventions.excerpts) {
|
|
41
|
-
lines.push(`### ${e.rule}`);
|
|
42
|
-
lines.push('');
|
|
43
|
-
lines.push(e.excerpt);
|
|
44
|
-
lines.push('');
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
lines.push('## Existing extension points');
|
|
48
|
-
if (brief.extensionPoints.length === 0) {
|
|
49
|
-
lines.push('_None detected._');
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
for (const ep of brief.extensionPoints) {
|
|
53
|
-
lines.push(`- \`${ep}\``);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
lines.push('');
|
|
57
|
-
lines.push('## Anticipated test breaks');
|
|
58
|
-
if (brief.anticipatedBreaks.length === 0) {
|
|
59
|
-
lines.push('_No anticipated breaks detected._');
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
for (const b of brief.anticipatedBreaks) {
|
|
63
|
-
lines.push(`- \`${b.testPath}\` — pattern: **${b.pattern}** — ${b.remediation}`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
lines.push('');
|
|
67
|
-
return lines.join('\n');
|
|
68
|
-
}
|
|
69
|
-
function renderPrompt(specId, description, brief) {
|
|
70
|
-
const lines = [];
|
|
71
|
-
lines.push(`# Implementation prompt — ${specId}`);
|
|
72
|
-
lines.push('');
|
|
73
|
-
// What to build — first non-empty line of description (up to 120 chars)
|
|
74
|
-
const goal = description
|
|
75
|
-
.split('\n')
|
|
76
|
-
.find((l) => l.trim().length > 0)
|
|
77
|
-
?.slice(0, 120) ?? specId;
|
|
78
|
-
lines.push('## What to build');
|
|
79
|
-
lines.push(goal.trim());
|
|
80
|
-
lines.push('');
|
|
81
|
-
/* v8 ignore start */
|
|
82
|
-
// Files to CREATE — infer from helpers/extension points
|
|
83
|
-
if (brief.extensionPoints.length > 0) {
|
|
84
|
-
lines.push('## Files to CREATE (exact paths)');
|
|
85
|
-
for (const ep of brief.extensionPoints.slice(0, 5)) {
|
|
86
|
-
lines.push(`- ${ep}`);
|
|
87
|
-
}
|
|
88
|
-
lines.push('');
|
|
89
|
-
}
|
|
90
|
-
// Reusable helpers — key symbols to import (avoids discovery)
|
|
91
|
-
if (brief.reusableHelpers.length > 0) {
|
|
92
|
-
lines.push('## Reusable helpers (import, do not rewrite)');
|
|
93
|
-
for (const h of brief.reusableHelpers.slice(0, 5)) {
|
|
94
|
-
lines.push(`- \`${h.symbol}\` from \`${h.path}\` — ${h.purpose}`);
|
|
95
|
-
}
|
|
96
|
-
lines.push('');
|
|
97
|
-
}
|
|
98
|
-
// Anticipated test breaks
|
|
99
|
-
if (brief.anticipatedBreaks.length > 0) {
|
|
100
|
-
lines.push('## Tests that will break (exact fixes)');
|
|
101
|
-
for (const b of brief.anticipatedBreaks) {
|
|
102
|
-
lines.push(`- \`${b.testPath}\` — ${b.remediation}`);
|
|
103
|
-
}
|
|
104
|
-
lines.push('');
|
|
105
|
-
}
|
|
106
|
-
/* v8 ignore stop */
|
|
107
|
-
lines.push('## Verify with');
|
|
108
|
-
lines.push('```');
|
|
109
|
-
lines.push('pnpm typecheck && pnpm lint && pnpm test');
|
|
110
|
-
lines.push('```');
|
|
111
|
-
return lines.join('\n');
|
|
112
|
-
}
|
|
113
|
-
export async function generateImplementationBrief(input) {
|
|
114
|
-
const { specId, specPath, projectPath, description, tags, target, scope } = input;
|
|
115
|
-
const [helpers, testPatterns, conventions, extensionPoints, anticipatedBreaks] = await Promise.all([
|
|
116
|
-
scanReusableHelpers(projectPath, description, tags).catch(() => []),
|
|
117
|
-
findTestPatterns(projectPath, description, tags, scope).catch(() => []),
|
|
118
|
-
extractConventions(projectPath, tags, target, scope).catch(() => ({ excerpts: [] })),
|
|
119
|
-
enumerateExtensionPoints(projectPath).catch(() => []),
|
|
120
|
-
predictTestBreaks(projectPath).catch(() => []),
|
|
121
|
-
]);
|
|
122
|
-
const brief = {
|
|
123
|
-
specId,
|
|
124
|
-
generatedAt: new Date().toISOString(),
|
|
125
|
-
reusableHelpers: helpers.slice(0, MAX_HELPERS),
|
|
126
|
-
testPatterns,
|
|
127
|
-
conventions,
|
|
128
|
-
extensionPoints,
|
|
129
|
-
anticipatedBreaks,
|
|
130
|
-
};
|
|
131
|
-
const specDir = dirname(specPath);
|
|
132
|
-
await mkdir(specDir, { recursive: true });
|
|
133
|
-
// Write both implementation-brief.md and prompt.md (SPEC-629) in parallel
|
|
134
|
-
await Promise.all([
|
|
135
|
-
writeFile(join(specDir, 'implementation-brief.md'), renderBrief(brief), 'utf-8'),
|
|
136
|
-
writeFile(join(specDir, 'prompt.md'), renderPrompt(specId, description, brief), 'utf-8'),
|
|
137
|
-
]);
|
|
138
|
-
}
|
|
139
|
-
//# sourceMappingURL=generator.js.map
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
// engine/implementation-brief/helpers-scanner.ts — SPEC-586: Semantic search for reusable helpers
|
|
2
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
-
import { join, relative, extname } from 'node:path';
|
|
4
|
-
const MAX_FILES = 300;
|
|
5
|
-
const MAX_DEPTH = 4;
|
|
6
|
-
const TOP_N = 10;
|
|
7
|
-
const CODE_EXT = new Set(['.ts', '.js', '.py', '.go', '.rs', '.java', '.kt', '.rb', '.cs']);
|
|
8
|
-
function extractKeywords(text) {
|
|
9
|
-
return text
|
|
10
|
-
.toLowerCase()
|
|
11
|
-
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
12
|
-
.split(/\s+/)
|
|
13
|
-
.filter((w) => w.length > 3)
|
|
14
|
-
.filter((w) => !STOP_WORDS.has(w));
|
|
15
|
-
}
|
|
16
|
-
const STOP_WORDS = new Set([
|
|
17
|
-
'this',
|
|
18
|
-
'that',
|
|
19
|
-
'with',
|
|
20
|
-
'from',
|
|
21
|
-
'have',
|
|
22
|
-
'will',
|
|
23
|
-
'when',
|
|
24
|
-
'then',
|
|
25
|
-
'given',
|
|
26
|
-
'spec',
|
|
27
|
-
'planu',
|
|
28
|
-
'tool',
|
|
29
|
-
'file',
|
|
30
|
-
'path',
|
|
31
|
-
'type',
|
|
32
|
-
'into',
|
|
33
|
-
'also',
|
|
34
|
-
'each',
|
|
35
|
-
'only',
|
|
36
|
-
'over',
|
|
37
|
-
'such',
|
|
38
|
-
'more',
|
|
39
|
-
'some',
|
|
40
|
-
'after',
|
|
41
|
-
'must',
|
|
42
|
-
]);
|
|
43
|
-
function scoreText(text, keywords) {
|
|
44
|
-
const lower = text.toLowerCase();
|
|
45
|
-
let score = 0;
|
|
46
|
-
for (const kw of keywords) {
|
|
47
|
-
if (lower.includes(kw)) {
|
|
48
|
-
score += kw.length;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return score;
|
|
52
|
-
}
|
|
53
|
-
async function scanDir(dir, projectPath, keywords, results, depth, budget) {
|
|
54
|
-
if (depth > MAX_DEPTH || budget.remaining <= 0) {
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
let entries;
|
|
58
|
-
try {
|
|
59
|
-
entries = await readdir(dir);
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
for (const entry of entries) {
|
|
65
|
-
if (budget.remaining <= 0) {
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist') {
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
const full = join(dir, entry);
|
|
72
|
-
let s;
|
|
73
|
-
try {
|
|
74
|
-
s = await stat(full);
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
if (s.isDirectory()) {
|
|
80
|
-
await scanDir(full, projectPath, keywords, results, depth + 1, budget);
|
|
81
|
-
}
|
|
82
|
-
else if (CODE_EXT.has(extname(entry))) {
|
|
83
|
-
const b = budget;
|
|
84
|
-
b.remaining -= 1;
|
|
85
|
-
const rel = relative(projectPath, full);
|
|
86
|
-
const score = scoreText(rel, keywords);
|
|
87
|
-
if (score > 0) {
|
|
88
|
-
results.push({ path: rel, score });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
function parseFunctionSymbol(line) {
|
|
94
|
-
const match = /export\s+(?:async\s+)?function\s+(\w+)|export\s+const\s+(\w+)\s*=|export\s+(?:type|interface|class)\s+(\w+)/.exec(line);
|
|
95
|
-
if (!match) {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
return match[1] ?? match[2] ?? match[3] ?? null;
|
|
99
|
-
}
|
|
100
|
-
function extractPurpose(lines, exportLine) {
|
|
101
|
-
for (let i = exportLine - 1; i >= Math.max(0, exportLine - 3); i--) {
|
|
102
|
-
const line = lines[i]?.trim() ?? '';
|
|
103
|
-
if (line.startsWith('//') || line.startsWith('*') || line.startsWith('/**')) {
|
|
104
|
-
return line.replace(/^[/*\s]+/, '').slice(0, 80);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return 'helper function';
|
|
108
|
-
}
|
|
109
|
-
async function extractHelpers(filePath, keywords, projectPath) {
|
|
110
|
-
let content;
|
|
111
|
-
try {
|
|
112
|
-
content = await readFile(filePath, 'utf-8');
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
return [];
|
|
116
|
-
}
|
|
117
|
-
const lines = content.split('\n');
|
|
118
|
-
const helpers = [];
|
|
119
|
-
for (let i = 0; i < lines.length; i++) {
|
|
120
|
-
const line = lines[i] ?? '';
|
|
121
|
-
if (!line.includes('export')) {
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
const symbol = parseFunctionSymbol(line);
|
|
125
|
-
if (!symbol) {
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
const symbolScore = scoreText(symbol, keywords);
|
|
129
|
-
if (symbolScore === 0) {
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
helpers.push({
|
|
133
|
-
symbol,
|
|
134
|
-
path: relative(projectPath, filePath),
|
|
135
|
-
line: i + 1,
|
|
136
|
-
purpose: extractPurpose(lines, i),
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
return helpers;
|
|
140
|
-
}
|
|
141
|
-
export async function scanReusableHelpers(projectPath, description, tags) {
|
|
142
|
-
const srcDir = join(projectPath, 'src');
|
|
143
|
-
const keywords = extractKeywords(`${description} ${tags.join(' ')}`);
|
|
144
|
-
if (keywords.length === 0) {
|
|
145
|
-
return [];
|
|
146
|
-
}
|
|
147
|
-
const candidates = [];
|
|
148
|
-
const budget = { remaining: MAX_FILES };
|
|
149
|
-
await scanDir(srcDir, projectPath, keywords, candidates, 0, budget);
|
|
150
|
-
candidates.sort((a, b) => b.score - a.score);
|
|
151
|
-
const top = candidates.slice(0, 20);
|
|
152
|
-
const helpers = [];
|
|
153
|
-
for (const c of top) {
|
|
154
|
-
const full = join(projectPath, c.path);
|
|
155
|
-
const extracted = await extractHelpers(full, keywords, projectPath);
|
|
156
|
-
helpers.push(...extracted);
|
|
157
|
-
if (helpers.length >= TOP_N) {
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return helpers.slice(0, TOP_N);
|
|
162
|
-
}
|
|
163
|
-
//# sourceMappingURL=helpers-scanner.js.map
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
// engine/implementation-brief/test-pattern-matcher.ts — SPEC-586: Find similar test patterns
|
|
2
|
-
import { readdir, stat } from 'node:fs/promises';
|
|
3
|
-
import { join, relative } from 'node:path';
|
|
4
|
-
const MAX_TEST_FILES = 200;
|
|
5
|
-
const TOP_N = 3;
|
|
6
|
-
function extractKeywords(text) {
|
|
7
|
-
return text
|
|
8
|
-
.toLowerCase()
|
|
9
|
-
.replace(/[^a-z0-9\s]/g, ' ')
|
|
10
|
-
.split(/\s+/)
|
|
11
|
-
.filter((w) => w.length > 3);
|
|
12
|
-
}
|
|
13
|
-
function scoreMatch(filePath, keywords) {
|
|
14
|
-
const lower = filePath.toLowerCase();
|
|
15
|
-
let score = 0;
|
|
16
|
-
for (const kw of keywords) {
|
|
17
|
-
if (lower.includes(kw)) {
|
|
18
|
-
score += kw.length;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return score;
|
|
22
|
-
}
|
|
23
|
-
async function collectTestFiles(dir, results, budget, depth) {
|
|
24
|
-
if (budget.remaining <= 0 || depth > 5) {
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
let entries;
|
|
28
|
-
try {
|
|
29
|
-
entries = await readdir(dir);
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
for (const entry of entries) {
|
|
35
|
-
if (budget.remaining <= 0) {
|
|
36
|
-
break;
|
|
37
|
-
}
|
|
38
|
-
if (entry.startsWith('.') || entry === 'node_modules') {
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
const full = join(dir, entry);
|
|
42
|
-
let s;
|
|
43
|
-
try {
|
|
44
|
-
s = await stat(full);
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
if (s.isDirectory()) {
|
|
50
|
-
await collectTestFiles(full, results, budget, depth + 1);
|
|
51
|
-
}
|
|
52
|
-
else if (entry.endsWith('.test.ts') || entry.endsWith('.test.js')) {
|
|
53
|
-
const b = budget;
|
|
54
|
-
b.remaining -= 1;
|
|
55
|
-
results.push(full);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
function explainRelevance(filePath, keywords) {
|
|
60
|
-
const matched = [];
|
|
61
|
-
const lower = filePath.toLowerCase();
|
|
62
|
-
for (const kw of keywords) {
|
|
63
|
-
if (lower.includes(kw)) {
|
|
64
|
-
matched.push(kw);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return matched.length > 0
|
|
68
|
-
? `matches keywords: ${matched.slice(0, 3).join(', ')}`
|
|
69
|
-
: 'structural similarity';
|
|
70
|
-
}
|
|
71
|
-
export async function findTestPatterns(projectPath, description, tags, scope) {
|
|
72
|
-
const testsDir = join(projectPath, 'tests');
|
|
73
|
-
const keywords = extractKeywords(`${description} ${tags.join(' ')} ${scope}`);
|
|
74
|
-
if (keywords.length === 0) {
|
|
75
|
-
return [];
|
|
76
|
-
}
|
|
77
|
-
const testFiles = [];
|
|
78
|
-
const budget = { remaining: MAX_TEST_FILES };
|
|
79
|
-
await collectTestFiles(testsDir, testFiles, budget, 0);
|
|
80
|
-
const scored = testFiles
|
|
81
|
-
.map((f) => ({ path: f, score: scoreMatch(relative(projectPath, f), keywords) }))
|
|
82
|
-
.filter((x) => x.score > 0)
|
|
83
|
-
.sort((a, b) => b.score - a.score)
|
|
84
|
-
.slice(0, TOP_N);
|
|
85
|
-
return scored.map((s) => ({
|
|
86
|
-
testPath: relative(projectPath, s.path),
|
|
87
|
-
whyRelevant: explainRelevance(relative(projectPath, s.path), keywords),
|
|
88
|
-
}));
|
|
89
|
-
}
|
|
90
|
-
//# sourceMappingURL=test-pattern-matcher.js.map
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import type { RiskAnalysisContext, RiskRegister } from '../../types/index.js';
|
|
2
|
-
/**
|
|
3
|
-
* Generate an initial risk register from spec metadata.
|
|
4
|
-
*/
|
|
5
|
-
export declare function generateRiskRegister(ctx: RiskAnalysisContext, totalBudget?: number): RiskRegister;
|
|
6
|
-
//# sourceMappingURL=risk-generator.d.ts.map
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
function buildRiskTemplates(ctx) {
|
|
2
|
-
const templates = [];
|
|
3
|
-
if (ctx.difficulty >= 4) {
|
|
4
|
-
templates.push({
|
|
5
|
-
description: 'High complexity may lead to estimation overrun',
|
|
6
|
-
category: 'technical',
|
|
7
|
-
probability: 40,
|
|
8
|
-
impactDays: 3,
|
|
9
|
-
mitigationPlan: 'Break down into smaller tasks and reassess estimates',
|
|
10
|
-
});
|
|
11
|
-
}
|
|
12
|
-
if (ctx.scope === 'cross-module' || ctx.scope === 'architectural') {
|
|
13
|
-
templates.push({
|
|
14
|
-
description: 'Cross-module changes risk breaking existing integrations',
|
|
15
|
-
category: 'integration',
|
|
16
|
-
probability: 35,
|
|
17
|
-
impactDays: 2,
|
|
18
|
-
mitigationPlan: 'Add integration tests covering all affected modules before merging',
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
if (ctx.dependencyCount > 3) {
|
|
22
|
-
templates.push({
|
|
23
|
-
description: 'Multiple dependencies increase coordination overhead',
|
|
24
|
-
category: 'dependency',
|
|
25
|
-
probability: 30,
|
|
26
|
-
impactDays: 2,
|
|
27
|
-
mitigationPlan: 'Establish dependency contracts early and monitor for version conflicts',
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
if (ctx.risk === 'high' || ctx.risk === 'critical') {
|
|
31
|
-
templates.push({
|
|
32
|
-
description: 'Spec flagged as high-risk requires extra review',
|
|
33
|
-
category: 'organizational',
|
|
34
|
-
probability: 50,
|
|
35
|
-
impactDays: 3,
|
|
36
|
-
mitigationPlan: 'Schedule mandatory peer review and add approval gates before deployment',
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
if (ctx.tags.includes('security')) {
|
|
40
|
-
templates.push({
|
|
41
|
-
description: 'Security-sensitive changes require audit',
|
|
42
|
-
category: 'security',
|
|
43
|
-
probability: 25,
|
|
44
|
-
impactDays: 4,
|
|
45
|
-
mitigationPlan: 'Engage security team for review and run SAST/DAST tools',
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
if (ctx.fileCount > 10) {
|
|
49
|
-
templates.push({
|
|
50
|
-
description: 'Large number of files increases merge conflict risk',
|
|
51
|
-
category: 'technical',
|
|
52
|
-
probability: 30,
|
|
53
|
-
impactDays: 1,
|
|
54
|
-
mitigationPlan: 'Use feature flags and incremental PRs to reduce merge surface',
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
return templates;
|
|
58
|
-
}
|
|
59
|
-
function buildRisks(ctx, templates) {
|
|
60
|
-
return templates.map((t, index) => ({
|
|
61
|
-
id: `R${index + 1}`,
|
|
62
|
-
description: t.description,
|
|
63
|
-
category: t.category,
|
|
64
|
-
probability: t.probability,
|
|
65
|
-
impactUsd: 0,
|
|
66
|
-
impactDays: t.impactDays,
|
|
67
|
-
owner: '',
|
|
68
|
-
status: 'identified',
|
|
69
|
-
mitigationPlan: t.mitigationPlan,
|
|
70
|
-
specId: ctx.specId,
|
|
71
|
-
}));
|
|
72
|
-
}
|
|
73
|
-
function computeAggregateScore(risks, totalBudgetDays) {
|
|
74
|
-
const rawScore = risks.reduce((acc, r) => acc + (r.probability / 100) * r.impactDays, 0);
|
|
75
|
-
const denominator = Math.max(totalBudgetDays, 1);
|
|
76
|
-
const score = (rawScore / denominator) * 100;
|
|
77
|
-
return Math.min(score, 100);
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Generate an initial risk register from spec metadata.
|
|
81
|
-
*/
|
|
82
|
-
export function generateRiskRegister(ctx, totalBudget) {
|
|
83
|
-
const budgetDays = totalBudget ?? 30;
|
|
84
|
-
const templates = buildRiskTemplates(ctx);
|
|
85
|
-
const risks = buildRisks(ctx, templates);
|
|
86
|
-
const aggregateRiskScore = computeAggregateScore(risks, budgetDays);
|
|
87
|
-
return {
|
|
88
|
-
specId: ctx.specId,
|
|
89
|
-
risks,
|
|
90
|
-
aggregateRiskScore,
|
|
91
|
-
generatedAt: new Date().toISOString(),
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
//# sourceMappingURL=risk-generator.js.map
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
export interface ReusableHelper {
|
|
2
|
-
symbol: string;
|
|
3
|
-
path: string;
|
|
4
|
-
line: number;
|
|
5
|
-
purpose: string;
|
|
6
|
-
}
|
|
7
|
-
export interface TestPatternMatch {
|
|
8
|
-
testPath: string;
|
|
9
|
-
whyRelevant: string;
|
|
10
|
-
}
|
|
11
|
-
export interface ConventionExcerpt {
|
|
12
|
-
rule: string;
|
|
13
|
-
excerpt: string;
|
|
14
|
-
}
|
|
15
|
-
export type AnticipatedBreakPattern = 'tool-count' | 'snapshot' | 'license-plans-sync' | 'streams-anchor' | 'other';
|
|
16
|
-
export interface AnticipatedBreak {
|
|
17
|
-
testPath: string;
|
|
18
|
-
pattern: AnticipatedBreakPattern;
|
|
19
|
-
remediation: string;
|
|
20
|
-
}
|
|
21
|
-
export interface ImplementationBrief {
|
|
22
|
-
specId: string;
|
|
23
|
-
generatedAt: string;
|
|
24
|
-
reusableHelpers: ReusableHelper[];
|
|
25
|
-
testPatterns: TestPatternMatch[];
|
|
26
|
-
conventions: {
|
|
27
|
-
excerpts: ConventionExcerpt[];
|
|
28
|
-
};
|
|
29
|
-
extensionPoints: string[];
|
|
30
|
-
anticipatedBreaks: AnticipatedBreak[];
|
|
31
|
-
}
|
|
32
|
-
export interface ImplementationBriefInput {
|
|
33
|
-
specId: string;
|
|
34
|
-
specPath: string;
|
|
35
|
-
projectPath: string;
|
|
36
|
-
description: string;
|
|
37
|
-
tags: string[];
|
|
38
|
-
target: string;
|
|
39
|
-
scope: string;
|
|
40
|
-
}
|
|
41
|
-
//# sourceMappingURL=implementation-brief.d.ts.map
|