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