@planu/cli 4.10.4 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/dist/config/version.js +1 -1
  3. package/dist/config/version.ts +1 -1
  4. package/dist/engine/compact/tool-list-compactor.d.ts +1 -1
  5. package/dist/engine/evidence-gates/lifecycle-gate.js +17 -1
  6. package/dist/engine/evidence-index/done-drift.js +4 -1
  7. package/dist/engine/local-first/tool-classification.d.ts +6 -0
  8. package/dist/engine/local-first/tool-classification.js +119 -0
  9. package/dist/engine/project-graph/extractors/decision-store-extractor.js +1 -1
  10. package/dist/engine/project-graph/extractors/git-extractor.js +1 -1
  11. package/dist/engine/project-graph/extractors/handoff-extractor.js +1 -1
  12. package/dist/engine/project-graph/extractors/spec-extractor.js +1 -1
  13. package/dist/engine/project-graph/extractors/validation-extractor.js +1 -1
  14. package/dist/engine/project-graph/index.d.ts +2 -1
  15. package/dist/engine/project-graph/index.js +2 -1
  16. package/dist/engine/project-graph/query.d.ts +1 -2
  17. package/dist/engine/project-graph/query.js +1 -10
  18. package/dist/engine/project-graph/redaction.d.ts +4 -0
  19. package/dist/engine/project-graph/redaction.js +11 -0
  20. package/dist/engine/qa-gate.js +13 -1
  21. package/dist/engine/scope-boundaries/scope-validator.js +14 -2
  22. package/dist/engine/validator/validation-report-writer.js +39 -8
  23. package/dist/tools/design-schema-sql/migrations.js +8 -3
  24. package/dist/tools/design-schema-sql/tables.js +19 -3
  25. package/dist/tools/list-specs.js +15 -59
  26. package/dist/tools/output-compressor.d.ts +14 -1
  27. package/dist/tools/output-compressor.js +121 -2
  28. package/dist/tools/package-handoff.js +136 -12
  29. package/dist/tools/status-handler.js +6 -1
  30. package/dist/tools/token-recording.d.ts +2 -1
  31. package/dist/tools/token-recording.js +21 -2
  32. package/dist/tools/update-status/dod-gates.d.ts +2 -2
  33. package/dist/tools/update-status/dod-gates.js +8 -28
  34. package/dist/tools/update-status/index.js +7 -1
  35. package/dist/tools/update-status/qa-gate.d.ts +5 -0
  36. package/dist/tools/update-status/qa-gate.js +50 -0
  37. package/dist/tools/update-status/response-builder.js +96 -2
  38. package/dist/tools/update-status-convention-gate.js +22 -6
  39. package/dist/tools/validate-lint.js +1 -1
  40. package/dist/tools/validate.js +147 -19
  41. package/dist/transports/transport-factory.js +13 -0
  42. package/dist/types/qa-gate.d.ts +3 -0
  43. package/dist/types/tool-classification.d.ts +8 -0
  44. package/dist/types/tool-classification.js +2 -0
  45. package/package.json +11 -18
  46. package/planu-native.json +8 -29
  47. package/planu-plugin.json +7 -35
  48. package/dist/engine/context-artifacts/index.d.ts +0 -2
  49. package/dist/engine/context-artifacts/index.js +0 -2
@@ -1,8 +1,21 @@
1
1
  import type { ToolResult } from '../types/index.js';
2
+ export interface StructuredContentCompactionOptions {
3
+ maxArrayItems?: number;
4
+ maxStringChars?: number;
5
+ maxObjectKeys?: number;
6
+ maxDepth?: number;
7
+ }
8
+ /**
9
+ * Compacts structuredContent without changing its broad contract shape.
10
+ * Arrays remain arrays, objects remain objects, and compaction metadata is added
11
+ * at top level when a successful response would otherwise return a large payload.
12
+ */
13
+ export declare function compactStructuredContent(structuredContent: Record<string, unknown>, options?: StructuredContentCompactionOptions): Record<string, unknown>;
2
14
  /**
3
15
  * Post-processes a ToolResult to compress verbose content blocks.
4
16
  * Applied to ALL tool outputs via safeWithTelemetry.
5
- * Preserves structuredContent untouched only compresses text blocks.
17
+ * Also bounds structuredContent so token recording reflects the actual visible
18
+ * payload instead of unbounded internal data.
6
19
  */
7
20
  export declare function compressToolOutput(result: ToolResult): ToolResult;
8
21
  //# sourceMappingURL=output-compressor.d.ts.map
@@ -9,6 +9,14 @@ const MIN_COMPRESS_LENGTH = 800;
9
9
  const HARD_CAP_CHARS = 2400;
10
10
  /** Max lines kept from a long markdown text block */
11
11
  const MAX_MARKDOWN_LINES = 40;
12
+ /** Default number of structured array items to keep in MCP responses. */
13
+ const MAX_STRUCTURED_ARRAY_ITEMS = 20;
14
+ /** Default max string length inside structuredContent. */
15
+ const MAX_STRUCTURED_STRING_CHARS = 2000;
16
+ /** Default object key budget inside structuredContent. */
17
+ const MAX_STRUCTURED_OBJECT_KEYS = 60;
18
+ /** Default recursion depth for structuredContent compaction. */
19
+ const MAX_STRUCTURED_DEPTH = 8;
12
20
  /**
13
21
  * Detects if a string is a JSON dump (starts with { or [).
14
22
  * Quick heuristic — avoids parsing large strings unnecessarily.
@@ -133,10 +141,114 @@ function compressTextBlock(text) {
133
141
  }
134
142
  return hardCap(truncateMarkdown(text));
135
143
  }
144
+ function pushArrayMeta(meta, value) {
145
+ meta.arrays.push(value);
146
+ }
147
+ function pushStringMeta(meta, value) {
148
+ meta.strings.push(value);
149
+ }
150
+ function pushObjectMeta(meta, value) {
151
+ meta.objects.push(value);
152
+ }
153
+ function pushDepthMeta(meta, path) {
154
+ meta.depthLimited.push(path);
155
+ }
156
+ function compactStructuredValue(value, path, depth, options, meta) {
157
+ if (typeof value === 'string') {
158
+ if (value.length <= options.maxStringChars) {
159
+ return value;
160
+ }
161
+ pushStringMeta(meta, {
162
+ path,
163
+ originalChars: value.length,
164
+ shownChars: options.maxStringChars,
165
+ });
166
+ return `${value.slice(0, options.maxStringChars)}\n... (${String(value.length - options.maxStringChars)} chars omitted)`;
167
+ }
168
+ if (value === null || typeof value !== 'object') {
169
+ return value;
170
+ }
171
+ if (depth >= options.maxDepth) {
172
+ pushDepthMeta(meta, path);
173
+ return '[structuredContent depth limit reached]';
174
+ }
175
+ if (Array.isArray(value)) {
176
+ const shown = value.slice(0, options.maxArrayItems);
177
+ if (value.length > shown.length) {
178
+ pushArrayMeta(meta, {
179
+ path,
180
+ total: value.length,
181
+ shown: shown.length,
182
+ omitted: value.length - shown.length,
183
+ });
184
+ }
185
+ return shown.map((item, index) => compactStructuredValue(item, `${path}[${String(index)}]`, depth + 1, options, meta));
186
+ }
187
+ const entries = Object.entries(value);
188
+ const keptEntries = entries.slice(0, options.maxObjectKeys);
189
+ if (entries.length > keptEntries.length) {
190
+ pushObjectMeta(meta, {
191
+ path,
192
+ totalKeys: entries.length,
193
+ shownKeys: keptEntries.length,
194
+ omitted: entries.length - keptEntries.length,
195
+ });
196
+ }
197
+ const out = {};
198
+ for (const [key, entryValue] of keptEntries) {
199
+ out[key] = compactStructuredValue(entryValue, path === '$' ? `$.${key}` : `${path}.${key}`, depth + 1, options, meta);
200
+ }
201
+ return out;
202
+ }
203
+ /**
204
+ * Compacts structuredContent without changing its broad contract shape.
205
+ * Arrays remain arrays, objects remain objects, and compaction metadata is added
206
+ * at top level when a successful response would otherwise return a large payload.
207
+ */
208
+ export function compactStructuredContent(structuredContent, options = {}) {
209
+ const resolved = {
210
+ maxArrayItems: options.maxArrayItems ?? MAX_STRUCTURED_ARRAY_ITEMS,
211
+ maxStringChars: options.maxStringChars ?? MAX_STRUCTURED_STRING_CHARS,
212
+ maxObjectKeys: options.maxObjectKeys ?? MAX_STRUCTURED_OBJECT_KEYS,
213
+ maxDepth: options.maxDepth ?? MAX_STRUCTURED_DEPTH,
214
+ };
215
+ const meta = {
216
+ structuredContentCompacted: true,
217
+ arrays: [],
218
+ strings: [],
219
+ objects: [],
220
+ depthLimited: [],
221
+ };
222
+ const compacted = compactStructuredValue(structuredContent, '$', 0, resolved, meta);
223
+ if (meta.arrays.length === 0 &&
224
+ meta.strings.length === 0 &&
225
+ meta.objects.length === 0 &&
226
+ meta.depthLimited.length === 0) {
227
+ return structuredContent;
228
+ }
229
+ if (compacted !== null && typeof compacted === 'object' && !Array.isArray(compacted)) {
230
+ const obj = compacted;
231
+ const existingCompaction = obj.compaction !== null && typeof obj.compaction === 'object'
232
+ ? obj.compaction
233
+ : {};
234
+ return {
235
+ ...obj,
236
+ compaction: {
237
+ ...existingCompaction,
238
+ ...meta,
239
+ },
240
+ };
241
+ }
242
+ return {
243
+ value: compacted,
244
+ compaction: meta,
245
+ };
246
+ }
136
247
  /**
137
248
  * Post-processes a ToolResult to compress verbose content blocks.
138
249
  * Applied to ALL tool outputs via safeWithTelemetry.
139
- * Preserves structuredContent untouched only compresses text blocks.
250
+ * Also bounds structuredContent so token recording reflects the actual visible
251
+ * payload instead of unbounded internal data.
140
252
  */
141
253
  export function compressToolOutput(result) {
142
254
  // Don't compress error results — they need full context for debugging
@@ -151,6 +263,13 @@ export function compressToolOutput(result) {
151
263
  ...block,
152
264
  text: compressTextBlock(block.text),
153
265
  }));
154
- return { ...result, content: compressed };
266
+ const structuredContent = result.structuredContent !== undefined
267
+ ? compactStructuredContent(result.structuredContent)
268
+ : undefined;
269
+ return {
270
+ ...result,
271
+ content: compressed,
272
+ ...(structuredContent !== undefined ? { structuredContent } : {}),
273
+ };
155
274
  }
156
275
  //# sourceMappingURL=output-compressor.js.map
@@ -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, formatTokenWasteReport, loadTokenWastePolicy, recommendRelevantTools, toolsFromPolicyGroups, } from '../engine/token-optimizer/index.js';
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: [{ type: 'text', text: formatted }],
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
- blockers: pkgWithScore.blockers,
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
- ...(minimalityReport !== null ? { minimality: minimalityReport } : {}),
307
- ...(tokenWasteReport !== null ? { tokenWaste: tokenWasteReport } : {}),
308
- ...(graphContext !== null ? { graphContext: graphContext.structured } : {}),
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 ? { btw_hint: btwHint } : {}),
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 all text content items into a single string.
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 all text content items into a single string.
56
+ * Concatenates visible text and compacted structuredContent into a single string.
43
57
  */
44
58
  export function extractOutputText(result) {
45
- return result.content
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
- /** SPEC-642: Block done if QA gate has not run and passed. */
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 { blocked: false, score: observedScore, forced: true, forcedReason: forceStatusReason };
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
- /** SPEC-642: Block done if QA gate has not run and passed. */
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(specId, effectiveGatePath, params.force ?? false);
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