@planu/cli 4.11.7 → 4.11.9

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 (52) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli/commands/serve.js +4 -0
  3. package/dist/config/license-plans.json +1 -0
  4. package/dist/engine/browser-validator.js +26 -21
  5. package/dist/engine/crash-shield/file-collector.d.ts +20 -3
  6. package/dist/engine/crash-shield/file-collector.js +137 -8
  7. package/dist/engine/crash-shield/index.d.ts +18 -1
  8. package/dist/engine/crash-shield/index.js +58 -17
  9. package/dist/engine/dogfooding/runtime-gap-detector.d.ts +3 -0
  10. package/dist/engine/dogfooding/runtime-gap-detector.js +386 -0
  11. package/dist/engine/figma/visual-qa.d.ts +2 -1
  12. package/dist/engine/figma/visual-qa.js +8 -7
  13. package/dist/engine/qa-gate.js +2 -1
  14. package/dist/engine/session-safeguard/checkpoint-runner.js +3 -7
  15. package/dist/engine/spec-state-machine/transition-spec.d.ts +16 -1
  16. package/dist/engine/spec-state-machine/transition-spec.js +19 -4
  17. package/dist/engine/triagier/classifier.d.ts +2 -2
  18. package/dist/engine/triagier/classifier.js +12 -15
  19. package/dist/index.js +12 -4
  20. package/dist/storage/approval-operation-lock.d.ts +10 -0
  21. package/dist/storage/approval-operation-lock.js +44 -0
  22. package/dist/storage/approval-store.d.ts +2 -0
  23. package/dist/storage/approval-store.js +9 -1
  24. package/dist/storage/spec-store.d.ts +29 -2
  25. package/dist/storage/spec-store.js +307 -7
  26. package/dist/tools/approval-handler.js +255 -124
  27. package/dist/tools/browser-validate-handler.js +17 -3
  28. package/dist/tools/dogfood-watch.d.ts +6 -0
  29. package/dist/tools/dogfood-watch.js +48 -0
  30. package/dist/tools/figma/visual-qa.js +2 -1
  31. package/dist/tools/tool-registry/core-tools.js +12 -0
  32. package/dist/tools/tool-registry/group-quality-compliance.js +12 -1
  33. package/dist/tools/update-status/file-sync.js +3 -2
  34. package/dist/tools/update-status/index.d.ts +2 -0
  35. package/dist/tools/update-status/index.js +1083 -821
  36. package/dist/tools/update-status/response-builder.js +11 -0
  37. package/dist/tools/update-status/side-effects.d.ts +16 -1
  38. package/dist/tools/update-status/side-effects.js +140 -0
  39. package/dist/tools/update-status/transition-guard.js +1 -1
  40. package/dist/tools/update-status-actions.d.ts +10 -2
  41. package/dist/tools/update-status-actions.js +166 -192
  42. package/dist/tools/update-status-convention-gate.d.ts +3 -1
  43. package/dist/tools/update-status-convention-gate.js +135 -7
  44. package/dist/types/browser-validator.d.ts +2 -0
  45. package/dist/types/dogfooding.d.ts +34 -0
  46. package/dist/types/dogfooding.js +2 -0
  47. package/dist/types/index.d.ts +1 -0
  48. package/dist/types/index.js +1 -0
  49. package/dist/types/spec/core.d.ts +28 -1
  50. package/package.json +25 -25
  51. package/planu-native.json +1 -1
  52. package/planu-plugin.json +1 -1
@@ -2,16 +2,17 @@ import { elicitOrFallback, buildEnumSchema } from '../../engine/elicitation/elic
2
2
  import { ti } from '../../i18n/index.js';
3
3
  import { AutopilotSummaryCollector } from '../../engine/autopilot/summary-collector.js';
4
4
  import { specStore, knowledgeStore } from '../../storage/index.js';
5
- import { transitionSpec } from '../../engine/spec-state-machine/transition-spec.js';
5
+ import { rollbackTransitionSpec, transitionSpec, } from '../../engine/spec-state-machine/transition-spec.js';
6
6
  import { calculateAccuracy } from '../../engine/estimator.js';
7
7
  import { cascadeCheck } from '../../engine/spec-versioner.js';
8
8
  import { runComplianceGates } from '../update-status-convention-gate.js';
9
- import { runImplementingActions, runDoneActions } from '../update-status-actions.js';
9
+ import { runDoneActions, runDoneSideEffects, runImplementingActions, runImplementingSideEffects, } from '../update-status-actions.js';
10
10
  import { compactObj } from '../../engine/compact-obj.js';
11
11
  import { checkTransition, checkDorGate, checkAmbiguityGate, checkReadinessGate, checkChallengeGate, resolveAutoAdvanceSteps, isReverseTransition, validateReverseTransition, } from './transition-guard.js';
12
12
  import { checkApprovedDepGate } from '../../engine/dep-guard/index.js';
13
13
  import { checkApprovalGate } from '../../engine/approval-workflow.js';
14
14
  import * as approvalStore from '../../storage/approval-store.js';
15
+ import { withApprovalSpecLock } from '../../storage/approval-operation-lock.js';
15
16
  import { isLocked, getLock } from '../../storage/spec-lock-store.js';
16
17
  import { runValidateGate, checkDoneGates, checkComplianceGate, checkQaGate, checkApprovedFormatGate, readApprovedValidationReportGate, checkSpecReviewGate, writeSpecReviewArtifact, } from './dod-gates.js';
17
18
  import { writeImplementationReviewReport } from '../../engine/validator/validation-report-writer.js';
@@ -20,11 +21,11 @@ import { buildStatusResponse, buildValidateBlockedResponse, buildDryRunResponse,
20
21
  import { recordDoneMetrics, syncSpecFiles, tryReconcile, recordTerminalTransitionEvent, } from './file-sync.js';
21
22
  import { appendEntry, getLastHash } from '../../storage/audit-trail-store.js';
22
23
  import { appendTransitionEvent } from '../../storage/transition-log.js';
23
- import { randomUUID as uuid } from 'node:crypto';
24
+ import { createHash, randomUUID as uuid } from 'node:crypto';
24
25
  import { updateFrontmatterField } from '../../engine/frontmatter-parser.js';
25
26
  import { atomicWriteFile } from '../../engine/safety/atomic-write-file.js';
26
27
  import { recordForceUsage } from '../../storage/force-analytics-store.js';
27
- import { maybeSafePushOnDone, runCascadeForResponse } from './side-effects.js';
28
+ import { maybeSafePushOnDone, queuePostCommitTasks, runCascadeForResponse, } from './side-effects.js';
28
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';
@@ -144,11 +145,265 @@ async function createVersionSnapshot(specId, projectId, tag) {
144
145
  await mkdir(dirname(snapshotPath), { recursive: true });
145
146
  await writeFile(snapshotPath, JSON.stringify(spec, null, 2), 'utf-8');
146
147
  }
147
- function checkIdempotentOrTransition(specId, current, target) {
148
- if (current === target) {
149
- return { content: [{ type: 'text', text: `Spec ${specId} is already ${target}.` }] };
148
+ function checkIdempotentOrTransition(projectId, spec, target) {
149
+ if (spec.status === target) {
150
+ const latestReceipt = [...(spec.statusHistory ?? [])]
151
+ .reverse()
152
+ .find((entry) => entry.status === target);
153
+ const committedAt = latestReceipt?.changedAt ?? spec.updatedAt;
154
+ const transitionId = latestReceipt?.transitionId ??
155
+ createHash('sha256')
156
+ .update(['update_status:legacy:v1', projectId, spec.id, target, committedAt].join('\0'))
157
+ .digest('hex')
158
+ .slice(0, 24);
159
+ const pendingBackgroundActions = [...(latestReceipt?.pendingBackgroundActions ?? [])];
160
+ return {
161
+ content: [
162
+ {
163
+ type: 'text',
164
+ text: `Spec ${spec.id} is already ${target}; the committed transition was reused.`,
165
+ },
166
+ ],
167
+ structuredContent: {
168
+ specId: spec.id,
169
+ previousStatus: latestReceipt?.fromStatus ?? target,
170
+ newStatus: target,
171
+ committed: true,
172
+ transitionId,
173
+ committedAt,
174
+ idempotent: true,
175
+ pendingBackgroundActions,
176
+ },
177
+ };
178
+ }
179
+ return checkTransition(spec.status, target);
180
+ }
181
+ function planBackgroundActions(args) {
182
+ const actions = ['cascade'];
183
+ if (args.newStatus === 'implementing') {
184
+ actions.push('implementing-side-effects');
185
+ }
186
+ if (args.newStatus === 'approved') {
187
+ actions.push('version-snapshot');
188
+ }
189
+ if (args.newStatus === 'done') {
190
+ actions.push('done-side-effects', 'reconcile');
191
+ if (args.hasActuals) {
192
+ actions.push('record-done-metrics');
193
+ }
194
+ if (args.hasProjectPath) {
195
+ actions.push('autopush');
196
+ }
197
+ }
198
+ if (args.shouldAutoStage) {
199
+ actions.push('git-autostage');
200
+ }
201
+ return actions;
202
+ }
203
+ function buildPostCommitTasks(args) {
204
+ const enabled = new Set(args.actionNames);
205
+ const tasks = [];
206
+ const versionSnapshotTag = args.versionSnapshotTag ?? `approved-${args.committedAt.replace(/[-:]/g, '').slice(0, 13)}`;
207
+ if (enabled.has('version-snapshot')) {
208
+ tasks.push({
209
+ name: 'version-snapshot',
210
+ run: () => createVersionSnapshot(args.spec.id, args.projectId, versionSnapshotTag),
211
+ });
212
+ }
213
+ if (enabled.has('implementing-side-effects')) {
214
+ tasks.push({
215
+ name: 'implementing-side-effects',
216
+ run: () => runImplementingSideEffects(args.projectId, args.spec.id),
217
+ });
218
+ }
219
+ if (enabled.has('done-side-effects')) {
220
+ tasks.push({
221
+ name: 'done-side-effects',
222
+ run: () => runDoneSideEffects(args.projectId, args.spec.id, args.spec.gitBranch),
223
+ });
150
224
  }
151
- return checkTransition(current, target);
225
+ if (enabled.has('reconcile')) {
226
+ tasks.push({
227
+ name: 'reconcile',
228
+ run: () => tryReconcile(args.spec.status, args.spec.id, args.projectId),
229
+ });
230
+ }
231
+ const actuals = args.actuals;
232
+ if (enabled.has('record-done-metrics') && actuals) {
233
+ tasks.push({
234
+ name: 'record-done-metrics',
235
+ run: () => recordDoneMetrics(args.projectId, args.spec.id, args.spec, actuals, args.knowledge?.projectPath),
236
+ });
237
+ }
238
+ const effectiveProjectPath = args.effectiveProjectPath;
239
+ if (enabled.has('autopush') && effectiveProjectPath) {
240
+ tasks.push({
241
+ name: 'autopush',
242
+ run: () => maybeSafePushOnDone(effectiveProjectPath, args.spec.status),
243
+ });
244
+ }
245
+ if (enabled.has('cascade')) {
246
+ tasks.push({
247
+ name: 'cascade',
248
+ run: () => runCascadeForResponse({
249
+ knowledge: args.knowledge,
250
+ allSpecs: args.allSpecs,
251
+ specId: args.spec.id,
252
+ newStatus: args.spec.status,
253
+ validateScore: args.validateScore,
254
+ projectId: args.projectId,
255
+ spec: args.spec,
256
+ currentStatus: args.previousStatus,
257
+ }),
258
+ });
259
+ }
260
+ if (enabled.has('git-autostage') && effectiveProjectPath) {
261
+ tasks.push({
262
+ name: 'git-autostage',
263
+ run: async () => {
264
+ const { git: gitCmd } = await import('../git/git-helpers.js');
265
+ await gitCmd(effectiveProjectPath, ['add', 'planu/']);
266
+ const { planuAutoCommit } = await import('./../../engine/git/planu-autocommit.js');
267
+ await planuAutoCommit({
268
+ projectPath: effectiveProjectPath,
269
+ specId: args.spec.id,
270
+ reason: args.spec.status === 'done' ? 'mark-done' : 'status-update',
271
+ });
272
+ },
273
+ });
274
+ }
275
+ const knownNames = new Set(tasks.map((task) => task.name));
276
+ for (const unknownName of enabled) {
277
+ if (knownNames.has(unknownName)) {
278
+ continue;
279
+ }
280
+ tasks.push({
281
+ name: unknownName,
282
+ run: () => Promise.reject(new Error(`Unknown post-commit task: ${unknownName}`)),
283
+ });
284
+ }
285
+ return tasks;
286
+ }
287
+ async function replayIdempotentPostCommitTasks(args) {
288
+ const receipt = [...(args.spec.statusHistory ?? [])]
289
+ .reverse()
290
+ .find((entry) => entry.status === args.spec.status);
291
+ if (!receipt?.transitionId || !receipt.pendingBackgroundActions?.length) {
292
+ return;
293
+ }
294
+ const knowledge = await knowledgeStore.getKnowledge(args.projectId);
295
+ const effectiveProjectPath = knowledge?.projectPath ?? args.projectPath;
296
+ const specAtTransition = { ...args.spec, status: receipt.status };
297
+ const tasks = buildPostCommitTasks({
298
+ actionNames: receipt.pendingBackgroundActions,
299
+ projectId: args.projectId,
300
+ spec: specAtTransition,
301
+ previousStatus: receipt.fromStatus ?? args.spec.status,
302
+ committedAt: receipt.changedAt,
303
+ knowledge,
304
+ allSpecs: await specStore.listSpecs(args.projectId),
305
+ validateScore: null,
306
+ actuals: specAtTransition.actuals ?? undefined,
307
+ effectiveProjectPath,
308
+ });
309
+ queuePostCommitTasks({
310
+ projectId: args.projectId,
311
+ specId: args.spec.id,
312
+ transitionId: receipt.transitionId,
313
+ tasks,
314
+ });
315
+ }
316
+ async function recoverPendingPostCommitTasks(projectId, projectPath) {
317
+ const [knowledge, specs] = await Promise.all([
318
+ knowledgeStore.getKnowledge(projectId),
319
+ specStore.listSpecs(projectId),
320
+ ]);
321
+ const effectiveProjectPath = knowledge?.projectPath ?? projectPath;
322
+ for (const spec of specs) {
323
+ const hasPendingWork = (spec.statusHistory ?? []).some((receipt) => receipt.transitionId && receipt.pendingBackgroundActions?.length);
324
+ if (!hasPendingWork) {
325
+ continue;
326
+ }
327
+ const recoveryLock = await acquireLock(effectiveProjectPath ?? projectPath ?? process.cwd(), spec.id, { reason: 'recover pending post-commit tasks' });
328
+ if (recoveryLock === null) {
329
+ continue;
330
+ }
331
+ try {
332
+ const currentSpec = typeof specStore.getSpecFresh === 'function'
333
+ ? await specStore.getSpecFresh(projectId, spec.id)
334
+ : spec;
335
+ if (!currentSpec) {
336
+ continue;
337
+ }
338
+ for (const receipt of currentSpec.statusHistory ?? []) {
339
+ if (!receipt.transitionId || !receipt.pendingBackgroundActions?.length) {
340
+ continue;
341
+ }
342
+ const specAtTransition = { ...currentSpec, status: receipt.status };
343
+ const tasks = buildPostCommitTasks({
344
+ actionNames: receipt.pendingBackgroundActions,
345
+ projectId,
346
+ spec: specAtTransition,
347
+ previousStatus: receipt.fromStatus ?? currentSpec.status,
348
+ committedAt: receipt.changedAt,
349
+ knowledge,
350
+ allSpecs: specs,
351
+ validateScore: null,
352
+ actuals: specAtTransition.actuals ?? undefined,
353
+ effectiveProjectPath,
354
+ });
355
+ queuePostCommitTasks({
356
+ projectId,
357
+ specId: currentSpec.id,
358
+ transitionId: receipt.transitionId,
359
+ tasks,
360
+ });
361
+ }
362
+ }
363
+ finally {
364
+ await releaseLock(recoveryLock).catch(() => undefined);
365
+ }
366
+ }
367
+ }
368
+ /** Recover durable post-commit work for every registered project after server startup. */
369
+ export async function recoverPendingPostCommitTasksAtStartup() {
370
+ const { getProjects } = await import('../../storage/global-projects-store.js');
371
+ const projects = await getProjects();
372
+ const results = await Promise.allSettled(projects.map((project) => recoverPendingPostCommitTasks(project.hash, project.path)));
373
+ for (let index = 0; index < results.length; index += 1) {
374
+ const result = results[index];
375
+ if (result?.status !== 'rejected') {
376
+ continue;
377
+ }
378
+ console.warn('[planu:post-commit] startup recovery failed for project', {
379
+ projectId: projects[index]?.hash ?? 'unknown',
380
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
381
+ });
382
+ }
383
+ }
384
+ function buildPersistenceFailureResponse(args) {
385
+ const committed = args.recoveryRequired ? null : false;
386
+ const message = args.recoveryRequired
387
+ ? `Transition persistence became indeterminate for ${args.specId}; manual recovery is required.`
388
+ : `Transition ${args.specId} → ${args.newStatus} was not committed; the previous state was restored.`;
389
+ return {
390
+ isError: true,
391
+ content: [{ type: 'text', text: `${message} Detail: ${args.reason}` }],
392
+ structuredContent: {
393
+ error: args.recoveryRequired
394
+ ? 'transition_recovery_required'
395
+ : 'transition_persistence_failed',
396
+ specId: args.specId,
397
+ previousStatus: args.previousStatus,
398
+ newStatus: args.newStatus,
399
+ committed,
400
+ recoveryRequired: args.recoveryRequired,
401
+ reason: args.reason,
402
+ fixHint: args.recoveryRequired
403
+ ? 'Run repair_frontmatter_drift and inspect the canonical spec store before retrying.'
404
+ : 'Fix the persistence error and retry update_status; no transition was committed.',
405
+ },
406
+ };
152
407
  }
153
408
  /** SPEC-628: Check crash scan rate-limit and return skip reason if within window. */
154
409
  async function checkCrashScanRateLimit(newStatus, effectiveGatePath) {
@@ -247,930 +502,937 @@ export async function handleUpdateStatus(params, server) {
247
502
  /* strict cleanup is best-effort here; validate fails closed if artifacts remain */
248
503
  }
249
504
  }
250
- try {
251
- // Get the current spec
252
- const spec = await specStore.getSpec(projectId, specId);
253
- if (!spec) {
254
- return {
255
- content: [{ type: 'text', text: ti('spec.notFound', { id: specId }) }],
256
- isError: true,
257
- };
258
- }
259
- const originalStatus = spec.status;
260
- if (newStatus === 'done' && actuals === undefined) {
261
- return {
262
- content: [
263
- {
264
- type: 'text',
265
- text: 'actuals_required: provide measured values for done. Use zero only when a metric is explicitly unavailable; Planu does not estimate provider tokens or costs.',
266
- },
267
- ],
268
- isError: true,
269
- structuredContent: {
270
- error: 'actuals_required',
271
- code: 422,
272
- fixHint: 'Provide actuals with measured values or zero for unavailable metrics. Values are preserved without estimation.',
273
- },
274
- };
275
- }
276
- // SPEC-301: Reject status changes when spec is locked by another holder (legacy manual lock)
277
- const lockError = await checkSpecLockGate(params.projectPath, specId);
278
- if (lockError) {
279
- return lockError;
280
- }
281
- // SPEC-731: dry_run never acquires the cross-process lock.
282
- // SPEC-719: Cross-process disk lock — acquire before any spec.md mutation.
283
- // Released in finally below. Best-effort when no projectPath is available.
284
- const isDryRun = params.dry_run === true;
285
- let crossProcessLockHandle = null;
286
- if (!isDryRun && params.projectPath !== undefined) {
287
- crossProcessLockHandle = await acquireLock(params.projectPath, specId, {
288
- reason: `update_status(${newStatus})`,
289
- });
290
- if (crossProcessLockHandle === null) {
291
- // acquireLock returns null only when another live session holds the lock
292
- // For dry_run, return a special response indicating lock is busy
293
- return {
294
- content: [
295
- {
296
- type: 'text',
297
- text: `Spec ${specId} is locked by another process (cross-process lock). Retry later or inspect data/.locks/planu/${specId}.lock`,
298
- },
299
- ],
300
- isError: true,
301
- };
302
- }
303
- }
304
- else if (isDryRun && params.projectPath !== undefined) {
305
- // SPEC-731: dry_run checks lock availability without acquiring it.
306
- // isLocked returns null when no lockfile exists (available), or a LockInfo when locked.
307
- const lockInfo = await isCrossProcessLocked(params.projectPath, specId).catch(() => null);
308
- const lockIsBusy = lockInfo !== null && !lockInfo.stale;
309
- if (lockIsBusy) {
310
- // Lock is busy — return dry_run result indicating this without blocking
311
- return buildDryRunResponse({
312
- ok: true,
313
- dryRun: true,
314
- wouldTransition: false,
315
- gates: {
316
- dod: 'skip',
317
- validate: 'skip',
318
- qa: 'skip',
319
- security: 'skip',
320
- lock: 'busy',
321
- compliance: 'skip',
322
- },
323
- blockingReasons: ['Cross-process lock is held by another process'],
324
- });
325
- }
326
- }
505
+ await recoverPendingPostCommitTasks(projectId, effectiveProjectPath).catch((error) => {
506
+ console.warn('[planu:post-commit] automatic recovery scan failed', {
507
+ projectId,
508
+ error: error instanceof Error ? error.message : String(error),
509
+ });
510
+ });
511
+ return withApprovalSpecLock(effectiveProjectPath, specId, async () => {
327
512
  try {
328
- if (spec.status === newStatus) {
329
- const idempotentResult = checkIdempotentOrTransition(specId, spec.status, newStatus);
330
- if (idempotentResult) {
331
- return idempotentResult;
332
- }
333
- }
334
- // SPEC-280/SPEC-1122: plan every intermediate state in memory. No status is
335
- // persisted until all gates for the complete path have passed.
336
- const { plannedStatuses, stepsExecuted } = buildAutoAdvancePlan(spec.status, newStatus);
337
- let plannedFromStatus = spec.status;
338
- for (const plannedStatus of plannedStatuses) {
339
- const transitionError = checkTransition(plannedFromStatus, plannedStatus);
340
- if (transitionError) {
341
- return transitionError;
342
- }
343
- plannedFromStatus = plannedStatus;
513
+ // SPEC-301: Reject status changes when spec is locked by another holder (legacy manual lock)
514
+ const lockError = await checkSpecLockGate(effectiveProjectPath, specId);
515
+ if (lockError) {
516
+ return lockError;
344
517
  }
345
- // SPEC-733: Detect reverse transition and validate mandatory reason
346
- const reverseTransition = isReverseTransition(spec.status, newStatus);
347
- if (reverseTransition) {
348
- const reverseValidation = validateReverseTransition({
349
- from: spec.status,
350
- to: newStatus,
351
- reason: params.reason,
518
+ // SPEC-731: dry_run never acquires the cross-process lock.
519
+ // SPEC-719: Cross-process disk lock — acquire before any spec.md mutation.
520
+ // Released in finally below. Best-effort when no projectPath is available.
521
+ const isDryRun = params.dry_run === true;
522
+ let crossProcessLockHandle = null;
523
+ if (!isDryRun) {
524
+ crossProcessLockHandle = await acquireLock(effectiveProjectPath, specId, {
525
+ reason: `update_status(${newStatus})`,
352
526
  });
353
- if (!reverseValidation.ok) {
527
+ if (crossProcessLockHandle === null) {
528
+ // acquireLock returns null only when another live session holds the lock
529
+ // For dry_run, return a special response indicating lock is busy
354
530
  return {
355
531
  content: [
356
532
  {
357
533
  type: 'text',
358
- text: formatKeyValue({
359
- error: 'invalid_input',
360
- message: reverseValidation.error,
361
- fixHint: reverseValidation.fixHint,
362
- }),
534
+ text: `Spec ${specId} is locked by another process (cross-process lock). Retry later or inspect data/.locks/planu/${specId}.lock`,
363
535
  },
364
536
  ],
365
537
  isError: true,
366
- structuredContent: {
367
- error: 'invalid_input',
368
- code: 422,
369
- fixHint: reverseValidation.fixHint,
370
- },
371
538
  };
372
539
  }
373
540
  }
374
- // Gate: approval policy must be satisfied before transitioning to 'approved'
375
- const approvalGateStatus = plannedStatuses.includes('approved') ? 'approved' : newStatus;
376
- const approvalError = await checkApprovalPolicyGate(projectId, specId, approvalGateStatus);
377
- if (approvalError) {
378
- return approvalError;
379
- }
380
- // Gate: DoR must pass before transitioning to 'implementing'
381
- const dorGateStatus = plannedStatuses.includes('implementing') ? 'implementing' : newStatus;
382
- const dorError = checkDorGate(spec, specId, projectId, dorGateStatus);
383
- if (dorError) {
384
- return dorError;
385
- }
386
- // SPEC-716/SPEC-780: Format gate — block 'approved' unless forceApprove bypasses with warnings
387
- const formatGate = await checkApprovedFormatGate(spec, approvalGateStatus, params.forceApprove);
388
- if (formatGate.blockResult) {
389
- return formatGate.blockResult;
390
- }
391
- // SPEC-632: Ambiguity gate — block 'approved' if score < 70
392
- const ambiguityError = await checkAmbiguityGate(spec, approvalGateStatus);
393
- if (ambiguityError) {
394
- return ambiguityError;
395
- }
396
- // SPEC-769: Readiness gate — block 'approved' if spec has 0 criteria or score < 70
397
- const readinessGate = await checkReadinessGate(spec, approvalGateStatus, params.forceApprove);
398
- if (readinessGate.blockResult) {
399
- return readinessGate.blockResult;
541
+ else {
542
+ // SPEC-731: dry_run checks lock availability without acquiring it.
543
+ // isLocked returns null when no lockfile exists (available), or a LockInfo when locked.
544
+ const lockInfo = await isCrossProcessLocked(effectiveProjectPath, specId).catch(() => null);
545
+ const lockIsBusy = lockInfo !== null && !lockInfo.stale;
546
+ if (lockIsBusy) {
547
+ // Lock is busy return dry_run result indicating this without blocking
548
+ return buildDryRunResponse({
549
+ ok: true,
550
+ dryRun: true,
551
+ wouldTransition: false,
552
+ gates: {
553
+ dod: 'skip',
554
+ validate: 'skip',
555
+ qa: 'skip',
556
+ security: 'skip',
557
+ lock: 'busy',
558
+ compliance: 'skip',
559
+ },
560
+ blockingReasons: ['Cross-process lock is held by another process'],
561
+ });
562
+ }
400
563
  }
401
- // SPEC-728: DepGuard — block 'approved' if spec participates in a dependency cycle
402
- let depGuardResult = null;
403
- if (plannedStatuses.includes('approved')) {
404
- const allSpecsForDepGuard = await specStore.listSpecs(projectId);
405
- depGuardResult = checkApprovedDepGate(spec, allSpecsForDepGuard);
406
- if (depGuardResult.blocked) {
564
+ try {
565
+ // Reload only after the per-spec lock is held. This snapshot is the sole
566
+ // input for gates, transition persistence, and rollback compensation.
567
+ const spec = crossProcessLockHandle !== null && typeof specStore.getSpecFresh === 'function'
568
+ ? await specStore.getSpecFresh(projectId, specId)
569
+ : await specStore.getSpec(projectId, specId);
570
+ if (!spec) {
571
+ return {
572
+ content: [{ type: 'text', text: ti('spec.notFound', { id: specId }) }],
573
+ isError: true,
574
+ };
575
+ }
576
+ const originalStatus = spec.status;
577
+ if (spec.status === newStatus) {
578
+ await replayIdempotentPostCommitTasks({
579
+ projectId,
580
+ spec,
581
+ projectPath: effectiveProjectPath,
582
+ });
583
+ const idempotentResult = checkIdempotentOrTransition(projectId, spec, newStatus);
584
+ if (idempotentResult) {
585
+ return idempotentResult;
586
+ }
587
+ }
588
+ if (newStatus === 'done' && actuals === undefined) {
407
589
  return {
408
- content: [{ type: 'text', text: `DepGuard blocked: ${depGuardResult.narrative}` }],
590
+ content: [
591
+ {
592
+ type: 'text',
593
+ text: 'actuals_required: provide measured values for done. Use zero only when a metric is explicitly unavailable; Planu does not estimate provider tokens or costs.',
594
+ },
595
+ ],
409
596
  isError: true,
410
597
  structuredContent: {
411
- error: 'DEP_GUARD_BLOCKED',
412
- code: depGuardResult.code,
413
- cyclePath: depGuardResult.cyclePath,
414
- narrative: depGuardResult.narrative,
415
- depGuard: depGuardResult,
598
+ error: 'actuals_required',
599
+ code: 422,
600
+ fixHint: 'Provide actuals with measured values or zero for unavailable metrics. Values are preserved without estimation.',
416
601
  },
417
602
  };
418
603
  }
419
- }
420
- // SPEC-964: Challenge gate block 'review' if challenge_spec was never run
421
- const challengeGateStatus = plannedStatuses.includes('review') ? 'review' : newStatus;
422
- const challengeGate = checkChallengeGate(spec, challengeGateStatus);
423
- if (challengeGate) {
424
- return challengeGate;
425
- }
426
- if (plannedStatuses.includes('approved')) {
427
- const specReviewError = await checkSpecReviewGate(specId, projectId, params.forceApprove);
428
- if (specReviewError) {
429
- return specReviewError;
604
+ // SPEC-280/SPEC-1122: plan every intermediate state in memory. No status is
605
+ // persisted until all gates for the complete path have passed.
606
+ const { plannedStatuses, stepsExecuted } = buildAutoAdvancePlan(spec.status, newStatus);
607
+ let plannedFromStatus = spec.status;
608
+ for (const plannedStatus of plannedStatuses) {
609
+ const transitionError = checkTransition(plannedFromStatus, plannedStatus);
610
+ if (transitionError) {
611
+ return transitionError;
612
+ }
613
+ plannedFromStatus = plannedStatus;
430
614
  }
431
- }
432
- // SPEC-595: Elicit confirmation for destructive status transitions (→done with forceStatus)
433
- const elicitResult = await runForceStatusElicitation(server, specId, newStatus, params.forceStatus);
434
- if (elicitResult) {
435
- return elicitResult;
436
- }
437
- // Load knowledge for project path (needed for DoD gates, validate trigger and HTML regen)
438
- const knowledge = await knowledgeStore.getKnowledge(projectId);
439
- // Resolve effective project path once — used across all gates below
440
- const effectiveGatePath = knowledge?.projectPath ?? params.projectPath;
441
- // SPEC-1044: SDD model-routing + context continuity hard gate.
442
- const sddRoutingGate = {
443
- blockResult: null,
444
- gateResults: { sddModelRouting: 'skip' },
445
- forcedReasons: [],
446
- };
447
- if (!shouldSkipSddRoutingGateForLegacyTestHarness()) {
448
- const routedStatuses = plannedStatuses.filter((status) => status === 'approved' || status === 'implementing' || status === 'done');
449
- for (const status of routedStatuses) {
450
- const gate = await checkSddModelRoutingGate({
451
- params,
452
- status,
453
- projectPath: effectiveGatePath,
615
+ // SPEC-733: Detect reverse transition and validate mandatory reason
616
+ const reverseTransition = isReverseTransition(spec.status, newStatus);
617
+ if (reverseTransition) {
618
+ const reverseValidation = validateReverseTransition({
619
+ from: spec.status,
620
+ to: newStatus,
621
+ reason: params.reason,
454
622
  });
455
- if (gate.blockResult) {
456
- return gate.blockResult;
623
+ if (!reverseValidation.ok) {
624
+ return {
625
+ content: [
626
+ {
627
+ type: 'text',
628
+ text: formatKeyValue({
629
+ error: 'invalid_input',
630
+ message: reverseValidation.error,
631
+ fixHint: reverseValidation.fixHint,
632
+ }),
633
+ },
634
+ ],
635
+ isError: true,
636
+ structuredContent: {
637
+ error: 'invalid_input',
638
+ code: 422,
639
+ fixHint: reverseValidation.fixHint,
640
+ },
641
+ };
457
642
  }
458
- Object.assign(sddRoutingGate.gateResults, gate.gateResults);
459
- sddRoutingGate.forcedReasons.push(...gate.forcedReasons);
460
643
  }
461
- if (sddRoutingGate.forcedReasons.length > 0) {
462
- sddRoutingGate.gateResults.sddModelRouting = 'forced';
644
+ // Gate: approval policy must be satisfied before transitioning to 'approved'
645
+ const approvalGateStatus = plannedStatuses.includes('approved') ? 'approved' : newStatus;
646
+ const approvalError = await checkApprovalPolicyGate(projectId, specId, approvalGateStatus);
647
+ if (approvalError) {
648
+ return approvalError;
463
649
  }
464
- }
465
- // SPEC-1054: BDD/SDD evidence gates. Non-trivial specs must carry
466
- // Discovery before approval, task-plan before implementation, and
467
- // traceability/contract evidence before done.
468
- if (!shouldSkipEvidenceGateForLegacyTestHarness()) {
469
- const evidenceStatuses = plannedStatuses.filter((status) => status === 'approved' || status === 'implementing' || status === 'done');
470
- for (const transition of evidenceStatuses) {
471
- const evidenceGate = await checkLifecycleEvidenceTransitionGate({
472
- spec,
473
- specId,
474
- projectId,
475
- projectPath: effectiveGatePath,
476
- transition,
477
- });
478
- if (evidenceGate !== null) {
479
- return evidenceGate;
650
+ // Gate: DoR must pass before transitioning to 'implementing'
651
+ const dorGateStatus = plannedStatuses.includes('implementing')
652
+ ? 'implementing'
653
+ : newStatus;
654
+ const dorError = checkDorGate(spec, specId, projectId, dorGateStatus);
655
+ if (dorError) {
656
+ return dorError;
657
+ }
658
+ // SPEC-716/SPEC-780: Format gate — block 'approved' unless forceApprove bypasses with warnings
659
+ const formatGate = await checkApprovedFormatGate(spec, approvalGateStatus, params.forceApprove);
660
+ if (formatGate.blockResult) {
661
+ return formatGate.blockResult;
662
+ }
663
+ // SPEC-632: Ambiguity gate — block 'approved' if score < 70
664
+ const ambiguityError = await checkAmbiguityGate(spec, approvalGateStatus);
665
+ if (ambiguityError) {
666
+ return ambiguityError;
667
+ }
668
+ // SPEC-769: Readiness gate — block 'approved' if spec has 0 criteria or score < 70
669
+ const readinessGate = await checkReadinessGate(spec, approvalGateStatus, params.forceApprove);
670
+ if (readinessGate.blockResult) {
671
+ return readinessGate.blockResult;
672
+ }
673
+ // SPEC-728: DepGuard — block 'approved' if spec participates in a dependency cycle
674
+ let depGuardResult = null;
675
+ if (plannedStatuses.includes('approved')) {
676
+ const allSpecsForDepGuard = await specStore.listSpecs(projectId);
677
+ depGuardResult = checkApprovedDepGate(spec, allSpecsForDepGuard);
678
+ if (depGuardResult.blocked) {
679
+ return {
680
+ content: [{ type: 'text', text: `DepGuard blocked: ${depGuardResult.narrative}` }],
681
+ isError: true,
682
+ structuredContent: {
683
+ error: 'DEP_GUARD_BLOCKED',
684
+ code: depGuardResult.code,
685
+ cyclePath: depGuardResult.cyclePath,
686
+ narrative: depGuardResult.narrative,
687
+ depGuard: depGuardResult,
688
+ },
689
+ };
480
690
  }
481
691
  }
482
- }
483
- // ---------------------------------------------------------------------------
484
- // BATCH A (parallel): code-reality + done-gates — independent of each other
485
- // SPEC-441: Code reality check before transitioning to 'implementing'
486
- // SPEC-335: DoD + security gates before transitioning to 'done'
487
- // ---------------------------------------------------------------------------
488
- // SPEC-642: QA gate — block done if typecheck + test:coverage have not passed
489
- // ---------------------------------------------------------------------------
490
- if (newStatus === 'done') {
491
- const qaGateResult = await checkQaGate(spec, effectiveGatePath, params.force ?? false);
492
- if (qaGateResult !== null) {
493
- return qaGateResult;
692
+ // SPEC-964: Challenge gate — block 'review' if challenge_spec was never run
693
+ const challengeGateStatus = plannedStatuses.includes('review') ? 'review' : newStatus;
694
+ const challengeGate = checkChallengeGate(spec, challengeGateStatus);
695
+ if (challengeGate) {
696
+ return challengeGate;
494
697
  }
495
- }
496
- // ---------------------------------------------------------------------------
497
- let codeRealityWarning = null;
498
- let forcedBypassWarning = null;
499
- const [codeRealityResult, doneGatesResult] = await Promise.all([
500
- // Code reality: only relevant for 'implementing'
501
- newStatus === 'implementing' && !reverseTransition && effectiveGatePath
502
- ? checkCodeReality(effectiveGatePath, spec.title).catch(() => null)
503
- : Promise.resolve(null),
504
- // Done gates: only relevant for 'done'
505
- newStatus === 'done'
506
- ? checkDoneGates(spec, specId, projectId, effectiveGatePath, params.force, params.forceStatusReason ?? params.reason ?? 'No force reason provided')
507
- : Promise.resolve(null),
508
- ]);
509
- // Process code reality result
510
- if (codeRealityResult?.status === 'complete' || codeRealityResult?.status === 'partial') {
511
- codeRealityWarning = codeRealityResult.warning;
512
- }
513
- // Process done gates result — may block
514
- if (doneGatesResult !== null) {
515
- if (doneGatesResult.blocked) {
516
- return doneGatesResult.blocked;
698
+ if (plannedStatuses.includes('approved')) {
699
+ const specReviewError = await checkSpecReviewGate(specId, projectId, params.forceApprove);
700
+ if (specReviewError) {
701
+ return specReviewError;
702
+ }
517
703
  }
518
- forcedBypassWarning = doneGatesResult.forcedBypassWarning;
519
- }
520
- // ---------------------------------------------------------------------------
521
- // BATCH B (parallel): validate + crash-shield + compliance — independent of
522
- // each other, all need spec/projectPath (available from Batch A outputs)
523
- // SPEC-222: Auto-validate before marking done
524
- // SPEC-442: Crash Shield warning on 'done'
525
- // SPEC-447: Compliance gate on 'review' or 'implementing'
526
- // ---------------------------------------------------------------------------
527
- let validateScore = null;
528
- let validateScoreSource = null;
529
- let crashShieldWarning = null;
530
- let crashShieldSkipReason = null;
531
- let complianceGateResult = null;
532
- let validationReportGate = null;
533
- // SPEC-628: Rate-limit crash scan — check before entering the parallel batch
534
- crashShieldSkipReason = await checkCrashScanRateLimit(newStatus, effectiveGatePath ?? null);
535
- const [validateGateResult, crashRisksReport, complianceResult] = await Promise.all([
536
- // Validate: only on 'done'.
537
- // SPEC-721: timeout lives inside runValidateGate (Promise.race) — do NOT wrap with
538
- // withToolTimeout here, which would silently convert timeout into blocked:false (fail-open).
539
- newStatus === 'done'
540
- ? runValidateGate(spec, effectiveGatePath ?? '', params.forceStatus ?? false, params.forceStatusReason, 9_000, { projectId, specId })
541
- : Promise.resolve(null),
542
- // Crash shield: only on 'done', skipped if rate-limited (SPEC-628)
543
- newStatus === 'done' && effectiveGatePath && !crashShieldSkipReason
544
- ? withToolTimeout(scanCrashRisks(effectiveGatePath).catch(() => null), 9_000, null)
545
- : Promise.resolve(null),
546
- // The heuristic scorer is a review aid. Done relies on the authoritative validate report.
547
- plannedStatuses.includes('review')
548
- ? withToolTimeout(checkComplianceGate(specId, projectId, effectiveGatePath), 9_000, {
549
- skipped: true,
550
- blocked: false,
551
- score: null,
552
- issues: [],
553
- })
554
- : Promise.resolve(null),
555
- ]);
556
- // Process validate result — may block (SPEC-721: fail-closed)
557
- let forcedValidateBypass = null;
558
- if (validateGateResult !== null) {
559
- if (validateGateResult.blocked) {
560
- return buildValidateBlockedResponse(specId, validateGateResult);
704
+ // SPEC-595: Elicit confirmation for destructive status transitions (→done with forceStatus)
705
+ const elicitResult = await runForceStatusElicitation(server, specId, newStatus, params.forceStatus);
706
+ if (elicitResult) {
707
+ return elicitResult;
561
708
  }
562
- validateScore = validateGateResult.score;
563
- if (validateGateResult.forced) {
564
- validateScoreSource = validateGateResult.scoreSource;
565
- forcedValidateBypass = {
566
- reason: validateGateResult.forcedReason,
567
- observedScore: validateGateResult.score,
568
- };
709
+ // Load knowledge for project path (needed for DoD gates, validate trigger and HTML regen)
710
+ const knowledge = await knowledgeStore.getKnowledge(projectId);
711
+ // Resolve effective project path once — used across all gates below
712
+ const effectiveGatePath = knowledge?.projectPath ?? params.projectPath;
713
+ // SPEC-1044: SDD model-routing + context continuity hard gate.
714
+ const sddRoutingGate = {
715
+ blockResult: null,
716
+ gateResults: { sddModelRouting: 'skip' },
717
+ forcedReasons: [],
718
+ };
719
+ if (!shouldSkipSddRoutingGateForLegacyTestHarness()) {
720
+ const routedStatuses = plannedStatuses.filter((status) => status === 'approved' || status === 'implementing' || status === 'done');
721
+ for (const status of routedStatuses) {
722
+ const gate = await checkSddModelRoutingGate({
723
+ params,
724
+ status,
725
+ projectPath: effectiveGatePath,
726
+ });
727
+ if (gate.blockResult) {
728
+ return gate.blockResult;
729
+ }
730
+ Object.assign(sddRoutingGate.gateResults, gate.gateResults);
731
+ sddRoutingGate.forcedReasons.push(...gate.forcedReasons);
732
+ }
733
+ if (sddRoutingGate.forcedReasons.length > 0) {
734
+ sddRoutingGate.gateResults.sddModelRouting = 'forced';
735
+ }
569
736
  }
570
- else {
571
- validateScoreSource = 'validateSpec';
737
+ // SPEC-1054: BDD/SDD evidence gates. Non-trivial specs must carry
738
+ // Discovery before approval, task-plan before implementation, and
739
+ // traceability/contract evidence before done.
740
+ if (!shouldSkipEvidenceGateForLegacyTestHarness()) {
741
+ const evidenceStatuses = plannedStatuses.filter((status) => status === 'approved' || status === 'implementing' || status === 'done');
742
+ for (const transition of evidenceStatuses) {
743
+ const evidenceGate = await checkLifecycleEvidenceTransitionGate({
744
+ spec,
745
+ specId,
746
+ projectId,
747
+ projectPath: effectiveGatePath,
748
+ transition,
749
+ });
750
+ if (evidenceGate !== null) {
751
+ return evidenceGate;
752
+ }
753
+ }
572
754
  }
573
- }
574
- if (newStatus === 'done' &&
575
- effectiveGatePath &&
576
- !shouldSkipStrictLayoutGateForLegacyTestHarness()) {
577
- const strictLayoutError = await validateStrictLayoutOrError({
578
- projectPath: effectiveGatePath,
579
- specId,
580
- specPath: spec.specPath,
581
- failClosedOnCrash: true,
582
- });
583
- if (strictLayoutError) {
584
- return strictLayoutError;
755
+ // ---------------------------------------------------------------------------
756
+ // BATCH A (parallel): code-reality + done-gates — independent of each other
757
+ // SPEC-441: Code reality check before transitioning to 'implementing'
758
+ // SPEC-335: DoD + security gates before transitioning to 'done'
759
+ // ---------------------------------------------------------------------------
760
+ // SPEC-642: QA gate — block done if typecheck + test:coverage have not passed
761
+ // ---------------------------------------------------------------------------
762
+ if (newStatus === 'done') {
763
+ const qaGateResult = await checkQaGate(spec, effectiveGatePath, params.force ?? false);
764
+ if (qaGateResult !== null) {
765
+ return qaGateResult;
766
+ }
585
767
  }
586
- }
587
- // Process crash shield result — record run timestamp on success (SPEC-628)
588
- /* c8 ignore next */
589
- if (crashRisksReport !== null) {
590
- if (crashRisksReport.score < 80) {
591
- const criticalCount = crashRisksReport.risks.filter((r) => r.severity === 'CRITICAL').length;
592
- crashShieldWarning =
593
- `Crash Shield: score ${String(crashRisksReport.score)}/100 — ${String(criticalCount)} critical risk(s) detected. ` +
594
- `Run \`scan_crash_risks\` for details.`;
768
+ // ---------------------------------------------------------------------------
769
+ let codeRealityWarning = null;
770
+ let forcedBypassWarning = null;
771
+ const [codeRealityResult, doneGatesResult] = await Promise.all([
772
+ // Code reality: only relevant for 'implementing'
773
+ newStatus === 'implementing' && !reverseTransition && effectiveGatePath
774
+ ? checkCodeReality(effectiveGatePath, spec.title).catch(() => null)
775
+ : Promise.resolve(null),
776
+ // Done gates: only relevant for 'done'
777
+ newStatus === 'done'
778
+ ? checkDoneGates(spec, specId, projectId, effectiveGatePath, params.force, params.forceStatusReason ?? params.reason ?? 'No force reason provided')
779
+ : Promise.resolve(null),
780
+ ]);
781
+ // Process code reality result
782
+ if (codeRealityResult?.status === 'complete' || codeRealityResult?.status === 'partial') {
783
+ codeRealityWarning = codeRealityResult.warning;
595
784
  }
596
- if (effectiveGatePath) {
597
- void recordCrashScanRun(effectiveGatePath).catch(() => {
598
- /* best-effort */
599
- });
785
+ // Process done gates result — may block
786
+ if (doneGatesResult !== null) {
787
+ if (doneGatesResult.blocked) {
788
+ return doneGatesResult.blocked;
789
+ }
790
+ forcedBypassWarning = doneGatesResult.forcedBypassWarning;
600
791
  }
601
- }
602
- // Process compliance gate result may block on 'review'
603
- if (complianceResult !== null) {
604
- complianceGateResult = complianceResult;
605
- if (plannedStatuses.includes('review') && complianceResult.blocked) {
606
- return buildBlockedByComplianceResponse(specId, complianceResult);
792
+ // ---------------------------------------------------------------------------
793
+ // BATCH B (parallel): validate + crash-shield + compliance — independent of
794
+ // each other, all need spec/projectPath (available from Batch A outputs)
795
+ // SPEC-222: Auto-validate before marking done
796
+ // SPEC-442: Crash Shield warning on 'done'
797
+ // SPEC-447: Compliance gate on 'review' or 'implementing'
798
+ // ---------------------------------------------------------------------------
799
+ let validateScore = null;
800
+ let validateScoreSource = null;
801
+ let crashShieldWarning = null;
802
+ let crashShieldSkipReason = null;
803
+ let complianceGateResult = null;
804
+ let validationReportGate = null;
805
+ // SPEC-628: Rate-limit crash scan — check before entering the parallel batch
806
+ crashShieldSkipReason = await checkCrashScanRateLimit(newStatus, effectiveGatePath ?? null);
807
+ const [validateGateResult, crashRisksReport, complianceResult] = await Promise.all([
808
+ // Validate: only on 'done'.
809
+ // SPEC-721: timeout lives inside runValidateGate (Promise.race) — do NOT wrap with
810
+ // withToolTimeout here, which would silently convert timeout into blocked:false (fail-open).
811
+ newStatus === 'done'
812
+ ? runValidateGate(spec, effectiveGatePath ?? '', params.forceStatus ?? false, params.forceStatusReason, 9_000, { projectId, specId })
813
+ : Promise.resolve(null),
814
+ // Crash shield: only on 'done', skipped if rate-limited (SPEC-628)
815
+ newStatus === 'done' && effectiveGatePath && !crashShieldSkipReason
816
+ ? withToolTimeout(scanCrashRisks(effectiveGatePath).catch(() => null), 9_000, null)
817
+ : Promise.resolve(null),
818
+ // The heuristic scorer is a review aid. Done relies on the authoritative validate report.
819
+ plannedStatuses.includes('review')
820
+ ? withToolTimeout(checkComplianceGate(specId, projectId, effectiveGatePath), 9_000, {
821
+ skipped: true,
822
+ blocked: false,
823
+ score: null,
824
+ issues: [],
825
+ })
826
+ : Promise.resolve(null),
827
+ ]);
828
+ // Process validate result — may block (SPEC-721: fail-closed)
829
+ let forcedValidateBypass = null;
830
+ if (validateGateResult !== null) {
831
+ if (validateGateResult.blocked) {
832
+ return buildValidateBlockedResponse(specId, validateGateResult);
833
+ }
834
+ validateScore = validateGateResult.score;
835
+ if (validateGateResult.forced) {
836
+ validateScoreSource = validateGateResult.scoreSource;
837
+ forcedValidateBypass = {
838
+ reason: validateGateResult.forcedReason,
839
+ observedScore: validateGateResult.score,
840
+ };
841
+ }
842
+ else {
843
+ validateScoreSource = 'validateSpec';
844
+ }
607
845
  }
608
- }
609
- // SPEC-190: Run all compliance gates in parallel (convention + constitution + compile + lint + test, non-blocking)
610
- // runComplianceGates internally skips heavy commands (compile/lint/test) for non-done transitions,
611
- // but 'approved' still needs constitutionWarnings — so run for 'done' and 'approved' only.
612
- const { conventionWarnings, constitutionWarnings, compileWarnings, lintWarnings, testWarnings, } = newStatus === 'done' || plannedStatuses.includes('approved')
613
- ? await runComplianceGates(projectId, spec.title, spec.tags, newStatus)
614
- : {
615
- conventionWarnings: [],
616
- constitutionWarnings: [],
617
- compileWarnings: [],
618
- lintWarnings: [],
619
- testWarnings: [],
620
- };
621
- if (newStatus === 'done' && !(params.force ?? params.forceStatus ?? false)) {
622
- if (!isDryRun && effectiveGatePath) {
623
- await writeImplementationReviewReport({
624
- projectId,
625
- specId,
626
- spec,
846
+ if (newStatus === 'done' &&
847
+ effectiveGatePath &&
848
+ !shouldSkipStrictLayoutGateForLegacyTestHarness()) {
849
+ const strictLayoutError = await validateStrictLayoutOrError({
627
850
  projectPath: effectiveGatePath,
628
- score: validateScore,
629
- lintPassed: lintWarnings.length === 0,
630
- conventionRegression: conventionWarnings.length > 0,
851
+ specId,
852
+ specPath: spec.specPath,
853
+ failClosedOnCrash: true,
631
854
  });
855
+ if (strictLayoutError) {
856
+ return strictLayoutError;
857
+ }
632
858
  }
633
- validationReportGate = await readApprovedValidationReportGate(specId, projectId, false);
634
- if (!validationReportGate.ok) {
635
- return validationReportGate.error;
859
+ // Process crash shield result — record run timestamp on success (SPEC-628)
860
+ /* c8 ignore next */
861
+ if (crashRisksReport !== null) {
862
+ if (crashRisksReport.score < 80) {
863
+ const criticalCount = crashRisksReport.risks.filter((r) => r.severity === 'CRITICAL').length;
864
+ crashShieldWarning =
865
+ `Crash Shield: score ${String(crashRisksReport.score)}/100 — ${String(criticalCount)} critical risk(s) detected. ` +
866
+ `Run \`scan_crash_risks\` for details.`;
867
+ }
868
+ if (effectiveGatePath) {
869
+ void recordCrashScanRun(effectiveGatePath).catch(() => {
870
+ /* best-effort */
871
+ });
872
+ }
636
873
  }
637
- if (validationReportGate.score !== null) {
638
- validateScore = validationReportGate.score;
639
- validateScoreSource = 'validation-report';
874
+ // Process compliance gate result — may block on 'review'
875
+ if (complianceResult !== null) {
876
+ complianceGateResult = complianceResult;
877
+ if (plannedStatuses.includes('review') && complianceResult.blocked) {
878
+ return buildBlockedByComplianceResponse(specId, complianceResult);
879
+ }
640
880
  }
641
- }
642
- // SPEC-731: dry_run short-circuit all gates have been evaluated above.
643
- // Do NOT call transitionSpec, appendTransitionEvent, or acquire any lock.
644
- if (isDryRun) {
645
- const dryRunGates = {
646
- dod: newStatus === 'done' ? (doneGatesResult?.blocked ? 'fail' : 'pass') : 'skip',
647
- validate: newStatus === 'done'
648
- ? validateGateResult === null
649
- ? 'skip'
650
- : 'pass' // blocked case already returned above (line 615)
651
- : 'skip',
652
- qa: newStatus === 'done' ? 'pass' : 'skip', // QA gate runs above — if we reach here, it passed
653
- security: newStatus === 'done' ? (doneGatesResult?.blocked ? 'fail' : 'pass') : 'skip',
654
- lock: 'available',
655
- compliance: complianceGateResult === null
656
- ? 'skip'
657
- : complianceGateResult.skipped
658
- ? 'skip'
659
- : complianceGateResult.blocked
660
- ? 'fail'
661
- : 'pass',
662
- };
663
- return buildDryRunResponse({
664
- ok: true,
665
- dryRun: true,
666
- wouldTransition: true,
667
- gates: dryRunGates,
668
- blockingReasons: [],
669
- });
670
- }
671
- // Entering review creates reviewer evidence only after every lifecycle gate has passed.
672
- // Auto-advance to approval must consume pre-existing approved reviewer evidence.
673
- if (newStatus === 'review') {
674
- const specReviewWriteError = await writeSpecReviewArtifact(spec, specId, projectId);
675
- if (specReviewWriteError) {
676
- return specReviewWriteError;
881
+ // SPEC-190: Run all compliance gates in parallel (convention + constitution + compile + lint + test, non-blocking)
882
+ // runComplianceGates internally skips heavy commands (compile/lint/test) for non-done transitions,
883
+ // but 'approved' still needs constitutionWarnings so run for 'done' and 'approved' only.
884
+ const { conventionWarnings, constitutionWarnings, compileWarnings, lintWarnings, testWarnings, } = newStatus === 'done' || plannedStatuses.includes('approved')
885
+ ? await runComplianceGates(projectId, spec.title, spec.tags, newStatus, specId)
886
+ : {
887
+ conventionWarnings: [],
888
+ constitutionWarnings: [],
889
+ compileWarnings: [],
890
+ lintWarnings: [],
891
+ testWarnings: [],
892
+ };
893
+ if (newStatus === 'done' && !(params.force ?? params.forceStatus ?? false)) {
894
+ if (!isDryRun && effectiveGatePath) {
895
+ await writeImplementationReviewReport({
896
+ projectId,
897
+ specId,
898
+ spec,
899
+ projectPath: effectiveGatePath,
900
+ score: validateScore,
901
+ lintPassed: lintWarnings.length === 0,
902
+ conventionRegression: conventionWarnings.length > 0,
903
+ });
904
+ }
905
+ validationReportGate = await readApprovedValidationReportGate(specId, projectId, false);
906
+ if (!validationReportGate.ok) {
907
+ return validationReportGate.error;
908
+ }
909
+ if (validationReportGate.score !== null) {
910
+ validateScore = validationReportGate.score;
911
+ validateScoreSource = 'validation-report';
912
+ }
677
913
  }
678
- }
679
- // Explicit actuals are preserved verbatim. Provider usage and cost are never inferred.
680
- const resolvedActuals = actuals;
681
- // Run transition-specific actions
682
- const implActions = newStatus === 'implementing' ? await runImplementingActions(projectId, specId) : null;
683
- const doneActions = newStatus === 'done' ? await runDoneActions(projectId, specId, spec.gitBranch) : null;
684
- // SPEC-720: Step 1 — Transition the status through the single authorised path
685
- const trigger = reverseTransition
686
- ? 'reopen'
687
- : stepsExecuted.length > 0
688
- ? 'auto-advance'
689
- : (params.trigger ?? 'user');
690
- const actor = params.actor ?? 'system';
691
- const viaSync = params.viaSync ?? false;
692
- await transitionSpec(projectId, specId, newStatus, {
693
- trigger: trigger,
694
- actor,
695
- viaSync,
696
- reason: params.reason,
697
- });
698
- // SPEC-1044: Every phase transition records model/context evidence.
699
- const transitionEvidenceMeta = {
700
- ...buildTransitionEvidenceMeta(params),
701
- ...(sddRoutingGate.forcedReasons.length > 0 && {
702
- forcedSddModelRouting: true,
703
- forcedSddModelRoutingReasons: sddRoutingGate.forcedReasons,
704
- }),
705
- };
706
- void appendTransitionEvent({
707
- projectId,
708
- specId,
709
- eventType: 'transition',
710
- from: originalStatus,
711
- to: newStatus,
712
- actor,
713
- reason: params.reason,
714
- sessionId: params.sessionId,
715
- modelId: params.modelId,
716
- gateResults: sddRoutingGate.gateResults,
717
- meta: transitionEvidenceMeta,
718
- }).catch(() => {
719
- /* best-effort — never block the transition */
720
- });
721
- // SPEC-733: Append 'reopen' event to transition-log when this is a reverse transition
722
- if (reverseTransition && params.reason) {
723
- void appendTransitionEvent({
724
- projectId,
725
- specId,
726
- eventType: 'reopen',
727
- from: originalStatus,
728
- to: newStatus,
729
- actor,
730
- reason: params.reason,
731
- }).catch((err) => {
732
- console.warn('[planu:transition] reopen_event_append_failed', {
733
- specId,
734
- error: err instanceof Error ? err.message : String(err),
735
- });
736
- });
737
- }
738
- // SPEC-720: Step 2 — Update non-status fields (reviewNotes, actuals) separately
739
- const nonStatusUpdates = {};
740
- // Store review notes when sending back (review->draft or approved->review)
741
- if (reviewNotes) {
742
- const existing = spec.reviewNotes ?? [];
743
- nonStatusUpdates.reviewNotes = [
744
- ...existing,
745
- `[${new Date().toISOString()}] ${originalStatus}→${newStatus}: ${reviewNotes}`,
746
- ];
747
- }
748
- // If actuals are provided (or auto-generated), save them
749
- if (resolvedActuals) {
750
- nonStatusUpdates.actuals = resolvedActuals;
751
- }
752
- // Update non-status fields (if any)
753
- const updatedSpec = Object.keys(nonStatusUpdates).length > 0
754
- ? await specStore.updateSpec(projectId, specId, nonStatusUpdates)
755
- : // Re-fetch the spec (transitionSpec already wrote the status)
756
- ((await specStore.getSpec(projectId, specId)) ?? spec);
757
- // SPEC-721: Audit-trail entry when forceStatus bypassed the validate gate
758
- let forceStatusAuditId = null;
759
- if (newStatus === 'done' && forcedValidateBypass !== null) {
760
- try {
761
- const auditId = uuid();
762
- const prevHash = getLastHash();
763
- // Hash this entry minimally — full chain hashing is handled by the audit engine on export
764
- const entry = {
765
- id: auditId,
766
- timestamp: new Date().toISOString(),
767
- toolName: 'update_status',
768
- inputSummary: `validate_gate_forced_bypass specId=${specId} score=${String(forcedValidateBypass.observedScore)} reason="${forcedValidateBypass.reason.slice(0, 80)}"`,
769
- outputType: 'success',
770
- durationMs: 0,
771
- specId,
772
- projectPath: effectiveGatePath ?? undefined,
773
- prevHash,
774
- hash: '',
775
- event: 'validate_gate_forced_bypass',
776
- details: {
777
- reason: forcedValidateBypass.reason,
778
- observedScore: forcedValidateBypass.observedScore,
779
- fromStatus: originalStatus,
780
- toStatus: newStatus,
781
- },
914
+ // SPEC-731: dry_run short-circuit — all gates have been evaluated above.
915
+ // Do NOT call transitionSpec, appendTransitionEvent, or acquire any lock.
916
+ if (isDryRun) {
917
+ const dryRunGates = {
918
+ dod: newStatus === 'done' ? (doneGatesResult?.blocked ? 'fail' : 'pass') : 'skip',
919
+ validate: newStatus === 'done'
920
+ ? validateGateResult === null
921
+ ? 'skip'
922
+ : 'pass' // blocked case already returned above (line 615)
923
+ : 'skip',
924
+ qa: newStatus === 'done' ? 'pass' : 'skip', // QA gate runs above — if we reach here, it passed
925
+ security: newStatus === 'done' ? (doneGatesResult?.blocked ? 'fail' : 'pass') : 'skip',
926
+ lock: 'available',
927
+ compliance: complianceGateResult === null
928
+ ? 'skip'
929
+ : complianceGateResult.skipped
930
+ ? 'skip'
931
+ : complianceGateResult.blocked
932
+ ? 'fail'
933
+ : 'pass',
782
934
  };
783
- appendEntry(entry);
784
- forceStatusAuditId = auditId;
935
+ return buildDryRunResponse({
936
+ ok: true,
937
+ dryRun: true,
938
+ wouldTransition: true,
939
+ gates: dryRunGates,
940
+ blockingReasons: [],
941
+ });
785
942
  }
786
- catch {
787
- /* best-effort audit must never block transition */
943
+ // Entering review creates reviewer evidence only after every lifecycle gate has passed.
944
+ // Auto-advance to approval must consume pre-existing approved reviewer evidence.
945
+ if (newStatus === 'review') {
946
+ const specReviewWriteError = await writeSpecReviewArtifact(spec, specId, projectId);
947
+ if (specReviewWriteError) {
948
+ return specReviewWriteError;
949
+ }
788
950
  }
789
- }
790
- // SPEC-780: Audit-trail entry when forceApprove bypassed readiness/format gates
791
- let forceApproveAuditId = null;
792
- if (newStatus === 'approved' && params.forceApprove) {
793
- const bypassedGates = [];
794
- if (readinessGate.qualityWarnings.length > 0) {
795
- bypassedGates.push('readiness');
951
+ // Explicit actuals are preserved verbatim. Provider usage and cost are never inferred.
952
+ const resolvedActuals = actuals;
953
+ // Run only fast, read-only transition checks before persistence.
954
+ const implActions = newStatus === 'implementing'
955
+ ? await runImplementingActions(projectId, specId, { deferSideEffects: true })
956
+ : null;
957
+ const doneActions = newStatus === 'done'
958
+ ? await runDoneActions(projectId, specId, spec.gitBranch, {
959
+ deferSideEffects: true,
960
+ })
961
+ : null;
962
+ const trigger = reverseTransition
963
+ ? 'reopen'
964
+ : stepsExecuted.length > 0
965
+ ? 'auto-advance'
966
+ : (params.trigger ?? 'user');
967
+ const actor = params.actor ?? 'system';
968
+ const viaSync = params.viaSync ?? false;
969
+ // SPEC-1044: Every phase transition records model/context evidence.
970
+ const transitionEvidenceMeta = {
971
+ ...buildTransitionEvidenceMeta(params),
972
+ ...(sddRoutingGate.forcedReasons.length > 0 && {
973
+ forcedSddModelRouting: true,
974
+ forcedSddModelRoutingReasons: sddRoutingGate.forcedReasons,
975
+ }),
976
+ };
977
+ const nonStatusUpdates = {};
978
+ // Store review notes when sending back (review->draft or approved->review)
979
+ if (reviewNotes) {
980
+ const existing = spec.reviewNotes ?? [];
981
+ nonStatusUpdates.reviewNotes = [
982
+ ...existing,
983
+ `[${new Date().toISOString()}] ${originalStatus}→${newStatus}: ${reviewNotes}`,
984
+ ];
796
985
  }
797
- if (formatGate.qualityWarnings.length > 0) {
798
- bypassedGates.push('format');
986
+ // If actuals are provided (or auto-generated), save them
987
+ if (resolvedActuals) {
988
+ nonStatusUpdates.actuals = resolvedActuals;
799
989
  }
800
- if (bypassedGates.length > 0) {
990
+ // SPEC-721: Audit-trail entry when forceStatus bypassed the validate gate
991
+ let forceStatusAuditId = null;
992
+ if (newStatus === 'done' && forcedValidateBypass !== null) {
801
993
  try {
802
994
  const auditId = uuid();
803
995
  const prevHash = getLastHash();
996
+ // Hash this entry minimally — full chain hashing is handled by the audit engine on export
804
997
  const entry = {
805
998
  id: auditId,
806
999
  timestamp: new Date().toISOString(),
807
1000
  toolName: 'update_status',
808
- inputSummary: `approve_gate_forced_bypass specId=${specId} bypassedGates=[${bypassedGates.join(',')}]`,
1001
+ inputSummary: `validate_gate_forced_bypass specId=${specId} score=${String(forcedValidateBypass.observedScore)} reason="${forcedValidateBypass.reason.slice(0, 80)}"`,
809
1002
  outputType: 'success',
810
1003
  durationMs: 0,
811
1004
  specId,
812
1005
  projectPath: effectiveGatePath ?? undefined,
813
1006
  prevHash,
814
1007
  hash: '',
815
- event: 'approve_gate_forced_bypass',
1008
+ event: 'validate_gate_forced_bypass',
816
1009
  details: {
817
- bypassedGates,
1010
+ reason: forcedValidateBypass.reason,
1011
+ observedScore: forcedValidateBypass.observedScore,
818
1012
  fromStatus: originalStatus,
819
1013
  toStatus: newStatus,
820
- originalBlockers: [
821
- ...readinessGate.qualityWarnings,
822
- ...formatGate.qualityWarnings,
823
- ],
824
1014
  },
825
1015
  };
826
1016
  appendEntry(entry);
827
- forceApproveAuditId = auditId;
1017
+ forceStatusAuditId = auditId;
828
1018
  }
829
1019
  catch {
830
1020
  /* best-effort — audit must never block transition */
831
1021
  }
832
1022
  }
833
- }
834
- // SPEC-1044: Audit forced SDD model/context gate bypasses.
835
- let sddRoutingAuditId = null;
836
- if (sddRoutingGate.forcedReasons.length > 0) {
837
- try {
838
- const auditId = uuid();
839
- const prevHash = getLastHash();
840
- const entry = {
841
- id: auditId,
842
- timestamp: new Date().toISOString(),
843
- toolName: 'update_status',
844
- inputSummary: `sdd_model_routing_forced_bypass specId=${specId} status=${newStatus}`,
845
- outputType: 'success',
846
- durationMs: 0,
847
- specId,
848
- projectPath: effectiveGatePath ?? undefined,
849
- prevHash,
850
- hash: '',
851
- event: 'sdd_model_routing_forced_bypass',
852
- details: {
853
- reasons: sddRoutingGate.forcedReasons,
854
- fromStatus: originalStatus,
855
- toStatus: newStatus,
856
- gateResults: sddRoutingGate.gateResults,
857
- evidence: buildTransitionEvidenceMeta(params),
858
- },
859
- };
860
- appendEntry(entry);
861
- sddRoutingAuditId = auditId;
862
- }
863
- catch {
864
- /* best-effort — audit must never block transition */
865
- }
866
- }
867
- // SPEC-969: Track forceStatus / forceApprove usage
868
- let forceAnalyticsWarning = null;
869
- if (params.forceStatus || params.forceApprove) {
870
- try {
871
- const allSpecsForAnalytics = await specStore.listSpecs(projectId);
872
- const analytics = await recordForceUsage(projectId, {
873
- specId,
874
- type: params.forceStatus ? 'forceStatus' : 'forceApprove',
875
- reason: params.forceStatusReason ?? params.reason ?? 'No reason provided',
876
- agentId: params.agentId ?? 'unknown',
877
- }, allSpecsForAnalytics.length);
878
- if (analytics.stats.ratio > 0.2) {
879
- const pct = Math.round(analytics.stats.ratio * 100);
880
- forceAnalyticsWarning =
881
- `${pct}% of specs bypassed gates — review process health. ` +
882
- `Consider re-running validate within 24h.`;
1023
+ // SPEC-780: Audit-trail entry when forceApprove bypassed readiness/format gates
1024
+ let forceApproveAuditId = null;
1025
+ if (newStatus === 'approved' && params.forceApprove) {
1026
+ const bypassedGates = [];
1027
+ if (readinessGate.qualityWarnings.length > 0) {
1028
+ bypassedGates.push('readiness');
1029
+ }
1030
+ if (formatGate.qualityWarnings.length > 0) {
1031
+ bypassedGates.push('format');
1032
+ }
1033
+ if (bypassedGates.length > 0) {
1034
+ try {
1035
+ const auditId = uuid();
1036
+ const prevHash = getLastHash();
1037
+ const entry = {
1038
+ id: auditId,
1039
+ timestamp: new Date().toISOString(),
1040
+ toolName: 'update_status',
1041
+ inputSummary: `approve_gate_forced_bypass specId=${specId} bypassedGates=[${bypassedGates.join(',')}]`,
1042
+ outputType: 'success',
1043
+ durationMs: 0,
1044
+ specId,
1045
+ projectPath: effectiveGatePath ?? undefined,
1046
+ prevHash,
1047
+ hash: '',
1048
+ event: 'approve_gate_forced_bypass',
1049
+ details: {
1050
+ bypassedGates,
1051
+ fromStatus: originalStatus,
1052
+ toStatus: newStatus,
1053
+ originalBlockers: [
1054
+ ...readinessGate.qualityWarnings,
1055
+ ...formatGate.qualityWarnings,
1056
+ ],
1057
+ },
1058
+ };
1059
+ appendEntry(entry);
1060
+ forceApproveAuditId = auditId;
1061
+ }
1062
+ catch {
1063
+ /* best-effort — audit must never block transition */
1064
+ }
883
1065
  }
884
1066
  }
885
- catch {
886
- /* best-effort analytics must never block transition */
887
- }
888
- }
889
- // SPEC-448: Auto version snapshot when spec is approved
890
- let versionSnapshotTag = null;
891
- if (newStatus === 'approved') {
892
- const tag = `approved-${new Date().toISOString().replace(/[-:]/g, '').slice(0, 13)}`;
893
- versionSnapshotTag = await createVersionSnapshot(specId, projectId, tag)
894
- .then(() => tag)
895
- .catch(() => null);
896
- }
897
- // SPEC-694: Auto-orchestration plan for cross-module/architectural specs on approved
898
- const orchestrationPlan = await resolveOrchestrationPlan(newStatus, spec.scope, specId, effectiveGatePath);
899
- // Sync spec.md frontmatter and inline ## Progress section.
900
- // SPEC-698: capture warning so it surfaces in the tool response (was silent before)
901
- const syncResult = await syncSpecFiles(updatedSpec, originalStatus, newStatus, effectiveGatePath);
902
- const frontmatterSyncWarnings = syncResult.warning ? [syncResult.warning] : [];
903
- // SPEC-1044: Keep the reconstructible context packet fresh on every phase change.
904
- if (effectiveGatePath) {
905
- void import('../../engine/session-context-generator.js')
906
- .then(({ generateSessionContext }) => generateSessionContext(effectiveGatePath, projectId))
907
- .catch(() => {
908
- /* best-effort — context gate blocks later phases if this cannot be reconstructed */
909
- });
910
- }
911
- // SPEC-769/SPEC-780: Write qualityWarnings to spec.md frontmatter when force-approved (best-effort)
912
- const qualityWarningsToWrite = [
913
- ...readinessGate.qualityWarnings,
914
- ...formatGate.qualityWarnings,
915
- ];
916
- const specPathForWarnings = updatedSpec.specPath;
917
- if (newStatus === 'approved' && qualityWarningsToWrite.length > 0 && specPathForWarnings) {
918
- void (async () => {
1067
+ // SPEC-1044: Audit forced SDD model/context gate bypasses.
1068
+ let sddRoutingAuditId = null;
1069
+ if (sddRoutingGate.forcedReasons.length > 0) {
919
1070
  try {
920
- const { readFile: fsReadFile } = await import('node:fs/promises');
921
- const content = await fsReadFile(specPathForWarnings, 'utf-8');
922
- const warningsJson = JSON.stringify(qualityWarningsToWrite);
923
- const updated = updateFrontmatterField(content, 'qualityWarnings', warningsJson);
924
- await atomicWriteFile(specPathForWarnings, updated);
1071
+ const auditId = uuid();
1072
+ const prevHash = getLastHash();
1073
+ const entry = {
1074
+ id: auditId,
1075
+ timestamp: new Date().toISOString(),
1076
+ toolName: 'update_status',
1077
+ inputSummary: `sdd_model_routing_forced_bypass specId=${specId} status=${newStatus}`,
1078
+ outputType: 'success',
1079
+ durationMs: 0,
1080
+ specId,
1081
+ projectPath: effectiveGatePath ?? undefined,
1082
+ prevHash,
1083
+ hash: '',
1084
+ event: 'sdd_model_routing_forced_bypass',
1085
+ details: {
1086
+ reasons: sddRoutingGate.forcedReasons,
1087
+ fromStatus: originalStatus,
1088
+ toStatus: newStatus,
1089
+ gateResults: sddRoutingGate.gateResults,
1090
+ evidence: buildTransitionEvidenceMeta(params),
1091
+ },
1092
+ };
1093
+ appendEntry(entry);
1094
+ sddRoutingAuditId = auditId;
925
1095
  }
926
1096
  catch {
927
- // best-effort — never blocks the transition
1097
+ /* best-effort — audit must never block transition */
928
1098
  }
929
- })();
930
- }
931
- // SPEC-723: Record terminal transition in hash-chained log (best-effort, fire-and-forget)
932
- // SPEC-734: Thread enriched payload — sessionId, modelId, gateResults
933
- if (newStatus === 'done' || newStatus === 'discarded') {
934
- // Build gateResults from gate outcomes resolved earlier in this handler
935
- const terminalGateResults = {};
936
- if (newStatus === 'done') {
937
- Object.assign(terminalGateResults, sddRoutingGate.gateResults);
938
- if (validateGateResult !== null) {
939
- // At this point blocked === false (we returned earlier if blocked was true)
940
- terminalGateResults.validate = validateGateResult.forced ? 'forced' : 'pass';
1099
+ }
1100
+ // SPEC-969: Track forceStatus / forceApprove usage
1101
+ let forceAnalyticsWarning = null;
1102
+ if (params.forceStatus || params.forceApprove) {
1103
+ try {
1104
+ const allSpecsForAnalytics = await specStore.listSpecs(projectId);
1105
+ const analytics = await recordForceUsage(projectId, {
1106
+ specId,
1107
+ type: params.forceStatus ? 'forceStatus' : 'forceApprove',
1108
+ reason: params.forceStatusReason ?? params.reason ?? 'No reason provided',
1109
+ agentId: params.agentId ?? 'unknown',
1110
+ }, allSpecsForAnalytics.length);
1111
+ if (analytics.stats.ratio > 0.2) {
1112
+ const pct = Math.round(analytics.stats.ratio * 100);
1113
+ forceAnalyticsWarning =
1114
+ `${pct}% of specs bypassed gates — review process health. ` +
1115
+ `Consider re-running validate within 24h.`;
1116
+ }
941
1117
  }
942
- if (complianceGateResult !== null) {
943
- terminalGateResults.compliance = complianceGateResult.skipped
944
- ? 'skip'
945
- : complianceGateResult.blocked
946
- ? 'fail'
947
- : 'pass';
1118
+ catch {
1119
+ /* best-effort — analytics must never block transition */
948
1120
  }
949
- if (crashRisksReport !== null) {
950
- terminalGateResults['crash-shield'] = crashRisksReport.score >= 80 ? 'pass' : 'fail';
1121
+ }
1122
+ const versionSnapshotTag = newStatus === 'approved'
1123
+ ? `approved-${new Date().toISOString().replace(/[-:]/g, '').slice(0, 13)}`
1124
+ : null;
1125
+ // SPEC-694: Auto-orchestration plan for cross-module/architectural specs on approved
1126
+ const orchestrationPlan = await resolveOrchestrationPlan(newStatus, spec.scope, specId, effectiveGatePath);
1127
+ const shouldAutoStage = Boolean(effectiveGatePath && spec.specPath) &&
1128
+ process.env.PLANU_ENABLE_AUTOCOMMIT === 'true' &&
1129
+ process.env.PLANU_SUPPRESS_AUTOCOMMIT !== 'true';
1130
+ const plannedBackgroundActions = planBackgroundActions({
1131
+ newStatus,
1132
+ hasActuals: resolvedActuals !== undefined,
1133
+ hasProjectPath: Boolean(effectiveGatePath),
1134
+ shouldAutoStage,
1135
+ });
1136
+ // Commit status and non-status fields in one store mutation, then pair it
1137
+ // with an atomic spec.md write while the per-spec lock is still held.
1138
+ const transitionRecord = await transitionSpec(projectId, specId, newStatus, {
1139
+ trigger: trigger,
1140
+ actor,
1141
+ viaSync,
1142
+ reason: params.reason,
1143
+ updates: nonStatusUpdates,
1144
+ pendingBackgroundActions: plannedBackgroundActions,
1145
+ expectedSpec: spec,
1146
+ });
1147
+ const updatedSpec = transitionRecord.spec;
1148
+ const syncResult = await syncSpecFiles(updatedSpec, originalStatus, newStatus, effectiveGatePath);
1149
+ if (syncResult.warning) {
1150
+ try {
1151
+ await rollbackTransitionSpec(transitionRecord);
1152
+ return buildPersistenceFailureResponse({
1153
+ specId,
1154
+ previousStatus: originalStatus,
1155
+ newStatus,
1156
+ reason: syncResult.warning.reason,
1157
+ recoveryRequired: false,
1158
+ });
951
1159
  }
952
- else if (crashShieldSkipReason) {
953
- terminalGateResults['crash-shield'] = 'skip';
1160
+ catch (rollbackError) {
1161
+ return buildPersistenceFailureResponse({
1162
+ specId,
1163
+ previousStatus: originalStatus,
1164
+ newStatus,
1165
+ reason: `${syncResult.warning.reason}; compensation failed: ` +
1166
+ (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)),
1167
+ recoveryRequired: true,
1168
+ });
954
1169
  }
955
1170
  }
956
- void recordTerminalTransitionEvent({
1171
+ const frontmatterSyncWarnings = [];
1172
+ const transitionId = transitionRecord.transitionId;
1173
+ const committedAt = transitionRecord.timestamp;
1174
+ void appendTransitionEvent({
957
1175
  projectId,
958
1176
  specId,
959
- specPath: updatedSpec.specPath,
960
- newStatus,
1177
+ eventType: 'transition',
961
1178
  from: originalStatus,
962
- actor: actor,
963
- // SPEC-734: sessionId from MCP context params (best-effort)
1179
+ to: newStatus,
1180
+ actor,
1181
+ reason: params.reason,
964
1182
  sessionId: params.sessionId,
965
- // SPEC-734: modelId from params or known convention
966
1183
  modelId: params.modelId,
967
- gateResults: Object.keys(terminalGateResults).length > 0 ? terminalGateResults : undefined,
968
- meta: transitionEvidenceMeta,
1184
+ gateResults: sddRoutingGate.gateResults,
1185
+ meta: { ...transitionEvidenceMeta, transitionId, committedAt },
969
1186
  }).catch(() => {
970
- /* best-effort — never block the transition */
1187
+ /* best-effort — never block the committed acknowledgement */
971
1188
  });
972
- }
973
- // SPEC-544: Final git add after all writes complete (covers auto-advance multi-step transitions)
974
- // SPEC-575: Auto-commit staged planu/ docs (idempotent, safe-fail)
975
- if (effectiveGatePath &&
976
- updatedSpec.specPath &&
977
- process.env.PLANU_ENABLE_AUTOCOMMIT === 'true' &&
978
- process.env.PLANU_SUPPRESS_AUTOCOMMIT !== 'true') {
979
- try {
980
- const { git: gitCmd } = await import('../git/git-helpers.js');
981
- await gitCmd(effectiveGatePath, ['add', 'planu/']);
982
- const { planuAutoCommit } = await import('./../../engine/git/planu-autocommit.js');
983
- void planuAutoCommit({
984
- projectPath: effectiveGatePath,
1189
+ if (reverseTransition && params.reason) {
1190
+ void appendTransitionEvent({
1191
+ projectId,
985
1192
  specId,
986
- reason: newStatus === 'done' ? 'mark-done' : 'status-update',
1193
+ eventType: 'reopen',
1194
+ from: originalStatus,
1195
+ to: newStatus,
1196
+ actor,
1197
+ reason: params.reason,
1198
+ meta: { transitionId, committedAt },
1199
+ }).catch((err) => {
1200
+ console.warn('[planu:transition] reopen_event_append_failed', {
1201
+ specId,
1202
+ error: err instanceof Error ? err.message : String(err),
1203
+ });
987
1204
  });
988
1205
  }
989
- catch {
990
- // best-effort — never blocks
1206
+ // SPEC-1044: Keep the reconstructible context packet fresh on every phase change.
1207
+ if (effectiveGatePath) {
1208
+ void import('../../engine/session-context-generator.js')
1209
+ .then(({ generateSessionContext }) => generateSessionContext(effectiveGatePath, projectId))
1210
+ .catch(() => {
1211
+ /* best-effort — context gate blocks later phases if this cannot be reconstructed */
1212
+ });
991
1213
  }
992
- }
993
- // If spec is done and has actuals, record metrics
994
- if (newStatus === 'done' && resolvedActuals) {
995
- await recordDoneMetrics(projectId, specId, spec, resolvedActuals, knowledge?.projectPath);
996
- }
997
- // Cascade check: notify downstream specs of the status change
998
- const allSpecs = await specStore.listSpecs(projectId);
999
- const cascadeResult = cascadeCheck(specId, allSpecs, newStatus);
1000
- const doneMetrics = computeDoneMetrics(newStatus, spec, resolvedActuals);
1001
- // SPEC-585: Autopush on done if configured (awaited so result is in autopilotSummary)
1002
- const autopushResult = newStatus === 'done' && effectiveGatePath
1003
- ? await maybeSafePushOnDone(effectiveGatePath, newStatus)
1004
- : null;
1005
- // SPEC-751: Auto-housekeeping post-done — delete merged feat/spec branch + worktree (best-effort)
1006
- if (newStatus === 'done' && effectiveGatePath) {
1007
- void (async () => {
1008
- try {
1009
- const specBranch = updatedSpec.gitBranch;
1010
- if (/^feat\/spec-\d+/.test(specBranch)) {
1011
- const { runHousekeepingSweep } = await import('../../engine/housekeeping/index.js');
1012
- await runHousekeepingSweep({
1013
- projectPath: effectiveGatePath,
1014
- dryRun: false,
1015
- aggressive: false,
1016
- includeStashes: false,
1017
- });
1214
+ // SPEC-769/SPEC-780: Write qualityWarnings to spec.md frontmatter when force-approved (best-effort)
1215
+ const qualityWarningsToWrite = [
1216
+ ...readinessGate.qualityWarnings,
1217
+ ...formatGate.qualityWarnings,
1218
+ ];
1219
+ const specPathForWarnings = updatedSpec.specPath;
1220
+ if (newStatus === 'approved' &&
1221
+ qualityWarningsToWrite.length > 0 &&
1222
+ specPathForWarnings) {
1223
+ void (async () => {
1224
+ try {
1225
+ const { readFile: fsReadFile } = await import('node:fs/promises');
1226
+ const content = await fsReadFile(specPathForWarnings, 'utf-8');
1227
+ const warningsJson = JSON.stringify(qualityWarningsToWrite);
1228
+ const updated = updateFrontmatterField(content, 'qualityWarnings', warningsJson);
1229
+ await atomicWriteFile(specPathForWarnings, updated);
1230
+ }
1231
+ catch {
1232
+ // best-effort — never blocks the transition
1233
+ }
1234
+ })();
1235
+ }
1236
+ // SPEC-723: Record terminal transition in hash-chained log (best-effort, fire-and-forget)
1237
+ // SPEC-734: Thread enriched payload — sessionId, modelId, gateResults
1238
+ if (newStatus === 'done' || newStatus === 'discarded') {
1239
+ // Build gateResults from gate outcomes resolved earlier in this handler
1240
+ const terminalGateResults = {};
1241
+ if (newStatus === 'done') {
1242
+ Object.assign(terminalGateResults, sddRoutingGate.gateResults);
1243
+ if (validateGateResult !== null) {
1244
+ // At this point blocked === false (we returned earlier if blocked was true)
1245
+ terminalGateResults.validate = validateGateResult.forced ? 'forced' : 'pass';
1246
+ }
1247
+ if (complianceGateResult !== null) {
1248
+ terminalGateResults.compliance = complianceGateResult.skipped
1249
+ ? 'skip'
1250
+ : complianceGateResult.blocked
1251
+ ? 'fail'
1252
+ : 'pass';
1253
+ }
1254
+ if (crashRisksReport !== null) {
1255
+ terminalGateResults['crash-shield'] =
1256
+ crashRisksReport.score >= 80 ? 'pass' : 'fail';
1257
+ }
1258
+ else if (crashShieldSkipReason) {
1259
+ terminalGateResults['crash-shield'] = 'skip';
1018
1260
  }
1019
1261
  }
1020
- catch {
1021
- /* best-effort — never block done transition */
1262
+ void recordTerminalTransitionEvent({
1263
+ projectId,
1264
+ specId,
1265
+ specPath: updatedSpec.specPath,
1266
+ newStatus,
1267
+ from: originalStatus,
1268
+ actor: actor,
1269
+ // SPEC-734: sessionId from MCP context params (best-effort)
1270
+ sessionId: params.sessionId,
1271
+ // SPEC-734: modelId from params or known convention
1272
+ modelId: params.modelId,
1273
+ gateResults: Object.keys(terminalGateResults).length > 0 ? terminalGateResults : undefined,
1274
+ meta: transitionEvidenceMeta,
1275
+ }).catch(() => {
1276
+ /* best-effort — never block the transition */
1277
+ });
1278
+ }
1279
+ const allSpecs = await specStore.listSpecs(projectId);
1280
+ const cascadeResult = cascadeCheck(specId, allSpecs, newStatus);
1281
+ const doneMetrics = computeDoneMetrics(newStatus, spec, resolvedActuals);
1282
+ const postCommitTasks = buildPostCommitTasks({
1283
+ actionNames: plannedBackgroundActions,
1284
+ projectId,
1285
+ spec: updatedSpec,
1286
+ previousStatus: originalStatus,
1287
+ committedAt,
1288
+ knowledge,
1289
+ allSpecs,
1290
+ validateScore,
1291
+ actuals: resolvedActuals,
1292
+ effectiveProjectPath: effectiveGatePath,
1293
+ versionSnapshotTag,
1294
+ });
1295
+ const pendingBackgroundActions = queuePostCommitTasks({
1296
+ projectId,
1297
+ specId,
1298
+ transitionId,
1299
+ tasks: postCommitTasks,
1300
+ });
1301
+ // SPEC-469: Collect autopilot summary for done-transition side effects
1302
+ const collector = new AutopilotSummaryCollector();
1303
+ if (newStatus === 'done') {
1304
+ if (validateScore !== null) {
1305
+ collector.pushOk('validate', `Validate score: ${String(validateScore)}/100`);
1306
+ }
1307
+ if (crashShieldWarning) {
1308
+ collector.pushFail('crash-shield', crashShieldWarning);
1309
+ }
1310
+ else if (crashShieldSkipReason) {
1311
+ collector.pushOk('crash-shield', `scan_crash_risks: skipped (${crashShieldSkipReason})`);
1312
+ }
1313
+ if (doneActions?.mergeWarning) {
1314
+ collector.pushFail('merge-check', doneActions.mergeWarning);
1315
+ }
1316
+ if (doneActions?.prSuggestion) {
1317
+ collector.pushOk('pr-created', doneActions.prSuggestion.title);
1022
1318
  }
1023
- })();
1024
- }
1025
- // SPEC-469: Collect autopilot summary for done-transition side effects
1026
- const collector = new AutopilotSummaryCollector();
1027
- if (newStatus === 'done') {
1028
- if (validateScore !== null) {
1029
- collector.pushOk('validate', `Validate score: ${String(validateScore)}/100`);
1030
1319
  }
1031
- if (crashShieldWarning) {
1032
- collector.pushFail('crash-shield', crashShieldWarning);
1320
+ if (newStatus === 'approved' && versionSnapshotTag) {
1321
+ collector.pushOk('version-snapshot', `Snapshot queued: ${versionSnapshotTag}`);
1033
1322
  }
1034
- else if (crashShieldSkipReason) {
1035
- collector.pushOk('crash-shield', `scan_crash_risks: skipped (${crashShieldSkipReason})`);
1323
+ if (newStatus === 'implementing' && implActions?.autoBranch) {
1324
+ collector.pushOk('branch-created', `Branch: ${implActions.autoBranch}`);
1036
1325
  }
1037
- if (doneActions?.mergeWarning) {
1038
- collector.pushFail('merge-check', doneActions.mergeWarning);
1326
+ // SPEC-600: Include cascade summaries from implementing/done actions
1327
+ if (implActions?.autopilotSummary) {
1328
+ for (const msg of implActions.autopilotSummary) {
1329
+ collector.pushOk('cascade', msg);
1330
+ }
1039
1331
  }
1040
- if (doneActions?.prSuggestion) {
1041
- collector.pushOk('pr-created', doneActions.prSuggestion.title);
1332
+ if (doneActions?.autopilotSummary) {
1333
+ for (const msg of doneActions.autopilotSummary) {
1334
+ collector.pushOk('cascade', msg);
1335
+ }
1042
1336
  }
1043
- }
1044
- if (newStatus === 'approved' && versionSnapshotTag) {
1045
- collector.pushOk('version-snapshot', `Snapshot created: ${versionSnapshotTag}`);
1046
- }
1047
- if (autopushResult) {
1048
- collector.pushOk(autopushResult.label, autopushResult.message);
1049
- }
1050
- if (newStatus === 'implementing' && implActions?.autoBranch) {
1051
- collector.pushOk('branch-created', `Branch: ${implActions.autoBranch}`);
1052
- }
1053
- // SPEC-600: Include cascade summaries from implementing/done actions
1054
- if (implActions?.autopilotSummary) {
1055
- for (const msg of implActions.autopilotSummary) {
1056
- collector.pushOk('cascade', msg);
1337
+ if (stepsExecuted.length > 0) {
1338
+ collector.pushOk('auto-advance', `Auto-advanced through: ${stepsExecuted.join(' ')}`);
1057
1339
  }
1058
- }
1059
- if (doneActions?.autopilotSummary) {
1060
- for (const msg of doneActions.autopilotSummary) {
1061
- collector.pushOk('cascade', msg);
1340
+ if (cascadeResult.affectedSpecs.length > 0) {
1341
+ collector.pushOk('cascade', `Notified ${String(cascadeResult.affectedSpecs.length)} downstream spec(s)`);
1062
1342
  }
1343
+ const result = compactObj({
1344
+ specId,
1345
+ projectId,
1346
+ previousStatus: originalStatus,
1347
+ newStatus,
1348
+ autoAdvanced: stepsExecuted.length > 0 ? true : null,
1349
+ stepsExecuted: stepsExecuted.length > 0 ? stepsExecuted : null,
1350
+ actuals: resolvedActuals ?? null,
1351
+ autoBranch: implActions?.autoBranch ?? null,
1352
+ protectedBranchWarning: implActions?.protectedBranchWarning ?? null,
1353
+ mergeWarning: doneActions?.mergeWarning ?? null,
1354
+ validationWarning: doneActions?.validationWarning ?? null,
1355
+ uncommittedWarning: doneActions?.uncommittedWarning ?? null,
1356
+ prSuggestion: doneActions?.prSuggestion ?? null,
1357
+ implementingSuggestions: implActions?.suggestions ?? null,
1358
+ codeRealityWarning,
1359
+ crashShieldWarning,
1360
+ forcedBypassWarning,
1361
+ forceAnalyticsWarning,
1362
+ validateScore,
1363
+ validateScoreSource,
1364
+ // SPEC-721: audit ID for the forced validate bypass (null when no bypass occurred)
1365
+ forceStatusAuditId,
1366
+ // SPEC-780: audit ID for the forced approve bypass (null when no bypass occurred)
1367
+ forceApproveAuditId,
1368
+ // SPEC-1044: audit ID for forced SDD model/context routing bypasses.
1369
+ sddRoutingAuditId,
1370
+ sddRoutingGateResults: sddRoutingGate.gateResults,
1371
+ constitutionWarnings: constitutionWarnings.length > 0 ? constitutionWarnings : null,
1372
+ conventionWarnings: conventionWarnings.length > 0 ? conventionWarnings : null,
1373
+ compileWarnings: compileWarnings.length > 0 ? compileWarnings : null,
1374
+ lintWarnings: lintWarnings.length > 0 ? lintWarnings : null,
1375
+ testWarnings: testWarnings.length > 0 ? testWarnings : null,
1376
+ cascade: cascadeResult,
1377
+ metrics: doneMetrics,
1378
+ complianceGateResult,
1379
+ versionSnapshotTag,
1380
+ orchestrationPlan,
1381
+ committed: true,
1382
+ transitionId,
1383
+ committedAt,
1384
+ idempotent: false,
1385
+ pendingBackgroundActions,
1386
+ // SPEC-728: DepGuard result (null when gate was not applicable)
1387
+ depGuard: depGuardResult,
1388
+ // SPEC-698: surface frontmatter sync failures in the response
1389
+ frontmatterSyncWarnings: frontmatterSyncWarnings.length > 0 ? frontmatterSyncWarnings : null,
1390
+ updatedAt: updatedSpec.updatedAt,
1391
+ message: ti('tools.update_status.success', { specId, status: newStatus }),
1392
+ ...(collector.hasEntries() ? { autopilotSummary: collector.getMessages() } : {}),
1393
+ });
1394
+ // SPEC-772 Scenario 4: surface validate failure from cascade as explicit warning
1395
+ const autopilotValidateWarning = validateScore !== null && validateScore < 100
1396
+ ? `Validate score ${String(validateScore)}/100 — below threshold. Check workspace_alerts for cascade details.`
1397
+ : null;
1398
+ if (autopilotValidateWarning) {
1399
+ result.autopilotValidateWarning = autopilotValidateWarning;
1400
+ }
1401
+ // SPEC-754: Append shell-hygiene reminder when transitioning to done on claude-code host
1402
+ const shellHygieneHint = newStatus === 'done' && detectHost() === 'claude-code' ? shellHygieneReminder() : null;
1403
+ return buildStatusResponse(result, specId, originalStatus, newStatus, null, spec.title, shellHygieneHint);
1063
1404
  }
1064
- if (stepsExecuted.length > 0) {
1065
- collector.pushOk('auto-advance', `Auto-advanced through: ${stepsExecuted.join(' → ')}`);
1066
- }
1067
- if (cascadeResult.affectedSpecs.length > 0) {
1068
- collector.pushOk('cascade', `Notified ${String(cascadeResult.affectedSpecs.length)} downstream spec(s)`);
1069
- }
1070
- const result = compactObj({
1071
- specId,
1072
- projectId,
1073
- previousStatus: originalStatus,
1074
- newStatus,
1075
- autoAdvanced: stepsExecuted.length > 0 ? true : null,
1076
- stepsExecuted: stepsExecuted.length > 0 ? stepsExecuted : null,
1077
- actuals: resolvedActuals ?? null,
1078
- autoBranch: implActions?.autoBranch ?? null,
1079
- protectedBranchWarning: implActions?.protectedBranchWarning ?? null,
1080
- mergeWarning: doneActions?.mergeWarning ?? null,
1081
- validationWarning: doneActions?.validationWarning ?? null,
1082
- uncommittedWarning: doneActions?.uncommittedWarning ?? null,
1083
- prSuggestion: doneActions?.prSuggestion ?? null,
1084
- implementingSuggestions: implActions?.suggestions ?? null,
1085
- codeRealityWarning,
1086
- crashShieldWarning,
1087
- forcedBypassWarning,
1088
- forceAnalyticsWarning,
1089
- validateScore,
1090
- validateScoreSource,
1091
- // SPEC-721: audit ID for the forced validate bypass (null when no bypass occurred)
1092
- forceStatusAuditId,
1093
- // SPEC-780: audit ID for the forced approve bypass (null when no bypass occurred)
1094
- forceApproveAuditId,
1095
- // SPEC-1044: audit ID for forced SDD model/context routing bypasses.
1096
- sddRoutingAuditId,
1097
- sddRoutingGateResults: sddRoutingGate.gateResults,
1098
- constitutionWarnings: constitutionWarnings.length > 0 ? constitutionWarnings : null,
1099
- conventionWarnings: conventionWarnings.length > 0 ? conventionWarnings : null,
1100
- compileWarnings: compileWarnings.length > 0 ? compileWarnings : null,
1101
- lintWarnings: lintWarnings.length > 0 ? lintWarnings : null,
1102
- testWarnings: testWarnings.length > 0 ? testWarnings : null,
1103
- cascade: cascadeResult,
1104
- metrics: doneMetrics,
1105
- complianceGateResult,
1106
- versionSnapshotTag,
1107
- orchestrationPlan,
1108
- // SPEC-728: DepGuard result (null when gate was not applicable)
1109
- depGuard: depGuardResult,
1110
- // SPEC-698: surface frontmatter sync failures in the response
1111
- frontmatterSyncWarnings: frontmatterSyncWarnings.length > 0 ? frontmatterSyncWarnings : null,
1112
- updatedAt: updatedSpec.updatedAt,
1113
- message: ti('tools.update_status.success', { specId, status: newStatus }),
1114
- ...(collector.hasEntries() ? { autopilotSummary: collector.getMessages() } : {}),
1115
- });
1116
- // SPEC-772: Run cascade — fast hooks awaited (results go into response), slow hooks fire-and-forget
1117
- const cascadeRunResult = await runCascadeForResponse({
1118
- knowledge,
1119
- allSpecs,
1120
- specId,
1121
- newStatus,
1122
- validateScore,
1123
- projectId,
1124
- spec,
1125
- currentStatus: originalStatus,
1126
- });
1127
- // Include fast hook results in response (Scenario 1: fast cascade results in humanSummary)
1128
- if (cascadeRunResult && cascadeRunResult.fastHookResults.length > 0) {
1129
- result.cascadeFastResults = cascadeRunResult.fastHookResults;
1130
- }
1131
- // SPEC-772 Scenario 4: surface validate failure from cascade as explicit warning
1132
- const autopilotValidateWarning = validateScore !== null && validateScore < 100
1133
- ? `Validate score ${String(validateScore)}/100 — below threshold. Check workspace_alerts for cascade details.`
1134
- : null;
1135
- if (autopilotValidateWarning) {
1136
- result.autopilotValidateWarning = autopilotValidateWarning;
1137
- }
1138
- // Auto-reconcile token costs when spec reaches done (best-effort, non-blocking)
1139
- const reconciliationMarkdown = await tryReconcile(newStatus, specId, projectId);
1140
- // SPEC-754: Append shell-hygiene reminder when transitioning to done on claude-code host
1141
- const shellHygieneHint = newStatus === 'done' && detectHost() === 'claude-code' ? shellHygieneReminder() : null;
1142
- return buildStatusResponse(result, specId, originalStatus, newStatus, reconciliationMarkdown, spec.title, shellHygieneHint);
1143
- }
1144
- finally {
1145
- // SPEC-719: Release cross-process lock in all exit paths (return, throw)
1146
- if (crossProcessLockHandle !== null) {
1147
- await releaseLock(crossProcessLockHandle).catch((err) => {
1148
- console.warn('[planu:lock] update_status release error', {
1149
- specId,
1150
- error: err instanceof Error ? err.message : String(err),
1405
+ finally {
1406
+ // SPEC-719: Release cross-process lock in all exit paths (return, throw)
1407
+ if (crossProcessLockHandle !== null) {
1408
+ await releaseLock(crossProcessLockHandle).catch((err) => {
1409
+ console.warn('[planu:lock] update_status release error', {
1410
+ specId,
1411
+ error: err instanceof Error ? err.message : String(err),
1412
+ });
1151
1413
  });
1152
- });
1414
+ }
1153
1415
  }
1154
1416
  }
1155
- }
1156
- catch (error) {
1157
- if (error instanceof LockBusyError) {
1417
+ catch (error) {
1418
+ if (error instanceof LockBusyError) {
1419
+ return {
1420
+ content: [
1421
+ {
1422
+ type: 'text',
1423
+ text: `Spec ${specId} is locked by another process (LOCK_BUSY). Retry later.`,
1424
+ },
1425
+ ],
1426
+ isError: true,
1427
+ };
1428
+ }
1429
+ const message = error instanceof Error ? error.message : String(error);
1158
1430
  return {
1159
- content: [
1160
- {
1161
- type: 'text',
1162
- text: `Spec ${specId} is locked by another process (LOCK_BUSY). Retry later.`,
1163
- },
1164
- ],
1431
+ content: [{ type: 'text', text: ti('errors.internalError', { message }) }],
1165
1432
  isError: true,
1166
1433
  };
1167
1434
  }
1168
- const message = error instanceof Error ? error.message : String(error);
1169
- return {
1170
- content: [{ type: 'text', text: ti('errors.internalError', { message }) }],
1171
- isError: true,
1172
- };
1173
- }
1435
+ });
1174
1436
  }); // end trackCost
1175
1437
  }
1176
1438
  //# sourceMappingURL=index.js.map