@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.
- package/CHANGELOG.md +16 -0
- package/dist/cli/commands/serve.js +4 -0
- package/dist/config/license-plans.json +1 -0
- package/dist/engine/actuals/git-analyzer.js +4 -4
- 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/diff-spec-generator.js +12 -5
- 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/git-safe-input.d.ts +6 -0
- package/dist/engine/git-safe-input.js +41 -0
- package/dist/engine/qa-gate.js +2 -1
- 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/code-impact-handler.js +4 -2
- 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 +1086 -812
- 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,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,
|
|
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(
|
|
147
|
-
if (
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
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:
|
|
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: '
|
|
411
|
-
code:
|
|
412
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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 (
|
|
455
|
-
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
|
+
};
|
|
456
642
|
}
|
|
457
|
-
Object.assign(sddRoutingGate.gateResults, gate.gateResults);
|
|
458
|
-
sddRoutingGate.forcedReasons.push(...gate.forcedReasons);
|
|
459
643
|
}
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
698
|
+
if (plannedStatuses.includes('approved')) {
|
|
699
|
+
const specReviewError = await checkSpecReviewGate(specId, projectId, params.forceApprove);
|
|
700
|
+
if (specReviewError) {
|
|
701
|
+
return specReviewError;
|
|
702
|
+
}
|
|
577
703
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
592
|
-
if (
|
|
593
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
|
|
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
|
-
|
|
852
|
+
specPath: spec.specPath,
|
|
853
|
+
failClosedOnCrash: true,
|
|
723
854
|
});
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
772
|
-
|
|
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
|
-
|
|
775
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
786
|
-
|
|
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
|
-
|
|
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: `
|
|
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: '
|
|
1008
|
+
event: 'validate_gate_forced_bypass',
|
|
804
1009
|
details: {
|
|
805
|
-
|
|
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
|
-
|
|
1017
|
+
forceStatusAuditId = auditId;
|
|
816
1018
|
}
|
|
817
1019
|
catch {
|
|
818
1020
|
/* best-effort — audit must never block transition */
|
|
819
1021
|
}
|
|
820
1022
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
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
|
-
|
|
874
|
-
|
|
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
|
|
909
|
-
const
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
1097
|
+
/* best-effort — audit must never block transition */
|
|
916
1098
|
}
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
? 'skip'
|
|
933
|
-
: complianceGateResult.blocked
|
|
934
|
-
? 'fail'
|
|
935
|
-
: 'pass';
|
|
1118
|
+
catch {
|
|
1119
|
+
/* best-effort — analytics must never block transition */
|
|
936
1120
|
}
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
941
|
-
|
|
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
|
-
|
|
1171
|
+
const frontmatterSyncWarnings = [];
|
|
1172
|
+
const transitionId = transitionRecord.transitionId;
|
|
1173
|
+
const committedAt = transitionRecord.timestamp;
|
|
1174
|
+
void appendTransitionEvent({
|
|
945
1175
|
projectId,
|
|
946
1176
|
specId,
|
|
947
|
-
|
|
948
|
-
newStatus,
|
|
1177
|
+
eventType: 'transition',
|
|
949
1178
|
from: originalStatus,
|
|
950
|
-
|
|
951
|
-
|
|
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:
|
|
956
|
-
meta: transitionEvidenceMeta,
|
|
1184
|
+
gateResults: sddRoutingGate.gateResults,
|
|
1185
|
+
meta: { ...transitionEvidenceMeta, transitionId, committedAt },
|
|
957
1186
|
}).catch(() => {
|
|
958
|
-
/* best-effort — never block the
|
|
1187
|
+
/* best-effort — never block the committed acknowledgement */
|
|
959
1188
|
});
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
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
|
-
|
|
978
|
-
|
|
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
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
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 (
|
|
1020
|
-
collector.
|
|
1320
|
+
if (newStatus === 'approved' && versionSnapshotTag) {
|
|
1321
|
+
collector.pushOk('version-snapshot', `Snapshot queued: ${versionSnapshotTag}`);
|
|
1021
1322
|
}
|
|
1022
|
-
|
|
1023
|
-
collector.pushOk('
|
|
1323
|
+
if (newStatus === 'implementing' && implActions?.autoBranch) {
|
|
1324
|
+
collector.pushOk('branch-created', `Branch: ${implActions.autoBranch}`);
|
|
1024
1325
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
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?.
|
|
1029
|
-
|
|
1332
|
+
if (doneActions?.autopilotSummary) {
|
|
1333
|
+
for (const msg of doneActions.autopilotSummary) {
|
|
1334
|
+
collector.pushOk('cascade', msg);
|
|
1335
|
+
}
|
|
1030
1336
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
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
|