@planu/cli 4.10.5 → 4.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -1
- package/dist/engine/compact/tool-list-compactor.d.ts +1 -1
- package/dist/engine/evidence-gates/lifecycle-gate.js +17 -1
- package/dist/engine/evidence-index/done-drift.js +4 -1
- package/dist/engine/local-first/tool-classification.d.ts +6 -0
- package/dist/engine/local-first/tool-classification.js +119 -0
- package/dist/engine/qa-gate.js +13 -1
- package/dist/engine/scope-boundaries/scope-validator.js +14 -2
- package/dist/engine/validator/validation-report-writer.js +3 -4
- package/dist/tools/list-specs.js +15 -59
- package/dist/tools/output-compressor.d.ts +14 -1
- package/dist/tools/output-compressor.js +121 -2
- package/dist/tools/package-handoff.js +136 -12
- package/dist/tools/status-handler.js +6 -1
- package/dist/tools/token-recording.d.ts +2 -1
- package/dist/tools/token-recording.js +21 -2
- package/dist/tools/update-status/dod-gates.d.ts +2 -2
- package/dist/tools/update-status/dod-gates.js +8 -28
- package/dist/tools/update-status/index.js +7 -1
- package/dist/tools/update-status/qa-gate.d.ts +5 -0
- package/dist/tools/update-status/qa-gate.js +50 -0
- package/dist/tools/update-status/response-builder.js +96 -2
- package/dist/tools/update-status-convention-gate.js +22 -6
- package/dist/tools/validate.js +137 -16
- package/dist/transports/transport-factory.js +13 -0
- package/dist/types/qa-gate.d.ts +3 -0
- package/dist/types/tool-classification.d.ts +8 -0
- package/dist/types/tool-classification.js +2 -0
- package/package.json +1 -1
- package/planu-native.json +8 -29
- package/planu-plugin.json +7 -35
|
@@ -3,7 +3,7 @@ import { specStore, knowledgeStore } from '../storage/index.js';
|
|
|
3
3
|
import { checkSpecReadiness } from '../engine/readiness-checker.js';
|
|
4
4
|
import { packageHandoff } from '../engine/handoff-packager.js';
|
|
5
5
|
import { detectParadigms } from '../engine/paradigm-detector.js';
|
|
6
|
-
import { analyzeContextPreflight, buildTokenWasteReport,
|
|
6
|
+
import { analyzeContextPreflight, buildTokenWasteReport, loadTokenWastePolicy, recommendRelevantTools, toolsFromPolicyGroups, } from '../engine/token-optimizer/index.js';
|
|
7
7
|
import { appendTransitionEvent } from '../storage/transition-log.js';
|
|
8
8
|
import { formatProjectGraphContext, queryProjectGraphSlice, } from '../engine/project-graph/index.js';
|
|
9
9
|
import { analyzeMinimalImplementation, loadMinimalImplementationPolicy, } from '../engine/minimality/index.js';
|
|
@@ -137,6 +137,98 @@ function formatHandoff(pkg) {
|
|
|
137
137
|
}
|
|
138
138
|
return lines.join('\n');
|
|
139
139
|
}
|
|
140
|
+
function topItems(items, limit) {
|
|
141
|
+
return items.slice(0, limit);
|
|
142
|
+
}
|
|
143
|
+
function buildHandoffSummaryText(args) {
|
|
144
|
+
const lines = [];
|
|
145
|
+
lines.push(`# Implementation Handoff Package - ${args.pkg.specId}`);
|
|
146
|
+
lines.push('');
|
|
147
|
+
lines.push(`Readiness Score: ${String(args.pkg.readinessScore)}/100`);
|
|
148
|
+
lines.push(`Blocked: ${args.pkg.blocked ? 'yes' : 'no'}`);
|
|
149
|
+
if (args.pkg.handoffPath) {
|
|
150
|
+
lines.push(`Handoff Path: ${args.pkg.handoffPath}`);
|
|
151
|
+
}
|
|
152
|
+
if (args.pkg.contextHash) {
|
|
153
|
+
lines.push(`Context Hash: ${args.pkg.contextHash}`);
|
|
154
|
+
}
|
|
155
|
+
lines.push(`Counts: ${String(args.pkg.criteria.length)} criteria, ${String(args.pkg.filesToModify.length)} files to modify, ${String(args.pkg.filesToCreate.length)} files to create, ${String(args.pkg.testPlan.length)} tests, ${String(args.pkg.risks.length)} risks.`);
|
|
156
|
+
if (args.topBlockers.length > 0) {
|
|
157
|
+
lines.push(`Top blockers: ${args.topBlockers.join('; ')}`);
|
|
158
|
+
}
|
|
159
|
+
if (args.topRisks.length > 0) {
|
|
160
|
+
lines.push(`Top risks: ${args.topRisks.join('; ')}`);
|
|
161
|
+
}
|
|
162
|
+
if (args.minimalityBlocked !== null) {
|
|
163
|
+
lines.push(`Minimality: ${args.minimalityBlocked ? 'blocked' : 'pass'}`);
|
|
164
|
+
}
|
|
165
|
+
if (args.tokenWasteRiskCount !== null) {
|
|
166
|
+
lines.push(`Token Waste Autopilot risks: ${String(args.tokenWasteRiskCount)}`);
|
|
167
|
+
}
|
|
168
|
+
lines.push(`Next Action: ${args.pkg.nextAction}`);
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push('Full handoff is available via the handoffPath artifact; it is not embedded by default.');
|
|
171
|
+
return lines.join('\n');
|
|
172
|
+
}
|
|
173
|
+
function summarizeMinimality(report) {
|
|
174
|
+
if (report === null) {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
const blockingFindings = report.findings.filter((finding) => finding.blocksDone);
|
|
178
|
+
return {
|
|
179
|
+
enabled: report.enabled,
|
|
180
|
+
blocked: report.blocked,
|
|
181
|
+
findingCount: report.findings.length,
|
|
182
|
+
blockingFindingCount: blockingFindings.length,
|
|
183
|
+
blockersTop3: topItems(blockingFindings, 3).map((finding) => ({
|
|
184
|
+
ruleId: finding.ruleId,
|
|
185
|
+
target: finding.target,
|
|
186
|
+
evidence: finding.evidence,
|
|
187
|
+
})),
|
|
188
|
+
safetyExceptionCount: report.safetyExceptions.length,
|
|
189
|
+
debtEvidenceCount: report.debtEvidence.length,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function summarizeTokenWaste(report) {
|
|
193
|
+
if (report === null) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
actionsTakenCount: report.actionsTaken.length,
|
|
198
|
+
recommendationCount: report.recommendations.length,
|
|
199
|
+
riskCount: report.risks.length,
|
|
200
|
+
risksTop3: topItems(report.risks, 3).map((risk) => ({
|
|
201
|
+
decision: risk.decision,
|
|
202
|
+
target: risk.target,
|
|
203
|
+
reason: risk.reason,
|
|
204
|
+
confidence: risk.confidence,
|
|
205
|
+
})),
|
|
206
|
+
modelEffort: {
|
|
207
|
+
effort: report.modelEffort.effort,
|
|
208
|
+
reason: report.modelEffort.reason,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function summarizeGraphContext(graphContext) {
|
|
213
|
+
if (graphContext === null || typeof graphContext !== 'object') {
|
|
214
|
+
return { available: false };
|
|
215
|
+
}
|
|
216
|
+
const graph = graphContext;
|
|
217
|
+
return {
|
|
218
|
+
available: true,
|
|
219
|
+
nodeCount: Array.isArray(graph.nodes) ? graph.nodes.length : 0,
|
|
220
|
+
edgeCount: Array.isArray(graph.edges) ? graph.edges.length : 0,
|
|
221
|
+
summary: graph.summary,
|
|
222
|
+
tokenSavings: graph.tokenSavings,
|
|
223
|
+
freshness: graph.freshness
|
|
224
|
+
? {
|
|
225
|
+
graphPath: graph.freshness.graphPath,
|
|
226
|
+
stale: graph.freshness.stale,
|
|
227
|
+
reason: graph.freshness.reason,
|
|
228
|
+
}
|
|
229
|
+
: undefined,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
140
232
|
async function buildHandoffTokenWasteReport(pkg, knowledge, spec) {
|
|
141
233
|
try {
|
|
142
234
|
const { policy } = await loadTokenWastePolicy(knowledge.projectPath);
|
|
@@ -277,12 +369,6 @@ export async function handlePackageHandoff(args) {
|
|
|
277
369
|
? `${formatHandoff(pkgWithScore)}\n${graphContext.text}`
|
|
278
370
|
: formatHandoff(pkgWithScore);
|
|
279
371
|
const minimalityReport = await buildMinimalImplementationReport(pkgWithScore, knowledge, spec, baseHandoffText);
|
|
280
|
-
const handoffText = minimalityReport !== null && minimalityReport.markdown.length > 0
|
|
281
|
-
? `${baseHandoffText}\n${minimalityReport.markdown}`
|
|
282
|
-
: baseHandoffText;
|
|
283
|
-
const formatted = tokenWasteReport !== null
|
|
284
|
-
? `${handoffText}\n${formatTokenWasteReport(tokenWasteReport)}`
|
|
285
|
-
: handoffText;
|
|
286
372
|
void appendTransitionEvent({
|
|
287
373
|
projectId,
|
|
288
374
|
specId,
|
|
@@ -296,16 +382,54 @@ export async function handlePackageHandoff(args) {
|
|
|
296
382
|
}).catch(() => {
|
|
297
383
|
/* best-effort — handoff rendering should not fail because telemetry failed */
|
|
298
384
|
});
|
|
385
|
+
const topBlockers = topItems(pkgWithScore.blockers, 3);
|
|
386
|
+
const topRisks = topItems(pkgWithScore.risks, 5);
|
|
387
|
+
const minimalitySummary = summarizeMinimality(minimalityReport);
|
|
388
|
+
const tokenWasteSummary = summarizeTokenWaste(tokenWasteReport);
|
|
389
|
+
const graphSummary = graphContext !== null ? summarizeGraphContext(graphContext.structured) : undefined;
|
|
299
390
|
return {
|
|
300
|
-
content: [
|
|
391
|
+
content: [
|
|
392
|
+
{
|
|
393
|
+
type: 'text',
|
|
394
|
+
text: buildHandoffSummaryText({
|
|
395
|
+
pkg: pkgWithScore,
|
|
396
|
+
topBlockers,
|
|
397
|
+
topRisks,
|
|
398
|
+
minimalityBlocked: minimalityReport?.blocked ?? null,
|
|
399
|
+
tokenWasteRiskCount: tokenWasteReport?.risks.length ?? null,
|
|
400
|
+
}),
|
|
401
|
+
},
|
|
402
|
+
],
|
|
301
403
|
structuredContent: {
|
|
404
|
+
specId,
|
|
302
405
|
blocked: pkgWithScore.blocked,
|
|
303
|
-
|
|
406
|
+
readinessScore: pkgWithScore.readinessScore,
|
|
407
|
+
blockersCount: pkgWithScore.blockers.length,
|
|
408
|
+
blockersTop3: topBlockers,
|
|
409
|
+
risksTop5: topRisks,
|
|
410
|
+
criteriaTop5: topItems(pkgWithScore.criteria, 5),
|
|
411
|
+
constraintsTop5: topItems(pkgWithScore.constraints, 5),
|
|
412
|
+
warningsTop5: topItems(pkgWithScore.warnings, 5),
|
|
304
413
|
handoffPath: pkgWithScore.handoffPath,
|
|
305
414
|
contextHash: pkgWithScore.contextHash,
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
415
|
+
artifactRefs: {
|
|
416
|
+
handoff: pkgWithScore.handoffPath,
|
|
417
|
+
},
|
|
418
|
+
counts: {
|
|
419
|
+
criteria: pkgWithScore.criteria.length,
|
|
420
|
+
filesToModify: pkgWithScore.filesToModify.length,
|
|
421
|
+
filesToCreate: pkgWithScore.filesToCreate.length,
|
|
422
|
+
ownership: pkgWithScore.ownership.length,
|
|
423
|
+
testPlan: pkgWithScore.testPlan.length,
|
|
424
|
+
risks: pkgWithScore.risks.length,
|
|
425
|
+
constraints: pkgWithScore.constraints.length,
|
|
426
|
+
warnings: pkgWithScore.warnings.length,
|
|
427
|
+
outOfScope: pkgWithScore.outOfScope.length,
|
|
428
|
+
},
|
|
429
|
+
nextAction: pkgWithScore.nextAction,
|
|
430
|
+
...(minimalitySummary !== undefined ? { minimality: minimalitySummary } : {}),
|
|
431
|
+
...(tokenWasteSummary !== undefined ? { tokenWaste: tokenWasteSummary } : {}),
|
|
432
|
+
...(graphSummary !== undefined ? { graphContext: graphSummary } : {}),
|
|
309
433
|
},
|
|
310
434
|
};
|
|
311
435
|
}
|
|
@@ -308,7 +308,12 @@ export async function handlePlanStatus(args) {
|
|
|
308
308
|
...(sessionTip !== undefined ? { sessionTip } : {}),
|
|
309
309
|
...(tokenWasteHint !== undefined ? { tokenWasteHint } : {}),
|
|
310
310
|
...(graphFreshnessHint !== undefined ? { graphFreshnessHint } : {}),
|
|
311
|
-
...(btwHint !== undefined
|
|
311
|
+
...(btwHint !== undefined
|
|
312
|
+
? {
|
|
313
|
+
btwHintAvailable: true,
|
|
314
|
+
btwHintReason: 'Use /btw for side questions when the current implementation context is large.',
|
|
315
|
+
}
|
|
316
|
+
: {}),
|
|
312
317
|
...(updateBanner !== undefined ? { update_banner: updateBanner } : {}),
|
|
313
318
|
// SPEC-751: pending cleanup counters
|
|
314
319
|
pending_cleanup: pendingCleanup,
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
export declare function recordToolTokens(toolName: string, projectPath: string, inputArgs: unknown, outputText: string, sessionId: string): void;
|
|
6
6
|
/**
|
|
7
7
|
* Extract output text from a ToolResult for token counting.
|
|
8
|
-
* Concatenates
|
|
8
|
+
* Concatenates visible text and compacted structuredContent into a single string.
|
|
9
9
|
*/
|
|
10
10
|
export declare function extractOutputText(result: {
|
|
11
11
|
content: {
|
|
12
12
|
type: string;
|
|
13
13
|
text?: string;
|
|
14
14
|
}[];
|
|
15
|
+
structuredContent?: Record<string, unknown>;
|
|
15
16
|
}): string;
|
|
16
17
|
//# sourceMappingURL=token-recording.d.ts.map
|
|
@@ -4,6 +4,20 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { countTokens, estimateCost } from '../engine/token-optimizer/counter.js';
|
|
5
5
|
import { recordEntry } from '../storage/token-ledger-store.js';
|
|
6
6
|
import { hashProjectPath } from '../storage/base-store.js';
|
|
7
|
+
const SECRET_KEY_PATTERN = /(api[-_]?key|auth|bearer|credential|password|secret|token)/i;
|
|
8
|
+
function stringifyForTokenCounting(value) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.stringify(value, (key, entryValue) => {
|
|
11
|
+
if (SECRET_KEY_PATTERN.test(key)) {
|
|
12
|
+
return '[redacted]';
|
|
13
|
+
}
|
|
14
|
+
return entryValue;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return '[unserializable structuredContent]';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
/**
|
|
8
22
|
* Record a tool call's token usage to the persistent ledger.
|
|
9
23
|
* Always fire-and-forget — never throws, never blocks the response.
|
|
@@ -39,11 +53,16 @@ export function recordToolTokens(toolName, projectPath, inputArgs, outputText, s
|
|
|
39
53
|
}
|
|
40
54
|
/**
|
|
41
55
|
* Extract output text from a ToolResult for token counting.
|
|
42
|
-
* Concatenates
|
|
56
|
+
* Concatenates visible text and compacted structuredContent into a single string.
|
|
43
57
|
*/
|
|
44
58
|
export function extractOutputText(result) {
|
|
45
|
-
|
|
59
|
+
const text = result.content
|
|
46
60
|
.flatMap((item) => (item.type === 'text' && typeof item.text === 'string' ? [item.text] : []))
|
|
47
61
|
.join('\n');
|
|
62
|
+
if (result.structuredContent === undefined) {
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
const structuredText = stringifyForTokenCounting(result.structuredContent);
|
|
66
|
+
return text.length > 0 ? `${text}\n${structuredText}` : structuredText;
|
|
48
67
|
}
|
|
49
68
|
//# sourceMappingURL=token-recording.js.map
|
|
@@ -28,6 +28,7 @@ export type ValidateGateResult = {
|
|
|
28
28
|
score: number | null;
|
|
29
29
|
forced: true;
|
|
30
30
|
forcedReason: string;
|
|
31
|
+
scoreSource: 'forced-best-effort-validateSpec';
|
|
31
32
|
};
|
|
32
33
|
/**
|
|
33
34
|
* SPEC-721 / SPEC-222 Trigger 1: Run validate engine before marking done.
|
|
@@ -99,8 +100,7 @@ export interface ComplianceGateResult {
|
|
|
99
100
|
* Never blocks the transition if the compliance engine is unavailable or fails.
|
|
100
101
|
*/
|
|
101
102
|
export declare function checkComplianceGate(specId: string, projectId: string, projectPath: string | undefined): Promise<ComplianceGateResult>;
|
|
102
|
-
|
|
103
|
-
export declare function checkQaGate(specId: string, projectPath: string | undefined, force: boolean): Promise<ToolResult | null>;
|
|
103
|
+
export { checkQaGate } from './qa-gate.js';
|
|
104
104
|
/**
|
|
105
105
|
* SPEC-716 / SPEC-780: Format gate for transition to 'approved'.
|
|
106
106
|
*
|
|
@@ -54,7 +54,13 @@ export async function runValidateGate(spec, projectPath, forceStatus, forceStatu
|
|
|
54
54
|
catch {
|
|
55
55
|
/* swallow — forced bypass must never throw */
|
|
56
56
|
}
|
|
57
|
-
return {
|
|
57
|
+
return {
|
|
58
|
+
blocked: false,
|
|
59
|
+
score: observedScore,
|
|
60
|
+
forced: true,
|
|
61
|
+
forcedReason: forceStatusReason,
|
|
62
|
+
scoreSource: 'forced-best-effort-validateSpec',
|
|
63
|
+
};
|
|
58
64
|
}
|
|
59
65
|
// ---- Normal path — fail-CLOSED --------------------------------------------
|
|
60
66
|
const TIMEOUT_SENTINEL = Symbol('timeout');
|
|
@@ -512,33 +518,7 @@ export async function checkComplianceGate(specId, projectId, projectPath) {
|
|
|
512
518
|
return { skipped: true, score: null, issues: [], blocked: false };
|
|
513
519
|
}
|
|
514
520
|
}
|
|
515
|
-
|
|
516
|
-
export async function checkQaGate(specId, projectPath, force) {
|
|
517
|
-
if (force || !projectPath) {
|
|
518
|
-
return null;
|
|
519
|
-
}
|
|
520
|
-
const { getGateState } = await import('../../storage/qa-gate-store.js');
|
|
521
|
-
const state = await getGateState(projectPath, specId).catch(() => null);
|
|
522
|
-
if (state?.lastResult?.passed === true) {
|
|
523
|
-
return null;
|
|
524
|
-
}
|
|
525
|
-
const reason = state === null ? 'QA gate not run' : 'QA gate did not pass';
|
|
526
|
-
return {
|
|
527
|
-
content: [
|
|
528
|
-
{
|
|
529
|
-
type: 'text',
|
|
530
|
-
text: `❌ ${reason} — run the QA checks first or use force:true to bypass`,
|
|
531
|
-
},
|
|
532
|
-
],
|
|
533
|
-
isError: true,
|
|
534
|
-
structuredContent: {
|
|
535
|
-
error: 'qa_gate_not_passed',
|
|
536
|
-
code: 422,
|
|
537
|
-
context: { specId, reason },
|
|
538
|
-
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.',
|
|
539
|
-
},
|
|
540
|
-
};
|
|
541
|
-
}
|
|
521
|
+
export { checkQaGate } from './qa-gate.js';
|
|
542
522
|
/**
|
|
543
523
|
* SPEC-716 / SPEC-780: Format gate for transition to 'approved'.
|
|
544
524
|
*
|
|
@@ -466,7 +466,7 @@ export async function handleUpdateStatus(params, server) {
|
|
|
466
466
|
// SPEC-642: QA gate — block done if typecheck + test:coverage have not passed
|
|
467
467
|
// ---------------------------------------------------------------------------
|
|
468
468
|
if (newStatus === 'done') {
|
|
469
|
-
const qaGateResult = await checkQaGate(
|
|
469
|
+
const qaGateResult = await checkQaGate(spec, effectiveGatePath, params.force ?? false);
|
|
470
470
|
if (qaGateResult !== null) {
|
|
471
471
|
return qaGateResult;
|
|
472
472
|
}
|
|
@@ -503,6 +503,7 @@ export async function handleUpdateStatus(params, server) {
|
|
|
503
503
|
// SPEC-447: Compliance gate on 'review' or 'implementing'
|
|
504
504
|
// ---------------------------------------------------------------------------
|
|
505
505
|
let validateScore = null;
|
|
506
|
+
let validateScoreSource = null;
|
|
506
507
|
let crashShieldWarning = null;
|
|
507
508
|
let crashShieldSkipReason = null;
|
|
508
509
|
let complianceGateResult = null;
|
|
@@ -537,11 +538,15 @@ export async function handleUpdateStatus(params, server) {
|
|
|
537
538
|
}
|
|
538
539
|
validateScore = validateGateResult.score;
|
|
539
540
|
if (validateGateResult.forced) {
|
|
541
|
+
validateScoreSource = validateGateResult.scoreSource;
|
|
540
542
|
forcedValidateBypass = {
|
|
541
543
|
reason: validateGateResult.forcedReason,
|
|
542
544
|
observedScore: validateGateResult.score,
|
|
543
545
|
};
|
|
544
546
|
}
|
|
547
|
+
else {
|
|
548
|
+
validateScoreSource = 'validateSpec';
|
|
549
|
+
}
|
|
545
550
|
}
|
|
546
551
|
if (newStatus === 'done' && !(params.force ?? params.forceStatus ?? false)) {
|
|
547
552
|
const validationReportError = await checkValidationReportGate(specId, projectId, false);
|
|
@@ -1045,6 +1050,7 @@ export async function handleUpdateStatus(params, server) {
|
|
|
1045
1050
|
forcedBypassWarning,
|
|
1046
1051
|
forceAnalyticsWarning,
|
|
1047
1052
|
validateScore,
|
|
1053
|
+
validateScoreSource,
|
|
1048
1054
|
// SPEC-721: audit ID for the forced validate bypass (null when no bypass occurred)
|
|
1049
1055
|
forceStatusAuditId,
|
|
1050
1056
|
// SPEC-780: audit ID for the forced approve bypass (null when no bypass occurred)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ToolResult } from '../../types/index.js';
|
|
2
|
+
import type { Spec } from '../../types/spec/core.js';
|
|
3
|
+
/** SPEC-642 / SPEC-1111: verify and persist local QA evidence before done. */
|
|
4
|
+
export declare function checkQaGate(spec: Spec, projectPath: string | undefined, force: boolean): Promise<ToolResult | null>;
|
|
5
|
+
//# sourceMappingURL=qa-gate.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** SPEC-642 / SPEC-1111: verify and persist local QA evidence before done. */
|
|
2
|
+
export async function checkQaGate(spec, projectPath, force) {
|
|
3
|
+
if (force || !projectPath) {
|
|
4
|
+
return null;
|
|
5
|
+
}
|
|
6
|
+
const specId = spec.id;
|
|
7
|
+
const { getGateState, saveGateState } = await import('../../storage/qa-gate-store.js');
|
|
8
|
+
const state = await getGateState(projectPath, specId).catch(() => null);
|
|
9
|
+
if (state?.lastResult?.passed === true) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
let effectiveResult = state?.lastResult ?? null;
|
|
13
|
+
if (effectiveResult === null) {
|
|
14
|
+
const { runQaGate } = await import('../../engine/qa-gate.js');
|
|
15
|
+
effectiveResult = await runQaGate(spec, projectPath);
|
|
16
|
+
await saveGateState(projectPath, specId, effectiveResult);
|
|
17
|
+
}
|
|
18
|
+
if (effectiveResult.passed) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const failedChecks = effectiveResult.checks.filter((check) => !check.passed);
|
|
22
|
+
const failedSummary = failedChecks.length > 0
|
|
23
|
+
? failedChecks
|
|
24
|
+
.map((check) => {
|
|
25
|
+
const command = check.command ?? check.name;
|
|
26
|
+
const reason = check.timedOut
|
|
27
|
+
? `timed out after ${String(check.durationMs)}ms`
|
|
28
|
+
: (check.errorMessage ?? 'exited with a non-zero status');
|
|
29
|
+
return `${command}: ${reason}`;
|
|
30
|
+
})
|
|
31
|
+
.join('; ')
|
|
32
|
+
: 'No failed check details recorded.';
|
|
33
|
+
const reason = 'QA gate did not pass';
|
|
34
|
+
return {
|
|
35
|
+
content: [
|
|
36
|
+
{
|
|
37
|
+
type: 'text',
|
|
38
|
+
text: `❌ ${reason} — ${failedSummary}. Fix the failing local QA checks or use force:true to bypass`,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
isError: true,
|
|
42
|
+
structuredContent: {
|
|
43
|
+
error: 'qa_gate_not_passed',
|
|
44
|
+
code: 422,
|
|
45
|
+
context: { specId, reason, failedChecks },
|
|
46
|
+
fixHint: 'Fix the failing QA command(s), rerun update_status(done), or use force:true to bypass with audit context.',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=qa-gate.js.map
|
|
@@ -116,7 +116,10 @@ export function buildStatusResponse(result, specId, currentStatus, newStatus, re
|
|
|
116
116
|
lines.push(`\n🤖 Tokens: ${tokenStr}${costStr}${r.actuals.estimated ? ' *(auto-estimated)*' : ''}`);
|
|
117
117
|
}
|
|
118
118
|
if (r.validateScore !== null && r.validateScore !== undefined) {
|
|
119
|
-
|
|
119
|
+
const sourceSuffix = r.validateScoreSource === 'forced-best-effort-validateSpec'
|
|
120
|
+
? ' (forced best-effort observation from validateSpec)'
|
|
121
|
+
: '';
|
|
122
|
+
lines.push(`\n✅ Validate score: ${String(r.validateScore)}/100${sourceSuffix}`);
|
|
120
123
|
}
|
|
121
124
|
if (r.cascade.affectedSpecs.length > 0) {
|
|
122
125
|
lines.push(`\n🔗 Downstream specs affected: ${r.cascade.affectedSpecs.map((s) => `SPEC-${s}`).join(', ')}`);
|
|
@@ -201,12 +204,103 @@ export function buildStatusResponse(result, specId, currentStatus, newStatus, re
|
|
|
201
204
|
: withNextSteps;
|
|
202
205
|
const displayTitle = specTitle ?? `SPEC-${specId}`;
|
|
203
206
|
const humanSummary = buildUpdateStatusSummary(displayTitle, newStatus);
|
|
207
|
+
const warningItems = [
|
|
208
|
+
r.protectedBranchWarning,
|
|
209
|
+
r.mergeWarning,
|
|
210
|
+
r.validationWarning,
|
|
211
|
+
r.codeRealityWarning,
|
|
212
|
+
r.crashShieldWarning,
|
|
213
|
+
r.forcedBypassWarning,
|
|
214
|
+
...(r.constitutionWarnings ?? []).map((w) => w.description),
|
|
215
|
+
...(r.conventionWarnings ?? []),
|
|
216
|
+
...(r.lintWarnings ?? []),
|
|
217
|
+
...(r.testWarnings ?? []),
|
|
218
|
+
...(r.compileWarnings ?? []),
|
|
219
|
+
...(r.frontmatterSyncWarnings ?? []).map((w) => `frontmatter sync failed for ${w.specId}: ${w.reason}`),
|
|
220
|
+
].filter((item) => typeof item === 'string' && item.length > 0);
|
|
221
|
+
const validationIssues = [
|
|
222
|
+
...(r.validationWarning ? [r.validationWarning] : []),
|
|
223
|
+
...(r.complianceGateResult?.issues ?? []),
|
|
224
|
+
...(r.lintWarnings ?? []),
|
|
225
|
+
...(r.testWarnings ?? []),
|
|
226
|
+
...(r.compileWarnings ?? []),
|
|
227
|
+
];
|
|
228
|
+
const structuredContent = {
|
|
229
|
+
specId,
|
|
230
|
+
previousStatus: currentStatus,
|
|
231
|
+
newStatus,
|
|
232
|
+
autoBranch: r.autoBranch ?? undefined,
|
|
233
|
+
protectedBranchWarning: r.protectedBranchWarning ?? undefined,
|
|
234
|
+
mergeWarning: r.mergeWarning ?? undefined,
|
|
235
|
+
validationWarning: r.validationWarning ?? undefined,
|
|
236
|
+
prSuggestion: r.prSuggestion ?? null,
|
|
237
|
+
metrics: r.metrics ?? undefined,
|
|
238
|
+
actuals: r.actuals ?? undefined,
|
|
239
|
+
cascade: {
|
|
240
|
+
...(result.cascade ?? {}),
|
|
241
|
+
affectedSpecs: r.cascade.affectedSpecs,
|
|
242
|
+
},
|
|
243
|
+
constitutionWarnings: r.constitutionWarnings?.slice(0, 5),
|
|
244
|
+
conventionWarnings: r.conventionWarnings?.slice(0, 5),
|
|
245
|
+
lintWarnings: r.lintWarnings?.slice(0, 5),
|
|
246
|
+
testWarnings: r.testWarnings?.slice(0, 5),
|
|
247
|
+
compileWarnings: r.compileWarnings?.slice(0, 5),
|
|
248
|
+
validateScore: r.validateScore ?? undefined,
|
|
249
|
+
validateScoreSource: r.validateScoreSource ?? undefined,
|
|
250
|
+
complianceGateResult: r.complianceGateResult
|
|
251
|
+
? {
|
|
252
|
+
skipped: r.complianceGateResult.skipped,
|
|
253
|
+
score: r.complianceGateResult.score,
|
|
254
|
+
blocked: r.complianceGateResult.blocked,
|
|
255
|
+
issues: r.complianceGateResult.issues.slice(0, 5),
|
|
256
|
+
}
|
|
257
|
+
: undefined,
|
|
258
|
+
forceApproveAuditId: typeof result.forceApproveAuditId === 'string' ? result.forceApproveAuditId : undefined,
|
|
259
|
+
forceStatusAuditId: typeof result.forceStatusAuditId === 'string' ? result.forceStatusAuditId : undefined,
|
|
260
|
+
qualityWarnings: Array.isArray(result.qualityWarnings)
|
|
261
|
+
? result.qualityWarnings.slice(0, 5)
|
|
262
|
+
: undefined,
|
|
263
|
+
interactiveQuestions: Array.isArray(result.interactiveQuestions)
|
|
264
|
+
? result.interactiveQuestions
|
|
265
|
+
: undefined,
|
|
266
|
+
transitionSummary: {
|
|
267
|
+
title: displayTitle,
|
|
268
|
+
from: currentStatus,
|
|
269
|
+
to: newStatus,
|
|
270
|
+
autoBranch: r.autoBranch ?? undefined,
|
|
271
|
+
versionSnapshotTag: r.versionSnapshotTag ?? undefined,
|
|
272
|
+
},
|
|
273
|
+
validationSummary: {
|
|
274
|
+
validateScore: r.validateScore ?? null,
|
|
275
|
+
validateScoreSource: r.validateScoreSource ?? null,
|
|
276
|
+
complianceScore: r.complianceGateResult?.score ?? null,
|
|
277
|
+
complianceBlocked: r.complianceGateResult?.blocked ?? false,
|
|
278
|
+
validationIssuesCount: validationIssues.length,
|
|
279
|
+
validationIssuesTop5: validationIssues.slice(0, 5),
|
|
280
|
+
},
|
|
281
|
+
blockersCount: validationIssues.length,
|
|
282
|
+
blockersTop3: validationIssues.slice(0, 3),
|
|
283
|
+
warningsCount: warningItems.length,
|
|
284
|
+
warningsTop5: warningItems.slice(0, 5),
|
|
285
|
+
affectedSpecsCount: r.cascade.affectedSpecs.length,
|
|
286
|
+
affectedSpecsTop5: r.cascade.affectedSpecs.slice(0, 5),
|
|
287
|
+
nextAction,
|
|
288
|
+
lifecycleMap,
|
|
289
|
+
humanSummary,
|
|
290
|
+
orchestrationPlan: r.orchestrationPlan
|
|
291
|
+
? {
|
|
292
|
+
waves: r.orchestrationPlan.executionOrder.length,
|
|
293
|
+
parallelSafetyVerdict: r.orchestrationPlan.parallelSafetyVerdict,
|
|
294
|
+
executionOrder: r.orchestrationPlan.executionOrder,
|
|
295
|
+
}
|
|
296
|
+
: undefined,
|
|
297
|
+
};
|
|
204
298
|
return {
|
|
205
299
|
content: [
|
|
206
300
|
{ type: 'text', text: markdownText },
|
|
207
301
|
{ type: 'text', text: humanSummary },
|
|
208
302
|
],
|
|
209
|
-
structuredContent
|
|
303
|
+
structuredContent,
|
|
210
304
|
};
|
|
211
305
|
}
|
|
212
306
|
/** Render a compact lifecycle map line showing current, next, and remaining steps */
|
|
@@ -22,7 +22,7 @@ async function execGate(cmd, cwd, timeoutMs) {
|
|
|
22
22
|
timeout: timeoutMs,
|
|
23
23
|
maxBuffer: 4 * 1024 * 1024,
|
|
24
24
|
});
|
|
25
|
-
return { output: stdout.trim(), ok: true };
|
|
25
|
+
return { output: stdout.trim(), ok: true, timedOut: false };
|
|
26
26
|
}
|
|
27
27
|
catch (err) {
|
|
28
28
|
const e = err;
|
|
@@ -31,7 +31,8 @@ async function execGate(cmd, cwd, timeoutMs) {
|
|
|
31
31
|
: typeof e.stdout === 'string'
|
|
32
32
|
? e.stdout
|
|
33
33
|
: '';
|
|
34
|
-
|
|
34
|
+
const timedOut = e.killed === true || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT';
|
|
35
|
+
return { output: raw.trim(), ok: false, timedOut, errorMessage: e.message };
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
function countLintIssues(output) {
|
|
@@ -87,23 +88,38 @@ async function checkTestGate(projectPath, testCmd) {
|
|
|
87
88
|
}
|
|
88
89
|
// Run tests with a reasonable timeout — only a warning if they fail
|
|
89
90
|
const cmd = testCmd ?? 'npx vitest run --reporter=verbose';
|
|
90
|
-
const
|
|
91
|
+
const timeoutMs = 120_000;
|
|
92
|
+
const { output, ok, timedOut, errorMessage } = await execGate(cmd, projectPath, timeoutMs);
|
|
91
93
|
if (ok) {
|
|
92
94
|
return [];
|
|
93
95
|
}
|
|
96
|
+
if (timedOut) {
|
|
97
|
+
return [`Tests: timed out after ${String(timeoutMs)}ms. Command: ${cmd}`];
|
|
98
|
+
}
|
|
94
99
|
// Extract summary line (e.g. "5 failed" or "FAIL src/foo.test.ts")
|
|
95
100
|
const failLine = output
|
|
96
101
|
.split('\n')
|
|
97
102
|
.reverse()
|
|
98
|
-
.find((l) =>
|
|
103
|
+
.find((l) => isFailureSummaryLine(l));
|
|
99
104
|
const summary = failLine ??
|
|
100
105
|
output
|
|
101
106
|
.split('\n')
|
|
102
|
-
.filter((l) => l.trim())
|
|
107
|
+
.filter((l) => l.trim() && !isPassingSummaryLine(l))
|
|
103
108
|
.slice(-1)[0] ??
|
|
104
|
-
|
|
109
|
+
errorMessage ??
|
|
110
|
+
'No failure summary available.';
|
|
105
111
|
return [`Tests: failed. ${summary.trim()}`];
|
|
106
112
|
}
|
|
113
|
+
function isPassingSummaryLine(line) {
|
|
114
|
+
return /^\s*(?:✓|PASS\b|Test Files\s+\d+\s+passed|Tests\s+\d+\s+passed)/i.test(line.trim());
|
|
115
|
+
}
|
|
116
|
+
function isFailureSummaryLine(line) {
|
|
117
|
+
const trimmed = line.trim();
|
|
118
|
+
if (trimmed.length === 0 || isPassingSummaryLine(trimmed)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return /\b(?:FAIL|failed|failing|error|errored)\b/i.test(trimmed);
|
|
122
|
+
}
|
|
107
123
|
// ---------------------------------------------------------------------------
|
|
108
124
|
// Convention gate (existing logic, unchanged)
|
|
109
125
|
// ---------------------------------------------------------------------------
|