@planu/cli 4.11.6 → 4.11.8

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