@planu/cli 4.10.5 → 4.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/engine/compact/tool-list-compactor.d.ts +1 -1
  3. package/dist/engine/evidence-gates/lifecycle-gate.js +17 -1
  4. package/dist/engine/evidence-index/done-drift.js +4 -1
  5. package/dist/engine/local-first/tool-classification.d.ts +7 -0
  6. package/dist/engine/local-first/tool-classification.js +313 -0
  7. package/dist/engine/qa-gate.js +13 -1
  8. package/dist/engine/scope-boundaries/scope-validator.js +14 -2
  9. package/dist/engine/validator/validation-report-writer.js +3 -4
  10. package/dist/tools/audit.js +1 -1
  11. package/dist/tools/challenge-spec.js +52 -2
  12. package/dist/tools/check-readiness.js +15 -8
  13. package/dist/tools/data-governance/audit-handler.js +21 -6
  14. package/dist/tools/data-governance/detect-handler.js +32 -15
  15. package/dist/tools/define-ui-contract.js +1 -4
  16. package/dist/tools/design-schema.js +1 -4
  17. package/dist/tools/detect-drift.js +48 -28
  18. package/dist/tools/flag-spec-gap.js +47 -12
  19. package/dist/tools/generate-sub-agent.js +2 -4
  20. package/dist/tools/list-specs.js +15 -59
  21. package/dist/tools/orchestrate-locking.js +15 -4
  22. package/dist/tools/output-compressor.d.ts +14 -1
  23. package/dist/tools/output-compressor.js +121 -2
  24. package/dist/tools/package-handoff.js +136 -12
  25. package/dist/tools/reverse-engineer/handler.js +9 -10
  26. package/dist/tools/rollback-release.js +20 -2
  27. package/dist/tools/spec-diff-handler.js +3 -3
  28. package/dist/tools/status-handler.js +6 -1
  29. package/dist/tools/suggest-mcp-server.js +2 -4
  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 +47 -52
  34. package/dist/tools/update-status/evidence-gate.js +6 -4
  35. package/dist/tools/update-status/index.js +10 -3
  36. package/dist/tools/update-status/qa-gate.d.ts +5 -0
  37. package/dist/tools/update-status/qa-gate.js +50 -0
  38. package/dist/tools/update-status/response-builder.js +96 -2
  39. package/dist/tools/update-status/transition-guard.js +33 -11
  40. package/dist/tools/update-status-convention-gate.js +22 -6
  41. package/dist/tools/validate-team-results.js +23 -1
  42. package/dist/tools/validate.js +137 -16
  43. package/dist/transports/http-transport.js +13 -0
  44. package/dist/transports/transport-factory.js +13 -0
  45. package/dist/types/qa-gate.d.ts +3 -0
  46. package/dist/types/tool-classification.d.ts +8 -0
  47. package/dist/types/tool-classification.js +2 -0
  48. package/package.json +10 -10
  49. package/planu-native.json +1 -1
  50. package/planu-plugin.json +1 -1
@@ -10,6 +10,7 @@ import { randomUUID } from 'node:crypto';
10
10
  import { withEscalation } from '../../engine/escalator/with-escalation.js';
11
11
  import { writeImplementationReviewReport } from '../../engine/validator/validation-report-writer.js';
12
12
  import { writeSpecReviewFeedback } from '../../engine/validator/spec-review-writer.js';
13
+ import { formatKeyValue } from '../output-formatter.js';
13
14
  /**
14
15
  * SPEC-721 / SPEC-222 Trigger 1: Run validate engine before marking done.
15
16
  *
@@ -54,7 +55,13 @@ export async function runValidateGate(spec, projectPath, forceStatus, forceStatu
54
55
  catch {
55
56
  /* swallow — forced bypass must never throw */
56
57
  }
57
- return { blocked: false, score: observedScore, forced: true, forcedReason: forceStatusReason };
58
+ return {
59
+ blocked: false,
60
+ score: observedScore,
61
+ forced: true,
62
+ forcedReason: forceStatusReason,
63
+ scoreSource: 'forced-best-effort-validateSpec',
64
+ };
58
65
  }
59
66
  // ---- Normal path — fail-CLOSED --------------------------------------------
60
67
  const TIMEOUT_SENTINEL = Symbol('timeout');
@@ -157,29 +164,36 @@ export async function checkDodGate(spec, specId, projectId, projectPath, _force)
157
164
  }).catch((err) => {
158
165
  console.error('[planu] DoD feedback event failed:', err);
159
166
  });
160
- const failedItems = requiredItems
161
- .filter((i) => i.status === 'failed')
162
- .map((i) => ({ id: i.id, description: i.description, category: i.category }));
163
167
  return {
164
168
  content: [
165
169
  {
166
170
  type: 'text',
167
- text: JSON.stringify({
168
- error: 'DoD gate failed',
169
- message: humanMessage,
171
+ text: [
172
+ humanMessage,
170
173
  expertSummary,
171
- blockingItems,
172
- failedItems,
173
- warningItems,
174
- fixHints,
175
- gateResults: dod.gateResults ?? null,
176
- hint: '💡 Alternatively: use force:true to bypass all gates',
177
- }, null, 2),
174
+ ...fixHints.slice(0, 3),
175
+ ...warningItems.slice(0, 2).map((item) => `Warning: ${item}`),
176
+ blockingItems.length > 3 ? `More blockers: ${blockingItems.length - 3}` : undefined,
177
+ warningItems.length > 0 ? `Warnings: ${warningItems.length}` : undefined,
178
+ 'Alternatively: use force:true to bypass all gates.',
179
+ ]
180
+ .filter(Boolean)
181
+ .join('\n'),
178
182
  },
179
183
  ],
180
184
  isError: true,
181
185
  structuredContent: {
182
- error: 'dod_gate_failed',
186
+ error: 'DoD gate failed',
187
+ errorCode: 'dod_gate_failed',
188
+ message: humanMessage,
189
+ expertSummary,
190
+ blockingItems,
191
+ failedItems: requiredItems
192
+ .filter((i) => i.status === 'failed')
193
+ .map((i) => ({ id: i.id, description: i.description, category: i.category })),
194
+ warningItems,
195
+ fixHints,
196
+ hint: 'Alternatively: use force:true to bypass all gates',
183
197
  code: 422,
184
198
  context: {
185
199
  specId,
@@ -402,13 +416,14 @@ function validationReportGateError(args) {
402
416
  content: [
403
417
  {
404
418
  type: 'text',
405
- text: JSON.stringify({
419
+ text: formatKeyValue({
406
420
  error: args.error,
407
421
  message: args.message,
408
422
  artifactPath,
409
- gates: args.gates,
423
+ failedGates: args.gates.filter((gate) => !gate.passed).length,
424
+ totalGates: args.gates.length,
410
425
  fixHint: args.fixHint,
411
- }, null, 2),
426
+ }, 'Validation report gate failed'),
412
427
  },
413
428
  ],
414
429
  isError: true,
@@ -426,13 +441,14 @@ function specReviewGateError(args) {
426
441
  content: [
427
442
  {
428
443
  type: 'text',
429
- text: JSON.stringify({
444
+ text: formatKeyValue({
430
445
  error: args.error,
431
446
  message: args.message,
432
447
  artifactPath,
433
- blockers: args.blockers,
448
+ blockers: args.blockers.length,
449
+ firstBlocker: args.blockers[0],
434
450
  fixHint: args.fixHint,
435
- }, null, 2),
451
+ }, 'Spec review gate failed'),
436
452
  },
437
453
  ],
438
454
  isError: true,
@@ -512,33 +528,7 @@ export async function checkComplianceGate(specId, projectId, projectPath) {
512
528
  return { skipped: true, score: null, issues: [], blocked: false };
513
529
  }
514
530
  }
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
- }
531
+ export { checkQaGate } from './qa-gate.js';
542
532
  /**
543
533
  * SPEC-716 / SPEC-780: Format gate for transition to 'approved'.
544
534
  *
@@ -576,17 +566,22 @@ export async function checkApprovedFormatGate(spec, newStatus, forceApprove = fa
576
566
  content: [
577
567
  {
578
568
  type: 'text',
579
- text: JSON.stringify({
569
+ text: formatKeyValue({
580
570
  error: 'SPEC_FORMAT_INVALID',
581
571
  message: `Spec format validation failed with ${String(result.errors.length)} error(s) — fix before approving, or pass forceApprove: true to bypass with warnings.`,
582
- errors: result.errors,
583
- warnings: result.warnings,
584
- }, null, 2),
572
+ errors: result.errors.length,
573
+ warnings: result.warnings.length,
574
+ firstError: result.errors[0]?.message,
575
+ firstErrorPath: result.errors[0]?.path,
576
+ }, 'Spec format invalid'),
585
577
  },
586
578
  ],
587
579
  isError: true,
588
580
  structuredContent: {
589
581
  error: 'SPEC_FORMAT_INVALID',
582
+ message: `Spec format validation failed with ${String(result.errors.length)} error(s) — fix before approving, or pass forceApprove: true to bypass with warnings.`,
583
+ errors: result.errors,
584
+ warnings: result.warnings,
590
585
  code: 422,
591
586
  context: {
592
587
  specId: spec.id,
@@ -7,6 +7,7 @@ import { buildSpecEvidenceIndex } from '../../engine/evidence-index/index-builde
7
7
  import { checkDoneDriftContract } from '../../engine/evidence-index/done-drift.js';
8
8
  import { parseTechnicalReferenceGroundingRecords } from '../../engine/spec-grounding/contract.js';
9
9
  import { extractAcceptanceCriteriaTexts } from '../../engine/spec-format/acceptance-criteria.js';
10
+ import { formatKeyValue } from '../output-formatter.js';
10
11
  /** SPEC-1054: BDD/SDD lifecycle evidence gate. */
11
12
  export async function checkLifecycleEvidenceTransitionGate(args) {
12
13
  const [criteriaResult, artifacts] = await Promise.all([
@@ -69,15 +70,16 @@ function evidenceGateError(args) {
69
70
  content: [
70
71
  {
71
72
  type: 'text',
72
- text: JSON.stringify({
73
+ text: formatKeyValue({
73
74
  error: 'lifecycle_evidence_gate_failed',
74
75
  message: `BDD/SDD evidence gate blocked ${args.transition}.`,
75
76
  specId: args.specId,
76
77
  transition: args.transition,
77
- issues: args.issues,
78
- requiredContractKinds: args.requiredContractKinds,
78
+ issues: args.issues.length,
79
+ firstIssue: args.issues[0]?.message,
80
+ requiredContractKinds: args.requiredContractKinds.length,
79
81
  fixHint: 'Add the missing evidence artifact to the external Planu project data handoff store, then retry the transition. Do not create JSON files under planu/specs/<spec>/.',
80
- }, null, 2),
82
+ }),
81
83
  },
82
84
  ],
83
85
  isError: true,
@@ -26,6 +26,7 @@ import { updateFrontmatterField } from '../../engine/frontmatter-parser.js';
26
26
  import { atomicWriteFile } from '../../engine/safety/atomic-write-file.js';
27
27
  import { recordForceUsage } from '../../storage/force-analytics-store.js';
28
28
  import { maybeSafePushOnDone, runCascadeForResponse } from './side-effects.js';
29
+ import { formatKeyValue } from '../output-formatter.js';
29
30
  import { checkCodeReality } from '../../engine/code-scanner/index.js';
30
31
  import { scanCrashRisks } from '../../engine/crash-shield/index.js';
31
32
  import { shouldSkipCrashScan, recordCrashScanRun, } from '../../engine/autopilot/crash-shield-rate-limiter.js';
@@ -341,11 +342,11 @@ export async function handleUpdateStatus(params, server) {
341
342
  content: [
342
343
  {
343
344
  type: 'text',
344
- text: JSON.stringify({
345
+ text: formatKeyValue({
345
346
  error: 'invalid_input',
346
347
  message: reverseValidation.error,
347
348
  fixHint: reverseValidation.fixHint,
348
- }, null, 2),
349
+ }),
349
350
  },
350
351
  ],
351
352
  isError: true,
@@ -466,7 +467,7 @@ export async function handleUpdateStatus(params, server) {
466
467
  // SPEC-642: QA gate — block done if typecheck + test:coverage have not passed
467
468
  // ---------------------------------------------------------------------------
468
469
  if (newStatus === 'done') {
469
- const qaGateResult = await checkQaGate(specId, effectiveGatePath, params.force ?? false);
470
+ const qaGateResult = await checkQaGate(spec, effectiveGatePath, params.force ?? false);
470
471
  if (qaGateResult !== null) {
471
472
  return qaGateResult;
472
473
  }
@@ -503,6 +504,7 @@ export async function handleUpdateStatus(params, server) {
503
504
  // SPEC-447: Compliance gate on 'review' or 'implementing'
504
505
  // ---------------------------------------------------------------------------
505
506
  let validateScore = null;
507
+ let validateScoreSource = null;
506
508
  let crashShieldWarning = null;
507
509
  let crashShieldSkipReason = null;
508
510
  let complianceGateResult = null;
@@ -537,11 +539,15 @@ export async function handleUpdateStatus(params, server) {
537
539
  }
538
540
  validateScore = validateGateResult.score;
539
541
  if (validateGateResult.forced) {
542
+ validateScoreSource = validateGateResult.scoreSource;
540
543
  forcedValidateBypass = {
541
544
  reason: validateGateResult.forcedReason,
542
545
  observedScore: validateGateResult.score,
543
546
  };
544
547
  }
548
+ else {
549
+ validateScoreSource = 'validateSpec';
550
+ }
545
551
  }
546
552
  if (newStatus === 'done' && !(params.force ?? params.forceStatus ?? false)) {
547
553
  const validationReportError = await checkValidationReportGate(specId, projectId, false);
@@ -1045,6 +1051,7 @@ export async function handleUpdateStatus(params, server) {
1045
1051
  forcedBypassWarning,
1046
1052
  forceAnalyticsWarning,
1047
1053
  validateScore,
1054
+ validateScoreSource,
1048
1055
  // SPEC-721: audit ID for the forced validate bypass (null when no bypass occurred)
1049
1056
  forceStatusAuditId,
1050
1057
  // 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
- lines.push(`\n✅ Validate score: ${String(r.validateScore)}/100`);
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: { ...result, nextAction, lifecycleMap, humanSummary },
303
+ structuredContent,
210
304
  };
211
305
  }
212
306
  /** Render a compact lifecycle map line showing current, next, and remaining steps */
@@ -8,6 +8,7 @@ import { checkReadinessInternal } from '../../engine/readiness-checker.js';
8
8
  import { validateEnglishOnlySpecText } from '../../engine/spec-language/english-only.js';
9
9
  import { checkGroundedSpecContract } from '../../engine/spec-grounding/contract.js';
10
10
  import { checkGenericSpecOutput } from '../../engine/spec-quality/generic-output-gate.js';
11
+ import { formatKeyValue } from '../output-formatter.js';
11
12
  /**
12
13
  * Valid state transitions for spec lifecycle.
13
14
  * draft -> review -> approved -> implementing -> done
@@ -133,16 +134,29 @@ export function checkDorGate(spec, specId, projectId, newStatus) {
133
134
  content: [
134
135
  {
135
136
  type: 'text',
136
- text: JSON.stringify({
137
- error: 'DoR gate failed',
138
- message: dorResult.humanMessage,
139
- expertSummary: dorResult.expertSummary,
140
- blockingItems: dorResult.blockingItems,
141
- warningItems: dorResult.warningItems,
142
- }, null, 2),
137
+ text: [
138
+ dorResult.humanMessage,
139
+ dorResult.expertSummary,
140
+ ...dorResult.blockingItems.slice(0, 3).map((item) => `Blocker: ${item}`),
141
+ dorResult.blockingItems.length > 3
142
+ ? `More blockers: ${String(dorResult.blockingItems.length - 3)}`
143
+ : undefined,
144
+ dorResult.warningItems.length > 0
145
+ ? `Warnings: ${String(dorResult.warningItems.length)}`
146
+ : undefined,
147
+ ]
148
+ .filter(Boolean)
149
+ .join('\n'),
143
150
  },
144
151
  ],
145
152
  isError: true,
153
+ structuredContent: {
154
+ error: 'DoR gate failed',
155
+ message: dorResult.humanMessage,
156
+ expertSummary: dorResult.expertSummary,
157
+ blockingItems: dorResult.blockingItems,
158
+ warningItems: dorResult.warningItems,
159
+ },
146
160
  };
147
161
  }
148
162
  return null;
@@ -163,16 +177,24 @@ export async function checkAmbiguityGate(spec, newStatus) {
163
177
  content: [
164
178
  {
165
179
  type: 'text',
166
- text: JSON.stringify({
180
+ text: formatKeyValue({
167
181
  error: 'Ambiguity gate failed',
168
182
  message: `Ambiguity score ${String(report.score)}/100 — below 70. Fix vague terms in criteria before approving.`,
169
183
  ambiguityScore: report.score,
170
- violations: report.violations.slice(0, 5),
171
- blockers: report.blockers.slice(0, 5),
172
- }, null, 2),
184
+ violations: report.violations.length,
185
+ blockers: report.blockers.length,
186
+ firstBlocker: report.blockers[0],
187
+ }, 'Ambiguity gate failed'),
173
188
  },
174
189
  ],
175
190
  isError: true,
191
+ structuredContent: {
192
+ error: 'Ambiguity gate failed',
193
+ message: `Ambiguity score ${String(report.score)}/100 — below 70. Fix vague terms in criteria before approving.`,
194
+ ambiguityScore: report.score,
195
+ violations: report.violations.slice(0, 5),
196
+ blockers: report.blockers.slice(0, 5),
197
+ },
176
198
  };
177
199
  }
178
200
  /**
@@ -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
- return { output: raw.trim(), ok: false };
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 { output, ok } = await execGate(cmd, projectPath, 20_000);
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) => /fail|error/i.test(l) && l.trim().length > 0);
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
  // ---------------------------------------------------------------------------
@@ -1,11 +1,33 @@
1
1
  import { specStore } from '../storage/index.js';
2
2
  import { validateTeamResults } from '../engine/team-planner/index.js';
3
3
  import { readFile } from 'node:fs/promises';
4
+ import { formatKeyValue } from './output-formatter.js';
4
5
  // ---------------------------------------------------------------------------
5
6
  // Helpers
6
7
  // ---------------------------------------------------------------------------
7
8
  function text(content) {
8
- return { content: [{ type: 'text', text: JSON.stringify(content, null, 2) }] };
9
+ const conflicts = Array.isArray(content.conflicts) ? content.conflicts.length : undefined;
10
+ const mergeOrder = Array.isArray(content.mergeOrder) ? content.mergeOrder.length : undefined;
11
+ const mergeOrderPreview = Array.isArray(content.mergeOrder)
12
+ ? content.mergeOrder.slice(0, 5).join(', ')
13
+ : undefined;
14
+ const warnings = Array.isArray(content.warnings) ? content.warnings.length : undefined;
15
+ return {
16
+ content: [
17
+ {
18
+ type: 'text',
19
+ text: formatKeyValue({
20
+ passed: content.passed,
21
+ conflicts,
22
+ mergeOrder,
23
+ mergeOrderPreview,
24
+ warnings,
25
+ summary: content.summary,
26
+ }) + '\nVerification commands: pnpm typecheck; pnpm lint; pnpm test',
27
+ },
28
+ ],
29
+ structuredContent: content,
30
+ };
9
31
  }
10
32
  function err(message) {
11
33
  return { content: [{ type: 'text', text: message }], isError: true };