project-autopilot 12.0.0 → 12.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +10 -10
  2. package/SKILL.md +1 -3
  3. package/adapters/agents.json +11 -1
  4. package/config.example.json +5 -3
  5. package/config.schema.json +28 -9
  6. package/docs/configuration.md +5 -3
  7. package/package.json +1 -1
  8. package/scripts/adapter-drivers.mjs +209 -22
  9. package/scripts/agent-contract.mjs +24 -2
  10. package/scripts/agent-invocation.mjs +32 -5
  11. package/scripts/autopilot.mjs +787 -222
  12. package/scripts/config-layout.mjs +6 -0
  13. package/scripts/config-migration.mjs +3 -3
  14. package/scripts/config-validation.mjs +12 -4
  15. package/scripts/e2e-detect.mjs +30 -0
  16. package/scripts/escalation-naming.mjs +19 -0
  17. package/scripts/failure-contract.mjs +25 -9
  18. package/scripts/gen-config-schema.mjs +7 -0
  19. package/scripts/idle-hang-watchdog.mjs +34 -7
  20. package/scripts/implementation-baseline.mjs +95 -4
  21. package/scripts/model-discovery.mjs +176 -38
  22. package/scripts/opencode-generation.mjs +116 -0
  23. package/scripts/per-task-budget.mjs +11 -7
  24. package/scripts/plan-review-rounds.mjs +164 -9
  25. package/scripts/planning-boundary.mjs +16 -6
  26. package/scripts/planning-continuation.mjs +43 -23
  27. package/scripts/planning-review-decision.mjs +3 -0
  28. package/scripts/planning-review-wiring.mjs +12 -8
  29. package/scripts/relay-guards.mjs +133 -11
  30. package/scripts/review-canary.mjs +5 -2
  31. package/scripts/role-fallback.mjs +23 -8
  32. package/scripts/run-history.mjs +32 -1
  33. package/scripts/run-observability.mjs +46 -2
  34. package/scripts/setup-discovery.mjs +2 -1
  35. package/scripts/setup.mjs +183 -81
  36. package/scripts/skill-copy.mjs +49 -0
  37. package/scripts/task-requirements.mjs +24 -0
  38. package/scripts/watch-report.mjs +146 -21
  39. package/scripts/watch-summary.mjs +26 -3
  40. package/scripts/plan-mode-model-note.mjs +0 -27
@@ -47,6 +47,7 @@ export function appendOrchestratorExitEvent(missionDir, signal) {
47
47
  }
48
48
 
49
49
  import {skillRoot} from "./paths.mjs";
50
+ import {recordSkillCopy, recordCommand} from "./skill-copy.mjs";
50
51
  import {cleanMissionScratch} from "./mission-scratch-cleanup.mjs";
51
52
  import {planningArtifactDigest,canSkipPlanningStateReview} from "./planning-artifact-digest.mjs";
52
53
  import {planUninstall, executeUninstall} from "./uninstall.mjs";
@@ -64,24 +65,23 @@ import {evaluateStandaloneReview, createStandaloneCheckpoint, artifactDigest as
64
65
  evaluateSpeckitGateReview, createSpeckitCheckpoint, manifestForGate, isReviewerPosition,
65
66
  SPECKIT_INITIAL_REVIEW, SPECKIT_PLANNER_REVISION, SPECKIT_POST_REVISION_REVIEW} from "./planning-review-checkpoint.mjs";
66
67
  import {preflightConfig} from "./preflight.mjs";
67
- import {planModeModelWarnings} from "./plan-mode-model-note.mjs";
68
68
  import {spawnRelayChild} from "./subprocess-relay.mjs";
69
- import {adapterDriver, finalAgentText, sessionIdFromEvents, accumulateUsage, recordRunModel, runModelPatch} from "./adapter-drivers.mjs";
70
- import {mergeTaskFacts} from "./task-requirements.mjs";
69
+ import {adapterDriver, finalAgentText, sessionIdFromEvents, accumulateUsage, recordRunModel, runModelPatch, applyClaudeReadOnlyScratchAllow} from "./adapter-drivers.mjs";
70
+ import {mergeTaskFacts,mergeTaskLineFacts,browserVerifierNote} from "./task-requirements.mjs";
71
71
  import {buildRoleRequirements, requirementFailure} from "./role-requirements.mjs";
72
72
  import { classifyPlanningRequest } from "./planning-request.mjs";
73
73
  import { appendWatchEvent } from "./watch-events.mjs";
74
- import { listSpecKitFeatureDirNames, evaluatePlanningWriteBoundary, CLAUDE_MD_MARKER_BEGIN, CLAUDE_MD_MARKER_END } from "./planning-boundary.mjs";
74
+ import { listSpecKitFeatureDirNames, evaluatePlanningWriteBoundary, normalizeRel, CLAUDE_MD_MARKER_BEGIN, CLAUDE_MD_MARKER_END } from "./planning-boundary.mjs";
75
75
  import { validatePlanningStagePostcondition, formatPostconditionError } from "./speckit-planning-postconditions.mjs";
76
- import { initRun, observe, finalizeRun, formatDuration, updateRunUsage, updateRunModel, updateRunSnapshots } from "./run-observability.mjs";
76
+ import { initRun, observe, finalizeRun, formatDuration, updateRunUsage, updateRunModel, updateRunSnapshots, setRunPreparing, prepareRun } from "./run-observability.mjs";
77
77
  import { changedFilesStat } from "./changed-files-stat.mjs";
78
78
  import { guardRelay, relayGuardEnabled } from "./relay-guard.mjs";
79
- import { nextRunSeq, writeWatchReportFile, listRunsOldestNewest, watchRunsDir, staleRunningNote } from "./run-history.mjs";
79
+ import { nextRunSeq, writeWatchReportFile, noE2eReportNote, appendNoE2eNoteToReport, listRunsOldestNewest, watchRunsDir, staleRunningNote } from "./run-history.mjs";
80
80
  import { updateSummary, updateSummaryForRunDir, scopeForRunDir } from "./watch-summary.mjs";
81
81
  import { recoverStaleRuns, markInterrupted } from "./run-recovery.mjs";
82
82
  import { recordCodeReviewRound, clearCodeReviewHistory, codeReviewerRoundLines, implementerEarlierRoundsText, codeReviewScopeKey } from "./code-review-rounds.mjs";
83
83
  import { scheduleEscalation, pendingEscalationModel, consumeEscalationFor, formatEscalationLog, recordVerifierRound, clearVerifierHistory, verifierRoundLines } from "./escalation-model.mjs";
84
- import { recordPlanReviewRound, seedPlanReviewHistory, clearPlanReviewHistory, planReviewerRoundLines, plannerEarlierRoundsText } from "./plan-review-rounds.mjs";
84
+ import { recordPlanReviewRound, seedPlanReviewHistory, clearPlanReviewHistory, planReviewPassNeedsConfirmation, planReviewConfirmationPending, markPlanReviewConfirmationPending, clearPlanReviewConfirmation, planReviewRevisionCount, recordPlanReviewRevision, clearPlanReviewRevisions, planReviewerRoundLines, planReviewerConfirmationLines, plannerEarlierRoundsText, recordPlanReviewNotes, planReviewNotesText, PLAN_REVIEW_SEVERITY_LINE } from "./plan-review-rounds.mjs";
85
85
  import { saveReviewFeedback, loadReviewFeedback, clearReviewFeedback, clearReviewFeedbackByPrefix, feedbackText } from "./review-feedback.mjs";
86
86
  import { taskLabel, recordFindingTasks, loadFindingTasks, carryDecision, findingsForTask, renderHandledFindings, fixPassBriefExcerpts, clearFindingTasks, clearFindingTasksByPrefix, FINDING_TASKS_STATE_KEY } from "./finding-task-locality.mjs";
87
87
  import { runFinalReviewFixRounds, parseGateGreenFiles, isNoWorkPerformed, isReviewerFeedbackFixPass, buildNoWorkAnswerSection } from "./final-review-fix-rounds.mjs";
@@ -94,7 +94,7 @@ import {resolveAdapterTransport} from "./adapter-transport.mjs";
94
94
  import {ensureBaseline, hasBaseline, removeBaseline, scopedDelta, scopedDeltaChunks, sanitizeKeySegment, scopedDeltaBeginMarker, SCOPED_DELTA_END_MARKER, listWorktreeEntries, rebaseBaselineOntoHead, headMovedSinceBaseline} from "./implementation-baseline.mjs";
95
95
  import {buildStandaloneHandoff, boundedDeltaBody, MISSION_FINAL_SECURITY_LINE, formatOperatorPhaseEdits, parseIncompleteSignal, parsePlanningIncompleteSignal, parseFoundEntries, sanitizeSummary, truncationWarning, reviewTruncationResult, formatGateResult, resolveGateDurations} from "./standalone-handoff.mjs";
96
96
  import {applyRedGateOverride, persistRedGateOverride} from "./verifier-red-gate-override.mjs";
97
- import {handlePlanningContinuation, planningNoProgressTouchedFiles, clearPlanningContinuations, hasSpentPlanningContinuations, PLANNING_CONTINUATION_SCOPES} from "./planning-continuation.mjs";
97
+ import {handlePlanningContinuation, planningNoProgressTouchedFiles, clearPlanningContinuations, clearPlanningContinuationScope, hasSpentPlanningContinuations, PLANNING_CONTINUATION_SCOPES} from "./planning-continuation.mjs";
98
98
  import {ensurePhaseBriefForCheckpoint, phaseBriefRelativePath} from "./role-progress-brief.mjs";
99
99
  import {buildTaskPrompt, taskDisplayRef, reviewTaskRef, allPhaseTaskRef, readOnlyHandoffNoteReminder} from "./role-progress-prompt.mjs";
100
100
  import {stableTaskIds} from "./role-progress-ids.mjs";
@@ -102,14 +102,15 @@ import {prepareTaskInvocation, commitTaskResult, evaluateCrashResume} from "./ro
102
102
  import {attributionMap, changedPaths as attributedChangedPaths, detectWorkAhead} from "./task-attribution.mjs";
103
103
  import {parseTaskResultContract} from "./task-result-contract.mjs";
104
104
  import {taskSizingRuleText, taskSizeReviewText, resolveTaskSizeTargetTurns, resolvePerTaskLimits, resolvePerRoleLimits, checkCapBreach, capUnitLabel, growingTerm, evaluateCapGraceWindow, checkWrapupTrigger, sessionStoreRelDir, sessionStoreSandboxSubpath, consumeContinuation, clearTaskContinuation, recordRolloverDeadEnd, readRolloverDeadEnds, clearRolloverDeadEnds, deadEndTail, sessionWorkDigest, resolveHandoffMaxChars, resolveHandoffNoteMaxChars, resolveHandoffWrapupTokens, handoffNoteRelPath, continuationBudgetKey, CONTINUATION_BUDGET_KEY_PREFIX, resolveContinuationAllowance, RELAY_TIMEOUT_DEFAULT_MS, HANDOFF_WRAPUP_TOKENS_DEFAULT, PER_ROLE_WRAPUP_TOKENS_DEFAULT, WRAPUP_MAX_TURNS, WRAPUP_TIMEOUT_MS, SESSION_STORE_WARN_BYTES, recordGenerationProgress, resolveNoProgressGenerations, clearNoProgress, noProgressDisplayLabel, handoffNoteCitedExcerpts, resolveIdleHangMs, resolveFirstOutputMs, resolveMaxSilenceMs, isHandoffNoteFresh, buildOrchestratorFallbackNote, scratchHandoffNotePath, SCRATCH_HANDOFF_NOTE_DEFAULT_DIR, pickFresherHandoffNote, handoffBudgetReminder, mergeFallbackWithPreviousNote, resolveBackstopRawTokens, evaluateBackstopBreach, recordBackstopFiring, readBackstopFirings, clearBackstopFirings, backstopLedgerKey, evaluateBackstopEscalation, handleRoleBackstopFiring, resolveSessionNumber} from "./per-task-budget.mjs";
105
- import {createIdleWatch, noteIdleOutput, noteIdleStdoutChunk, noteIdleStderrText, evaluateIdleWatch, formatIdleHangMessage, formatRateLimitedMessage, adapterMarkerFor, extractSessionIdFromChunk, noteIdleSessionId, IDLE_HANG_KILL_GRACE_MS} from "./idle-hang-watchdog.mjs";
105
+ import {createIdleWatch, noteIdleOutput, noteIdleStdoutChunk, noteIdleStderrText, evaluateIdleWatch, formatIdleHangMessage, formatRateLimitedMessage, adapterMarkerFor, extractSessionIdFromChunk, noteIdleSessionId, IDLE_HANG_KILL_GRACE_MS, FREE_QUOTA_REASON} from "./idle-hang-watchdog.mjs";
106
106
  import {deriveRoleDeadline, collectRoleSamples, ROLE_DEADLINE_FLOOR_MS} from "./role-deadline.mjs";
107
107
  import {assembleAdapterLaunchPlan} from "./launch-plan.mjs";
108
108
  import {ensureOpenCodeCacheSeeded, acquireOpenCodeCachePin, releaseOpenCodeCachePin, defaultOpenCodeCacheRoot, openCodeSandboxCatalog} from "./opencode-cache.mjs";
109
109
  import {cleanStaleOpencodeLibs} from "./tmp-so-cleanup.mjs";
110
110
  import {assembleAgentEnvironment} from "./agent-environment.mjs";
111
111
  import {extendFallbackWithFree, markTriedFreeModel} from "./free-fallback-discovery.mjs";
112
- import {discoverModelCatalog} from "./model-discovery.mjs";
112
+ import {discoverModelCatalog, discoverCapabilities} from "./model-discovery.mjs";
113
+ import {isOpenCodeAdapter} from "./opencode-generation.mjs";
113
114
  import {runFailedBeforeFirstStep, isSessionNotFound, storedSessionMatches} from "./session-reset.mjs";
114
115
  import {failedRunDidWork, appendFailureHandoff} from "./failure-handoff.mjs";
115
116
  import {decideRoleFallback, resolveEffectiveRoleModel, formatRoleFallbackLog, decideRoleFallbackRetry, stampStickyFallback, staleStickyFallback, markExhausted, isExhausted, skipExhaustedRoleModel} from "./role-fallback.mjs";
@@ -193,9 +194,18 @@ function linkDetachedOrchestratorLog(s){
193
194
  detachedLogLinked=true;
194
195
  }catch{}
195
196
  }
197
+ // Watch-report refresh on a real pipeline transition only. A save that leaves
198
+ // phase/step unchanged (a run counter, a usage tick, …) must not re-render.
199
+ let lastStatePhaseStep=null;
200
+ const phaseStepKey=s=>`${typeof s?.phase==="string"?s.phase:""}\u0000${typeof s?.step==="string"?s.step:""}`;
196
201
  const saveState=s=>{
197
202
  linkDetachedOrchestratorLog(s);
203
+ const nextKey=phaseStepKey(s);
204
+ let prevKey=lastStatePhaseStep;
205
+ if(prevKey===null){ try{ prevKey=phaseStepKey(JSON.parse(read(statePath))); }catch{ prevKey=null; } }
198
206
  atomicWriteFileSync(statePath,JSON.stringify({...s,updatedAt:now()},null,2));
207
+ lastStatePhaseStep=nextKey;
208
+ if(prevKey!==null&&prevKey!==nextKey){ try{ refreshHook(s,"state-transition"); }catch{ /* best-effort */ } }
199
209
  };
200
210
  // Helper: resolve the full mission watch-events directory path from state.
201
211
  // Returns null when no mission is active. Watch writes must never break a run,
@@ -994,6 +1004,7 @@ function stopCommand(args=[]){
994
1004
  // materialised cannot invent one.
995
1005
  const missionDir = getMissionWatchDir(s);
996
1006
  try{ if (missionDir && fs.existsSync(missionDir)) appendWatchEvent(missionDir, { type: "operator", reason: "stop", scope: "mission", live: false }); }catch{}
1007
+ try{ refreshWatchReport(s); }catch{ /* best-effort: a watch write must never break a run */ }
997
1008
  return 0;
998
1009
  }
999
1010
  const pid=found.pid;
@@ -1027,9 +1038,13 @@ function stopCommand(args=[]){
1027
1038
  // itself is untouched — kill + keep.
1028
1039
  const survivors=verifyAndKillSurvivors(s,pid,"ap stop");
1029
1040
  const marked=sweepDeadRuns(s,pid,killed.method,"ap stop");
1030
- try{ refreshWatchReport(s); }catch{ /* best-effort: a watch write must never break a run */ }
1041
+ // Record the operator stop BEFORE refreshing: the report derives STOPPED
1042
+ // from the last stop event when the owner PID file is already gone (the
1043
+ // killed orchestrator cannot rewrite its own report), so a refresh first
1044
+ // would freeze the final report at RUNNING.
1031
1045
  const missionDir = getMissionWatchDir(s);
1032
1046
  try{ if (missionDir) appendWatchEvent(missionDir, { type: "operator", reason: "stop", scope: "mission", live: true }); }catch{}
1047
+ try{ refreshWatchReport(s); }catch{ /* best-effort: a watch write must never break a run */ }
1033
1048
  console.log(`The mission is saved — \`ap run\` (no message) resumes where it stopped.${marked?` Marked ${marked} dead run(s) INTERRUPTED.`:""}${survivors.killed?` Verified killed ${survivors.killed} surviving adapter run(s).`:""}${survivors.skipped?` Skipped ${survivors.skipped} stale PID(s).`:""}`);
1034
1049
  return 0;
1035
1050
  }
@@ -2416,12 +2431,17 @@ function writeSpeckitGateCheckpoint(s,{feature,gate,gateIndex,substage}){
2416
2431
  // Regenerate the written run report after every delegated step so the
2417
2432
  // report stays current mid-run. Best-effort: if the project has no
2418
2433
  // .autopilot/report.mjs (feature not enabled there), do nothing.
2434
+ // Appends the no-E2E note to the mission REPORT.md (only when tests.e2e is none/unset and a UI was detected).
2435
+ function appendNoE2eReportNote(){
2436
+ try{ appendNoE2eNoteToReport(repo,path.join(repo,".autopilot","missions",String(missionFor(repo,loadState())),"REPORT.md")); }catch{}
2437
+ }
2419
2438
  function regenerateReport(){
2420
2439
  const script=path.join(repo,".autopilot","report.mjs");
2421
2440
  if(!fs.existsSync(script)) return;
2422
2441
  try{
2423
2442
  const r=spawnSync(process.execPath,[script],{cwd:repo,encoding:"utf8",stdio:"inherit",shell:false});
2424
2443
  if(r.status!==0) console.warn(`[autopilot] report regeneration exited ${r.status ?? "unknown"}`);
2444
+ appendNoE2eReportNote();
2425
2445
  }catch(e){
2426
2446
  console.warn("[autopilot] report regeneration failed (non-fatal):",e.message);
2427
2447
  }
@@ -2617,21 +2637,42 @@ function openCodeSandboxModelWarnings(roles, catalogOverride=undefined){
2617
2637
  // before any role launch, phase transition, or run directory. A genuine spawn
2618
2638
  // failure later on is still reported with its real diagnostics by the relay
2619
2639
  // itself.
2620
- function preflightConfiguredRoles(c){
2640
+ //
2641
+ // Capability discovery also happens here, before any role launches: it is the
2642
+ // one place that reads `run --help` for OpenCode, and rememberOpenCodeGeneration
2643
+ // caches the 1.x/2.x answer under the same per-binary key the driver and the
2644
+ // invocation verifier read. Without this warm-up those launch-time callers
2645
+ // would fall back to a synchronous help probe, blocking the event loop once
2646
+ // per process (and re-probing if their key did not match discovery's).
2647
+ async function preflightConfiguredRoles(c){
2621
2648
  for(const [roleName,role] of Object.entries(c.roles||{})){
2622
2649
  if(!agents[role.adapter]) throw new Error(`Unknown adapter '${role.adapter}' for role '${roleName}'.`);
2623
2650
  }
2624
- for(const w of planModeModelWarnings(c.roles)) console.warn(`Warning: ${w}`);
2625
2651
  for(const w of openCodeSandboxModelWarnings(c.roles)) console.warn(`Warning: ${w}`);
2626
2652
  const {ok,results}=preflightConfig({repo,config:c,agents});
2653
+ const seen=new Set();
2654
+ for(const role of Object.values(c.roles||{})){
2655
+ const adapter=agents[role.adapter];
2656
+ if(!adapter || !isOpenCodeAdapter(adapter) || seen.has(role.adapter)) continue;
2657
+ seen.add(role.adapter);
2658
+ try{ await discoverCapabilities(adapter); }
2659
+ catch{ /* leave the cache cold; detectOpenCodeGeneration is the last resort */ }
2660
+ }
2627
2661
  if(ok) return;
2628
2662
  const failed=results.filter(entry=>!entry.ok);
2629
2663
  throw new Error(failed.map(entry=>`Preflight failed for role '${entry.roleName}' (${entry.adapter}):\n${entry.problems.map(p=>` - ${p}`).join("\n")}`).join("\n"));
2630
2664
  }
2631
2665
 
2632
- function buildRelayArgs(role,a,readOnly,resume,state,{trustedEnvelope=false,promptText=null,forceResume=false,stdinSink=null}={}){
2666
+ function buildRelayArgs(role,a,readOnly,resume,state,{trustedEnvelope=false,promptText=null,forceResume=false,stdinSink=null,scratchPath=null}={}){
2633
2667
  let args=[...(readOnly?a.read_only:a.write)];
2634
2668
  args=applyAgentSelections(args,role,a);
2669
+ // Backlog #2: Claude read-only roles run in default mode with an explicit
2670
+ // allow list, so the sanctioned scratch Write/Edit rules must name the
2671
+ // exact scratch path this launch's brief points at (the envelope's
2672
+ // /workspace/scratch, or the disposable workspace's real scratch dir).
2673
+ // Derived from the same prompt-visible path the role is told to use,
2674
+ // never a hard-coded host path. Non-Claude adapters are untouched.
2675
+ if(readOnly && role.adapter==="claude") args=applyClaudeReadOnlyScratchAllow(args,scratchPath);
2635
2676
  // forceResume: the TASK-34 wrap-up turn resumes the session that was JUST
2636
2677
  // stopped at the hand-off threshold. `role.resume` is the operator's choice
2637
2678
  // for NEW attempts continuing an old session — it must not veto this
@@ -2768,6 +2809,10 @@ function makeRelayResult({roleName,role,a,p,args,invocationVerification,output,s
2768
2809
  configuredModel:(typeof role?.model==="string"&&role.model!==""?role.model:null),
2769
2810
  resolvedModel:modelResolution?.resolvedModel??null,
2770
2811
  modelMismatch:modelResolution?.modelMismatch??null,
2812
+ // Backlog #4 part B: 2.x's final step has no step_finish, so its summed
2813
+ // usage is a lower bound. Additive/open code-only; 1.x and other adapters
2814
+ // never carry this key.
2815
+ ...(role.adapter==="opencode"&&usage?.usage_partial===true?{usage_partial:true}:{}),
2771
2816
  // Mission 093 Part 1 task 2.3: run-scoped layout fields — snapshot wall
2772
2817
  // clocks plus the continuation-scope session number (all null on runs
2773
2818
  // that own no snapshot/scope, never fabricated). Additive — every reader
@@ -2788,7 +2833,7 @@ function makeRelayResult({roleName,role,a,p,args,invocationVerification,output,s
2788
2833
  carriedForwardTasks:(Array.isArray(carriedForwardTasks)&&carriedForwardTasks.length>0?carriedForwardTasks:null),
2789
2834
  startedAt:started,
2790
2835
  finishedAt:finished,
2791
- review:verdict.status==="available"?{decision:verdict.decision,findings:verdict.findings,...(Array.isArray(verdict.previous)&&verdict.previous.length?{previous:verdict.previous}:{})}:null,
2836
+ review:verdict.status==="available"?{decision:verdict.decision,findings:verdict.findings,...(Array.isArray(verdict.previous)&&verdict.previous.length?{previous:verdict.previous}:{}),...(Array.isArray(verdict.notes)&&verdict.notes.length?{notes:verdict.notes}:{})}:null,
2792
2837
  gitHeadBefore:before.head,
2793
2838
  gitHeadAfter:after.head,
2794
2839
  treeChanged:before.fingerprint!==after.fingerprint,
@@ -2871,6 +2916,26 @@ function runDirFor(s,id,roleName){
2871
2916
  const role=String(roleName||"relay").replace(/[^A-Za-z0-9_]/g,"_");
2872
2917
  return {dir:path.join(base,`${seq}-${role}-${String(id).slice(0,8)}`),mission};
2873
2918
  }
2919
+ // The planning stage a SpecKit/GSD run worked on, from the recorded state step
2920
+ // (`s.step`). Standalone planning keeps its historical `plan/tasks` report
2921
+ // label, so only the two stage-based loops map a stage here. SpecKit gates use
2922
+ // the step ids (`review-spec`/`review-plan`/`review-tasks`); GSD uses
2923
+ // `gsd-<stage>` (plus the `-revision-N`/`-review-failed` suffixes).
2924
+ const PLANNING_STAGE_BY_STEP={
2925
+ specify:"spec",spec:"spec","review-spec":"spec","spec-review":"spec",
2926
+ plan:"plan","review-plan":"plan","plan-review":"plan",
2927
+ tasks:"tasks","review-tasks":"tasks","tasks-review":"tasks",
2928
+ discuss:"discuss",research:"research","plan-check":"plan-check","review-plan-check":"plan-check",
2929
+ };
2930
+ function planningStageFromState(s, taskRef=null){
2931
+ const ref=typeof taskRef==="string"?taskRef:"";
2932
+ if(!ref.startsWith("speckit-planning-")&&!ref.startsWith("gsd-planning-")) return null;
2933
+ const key=String(s?.step||"").toLowerCase()
2934
+ .replace(/^(?:speckit|gsd)-/,"")
2935
+ .replace(/-revision-\d+$/,"")
2936
+ .replace(/-review-failed$/,"");
2937
+ return PLANNING_STAGE_BY_STEP[key]||null;
2938
+ }
2874
2939
  function observabilityMeta(s, taskRef=null){
2875
2940
  const text=typeof s?.currentTask?.text==="string"?s.currentTask.text:(typeof s?.currentTask==="string"?s.currentTask:"");
2876
2941
  const tm=text.match(/^T\d+/);
@@ -2889,6 +2954,10 @@ function observabilityMeta(s, taskRef=null){
2889
2954
  // scope is unknown). Supplied explicitly per relay call, never derived
2890
2955
  // from global state.
2891
2956
  taskRef: typeof taskRef==="string"&&taskRef ? taskRef : null,
2957
+ // The SpecKit/GSD planning stage this run worked on (`spec`/`plan`/`tasks`
2958
+ // or `discuss`/`research`/`plan`/`plan-check`). Null for every other run
2959
+ // and for standalone planning (whose report label stays `plan/tasks`).
2960
+ planningStage: planningStageFromState(s,taskRef),
2892
2961
  attempt: s?.attempt!=null ? String(s.attempt) : null
2893
2962
  };
2894
2963
  }
@@ -3015,6 +3084,9 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
3015
3084
  // meta computation is value-identical on paths that reach spawn.
3016
3085
  const meta=observabilityMeta(s,taskRef);
3017
3086
  if(runNote) meta.note=runNote;
3087
+ // Backlog #7: reasoning/variant of the effective (fallback/escalation-aware) role.
3088
+ meta.reasoning=(typeof role.reasoning==="string"&&role.reasoning!==""?role.reasoning:null);
3089
+ meta.variant=(typeof role.variant==="string"&&role.variant!==""?role.variant:null);
3018
3090
  // Mission 093 Part 1 task 2.2: the effective (active/fallback-resolved)
3019
3091
  // model at launch. Null when the CLI chose its default (""/missing is
3020
3092
  // never a fabricated model id); the stream-resolved actual model lands on
@@ -3035,7 +3107,7 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
3035
3107
  // this run. Only a non-empty list survives; anything else (including the
3036
3108
  // null default on non-carry runs) degrades to null, never fabricated.
3037
3109
  const carriedForwardTasksValue=(Array.isArray(carriedForwardTasks)&&carriedForwardTasks.length>0?carriedForwardTasks:null);
3038
- initRun(dir,{role:roleName,runId:id,...meta,configuredModel,resolvedModel:null,modelMismatch:null,sessionNumber,snapshotBeforeStartedAt:null,snapshotBeforeAt:null,snapshotAfterStartedAt:null,snapshotAfterAt:null,planRevision:planRevisionValue,carriedForwardTasks:carriedForwardTasksValue});
3110
+ initRun(dir,{role:roleName,runId:id,...meta,configuredModel,resolvedModel:null,modelMismatch:null,sessionNumber,snapshotBeforeStartedAt:null,snapshotBeforeAt:null,snapshotAfterStartedAt:null,snapshotAfterAt:null,planRevision:planRevisionValue,carriedForwardTasks:carriedForwardTasksValue,deferStart:true,last_action:"preparing: setup"});
3039
3111
  // Backlog 003#31: track the active run for the SIGTERM/SIGINT flush
3040
3112
  // (installed once below). Deliberately never cleared: markInterrupted()
3041
3113
  // only touches `running` status files, so a stale reference to a finished
@@ -3308,7 +3380,7 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
3308
3380
  // inside buildRelayArgs and land the framed line in this sink; every other
3309
3381
  // adapter sends the raw prompt text on stdin unchanged.
3310
3382
  const relayStdin={text:null};
3311
- const args=buildRelayArgs(role,a,readOnly,resume,stateForRelay,{trustedEnvelope:attemptEnvelope, promptText: relayTaskText, stdinSink:relayStdin, forceResume:isWrapUp});
3383
+ const args=buildRelayArgs(role,a,readOnly,resume,stateForRelay,{trustedEnvelope:attemptEnvelope, promptText: relayTaskText, stdinSink:relayStdin, forceResume:isWrapUp, scratchPath:promptScratch});
3312
3384
  const relayStdinText=relayStdin.text==null?relayTaskText:relayStdin.text;
3313
3385
  // AUTHORITATIVE mission transport: the fully constructed mission request
3314
3386
  // (manifest argv + brief stdin) is resolved once here, and exactly
@@ -3415,12 +3487,19 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
3415
3487
  }
3416
3488
  const missionTransport=missionLaunchPlan.transport;
3417
3489
  const executionEnvelope=missionLaunchPlan.envelope;
3490
+ // Backlog #4 part B: 2.x invocation adds --standalone (part A). The output
3491
+ // reader uses that same launch fact to treat a text-bearing step without a
3492
+ // final step_finish as partial usage, without changing 1.x's exact sums.
3493
+ const openCodeUsageOptions=role.adapter==="opencode"?{generation:missionTransport.invocation.args.includes("--standalone")?"2":"1"}:undefined;
3418
3494
  const invocationVerification=verifyAgentInvocation(missionTransport.invocation.args,role,a);
3419
3495
  if(!invocationVerification.ok){ return failClosingWorkspace(`CLI invocation verification failed for ${roleName}: ${invocationVerification.errors.join(" ")}`); }
3420
3496
  const brief=path.join(dir,"brief.txt");
3421
3497
  write(brief, relayTaskText);
3422
3498
 
3499
+ try{ setRunPreparing(dir,"git-guard"); }catch{}
3500
+ const guardStartedAt=Date.now();
3423
3501
  const guard=createGitMutationGuard(effectiveScratchDir||dir,effectiveCwd);
3502
+ try{ prepareRun(dir,{action:"git-guard",durationMs:Date.now()-guardStartedAt}); }catch{}
3424
3503
  // TASK 16: give the Layer-1 scope check a sandbox-side protected identity.
3425
3504
  // Inside the bwrap envelope the repository is remounted at
3426
3505
  // `/workspace/repository` while the guard directory lives under the sibling
@@ -3458,7 +3537,9 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
3458
3537
  // takes fresh, and the outcome is logged. The direct gitSnapshot(cwd)
3459
3538
  // fallback below keeps a snapshot call inside runRelay after
3460
3539
  // createGitMutationGuard( for the fresh path.
3540
+ try{ setRunPreparing(dir,"snapshot-before"); }catch{}
3461
3541
  const snapshotBeforeStartedAt=now();
3542
+ let snapshotBeforeMode="fresh";
3462
3543
  const before=(()=>{
3463
3544
  try{
3464
3545
  const currentGuards=collectSnapshotGuards(cwd);
@@ -3475,6 +3556,7 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
3475
3556
  // through to a fresh gitSnapshot(cwd) that re-throws the same
3476
3557
  // topology error before spawn.
3477
3558
  assertSupportedTopology(cwd);
3559
+ snapshotBeforeMode="reused";
3478
3560
  console.log(`[snapshot-reuse] snapshot reused (${decision.reason})`);
3479
3561
  return cached.snapshot;
3480
3562
  }
@@ -3489,6 +3571,7 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
3489
3571
  // it forward, so the full rewrite there cannot wipe it).
3490
3572
  const snapshotBeforeAt=now();
3491
3573
  try{ updateRunSnapshots(dir,{snapshotBeforeStartedAt,snapshotBeforeAt}); }catch{}
3574
+ try{ prepareRun(dir,{action:"snapshot-before",detail:snapshotBeforeMode,durationMs:Date.parse(snapshotBeforeAt)-Date.parse(snapshotBeforeStartedAt)}); }catch{}
3492
3575
 
3493
3576
  const started=now();
3494
3577
  // Task 15.6K: the relay's effective repository is authoritative for
@@ -3754,7 +3837,7 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
3754
3837
  try{ recordRunModel(dir,role.adapter,capEvents,configuredModel); }catch{}
3755
3838
  try{ updateSummaryForRunDir(dir); }catch{}
3756
3839
  let usage=null;
3757
- try{ usage=accumulateUsage(role.adapter,capEvents); }catch{ return; }
3840
+ try{ usage=accumulateUsage(role.adapter,capEvents,openCodeUsageOptions); }catch{ return; }
3758
3841
  if(!usage) return;
3759
3842
  // TASK-27: live per-invocation totals — the usage decoded above already
3760
3843
  // flows through here every second, so pass it to the cadenced persist
@@ -3866,7 +3949,7 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
3866
3949
  try{ verdict=evaluateIdleWatch(idleWatch); }catch{ return; }
3867
3950
  if(!verdict||verdict.status==="ok") return;
3868
3951
  if(verdict.status==="hung") idleBreach={kind:"hung",idleMs:verdict.idleMs,noOutput:verdict.noOutput===true};
3869
- else if(verdict.status==="rate_limited") idleBreach={kind:"rate_limited",provider:verdict.provider||null,model:verdict.model||null};
3952
+ else if(verdict.status==="rate_limited") idleBreach={kind:"rate_limited",provider:verdict.provider||null,model:verdict.model||null,reason:verdict.reason||null};
3870
3953
  else return;
3871
3954
  if(capPid!=null){
3872
3955
  try{ process.kill(capPid,"SIGTERM"); }catch{}
@@ -3962,7 +4045,7 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
3962
4045
  const observations=driver?driver.observe(events,{readPath:"",productionWritePath:"",outsideWritePath:""}):[];
3963
4046
  // Absent (not zero) when the adapter/event stream carries no per-turn
3964
4047
  // usage signal -- see accumulateUsage()'s own docblock.
3965
- const usage=driver?accumulateUsage(role.adapter,events):null;
4048
+ const usage=driver?accumulateUsage(role.adapter,events,openCodeUsageOptions):null;
3966
4049
  // Mission 093 Part 1 task 2.2: final model resolution from the complete
3967
4050
  // decoded stream (the live tick may have missed the terminal signal).
3968
4051
  // runModelPatch is pure; the persist lands on status.json below.
@@ -4039,10 +4122,11 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
4039
4122
  result.failure=failure("IDLE_HANG","infrastructure",true,[{source:"relay",message:idleMessage,role:roleName,adapter:role.adapter,model:role.model||null,idleMs:idleBreach.idleMs}]);
4040
4123
  try{ observe(dir,{stream:"stdout",bytes:0,minIntervalMs:0,last:`IDLE_HANG | ROLE=${roleName} | ADAPTER=${role.adapter} | IDLE_MS=${idleBreach.idleMs}`}); }catch{}
4041
4124
  }else{
4042
- const rateMessage=formatRateLimitedMessage({role:roleName,adapter:role.adapter,model:role.model||null,provider:idleBreach.provider});
4043
- result.idleHang={kind:"rate_limited",provider:idleBreach.provider||null,model:idleBreach.model||null,message:rateMessage};
4044
- result.failure=failure("RATE_LIMITED","infrastructure",false,[{source:"relay",message:rateMessage,role:roleName,adapter:role.adapter,model:idleBreach.model||role.model||null,provider:idleBreach.provider||null}]);
4045
- try{ observe(dir,{stream:"stdout",bytes:0,minIntervalMs:0,last:`RATE_LIMITED | ROLE=${roleName} | ADAPTER=${role.adapter} | PROVIDER=${idleBreach.provider||role.adapter}`}); }catch{}
4125
+ const freeQuota=idleBreach.reason===FREE_QUOTA_REASON;
4126
+ const rateMessage=formatRateLimitedMessage({role:roleName,adapter:role.adapter,model:role.model||null,provider:idleBreach.provider,freeQuota});
4127
+ result.idleHang={kind:"rate_limited",provider:idleBreach.provider||null,model:idleBreach.model||null,reason:idleBreach.reason||null,message:rateMessage};
4128
+ result.failure=failure("RATE_LIMITED","infrastructure",false,[{source:"relay",message:rateMessage,role:roleName,adapter:role.adapter,model:idleBreach.model||role.model||null,provider:idleBreach.provider||null,reason:idleBreach.reason||null}]);
4129
+ try{ observe(dir,{stream:"stdout",bytes:0,minIntervalMs:0,last:`RATE_LIMITED | ROLE=${roleName} | ADAPTER=${role.adapter} | PROVIDER=${idleBreach.provider||role.adapter}${freeQuota?" | FREE_QUOTA=true":""}`}); }catch{}
4046
4130
  }
4047
4131
  }
4048
4132
  // Mission 087 task 2.1: single per-role fallback choke point. runRelay is
@@ -4056,7 +4140,7 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
4056
4140
  // fallbackSwitch marker; the at-once re-run below returns the retry's
4057
4141
  // result). Exhausted and
4058
4142
  // non-RATE_LIMITED decisions leave state and behavior exactly as before.
4059
- if(result?.failure?.class==="RATE_LIMITED"||result?.failure?.class==="PROVIDER_ERROR"||(result?.failure?.class==="IDLE_HANG"&&result?.idleHang?.noOutput===true)){
4143
+ if(result?.failure?.class==="RATE_LIMITED"||result?.failure?.class==="AUTH_REQUIRED"||result?.failure?.class==="PROVIDER_ERROR"||(result?.failure?.class==="IDLE_HANG"&&result?.idleHang?.noOutput===true)){
4060
4144
  if(escalated){
4061
4145
  // Pending #22: the escalation model itself failed. The normal fallback
4062
4146
  // chain is untouched (the normal model never failed): drop the pending
@@ -4071,6 +4155,12 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
4071
4155
  }else
4072
4156
  try{
4073
4157
  const from={adapter:role.adapter,model:role.model,reasoning:role.reasoning,variant:role.variant};
4158
+ // Backlog #14: a free-tier quota/billing rejection is RATE_LIMITED, but
4159
+ // the operator-facing line must say so explicitly instead of looking
4160
+ // like a transient 429. Purely a label: switching/recording below is
4161
+ // the unchanged rate-limit path.
4162
+ const freeQuotaExhausted=(Array.isArray(result.failure.evidence)?result.failure.evidence:[]).some(entry=>entry?.reason===FREE_QUOTA_REASON);
4163
+ const quotaLabel=freeQuotaExhausted?", free quota used up":"";
4074
4164
  // Backlog #18: record the RATE_LIMITED model mission-wide (same place
4075
4165
  // as the per-role tried list) so every other role skips it too.
4076
4166
  if(result.failure.class==="RATE_LIMITED") s.exhaustedModels=markExhausted(s.exhaustedModels,from,roleName,now());
@@ -4079,7 +4169,7 @@ async function runRelay(roleName,task,c,s,{cwd=repo,readOnly=false,resume=false,
4079
4169
  decision=await extendFallbackWithFree({decision,role:roleName,roleConfig:c.roles?.[roleName]??null,activeModel:s.activeRoleModels?.[roleName]??null,failureClass:result.failure.class,noOutput:result?.idleHang?.noOutput===true,tried:s.triedFreeModels?.[roleName]??[],enabled:c.freeModelAutoFallback!==false,adapters:agents,discover:(a)=>agents[a]?discoverModelCatalog(agents[a]):[],exhausted:s.exhaustedModels,policy:c.policy,nowMs:Date.now()});
4080
4170
  if(decision?.action==="switch"&&decision.next){
4081
4171
  s.activeRoleModels={...(s.activeRoleModels||{}),[roleName]:stampStickyFallback(decision.next,c.roles?.[roleName]??null,now())};
4082
- console.log(formatRoleFallbackLog({role:roleName,from,to:decision.next,reason:decision.autoDiscovered?`${result.failure.class}, auto-discovered free model`:result.failure.class}));
4172
+ console.log(formatRoleFallbackLog({role:roleName,from,to:decision.next,reason:decision.autoDiscovered?`${result.failure.class}${quotaLabel}, auto-discovered free model`:`${result.failure.class}${quotaLabel}`}));
4083
4173
  result.fallbackSwitch={from,to:decision.next,reason:decision.reason};
4084
4174
  if(persistState){ try{ saveState(s); }catch{} }
4085
4175
  }else if(decision?.action==="escalate"&&Array.isArray(result.failure.evidence)){
@@ -4598,8 +4688,79 @@ function noteEscalationTrigger(s,c,fixer,scopeKey,source,previous){
4598
4688
  function review(reviewer,task,c,s,feedback="",requirements=[],taskRef=null,timeoutMs=null,capEnabled=false,roundKey=null,resultExtra=null){
4599
4689
  // Backlog #118: code-review round lines (exhaustive round 1 / verify previous findings).
4600
4690
  if(roundKey) task+=`\n\n${codeReviewerRoundLines(s,roundKey).join("\n")}`;
4691
+ if(reviewer==="code_reviewer") task+=`\n\n${CODE_REVIEW_REAL_TESTS_RULE}`;
4601
4692
  return relay(reviewer,task+(feedback?`\n\nFEEDBACK TO ADDRESS:\n${feedback}`:""),c,s,{readOnly:true,review:true,resume:false,requirements,taskRef,timeoutMs,capEnabled,resultExtra});
4602
4693
  }
4694
+ function planningScopeParts(framework,role){
4695
+ return {taskRef:`${framework}-planning-${role}`,contScope:`task:${framework}:planning:${role}`};
4696
+ }
4697
+ function planningContinuationText(s,framework,role){
4698
+ const {taskRef,contScope}=planningScopeParts(framework,role);
4699
+ const deadEnds=readRolloverDeadEnds(s,contScope);
4700
+ const deadEndsText=deadEnds.length?`Prior dead ends (continuation context — do not retry blindly):\n${deadEnds.map((d)=>`- Generation ${d.generation} (${d.reason} ${d.used}/${d.limit}): ${d.tail}`).join("\n")}`:"";
4701
+ const note=readHandoffNote(s?.mission,taskRef);
4702
+ const noteText=note?`Prior ${role==="planner"?"planner":"reviewer"} handoff note (continuation context — continue from where it stopped):\n${note}`:"";
4703
+ return {taskRef,contScope,deadEndsText,noteText};
4704
+ }
4705
+ function planningBudgetReminderFor(s,c,role){
4706
+ const eff=effectiveRoleModelFor(s,c,role);
4707
+ const pat=eff?(agents[eff.adapter]?.free_model_pattern??null):null;
4708
+ return handoffBudgetReminder(resolvePerRoleLimits(role,c.policy,eff?{adapter:eff.adapter,model:eff.model,free_model_pattern:pat}:{}).perTaskMaxTurns);
4709
+ }
4710
+ // A capped READ-ONLY review that already emitted its complete AUTOPILOT_REVIEW
4711
+ // verdict has done its job — the cap only stopped the process from exiting
4712
+ // cleanly afterwards. Accept that verdict (PASS or FAIL) as this round's
4713
+ // outcome instead of spending a continuation to re-run the same review.
4714
+ // INCOMPLETE and reviews with no parseable verdict keep the continuation path.
4715
+ function acceptCappedFinalVerdict(subject,result){
4716
+ if(!result?.capped || !result.review || !["PASS","FAIL"].includes(result.review.decision)) return false;
4717
+ const capped=result.capped;
4718
+ const type=capped.type||"unknown";
4719
+ console.log(`${subject} hit the ${type} cap (${capped.used}/${capped.limit} ${capUnitLabel(type)}) after already returning its final ${result.review.decision} verdict; accepting it instead of continuing.`);
4720
+ result.status="completed";
4721
+ result.cappedVerdictAccepted=true;
4722
+ delete result.capped;
4723
+ return true;
4724
+ }
4725
+ function handleCappedPlanningResult({c,s,framework,role,result,subject,touchedFiles}){
4726
+ const {taskRef,contScope}=planningScopeParts(framework,role);
4727
+ const mission=s.mission||null;
4728
+ if(result.capped.backstop===true||result.capped.type==="backstop"){
4729
+ const backstop=handleRoleBackstopFiring(s,contScope,result);
4730
+ if(backstop.escalate){
4731
+ const last=backstop.last;
4732
+ s.phase="escalated";s.step="runaway_session_escalation";
4733
+ s.phaseCheckpointError={code:"BACKSTOP_CONSECUTIVE_FIRINGS",evidence:{role,totalRawTokens:last.totalRawTokens??result.capped.used,turns:last.turns??null,maxSingleTurnContext:last.maxSingleTurnContext??null,firings:[...backstop.firings]}};
4734
+ saveState(s);
4735
+ die(`${subject} fired the cumulative backstop twice in a row (${last.totalRawTokens??result.capped.used}/${result.capped.limit} cumulative raw tokens, ${last.turns??"?"} turns, largest single-turn context ${last.maxSingleTurnContext??"?"}). STOP — human intervention required.`);
4736
+ }
4737
+ saveState(s);
4738
+ console.log(`${subject} crossed the cumulative backstop (${result.capped.used}/${result.capped.limit} cumulative raw tokens); continuing as backstop session ${backstop.firings.length+1}.`);
4739
+ return;
4740
+ }
4741
+ clearBackstopFirings(s,contScope);
4742
+ maybeApplyPendingContinuationReset(s,contScope);
4743
+ const cont=handlePlanningContinuation(s,role,result,contScope,c.policy,'capped');
4744
+ if(!cont.allowed){
4745
+ recordLastContinuation(s,{contScope,taskId:null,missionName:mission,taskRef});
4746
+ die(`${subject} did not converge after ${cont.used} capped generations (${result.capped.type} cap ${result.capped.used}/${result.capped.limit}). STOP — human intervention required.\nResume: ap reset --scope ${contScope} then ap run`);
4747
+ }
4748
+ if(role==="planner") stopOnNoProgress(s,c,{framework,contScope,taskId:null,missionName:mission,taskRef,touchedFiles:touchedFiles||[],checkpoint:s[CHECKPOINT_STATE_KEY],step:`${framework}_planning_no_progress`});
4749
+ console.log(`${subject} hit the ${result.capped.type} cap (${result.capped.used}/${result.capped.limit} ${capUnitLabel(result.capped.type)}); continuing as session ${cont.used+1} of ${cont.limit+1}.`);
4750
+ }
4751
+ function handleIncompletePlanningResult({c,s,framework,role,result,subject,touchedFiles}){
4752
+ const {taskRef,contScope}=planningScopeParts(framework,role);
4753
+ const mission=s.mission||null;
4754
+ clearBackstopFirings(s,contScope);
4755
+ maybeApplyPendingContinuationReset(s,contScope);
4756
+ const cont=handlePlanningContinuation(s,role,result,contScope,c.policy,'incomplete');
4757
+ if(!cont.allowed){
4758
+ recordLastContinuation(s,{contScope,taskId:null,missionName:mission,taskRef});
4759
+ die(`${subject} did not converge after ${cont.used} generations (last generation stopped voluntarily as incomplete). STOP — human intervention required.\nResume: ap reset --scope ${contScope} then ap run`);
4760
+ }
4761
+ if(role==="planner") stopOnNoProgress(s,c,{framework,contScope,taskId:null,missionName:mission,taskRef,touchedFiles:touchedFiles||[],checkpoint:s[CHECKPOINT_STATE_KEY],step:`${framework}_planning_no_progress`});
4762
+ console.log(`${subject} stopped voluntarily as incomplete; continuing as session ${cont.used+1} of ${cont.limit+1}.`);
4763
+ }
4603
4764
  // TASK-26 (003#5 part b) — derive a code_reviewer/verifier kill deadline
4604
4765
  // from measured run durations. Returns {timeoutMs, note, derived}: the
4605
4766
  // deadline to pass to the relay, one brief Time-budget line appended to the
@@ -4717,18 +4878,27 @@ async function reviewExistingPlanning(c,s,state,request,opts={}){
4717
4878
  : framework==="standalone"
4718
4879
  ? "standalone Autopilot planning artifact"
4719
4880
  : "SpecKit planning artifacts";
4720
- const prompt=[
4721
- `Validate the existing ${subject} before execution or continuation.`,
4722
- "Do not regenerate planning artifacts unless the orchestrator explicitly asks for revision.",
4723
- state.status==="complete"
4724
- ? "Read the actual planning artifacts on disk and determine whether they are complete, internally consistent, and ready for execution."
4725
- : "Read only the planning artifacts that already exist. Validate that the completed portion is internally consistent and safe to continue from the detected entry point; do not fail merely because later artifacts are intentionally missing.",
4726
- framework==="gsd"?`Target phase: ${state.phase||"unknown"}. Check PROJECT/REQUIREMENTS/ROADMAP/STATE, RESEARCH/PLAN files, dependencies, verification commands, and phase scope.`:framework==="standalone"?`Check .autopilot/missions/<mission>/PLAN.md and TASKS.md. Validate objective/scope/risks in PLAN.md, phased checklist ordering/accept/verify/capabilities in TASKS.md (each task line is "- [ ] <text> — accept: ... ; verify: ... <!-- autopilot-requires: ... -->"), cross-file consistency, and that no task runs the whole test suite (a task's verify runs only its linked tests; the full suite ${(c?.gates?.commands||[]).length?"is run by the orchestrator as a gate after code review":"belongs to the verifier"}).${verifyDenylistPromptNote(effectiveVerifyDenylist(c))?` ${verifyDenylistPromptNote(effectiveVerifyDenylist(c))}`:""}`:"Check the current SpecKit specification/plan/tasks artifacts, their consistency, task derivation, constraints, and unresolved review gates.",
4727
- `Detected planning state: ${JSON.stringify(state)}`,
4728
- `Project request/context: ${request}`,
4729
- "Return exactly one AUTOPILOT_REVIEW JSON sentinel with decision PASS or FAIL and concise findings."
4730
- ].join("\n\n");
4731
- const rr0=await relayAsync("plan_reviewer",prompt,c,s,{readOnly:true,review:true});
4881
+ const buildPrompt=()=>{
4882
+ const {taskRef,deadEndsText,noteText}=planningContinuationText(s,framework,"plan_reviewer");
4883
+ const budgetReminder=planningBudgetReminderFor(s,c,"plan_reviewer");
4884
+ return [
4885
+ `Validate the existing ${subject} before execution or continuation.`,
4886
+ "Do not regenerate planning artifacts unless the orchestrator explicitly asks for revision.",
4887
+ state.status==="complete"
4888
+ ? "Read the actual planning artifacts on disk and determine whether they are complete, internally consistent, and ready for execution."
4889
+ : "Read only the planning artifacts that already exist. Validate that the completed portion is internally consistent and safe to continue from the detected entry point; do not fail merely because later artifacts are intentionally missing.",
4890
+ framework==="gsd"?`Target phase: ${state.phase||"unknown"}. Check PROJECT/REQUIREMENTS/ROADMAP/STATE, RESEARCH/PLAN files, dependencies, verification commands, and phase scope.`:framework==="standalone"?`Check .autopilot/missions/<mission>/PLAN.md and TASKS.md. Validate objective/scope/risks in PLAN.md, phased checklist ordering/accept/verify/capabilities in TASKS.md (each task line is "- [ ] <text> — accept: ... ; verify: ... <!-- autopilot-requires: ... -->"), cross-file consistency, and that no task runs the whole test suite (a task's verify runs only its linked tests; the full suite ${(c?.gates?.commands||[]).length?"is run by the orchestrator as a gate after code review":"belongs to the verifier"}).${verifyDenylistPromptNote(effectiveVerifyDenylist(c))?` ${verifyDenylistPromptNote(effectiveVerifyDenylist(c))}`:""}`:"Check the current SpecKit specification/plan/tasks artifacts, their consistency, task derivation, constraints, and unresolved review gates.",
4891
+ PLAN_REVIEW_TESTING_RULE,
4892
+ `Detected planning state: ${JSON.stringify(state)}`,
4893
+ `Keep a hand-off note at ${scratchHandoffNotePath(SCRATCH_HANDOFF_NOTE_DEFAULT_DIR, taskRef)}. OVERWRITE it (never append) at each milestone — after every important finding and before the review verdict — with: 1. Done — files changed (names only; details are in the worktree). 2. Found — facts the next session needs: exact file:line, function names, how the code works there. 3. Next — the exact next edit: file, line, what to change. 4. Dead ends — what you tried that did not work, and why. If your session is stopped, the next session starts from this note.${budgetReminder ? ` ${budgetReminder}` : ""}`,
4894
+ deadEndsText,
4895
+ noteText,
4896
+ `Project request/context: ${request}`,
4897
+ "Return exactly one AUTOPILOT_REVIEW JSON sentinel with decision PASS or FAIL and concise findings."
4898
+ ].filter(Boolean).join("\n\n");
4899
+ };
4900
+ const {taskRef,contScope}=planningScopeParts(framework,"plan_reviewer");
4901
+ const runReview=()=>relayAsync("plan_reviewer",buildPrompt(),c,s,{readOnly:true,review:true,capEnabled:true,taskRef});
4732
4902
  // TASK-14 Evidence call site 4: the adapter/infrastructure retry path is
4733
4903
  // gated by the positive allowlist `isPlanningReviewRetryFramework()`
4734
4904
  // (standalone or speckit only — never a negative `framework !== "gsd"`
@@ -4744,14 +4914,38 @@ async function reviewExistingPlanning(c,s,state,request,opts={}){
4744
4914
  // revision — stays completely untouched regardless of framework, and never
4745
4915
  // records: those classes pass through the helper unchanged and reach the
4746
4916
  // existing die() below byte-for-byte as today.
4747
- let rr=rr0;
4748
- if(isPlanningReviewRetryFramework(framework)){
4749
- rr=await resolvePlanningReviewAttempt(c,s,"existing-planning-review",rr,
4750
- ()=>relayAsync("plan_reviewer",prompt,c,s,{readOnly:true,review:true}),
4751
- {policyMessage:`Existing planning reviewer violated relay policy.`,escalatePrefix:`Existing ${framework} planning review`});
4752
- } else if(rr.policyViolation) die(`Existing planning reviewer violated relay policy.`);
4753
- // Mission 031 Phase 3 task 3.5: INCOMPLETE-as-FAIL (no continuation): existing-planning-review treats INCOMPLETE as FAIL with explicit reason.
4754
- if(rr.review?.decision==="INCOMPLETE") console.log("Existing planning review INCOMPLETE treated as FAIL (no continuation).");
4917
+ let rr;
4918
+ for(;;){
4919
+ rr=await runReview();
4920
+ if(rr.capped && acceptCappedFinalVerdict(`Existing ${framework} planning reviewer`,rr)){ /* verdict already complete */ }
4921
+ if(rr.capped){
4922
+ handleCappedPlanningResult({c,s,framework,role:"plan_reviewer",result:rr,subject:`Existing ${framework} planning reviewer`});
4923
+ continue;
4924
+ }
4925
+ clearBackstopFirings(s,contScope);
4926
+ if(parseIncompleteSignal(rr.review ?? rr)){
4927
+ handleIncompletePlanningResult({c,s,framework,role:"plan_reviewer",result:rr,subject:`Existing ${framework} planning reviewer`});
4928
+ continue;
4929
+ }
4930
+ if(isPlanningReviewRetryFramework(framework)){
4931
+ rr=await resolvePlanningReviewAttempt(c,s,"existing-planning-review",rr,
4932
+ runReview,
4933
+ {policyMessage:`Existing planning reviewer violated relay policy.`,escalatePrefix:`Existing ${framework} planning review`});
4934
+ if(rr.capped && acceptCappedFinalVerdict(`Existing ${framework} planning reviewer`,rr)){ /* verdict already complete */ }
4935
+ if(rr.capped){
4936
+ handleCappedPlanningResult({c,s,framework,role:"plan_reviewer",result:rr,subject:`Existing ${framework} planning reviewer`});
4937
+ continue;
4938
+ }
4939
+ clearBackstopFirings(s,contScope);
4940
+ if(parseIncompleteSignal(rr.review ?? rr)){
4941
+ handleIncompletePlanningResult({c,s,framework,role:"plan_reviewer",result:rr,subject:`Existing ${framework} planning reviewer`});
4942
+ continue;
4943
+ }
4944
+ } else if(rr.policyViolation) die(`Existing planning reviewer violated relay policy.`);
4945
+ break;
4946
+ }
4947
+ if(rr.review) clearPlanningContinuationScope(s,contScope);
4948
+ clearHandoffNote(s.mission,taskRef);
4755
4949
  if(rr.status!=="completed" || rr.review?.decision!=="PASS") {
4756
4950
  s.phase="escalated";s.step="existing-planning-review-failed";s.planningStateReview=rr.review||null;saveState(s);
4757
4951
  die(`Existing ${framework} planning is not ready for execution.`);
@@ -4925,24 +5119,25 @@ async function ensurePlanningReady(c,s,request){
4925
5119
  delete s.standalonePlanReview;
4926
5120
  delete s.reviewFeedback;
4927
5121
  delete s.planReviewHistory;
5122
+ delete s.planReviewNotes;
4928
5123
  delete s.codeReviewHistory;
4929
5124
  delete s.verifierHistory;
4930
5125
  delete s.escalationSwitches;
4931
5126
  delete s.escalationPending;
4932
- // A new mission's name is unknown until the specify step creates its
4933
- // SpecKit feature dir, so SpecKit/GSD must blindly reserve the next
4934
- // sequence here (stable across planning runs) — there is nothing yet to
4935
- // match an existing mission folder against. Standalone is different: its
4936
- // name is already fully derivable from `request` via `nameFromRequest`
4937
- // at this exact point, so blindly reserving `nextMissionSeq` here was
4938
- // wrong — it always outbids `resolveStandaloneMission`'s own later,
4939
- // name-aware `existingMissionSeqForName` lookup (`missionFor`'s seq
5127
+ // A new mission's name is derivable from `request` via `nameFromRequest`
5128
+ // for every framework at this exact point (SpecKit's short-name algorithm
5129
+ // is the same one `nameFromRequest` ports), so blindly reserving
5130
+ // `nextMissionSeq` here was wrong for all of them — it always outbids the
5131
+ // later name-aware `existingMissionSeqForName` lookup (`missionFor`'s seq
4940
5132
  // selection gives absolute priority to a pre-set `s.missionSeq`),
4941
5133
  // permanently orphaning any mission directory that already exists under
4942
5134
  // this exact name (e.g. one a WATCH-REPORT.md refresh already created
4943
5135
  // moments earlier in this same run) behind a needlessly incremented,
4944
5136
  // second sequence number. Reuse that existing sequence when the name
4945
- // already matches one on disk, exactly as a resume would.
5137
+ // already matches a reusable (empty / WATCH-REPORT-only) folder on disk,
5138
+ // exactly as a resume would. SpecKit's mission folder is later renamed to
5139
+ // the spec name in persistDetectedMission, but the reused seq survives
5140
+ // that rename, so no empty provisional folder is left behind.
4946
5141
  s.mission=null;
4947
5142
  delete s.fixtureGitBlockWarning; delete s.fixtureGitBlockWarningPhaseIndex;
4948
5143
  // TASK-36: only THIS new-request site filters to reusable folders. The
@@ -4953,14 +5148,11 @@ async function ensurePlanningReady(c,s,request){
4953
5148
  // the unfiltered reuse: `missionFor`/`resolveStandaloneMission` (a
4954
5149
  // continuation with plain `ap run` or the same description lands in the
4955
5150
  // same folder) and `persistDetectedMission` (SpecKit, where
4956
- // `st.missionSeq` is already pinned from `nextMissionSeq`). Twin-loop
4957
- // note: SpecKit always takes `nextMissionSeq` below, so this filter is
4958
- // standalone-only and no SpecKit code change is needed — pinned by the
4959
- // SpecKit case in scripts/test-mission-folder-reuse.mjs.
5151
+ // `st.missionSeq` is already pinned here). Both-loops rule: standalone,
5152
+ // SpecKit and GSD all apply the same reusable-provisional-folder reuse
5153
+ // here.
4960
5154
  const planningStartedAtMs=Date.now();
4961
- s.missionSeq=state.framework==="standalone"
4962
- ? (existingMissionSeqForName(repo,nameFromRequest(request),{reusableOnly:true,since:planningStartedAtMs})||String(nextMissionSeq(repo)))
4963
- : String(nextMissionSeq(repo));
5155
+ s.missionSeq=existingMissionSeqForName(repo,nameFromRequest(request),{reusableOnly:true,since:planningStartedAtMs})||String(nextMissionSeq(repo));
4964
5156
  s.planningStartedAt=new Date(planningStartedAtMs).toISOString();
4965
5157
  // Freeze which SpecKit feature directories already exist BEFORE this new
4966
5158
  // request's planner ever runs, and clear any previously resolved feature
@@ -4998,6 +5190,16 @@ async function ensurePlanningReady(c,s,request){
4998
5190
  if(loadReviewFeedback(s,speckitStageFeedbackKey(st)).length>0){ pendingSpeckitStage=st; break; }
4999
5191
  }
5000
5192
  }
5193
+ // A passed standalone plan review whose artifacts are unchanged is an
5194
+ // approval: the resume skips re-planning/re-review. Computed before the
5195
+ // complete-planning shortcut so an already-approved mission that reaches
5196
+ // "all tasks ticked" is not asked for approval a second time mid-mission.
5197
+ let standalonePlanApproved=false;
5198
+ if(!startNewPlanning && state.framework==="standalone"){
5199
+ const approvedMission=resolveStandaloneMission(repo,s,effectiveRequest);
5200
+ const approvedDigest=planningArtifactDigest(standalone.planPath(repo,approvedMission),standalone.tasksPath(repo,approvedMission));
5201
+ standalonePlanApproved=canSkipPlanningStateReview(s.planningStateReview,approvedDigest);
5202
+ }
5001
5203
  // !startNewPlanning keeps the original `} else if` semantics: a new
5002
5204
  // request never takes the artifacts-complete shortcut.
5003
5205
  if(!startNewPlanning && state.status==="complete" && !recoverableCheckpointResume && !standaloneReviewResume && !speckitGateReviewResume && !hasPendingStandalonePlan && !pendingSpeckitStage) {
@@ -5005,8 +5207,10 @@ async function ensurePlanningReady(c,s,request){
5005
5207
  // Mission 099 task 2.4: the pre-existing complete-planning shortcut is the
5006
5208
  // same approval boundary as a freshly planned pass. Standalone and SpecKit
5007
5209
  // pause after the existing-plan review when approval is required; GSD keeps
5008
- // its unconditional tasks_ready behavior.
5009
- if((state.framework==="standalone"||state.framework==="speckit") && planApprovalRequired(c.workflow)){
5210
+ // its unconditional tasks_ready behavior. A standalone plan that already
5211
+ // passed review (unchanged digest) was approved earlier in this mission —
5212
+ // resuming after it is not a second approval boundary.
5213
+ if((state.framework==="speckit" || (state.framework==="standalone" && !standalonePlanApproved)) && planApprovalRequired(c.workflow)){
5010
5214
  const artifacts=planApprovalArtifacts(repo,state,s,effectiveRequest);
5011
5215
  const lines=beginPlanApprovalPause(s,{framework:state.framework,artifacts});
5012
5216
  saveState(s);
@@ -5075,8 +5279,19 @@ async function ensurePlanningReady(c,s,request){
5075
5279
  return detectPlanningState(c,s);
5076
5280
  }
5077
5281
  if(state.framework==="standalone") {
5078
- if(startNewPlanning || state.status!=="complete" || standaloneReviewResume || (!startNewPlanning && hasPendingStandalonePlan)) return runStandalonePlanning(c,s,effectiveRequest,{resumePosition:standaloneReviewResume});
5282
+ if(startNewPlanning || (!standalonePlanApproved && state.status!=="complete") || standaloneReviewResume || (!startNewPlanning && hasPendingStandalonePlan)) return runStandalonePlanning(c,s,effectiveRequest,{resumePosition:standaloneReviewResume});
5079
5283
  await reviewExistingPlanning(c,s,state,effectiveRequest,{allowSkip:true});
5284
+ // Only the approved-plan-before-implementation resume pauses here. The
5285
+ // pre-existing complete-plan path (all tasks ticked) keeps its old
5286
+ // reviewExistingPlanning -> tasks_ready behavior, even mid-mission.
5287
+ if(standalonePlanApproved && state.status!=="complete" && planApprovalRequired(c.workflow)){
5288
+ const artifacts=planApprovalArtifacts(repo,state,s,effectiveRequest);
5289
+ const lines=beginPlanApprovalPause(s,{framework:"standalone",artifacts});
5290
+ saveState(s);
5291
+ for(const line of lines) console.log(line);
5292
+ refreshWatchReport(s);
5293
+ return state;
5294
+ }
5080
5295
  s.phase="tasks_ready";s.step="tasks_ready";saveState(s);
5081
5296
  return state;
5082
5297
  }
@@ -5133,20 +5348,74 @@ const PLANNER_PATHS_LINE="Use only absolute sandbox-visible artifact paths given
5133
5348
  // Deliberately contains no semicolon (the standalone tasksLine statement is
5134
5349
  // sliced on ";" by the static prompt pins).
5135
5350
  const VERIFY_LENGTH_RULE="Every task verify is ONE short command naming a test file (at most 500 characters) and never an inline `node -e` script: the recorded verify is re-run verbatim, so a script a task needs must live in its own test file instead.";
5351
+ // Testing-policy rule for planners (same no-semicolon constraint as above).
5352
+ const TESTING_POLICY_RULE="Testing policy: every task that adds or changes behavior includes its own tests, written at the right level (unit, integration or component), and names the test file in its verify command. Plan one E2E test per user-facing feature.";
5353
+ const TESTING_POLICY_RULE_E2E_SET="Testing policy: every task that adds or changes behavior includes its own tests, written at the right level (unit, integration or component), and names the test file in its verify command. Plan one E2E test per user-facing feature using the configured E2E framework, and put that E2E test file in the verify of the task.";
5354
+ const TESTING_POLICY_RULE_E2E_NONE="Testing policy: every task that adds or changes behavior includes its own tests, written at the right level (unit, integration or component), and names the test file in its verify command. No E2E framework is configured, so cover UI features with component tests, and plan one E2E test per user-facing feature once a framework is set up.";
5355
+ export {TESTING_POLICY_RULE, TESTING_POLICY_RULE_E2E_SET, TESTING_POLICY_RULE_E2E_NONE};
5356
+ const E2E_SETUP_PHRASE="E2E setup";
5357
+ export function clearE2eSetupPending(repo){
5358
+ const f=path.join(repo,".autopilot","config.json");
5359
+ let raw;try{raw=fs.readFileSync(f,"utf8");}catch{return false;}
5360
+ let j;try{j=JSON.parse(raw);}catch{return false;}
5361
+ if(!j||typeof j!=="object"||!j.tests||j.tests.e2e_setup_pending!==true)return false;
5362
+ // Edit only the raw `true` token of tests.e2e_setup_pending so every other byte stays as written.
5363
+ let depth=0,topKey=null,i=0,at=-1;
5364
+ while(i<raw.length&&at<0){
5365
+ const ch=raw[i];
5366
+ if(ch==="\""){
5367
+ let k=i+1;while(k<raw.length&&raw[k]!=="\"")k+=raw[k]==="\\"?2:1;
5368
+ const str=raw.slice(i+1,k);let m=k+1;while(/\s/.test(raw[m]||""))m++;
5369
+ if(raw[m]===":"&&depth===1)topKey=str;
5370
+ else if(raw[m]===":"&&depth===2&&topKey==="tests"&&str==="e2e_setup_pending"){
5371
+ m++;while(/\s/.test(raw[m]||""))m++;
5372
+ if(raw.startsWith("true",m))at=m;
5373
+ }
5374
+ i=k+1;continue;
5375
+ }
5376
+ if(ch==="{"||ch==="[")depth++;else if(ch==="}"||ch==="]")depth--;
5377
+ i++;
5378
+ }
5379
+ if(at<0)return false;
5380
+ fs.writeFileSync(f,raw.slice(0,at)+"false"+raw.slice(at+4));
5381
+ return true;
5382
+ }
5383
+ function e2eSettingsOf(c){ const v=c?.tests||c?.verification||c?.capabilities?.verification||{}; return {e2e:v.e2e||null,pending:v.e2e_setup_pending===true}; }
5384
+ function testingPolicyRuleNote(c){
5385
+ const {e2e}=e2eSettingsOf(c);
5386
+ return e2e==="playwright"||e2e==="cypress"?TESTING_POLICY_RULE_E2E_SET:TESTING_POLICY_RULE_E2E_NONE;
5387
+ }
5388
+ function testingPolicyPromptNote(c){
5389
+ const {e2e,pending}=e2eSettingsOf(c);
5390
+ const tool=e2e==="playwright"||e2e==="cypress"?e2e:null;
5391
+ let note=testingPolicyRuleNote(c);
5392
+ if(pending&&tool){
5393
+ const install=tool==="playwright"?"install @playwright/test plus a Playwright config":"install cypress plus a Cypress config";
5394
+ note+=` E2E setup is pending: the first task's text must literally contain "${E2E_SETUP_PHRASE}" and must ${install}, each with a smoke test.`;
5395
+ }
5396
+ return note;
5397
+ }
5398
+ function tasksIncludeE2eSetup(tasks){ return (tasks||[]).some(t=>String(t?.text??"").includes(E2E_SETUP_PHRASE)); }
5399
+ export {E2E_SETUP_PHRASE, testingPolicyPromptNote, testingPolicyRuleNote, tasksIncludeE2eSetup, artifactContractNoteFor};
5400
+ // Plan-review testing policy, one canonical string shared by every plan-review prompt.
5401
+ const PLAN_REVIEW_TESTING_RULE="Testing policy (BLOCKING): FAIL a task that adds or changes behavior but has no test and no test file in its verify, citing the task ID. When tests.e2e is playwright or cypress, also FAIL a user-facing UI feature that has no E2E test, citing the task ID. When tests.e2e is none or unset, do not require E2E tests.";
5402
+ export {PLAN_REVIEW_TESTING_RULE};
5403
+ const CODE_REVIEW_REAL_TESTS_RULE="Real-tests policy: FAIL tests that have empty bodies or always-true asserts (for example check(name, true) or assert(true)); every test must exercise the behavior the change adds or modifies.";
5404
+ export {CODE_REVIEW_REAL_TESTS_RULE};
5136
5405
  // Planning-prompt rules, same one-canonical-string pattern as PLANNER_PATHS_LINE
5137
5406
  // above: each rule is defined once here and referenced by constant from prompts,
5138
5407
  // never copied inline. Wording keeps clear of legacy cite forms.
5139
5408
  // Reviewer one-pass rule: report everything visible in a single pass instead of
5140
5409
  // stopping at the first finding, so review rounds stop costing one round per finding.
5141
- const REVIEWER_ALL_FINDINGS_LINE="Report ALL findings you can see in ONE pass — every plan and task inconsistency and every stale test pin a task would break — instead of stopping at the first one or two.";
5410
+ const REVIEWER_ALL_FINDINGS_LINE="Report everything you can see in ONE pass — every blocking plan or task problem and every stale test pin a task would break in \"findings\", every minor point in \"notes\" — instead of stopping at the first one or two.";
5142
5411
  // Planner read-range rule: keep large sources out of context by reading only the
5143
5412
  // cited ranges, starting from the file and line references given in the request.
5144
5413
  const PLANNER_READ_RANGE_LINE="Read large source files only by line range or grep starting from the file:line references cited in the request — never read a whole large file into context.";
5145
- async function runPlanningPhase(c, step, request, s){
5414
+ async function runPlanningPhase(c, step, request, s, planRevision=null){
5146
5415
  s.phase="planning"; s.step=step; saveState(s);
5147
5416
  const boundaryActive=Array.isArray(s.planningPreexistingFeatureDirs) && SPECKIT_PLANNING_STAGES.has(step);
5148
5417
  const boundaryNote=boundaryActive
5149
- ? boundaryNoteFor(step,s.speckitFeatureDir)
5418
+ ? boundaryNoteFor(step,s.speckitFeatureDir,{revision:!!planRevision})
5150
5419
  : "";
5151
5420
  // TASK 15.6C: the write-boundary note above says WHERE the Planner may
5152
5421
  // write; it never said what the file at that path must actually contain.
@@ -5157,7 +5426,7 @@ async function runPlanningPhase(c, step, request, s){
5157
5426
  // speckit-planning-postconditions.mjs for every stage, up front, so the
5158
5427
  // Planner does not have to independently reverse-engineer it.
5159
5428
  const contractNote=boundaryActive
5160
- ? artifactContractNoteFor(step,s.speckitFeatureDir,c.policy)
5429
+ ? artifactContractNoteFor(step,s.speckitFeatureDir,c.policy,c)
5161
5430
  : "";
5162
5431
  // TASK 15.6C: when this exact stage failed last time in a RECOVERABLE way
5163
5432
  // — either its postcondition (enforcePlanningStagePostcondition) or the
@@ -5176,8 +5445,9 @@ async function runPlanningPhase(c, step, request, s){
5176
5445
  // problem.
5177
5446
  const gateFindings=feedbackText(loadReviewFeedback(s,speckitStageFeedbackKey(step)));
5178
5447
  const gateEarlierRounds=plannerEarlierRoundsText(s,speckitStageFeedbackKey(step),c.workflow?.plan_review_convergence!==false);
5448
+ const gateNotes=planReviewNotesText(s,speckitStageFeedbackKey(step));
5179
5449
  const gateNote=gateFindings
5180
- ? `\n\nThe plan reviewer REJECTED the previous attempt at this stage. Address every finding; do not repeat the same output:\n${gateFindings}${gateEarlierRounds ? `\n\n${gateEarlierRounds}` : ""}`
5450
+ ? `\n\nThe plan reviewer REJECTED the previous attempt at this stage. Address every finding; do not repeat the same output:\n${gateFindings}${gateEarlierRounds ? `\n\n${gateEarlierRounds}` : ""}${gateNotes ? `\n\n${gateNotes}` : ""}`
5181
5451
  : "";
5182
5452
  // Phase 9 task 9.1: absolute sandbox-visible SpecKit artifact paths cited
5183
5453
  // in the prompt itself, so PLANNER_PATHS_LINE's "read the exact paths
@@ -5187,26 +5457,62 @@ async function runPlanningPhase(c, step, request, s){
5187
5457
  const speckitPlanAbs=path.join(repo,"specs",speckitFeatureName,"plan.md");
5188
5458
  const speckitTasksAbs=path.join(repo,"specs",speckitFeatureName,"tasks.md");
5189
5459
  const speckitPathsLine=`SpecKit artifact paths (absolute, sandbox-visible): spec: ${speckitSpecAbs}; plan: ${speckitPlanAbs}; tasks: ${speckitTasksAbs}.`;
5190
- const task=`Planning phase: ${step}.
5460
+ let finalR=null;
5461
+ for(;;){
5462
+ const {taskRef,deadEndsText,noteText}=planningContinuationText(s,"speckit","planner");
5463
+ const budgetReminder=planningBudgetReminderFor(s,c,"planner");
5464
+ const task=`Planning phase: ${step}.
5191
5465
  Use the selected planning framework's installed commands/skills and project artifacts.
5192
5466
  ${speckitPathsLine}
5193
5467
  ${PLANNER_PATHS_LINE}
5194
5468
  ${PLANNER_READ_RANGE_LINE}
5195
5469
  Do not implement production code. Do not commit/push.${boundaryNote}${contractNote}${correctionNote}${gateNote}
5196
- Keep a hand-off note at ${handoffNoteRelPath(s.mission, 'standalone-planning-planner')}. OVERWRITE it (never append) at each milestone — after every important finding, before writing the stage artifact — with: 1. Done — files changed (names only; details are in the worktree). 2. Found — facts the next session needs: exact file:line, function names, how the code works there. 3. Next — the exact next edit: file, line, what to change. 4. Dead ends — what you tried that did not work, and why. If your session is stopped, the next session starts from this note. No loop currently reads this note (this relay runs with no taskRef/capEnabled); writing it is an allowed exception to the 'Write only' list above.
5470
+ ${deadEndsText?`${deadEndsText}\n\n`:""}${noteText?`${noteText}\n\n`:""}Keep a hand-off note at ${handoffNoteRelPath(s.mission, taskRef)}. OVERWRITE it (never append) at each milestone — after every important finding, before writing the stage artifact — with: 1. Done — files changed (names only; details are in the worktree). 2. Found — facts the next session needs: exact file:line, function names, how the code works there. 3. Next — the exact next edit: file, line, what to change. 4. Dead ends — what you tried that did not work, and why. If your session is stopped, the next session starts from this note.${budgetReminder ? ` ${budgetReminder}` : ""}
5197
5471
  Project request:
5198
5472
  ${request}`;
5199
- // Always fresh: s.sessionId is never assigned (this call also drives
5200
- // gate-failure revisions, whose corrective findings are already embedded
5201
- // in `task` above), so resuming here would only ever mean falling back to
5202
- // an unrelated prior session, not this one.
5203
- const r=await relay("planner",task,c,s,{resume:false});
5473
+ // Always fresh: s.sessionId is never assigned (this call also drives
5474
+ // gate-failure revisions, whose corrective findings are already embedded
5475
+ // in `task` above), so resuming here would only ever mean falling back to
5476
+ // an unrelated prior session, not this one.
5477
+ const r=await relay("planner",task,c,s,{resume:false,capEnabled:true,taskRef,planRevision});
5478
+ if(r.capped){
5479
+ handleCappedPlanningResult({c,s,framework:"speckit",role:"planner",result:r,subject:`SpecKit planner ${step}`,touchedFiles:r.touchedFiles||[]});
5480
+ continue;
5481
+ }
5482
+ clearBackstopFirings(s,planningScopeParts("speckit","planner").contScope);
5483
+ if(parsePlanningIncompleteSignal(r.finalMessage ?? r)){
5484
+ handleIncompletePlanningResult({c,s,framework:"speckit",role:"planner",result:r,subject:`SpecKit planner ${step}`,touchedFiles:r.touchedFiles||[]});
5485
+ continue;
5486
+ }
5487
+ finalR=r;
5488
+ break;
5489
+ }
5490
+ const r=finalR;
5204
5491
  // Backlog #54: RATE_LIMITED short-circuits here — before the status die below —
5205
5492
  // so the planner is never treated as a generic planner failure.
5206
5493
  escalateIfRateLimited(c,s,"speckit-gate-review",r,{fatal:die,saveState,escalatePrefix:`SpecKit planner ${step}`});
5207
5494
  if(r.status!=="completed" || r.policyViolation) die(`Planner failed or violated relay policy in phase ${step}.`);
5208
- enforcePlanningWriteBoundary(s,step,r.touchedFiles||[]);
5495
+ enforcePlanningWriteBoundary(s,step,r.touchedFiles||[],{revision:!!planRevision});
5496
+ // Record which earlier-stage artifacts a revision actually edited, so the
5497
+ // next review re-checks them (SpecKit gate revision option (a)).
5498
+ if(!!planRevision && s.speckitFeatureDir){
5499
+ const earlierAllowed = step==="plan" ? ["spec.md"] : step==="tasks" ? ["spec.md","plan.md"] : [];
5500
+ const earlierEdited = (r.touchedFiles||[]).map(normalizeRel).filter((f) => earlierAllowed.some((a) => f===`specs/${s.speckitFeatureDir}/${a}`));
5501
+ if(earlierEdited.length){
5502
+ s.planningEarlierStageEdits = { stage: step, files: earlierEdited, at: now() };
5503
+ saveState(s);
5504
+ }
5505
+ }
5209
5506
  enforcePlanningStagePostcondition(s,step);
5507
+ // Item 6: the planner completed this stage uncapped, so its continuation
5508
+ // allowance starts fresh on the next stage/revision.
5509
+ const {taskRef:plannerTaskRef,contScope:plannerContScope}=planningScopeParts("speckit","planner");
5510
+ clearPlanningContinuationScope(s,plannerContScope);
5511
+ // Backlog #7: clear the completed planner's hand-off note so a later
5512
+ // revision's rate-limit fallback cannot quote this finished revision as
5513
+ // "continuation context" (mirrors the reviewer path's clearHandoffNote).
5514
+ clearHandoffNote(s.mission,plannerTaskRef);
5515
+ saveState(s);
5210
5516
  return r;
5211
5517
  }
5212
5518
 
@@ -5214,11 +5520,27 @@ ${request}`;
5214
5520
  // this stage may touch — kept in sync with planning-boundary.mjs's actual
5215
5521
  // per-stage allowlists so the Planner is told the real contract, but the
5216
5522
  // die() in enforcePlanningWriteBoundary below is what actually stops it.
5217
- function boundaryNoteFor(step,featureDir){
5523
+ // Reviewer sentence naming the earlier-stage artifacts a planner revision
5524
+ // edited — only for the stage those edits were made in, so a later stage's
5525
+ // review is never told about an old stage's edits.
5526
+ function earlierStageEditsNote(s,gateStage){
5527
+ const e=s.planningEarlierStageEdits;
5528
+ if(!e?.files?.length || e.stage!==gateStage) return "";
5529
+ return ` The planner edited earlier-stage artifact(s) to resolve a finding: ${e.files.join(", ")}. Re-check them too.`;
5530
+ }
5531
+ function boundaryNoteFor(step,featureDir,{revision=false}={}){
5218
5532
  const feature=featureDir?`specs/${featureDir}/`:"specs/<new-feature>/";
5219
5533
  if(step==="specify") return `\nThis is a NEW feature. Write only: .specify/feature.json and ${feature}spec.md (plus ${feature}checklists/** if needed). Do not touch any other specs/ feature directory, do not write plan.md/tasks.md yet, and do not create any implementation deliverable.`;
5220
- if(step==="plan") return `\nFeature '${featureDir||"<new-feature>"}'. Write only: ${feature}plan.md, research.md, data-model.md, quickstart.md, contracts/**, and — if needed — ONLY the content between the ${CLAUDE_MD_MARKER_BEGIN} / ${CLAUDE_MD_MARKER_END} markers in CLAUDE.md (nothing else in CLAUDE.md may change). Do not write tasks.md yet, and do not create any implementation deliverable.`;
5221
- if(step==="tasks") return `\nFeature '${featureDir||"<new-feature>"}'. Write only: ${feature}tasks.md. Do not create any implementation deliverable.`;
5534
+ if(step==="plan") {
5535
+ let note=`\nFeature '${featureDir||"<new-feature>"}'. Write only: ${feature}plan.md, research.md, data-model.md, quickstart.md, contracts/**, and — if needed — ONLY the content between the ${CLAUDE_MD_MARKER_BEGIN} / ${CLAUDE_MD_MARKER_END} markers in CLAUDE.md (nothing else in CLAUDE.md may change). Do not write tasks.md yet, and do not create any implementation deliverable.`;
5536
+ if(revision) note += ` Revision mode: you may also edit an earlier-stage artifact cited by a finding (spec.md during plan) in the same feature dir, in addition to this stage's files. Never edit a later-stage artifact (tasks.md) or feature.json. Record which earlier-stage artifacts you edited in your hand-off note.`;
5537
+ return note;
5538
+ }
5539
+ if(step==="tasks") {
5540
+ let note=`\nFeature '${featureDir||"<new-feature>"}'. Write only: ${feature}tasks.md. Do not create any implementation deliverable.`;
5541
+ if(revision) note += ` Revision mode: you may also edit an earlier-stage artifact cited by a finding (spec.md, plan.md during tasks) in the same feature dir, in addition to this stage's files. Never edit feature.json or CLAUDE.md. Record which earlier-stage artifacts you edited in your hand-off note.`;
5542
+ return note;
5543
+ }
5222
5544
  return "";
5223
5545
  }
5224
5546
 
@@ -5229,7 +5551,7 @@ function boundaryNoteFor(step,featureDir){
5229
5551
  // names the required SHAPE of what lands at those paths, so the Planner is
5230
5552
  // told the real speckit-specify/-plan/-tasks contract instead of having to
5231
5553
  // independently discover it.
5232
- function artifactContractNoteFor(step,featureDir,policy){
5554
+ function artifactContractNoteFor(step,featureDir,policy,cfg){
5233
5555
  const sizeTarget=resolveTaskSizeTargetTurns(policy);
5234
5556
  const feature=featureDir?`specs/${featureDir}`:"specs/<new-feature>";
5235
5557
  // Phase 9 task 9.1: absolute sandbox-visible twins of the relative feature
@@ -5263,6 +5585,7 @@ Artifact contract for this stage (validated on disk before it is accepted):
5263
5585
  - Split build-then-wire work into two tasks: a task that needs both a new design/helper AND its wiring into a large file must be two tasks (build, then wire) — one task to build the helper/design, then a second task to wire it in — never combine both in a single task.
5264
5586
  - ${taskSizingRuleText(sizeTarget)}
5265
5587
  - ${VERIFY_LENGTH_RULE}
5588
+ - ${testingPolicyRuleNote(cfg)}
5266
5589
  - If a previous attempt at this stage failed, the retry instruction carries a structured AUTOPILOT_PLANNING_CORRECTION={...} line naming the exact stage, artifact, expected path, and reason code — satisfy that contract exactly rather than rewriting the file a different way.`;
5267
5590
  return "";
5268
5591
  }
@@ -5402,7 +5725,7 @@ function classifyRecoverableBoundaryViolation(step,evaluation){
5402
5725
  // violation shape gets no checkpoint at all and remains exactly as
5403
5726
  // non-recoverable as before — no automatic resume, human intervention
5404
5727
  // required.
5405
- function enforcePlanningWriteBoundary(s,step,touchedFiles){
5728
+ function enforcePlanningWriteBoundary(s,step,touchedFiles,{revision=false}={}){
5406
5729
  if(!Array.isArray(s.planningPreexistingFeatureDirs) || !SPECKIT_PLANNING_STAGES.has(step)) return;
5407
5730
  const evaluation=evaluatePlanningWriteBoundary({
5408
5731
  stage:step,
@@ -5410,7 +5733,8 @@ function enforcePlanningWriteBoundary(s,step,touchedFiles){
5410
5733
  preexistingFeatureDirs:s.planningPreexistingFeatureDirs,
5411
5734
  resolvedFeatureDir:s.speckitFeatureDir||null,
5412
5735
  readFile:(rel)=>{ try{ return fs.readFileSync(path.join(repo,rel),"utf8"); }catch{ return null; } },
5413
- claudeMdBaseline:s.planningClaudeMdBaseline??null
5736
+ claudeMdBaseline:s.planningClaudeMdBaseline??null,
5737
+ revision
5414
5738
  });
5415
5739
  if(!evaluation.ok){
5416
5740
  s.phase="escalated";s.step="planning-write-boundary-violation";
@@ -5604,7 +5928,13 @@ async function runStandalonePlanning(c,s,request,opts={}){
5604
5928
  // validated (reviewer stage) or skipped (writable stage) by the task 12
5605
5929
  // evaluator during the consult above — never here.
5606
5930
  let skipPlannerFirst=opts?.resumePosition===STANDALONE_REVIEWER_POSITION;
5931
+ const planReviewConverge=c.workflow?.plan_review_convergence!==false;
5607
5932
  while(true){
5933
+ // A scoped PASS scheduled the confirmation full review. The persisted
5934
+ // marker (not a local flag) is what makes a kill during that call resume
5935
+ // into the confirmation review instead of accepting the scoped PASS.
5936
+ const confirming=planReviewConfirmationPending(s,STANDALONE_PLAN_FEEDBACK_KEY);
5937
+ if(confirming) skipPlannerFirst=true;
5608
5938
  // Mission 086: a queued `ap reset --scope standalone-plan-revision`
5609
5939
  // applies at the top of every revision-loop iteration, restarting the
5610
5940
  // revision budget instead of escalating. Stays queued while nothing is
@@ -5620,7 +5950,7 @@ async function runStandalonePlanning(c,s,request,opts={}){
5620
5950
  // parseTasksFile()/phases() regex tolerates the ID as literal leading text
5621
5951
  // in task.text, so nothing downstream breaks.
5622
5952
  const tasksLineSuffix=(c.gates?.commands||[]).length?"; the orchestrator runs the full suite as a gate after code review, and the verifier analyses its result (backlog #27).":"; the full suite is the verifier's job after the phase (backlog #27).";
5623
- const tasksLine="Content for TASKS.md: use \"## Phase 1: <name>\" headings and \"- [ ] **<phase>.<task>** <text> — accept: ... ; verify: ...\" one line per task (no nested bullets; fences ignored), where **<phase>.<task>** is a stable per-phase ID (e.g. **2.3** = 3rd task in Phase 2). IDs are assigned in appearance order within each phase and MUST stay stable across revisions — never renumber an existing task because another task was added/removed elsewhere in the same phase; a new task in that phase takes the next unused number. Each task must be actionable, bounded, and independently reviewable. Do not add any autopilot-requires annotation — capability needs are detected from the accept/verify text, and an unrecognized annotation fails the mission at verification. Never make running the whole test suite a task (no task whose accept/verify runs the full suite, e.g. an all-tests runner): a task's verify runs only the tests linked to that task" + tasksLineSuffix + " " + VERIFY_LENGTH_RULE + (verifyDenylistPromptNote(effectiveVerifyDenylist(c))?`\n\n${verifyDenylistPromptNote(effectiveVerifyDenylist(c))}`:"");
5953
+ const tasksLine="Content for TASKS.md: use \"## Phase 1: <name>\" headings and \"- [ ] **<phase>.<task>** <text> — accept: ... ; verify: ...\" one line per task (no nested bullets; fences ignored), where **<phase>.<task>** is a stable per-phase ID (e.g. **2.3** = 3rd task in Phase 2). IDs are assigned in appearance order within each phase and MUST stay stable across revisions — never renumber an existing task because another task was added/removed elsewhere in the same phase; a new task in that phase takes the next unused number. Each task must be actionable, bounded, and independently reviewable. Do not add any autopilot-requires annotation — capability needs are detected from the accept/verify text, and an unrecognized annotation fails the mission at verification. Never make running the whole test suite a task (no task whose accept/verify runs the full suite, e.g. an all-tests runner): a task's verify runs only the tests linked to that task" + tasksLineSuffix + " " + VERIFY_LENGTH_RULE + " " + testingPolicyPromptNote(c) + " " + (verifyDenylistPromptNote(effectiveVerifyDenylist(c))?`\n\n${verifyDenylistPromptNote(effectiveVerifyDenylist(c))}`:"");
5624
5954
  // Mission 026 task 2.1: build-then-wire split rule. A task that needs
5625
5955
  // both a new design/helper AND its wiring into a large file is two
5626
5956
  // tasks, never one — the same wording is reused by the SpecKit loop
@@ -5685,6 +6015,7 @@ async function runStandalonePlanning(c,s,request,opts={}){
5685
6015
  plannerHandoffNoteText,
5686
6016
  `Project request/context: ${request}`,
5687
6017
  `Keep a hand-off note at ${handoffNoteRelPath(mission, 'standalone-planning-planner')}. OVERWRITE it (never append) at each milestone — after every important finding, before writing PLAN.md/TASKS.md — with: 1. Done — files changed (names only; details are in the worktree). 2. Found — facts the next session needs: exact file:line, function names, how the code works there. 3. Next — the exact next edit: file, line, what to change. 4. Dead ends — what you tried that did not work, and why. If your session is stopped, the next session starts from this note.${plannerBudgetReminder ? ` ${plannerBudgetReminder}` : ""}`,
6018
+ planReviewNotesText(s,STANDALONE_PLAN_FEEDBACK_KEY),
5688
6019
  plannerEarlierRoundsText(s,STANDALONE_PLAN_FEEDBACK_KEY,c.workflow?.plan_review_convergence!==false),
5689
6020
  `Plan reviewer feedback that MUST be addressed:\n${feedback}`
5690
6021
  ].filter(Boolean).join("\n\n"):[
@@ -5807,6 +6138,14 @@ async function runStandalonePlanning(c,s,request,opts={}){
5807
6138
  }
5808
6139
  if(!fs.existsSync(tasksFile))die(`Planner did not create ${tasksFile} (and auto-split did not produce it).`);
5809
6140
  }
6141
+ // Item 6: the planner finished its stage (not capped, not incomplete), so
6142
+ // its continuation allowance starts fresh on the next planner run.
6143
+ clearPlanningContinuationScope(s,'task:standalone:planning:planner');
6144
+ // Backlog #7: a completed planner run's hand-off note is stale resume
6145
+ // context for the NEXT revision — and for any rate-limit fallback of a
6146
+ // later run that wrote no note of its own. Clear it like the reviewer
6147
+ // path does, so a fallback never quotes an earlier, finished revision.
6148
+ clearHandoffNote(mission,'standalone-planning-planner');
5810
6149
  // TASK-14 Phase 5 task 15: explicit reviewer-pending checkpoint, set
5811
6150
  // immediately after the planner completes and before the reviewer call.
5812
6151
  // A restart in this state resumes at the reviewer with content validated
@@ -5842,12 +6181,13 @@ async function runStandalonePlanning(c,s,request,opts={}){
5842
6181
  "When a finding concerns a TASKS.md item, `location` MUST cite its numeric ID exactly as written in the file (e.g. 'TASKS.md 2.3'), not a description or recount of position. When a finding concerns PLAN.md, cite its existing numbered list item (e.g. 'PLAN.md Scope 4', 'PLAN.md Risks 2') or, for unnumbered sections (Objective, Constraints), the section heading.",
5843
6182
  "FAIL a single task that combines building a new design/helper AND wiring it into a large file — such build-then-wire work must be two tasks (build, then wire); cite the oversized task by its numeric ID exactly as written in the file (e.g. 'TASKS.md 2.3').",
5844
6183
  taskSizeReviewText(resolveTaskSizeTargetTurns(c.policy))+"; cite the oversized task by its numeric ID exactly as written in the file (e.g. 'TASKS.md 2.3').",
6184
+ PLAN_REVIEW_TESTING_RULE,
5845
6185
  `FAIL any phase with more than ${MAX_PHASE_TASKS} tasks, citing the phase (e.g. 'TASKS.md Phase 2'); it must be split into additional phases.`,
5846
6186
  "FAIL a task that changes no files (only runs tests / regression-checks), citing the task ID (e.g. 'TASKS.md 2.3'). Do NOT FAIL small tasks for being small or for not being merged — small one-action tasks are deliberate. Do not FAIL a phase merely for having few tasks: phases are cut by deliverable, and task size wins over phase count.",
5847
6187
  ...planReviewerRoundLines(s,STANDALONE_PLAN_FEEDBACK_KEY,c.workflow?.plan_review_convergence!==false),
5848
6188
  "Do not modify either file. End with the required AUTOPILOT_REVIEW sentinel.",
5849
6189
  `Keep a hand-off note at ${scratchHandoffNotePath(SCRATCH_HANDOFF_NOTE_DEFAULT_DIR, 'standalone-planning-plan_reviewer')}. OVERWRITE it (never append) at each milestone — after every important finding and before the review verdict — with: 1. Done — files changed (names only; details are in the worktree). 2. Found — facts the next session needs: exact file:line, function names, how the code works there. 3. Next — the exact next edit: file, line, what to change. 4. Dead ends — what you tried that did not work, and why. If your session is stopped, the next session starts from this note.${planReviewerBudgetReminder ? ` ${planReviewerBudgetReminder}` : ""}`,
5850
- feedback?`Prior findings to re-check:\n${feedback}`:"",
6190
+ !confirming&&feedback?`Prior findings to re-check:\n${feedback}`:"",
5851
6191
  reviewerHandoffNote?`Prior reviewer handoff note (continuation context — continue from where it stopped):\n${reviewerHandoffNote}`:"",
5852
6192
  verifyDenylistPromptNote(effectiveVerifyDenylist(c))
5853
6193
  ].filter(Boolean).join("\n\n");
@@ -5866,9 +6206,10 @@ async function runStandalonePlanning(c,s,request,opts={}){
5866
6206
  const standaloneTasksText=fs.existsSync(tasksFile)?fs.readFileSync(tasksFile,"utf8"):"";
5867
6207
  const denied=[...verifyDenylistFindings(standaloneVerifyEntries,effectiveVerifyDenylist(c)),...verifyLengthFindings(standaloneVerifyEntries),...verifyShardFindings(standaloneVerifyEntries),...phaseTaskCountFindings(standaloneTasksText),...unrecognizedAnnotationFindings(standaloneTasksText)];
5868
6208
  if(denied.length){
6209
+ if(confirming) clearPlanReviewConfirmation(s,STANDALONE_PLAN_FEEDBACK_KEY);
5869
6210
  revision++;
5870
6211
  const planFindings=saveReviewFeedback(s,STANDALONE_PLAN_FEEDBACK_KEY,denied);
5871
- recordPlanReviewRound(s,STANDALONE_PLAN_FEEDBACK_KEY,planFindings);
6212
+ recordPlanReviewRound(s,STANDALONE_PLAN_FEEDBACK_KEY,planFindings,[],{reviewer:false});
5872
6213
  feedback=feedbackText(planFindings)||"Plan reviewer returned FAIL without findings.";
5873
6214
  findingCount=planFindings.length;
5874
6215
  s.standalonePlanReview={revision,findings:planFindings};
@@ -5887,8 +6228,10 @@ async function runStandalonePlanning(c,s,request,opts={}){
5887
6228
  }
5888
6229
  continue;
5889
6230
  }
5890
- let reviewResult=await relayAsync("plan_reviewer",reviewPrompt,c,s,{readOnly:true,review:true,resume:false,capEnabled:true,taskRef:'standalone-planning-plan_reviewer',planRevision:revision});
6231
+ if(confirming) console.log("plan review: full re-check");
6232
+ let reviewResult=await relayAsync("plan_reviewer",reviewPrompt,c,s,{readOnly:true,review:true,resume:false,capEnabled:true,taskRef:'standalone-planning-plan_reviewer',planRevision:revision,...(confirming?{runNote:"full re-check",resultExtra:{planReviewConfirmation:true}}:{})});
5891
6233
  if(reviewResult.policyViolation)die("Standalone plan reviewer violated relay policy.");
6234
+ if(reviewResult.capped && acceptCappedFinalVerdict("Standalone plan review",reviewResult)){ /* verdict already complete */ }
5892
6235
  if(reviewResult.capped){
5893
6236
  // Mission 031 Phase 7 task 7.1: a standalone plan_reviewer cap breach
5894
6237
  // is a continuation event, never a failure. Checked BEFORE
@@ -5976,8 +6319,9 @@ async function runStandalonePlanning(c,s,request,opts={}){
5976
6319
  // escalate naming class/domain; functional/output-contract outcomes flow
5977
6320
  // through to the unchanged revision behavior below with nothing recorded.
5978
6321
  reviewResult=await resolvePlanningReviewAttempt(c,s,"standalone-plan-review",reviewResult,
5979
- ()=>relayAsync("plan_reviewer",reviewPrompt,c,s,{readOnly:true,review:true,resume:false,capEnabled:true,taskRef:'standalone-planning-plan_reviewer',planRevision:revision}),
6322
+ ()=>relayAsync("plan_reviewer",reviewPrompt,c,s,{readOnly:true,review:true,resume:false,capEnabled:true,taskRef:'standalone-planning-plan_reviewer',planRevision:revision,...(confirming?{runNote:"full re-check",resultExtra:{planReviewConfirmation:true}}:{})}),
5980
6323
  {policyMessage:"Standalone plan reviewer violated relay policy.",escalatePrefix:"Standalone plan review"});
6324
+ if(reviewResult.capped && acceptCappedFinalVerdict("Standalone plan review",reviewResult)){ /* verdict already complete */ }
5981
6325
  if(reviewResult.capped){
5982
6326
  // Mission 031 Phase 7 task 7.1: a capped reviewer retry (the retry
5983
6327
  // lambda above re-invokes the same cap-guarded call) that passes
@@ -6020,6 +6364,11 @@ async function runStandalonePlanning(c,s,request,opts={}){
6020
6364
  // Backlog #59 part (a): the retried plan_reviewer run ended WITHOUT a
6021
6365
  // backstop firing — reset the consecutive-firing count.
6022
6366
  clearBackstopFirings(s,'task:standalone:planning:plan_reviewer');
6367
+ // Item 6: this uncapped run produced a verdict, so the reviewer's
6368
+ // continuation allowance starts fresh on the next review (including the
6369
+ // post-revision/confirmation reviews) instead of inheriting this round's
6370
+ // spent generations.
6371
+ if(reviewResult.review) clearPlanningContinuationScope(s,'task:standalone:planning:plan_reviewer');
6023
6372
  // Mission 031 Phase 7 task 7.1: the reviewer finished without being
6024
6373
  // capped (PASS or FAIL below), so its wrap-up handoff note — if any —
6025
6374
  // is stale resume context. Clear it now, before the PASS break or the
@@ -6028,11 +6377,23 @@ async function runStandalonePlanning(c,s,request,opts={}){
6028
6377
  // never quotes a stale "continue from where it stopped" note.
6029
6378
  clearHandoffNote(mission,'standalone-planning-plan_reviewer');
6030
6379
  if(reviewResult.status==="completed" && reviewResult.review?.decision==="PASS"){
6380
+ // A scoped PASS (one that only verified earlier findings) is not enough:
6381
+ // schedule one fresh FULL confirmation review before accepting. The
6382
+ // marker + reviewer-pending checkpoint make that survive a kill.
6383
+ if(!confirming && planReviewPassNeedsConfirmation(s,STANDALONE_PLAN_FEEDBACK_KEY,planReviewConverge)){
6384
+ markPlanReviewConfirmationPending(s,STANDALONE_PLAN_FEEDBACK_KEY);
6385
+ writeStandaloneReviewerPendingCheckpoint(s,mission);
6386
+ saveState(s);
6387
+ console.log("plan review: scoped PASS requires a confirmation full review");
6388
+ continue;
6389
+ }
6031
6390
  // A PASS must not leave the previous FAIL's findings readable by a
6032
6391
  // later resume, which would re-feed stale corrections into a plan
6033
6392
  // that has since been accepted.
6393
+ clearPlanReviewConfirmation(s,STANDALONE_PLAN_FEEDBACK_KEY);
6034
6394
  clearReviewFeedback(s,STANDALONE_PLAN_FEEDBACK_KEY);
6035
6395
  clearPlanReviewHistory(s,STANDALONE_PLAN_FEEDBACK_KEY);
6396
+ clearPlanReviewRevisions(s,STANDALONE_PLAN_FEEDBACK_KEY);
6036
6397
  clearFindingTasks(s,STANDALONE_PLAN_FEEDBACK_KEY);
6037
6398
  delete s.standalonePlanReview;
6038
6399
  // TASK-14 Phase 5 task 15: cleared on PASS in the SAME state-save call
@@ -6043,8 +6404,10 @@ async function runStandalonePlanning(c,s,request,opts={}){
6043
6404
  state=standalone.state(repo,mission);
6044
6405
  break;
6045
6406
  }
6407
+ if(confirming) clearPlanReviewConfirmation(s,STANDALONE_PLAN_FEEDBACK_KEY);
6046
6408
  revision++;
6047
6409
  const planFindings=saveReviewFeedback(s,STANDALONE_PLAN_FEEDBACK_KEY,reviewResult.review?.findings||[]);
6410
+ recordPlanReviewNotes(s,STANDALONE_PLAN_FEEDBACK_KEY,reviewResult.review?.notes);
6048
6411
  recordPlanReviewRound(s,STANDALONE_PLAN_FEEDBACK_KEY,planFindings,reviewResult.review?.previous);noteEscalationTrigger(s,c,"planner",STANDALONE_PLAN_FEEDBACK_KEY,"plan_reviewer",reviewResult.review?.previous);
6049
6412
  feedback=feedbackText(planFindings) || "Plan reviewer returned FAIL without findings.";
6050
6413
  findingCount=planFindings.length;
@@ -6146,13 +6509,19 @@ async function runSpecKitPlanning(c,s,request,opts={}){
6146
6509
  const name=stripSpecNumber(best);
6147
6510
  const seq=st.missionSeq||missionSeqOf(st.mission)||existingMissionSeqForName(repo,name)||String(nextMissionSeq(repo));
6148
6511
  const unified=sanitizeMissionName(`${seq}-${name}`);
6149
- if(st.mission===unified){ st.missionSeq=seq; saveState(st); return; }
6150
- if(st.mission){
6151
- const oldDir=path.join(repo,".autopilot","missions",sanitizeMissionName(st.mission));
6512
+ // The provisional folder may live under the request-derived name even when
6513
+ // `st.mission` is still null (new request: mission is pinned only as
6514
+ // `missionSeq` until the spec dir appears), so resolve the on-disk folder
6515
+ // name from missionFor() as well — otherwise a reused provisional folder
6516
+ // is left behind and the mission points at a non-existent directory.
6517
+ const prevMission = st.mission ? String(st.mission) : (missionFor(repo, st) || null);
6518
+ if(prevMission===unified){ st.mission=unified; st.missionSeq=seq; saveState(st); return; }
6519
+ if(prevMission){
6520
+ const oldDir=path.join(repo,".autopilot","missions",sanitizeMissionName(prevMission));
6152
6521
  const newDir=path.join(repo,".autopilot","missions",unified);
6153
6522
  if(fs.existsSync(oldDir) && !fs.existsSync(newDir)){
6154
6523
  fs.renameSync(oldDir,newDir);
6155
- migrationLog(repo,`renamed mission folder ${st.mission} -> ${unified} (unified to spec name ${best})`);
6524
+ migrationLog(repo,`renamed mission folder ${prevMission} -> ${unified} (unified to spec name ${best})`);
6156
6525
  }
6157
6526
  }
6158
6527
  st.mission=unified;
@@ -6174,8 +6543,34 @@ async function runSpecKitPlanning(c,s,request,opts={}){
6174
6543
  // are what actually stop an invalid tasks.md from reaching
6175
6544
  // implementation). Other gates and frameworks are untouched.
6176
6545
  const tasksGateNote = String(step.id||"").includes("tasks")
6177
- ? "\nAdditionally, enforce the phase-observability contract on the tasks artifact: every '## Phase N' section must be implementable into at least one Git-observable file addition, modification, or deletion. FAIL the gate if any phase can produce no Git-observable file change — for example a phase whose only task is creating an empty directory (Git cannot represent empty directories, so such a phase could never be code-reviewed or verified), or a phase of only reads, checks, or planning notes with no persistent file output. Setup phases that modify observable files (package.json, configuration, source files, documentation) satisfy this contract and must not fail it. FAIL a single task that combines building a new design/helper AND wiring it into a large file — such build-then-wire work must be two tasks (build, then wire); cite the oversized task by its ID exactly as written in the file (e.g. 'T001'). "+taskSizeReviewText(resolveTaskSizeTargetTurns(c.policy))+`; cite the oversized task by its ID exactly as written in the file (e.g. 'T001'). FAIL any phase with more than ${MAX_PHASE_TASKS} tasks, citing the phase; it must be split into additional phases.`
6546
+ ? "\nAdditionally, enforce the phase-observability contract on the tasks artifact: every '## Phase N' section must be implementable into at least one Git-observable file addition, modification, or deletion. FAIL the gate if any phase can produce no Git-observable file change — for example a phase whose only task is creating an empty directory (Git cannot represent empty directories, so such a phase could never be code-reviewed or verified), or a phase of only reads, checks, or planning notes with no persistent file output. Setup phases that modify observable files (package.json, configuration, source files, documentation) satisfy this contract and must not fail it. FAIL a single task that combines building a new design/helper AND wiring it into a large file — such build-then-wire work must be two tasks (build, then wire); cite the oversized task by its ID exactly as written in the file (e.g. 'T001'). "+taskSizeReviewText(resolveTaskSizeTargetTurns(c.policy))+`; cite the oversized task by its ID exactly as written in the file (e.g. 'T001'). FAIL any phase with more than ${MAX_PHASE_TASKS} tasks, citing the phase; it must be split into additional phases. ${PLAN_REVIEW_TESTING_RULE}`
6178
6547
  : "";
6548
+ const planReviewerTaskRef=planningScopeParts("speckit","plan_reviewer").taskRef;
6549
+ const planReviewerContScope=planningScopeParts("speckit","plan_reviewer").contScope;
6550
+ const planReviewerContextText=()=>{
6551
+ const {deadEndsText,noteText}=planningContinuationText(s,"speckit","plan_reviewer");
6552
+ const budgetReminder=planningBudgetReminderFor(s,c,"plan_reviewer");
6553
+ return [deadEndsText,noteText,`Keep a hand-off note at ${scratchHandoffNotePath(SCRATCH_HANDOFF_NOTE_DEFAULT_DIR, planReviewerTaskRef)}. OVERWRITE it (never append) at each milestone — after every important finding and before the review verdict — with: 1. Done — files changed (names only; details are in the worktree). 2. Found — facts the next session needs: exact file:line, function names, how the code works there. 3. Next — the exact next edit: file, line, what to change. 4. Dead ends — what you tried that did not work, and why. If your session is stopped, the next session starts from this note.${budgetReminder ? ` ${budgetReminder}` : ""}`].filter(Boolean).join("\n\n");
6554
+ };
6555
+ const runCappedReview=async(prompt,opts={})=>{
6556
+ for(;;){
6557
+ const result=await relay("plan_reviewer",`${prompt}\n\n${planReviewerContextText()}`,c,s,{readOnly:true,review:true,resume:false,...opts,capEnabled:true,taskRef:planReviewerTaskRef});
6558
+ if(result.capped && acceptCappedFinalVerdict(`SpecKit gate ${step.id} plan reviewer`,result)){ /* verdict already complete */ }
6559
+ if(result.capped){
6560
+ handleCappedPlanningResult({c,s,framework:"speckit",role:"plan_reviewer",result,subject:`SpecKit gate ${step.id} plan reviewer`});
6561
+ continue;
6562
+ }
6563
+ clearBackstopFirings(s,planReviewerContScope);
6564
+ if(parseIncompleteSignal(result.review ?? result)){
6565
+ handleIncompletePlanningResult({c,s,framework:"speckit",role:"plan_reviewer",result,subject:`SpecKit gate ${step.id} plan reviewer`});
6566
+ continue;
6567
+ }
6568
+ // Item 6: this uncapped run produced a verdict, so the reviewer's
6569
+ // continuation allowance starts fresh on the next gate review.
6570
+ if(result.review) clearPlanningContinuationScope(s,planReviewerContScope);
6571
+ return result;
6572
+ }
6573
+ };
6179
6574
  // The planning stage this gate governs. Computed for BOTH outcomes,
6180
6575
  // not just the failing one: a FAIL persists its findings against this
6181
6576
  // stage, and a PASS has to be able to clear them again.
@@ -6189,14 +6584,42 @@ async function runSpecKitPlanning(c,s,request,opts={}){
6189
6584
  const resumeSubstage=(gateResume && gateResume.gateIndex===idx)?gateResume.substage:null;
6190
6585
  const resumingAtPlannerRevision=resumeSubstage===SPECKIT_PLANNER_REVISION;
6191
6586
  const resumingAtReReview=resumeSubstage===SPECKIT_POST_REVISION_REVIEW;
6587
+ const gateKey=speckitStageFeedbackKey(gateStage);
6588
+ const planReviewConverge=c.workflow?.plan_review_convergence!==false;
6192
6589
  const initialReviewPrompt=
6193
6590
  `Review the current artifact state at the SpecKit gate "${step.id}".
6194
- Message: ${step.message||""}${tasksGateNote}
6591
+ Message: ${step.message||""}${tasksGateNote}${earlierStageEditsNote(s,gateStage)}
6195
6592
  ${REVIEWER_ALL_FINDINGS_LINE}
6196
6593
  ${planReviewerRoundLines(s,speckitStageFeedbackKey(gateStage),c.workflow?.plan_review_convergence!==false).join("\n")}
6197
6594
  Return PASS or FAIL with findings.`;
6595
+ const confirmationPrompt=
6596
+ `Review the current artifact state at the SpecKit gate "${step.id}" as a confirmation full review after a scoped re-review PASS. No previous findings are listed, so review the whole artifact again from scratch; do not limit this to earlier findings or recent changes.
6597
+ Message: ${step.message||""}${tasksGateNote}${earlierStageEditsNote(s,gateStage)}
6598
+ ${REVIEWER_ALL_FINDINGS_LINE}
6599
+ ${planReviewerConfirmationLines().join("\n")}
6600
+ Return PASS or FAIL with findings.`;
6601
+ const confirmationNote="full re-check";
6602
+ const confirmationRelayOpts={readOnly:true,review:true,resume:false,runNote:confirmationNote,resultExtra:{planReviewConfirmation:true}};
6603
+ const runGateConfirmation=async()=>{
6604
+ console.log(`SpecKit gate ${step.id}: ${confirmationNote}`);
6605
+ let cr=await runCappedReview(confirmationPrompt,confirmationRelayOpts);
6606
+ if(cr.policyViolation) die(`SpecKit gate ${step.id} reviewer violated read-only relay policy.`);
6607
+ escalateIfRateLimited(c,s,"speckit-gate-review",cr,{fatal:die,saveState,escalatePrefix:`SpecKit gate ${step.id} confirmation review`});
6608
+ cr=await resolvePlanningReviewAttempt(c,s,"speckit-gate-review",cr,
6609
+ ()=>runCappedReview(confirmationPrompt,confirmationRelayOpts),
6610
+ {policyMessage:`SpecKit gate ${step.id} reviewer violated read-only relay policy.`,escalatePrefix:`SpecKit gate ${step.id} confirmation review`});
6611
+ clearHandoffNote(s.mission,planReviewerTaskRef);
6612
+ return cr;
6613
+ };
6614
+ // The persisted marker (not a local flag) makes a kill during the
6615
+ // confirmation review resume into the confirmation, never into accepting.
6616
+ const confirmationPendingAtEntry=planReviewConfirmationPending(s,gateKey);
6198
6617
  let rr;
6199
- if(resumingAtPlannerRevision || resumingAtReReview){
6618
+ let kind="initial"; // "initial" | "rereview" | "confirmation"
6619
+ if(confirmationPendingAtEntry){
6620
+ kind="confirmation";
6621
+ rr=await runGateConfirmation();
6622
+ } else if(resumingAtPlannerRevision || resumingAtReReview){
6200
6623
  // Restarted inside the revision block: the initial review already ran
6201
6624
  // and failed before the interruption, and its findings are persisted
6202
6625
  // against this stage. Re-running it would pay again for a review the
@@ -6225,12 +6648,12 @@ Return PASS or FAIL with findings.`;
6225
6648
  // rr.review.findings, and raw objects would reach the
6226
6649
  // post-revision reviewer as "[object Object]".
6227
6650
  const deniedText=denied.map(f=>`${f.location}: ${f.problem} — ${f.correction}`);
6228
- rr={status:"completed",review:{decision:"FAIL",findings:deniedText},failure:failure("PLANNING_REJECTED","planning",true,deniedText)};
6651
+ rr={status:"completed",review:{decision:"FAIL",findings:deniedText},failure:failure("PLANNING_REJECTED","planning",true,deniedText),deterministic:true};
6229
6652
  } else {
6230
- rr=await review("plan_reviewer",initialReviewPrompt,c,s);
6653
+ rr=await runCappedReview(initialReviewPrompt);
6231
6654
  }
6232
6655
  } else {
6233
- rr=await review("plan_reviewer",initialReviewPrompt,c,s);
6656
+ rr=await runCappedReview(initialReviewPrompt);
6234
6657
  }
6235
6658
  if(rr.policyViolation){
6236
6659
  die(`SpecKit gate ${step.id} reviewer violated read-only relay policy.`);
@@ -6247,67 +6670,131 @@ Return PASS or FAIL with findings.`;
6247
6670
  // and no runPlanningPhase revision.
6248
6671
  escalateIfRateLimited(c,s,"speckit-gate-review",rr,{fatal:die,saveState,escalatePrefix:`SpecKit gate ${step.id} plan review`});
6249
6672
  rr=await resolvePlanningReviewAttempt(c,s,"speckit-gate-review",rr,
6250
- ()=>review("plan_reviewer",initialReviewPrompt,c,s),
6673
+ ()=>runCappedReview(initialReviewPrompt),
6251
6674
  {policyMessage:`SpecKit gate ${step.id} reviewer violated read-only relay policy.`,escalatePrefix:`SpecKit gate ${step.id} plan review`});
6675
+ clearHandoffNote(s.mission,planReviewerTaskRef);
6252
6676
  }
6253
- // Mission 031 Phase 3 task 3.5: INCOMPLETE-as-FAIL (no continuation): speckit-gate-initial-review has no continuation loop — an INCOMPLETE decision is treated as FAIL with explicit reason and never consumes a continuation.
6254
- if(rr.review?.decision==="INCOMPLETE") console.log(`SpecKit gate ${step.id} initial plan review INCOMPLETE treated as FAIL (no continuation).`);
6255
- if(rr.status!=="completed" || rr.review?.decision!=="PASS") {
6256
- // TASK-14 Phase 6 tasks 19-20: resuming at post_revision_review skips
6257
- // the whole revision — the Planner already produced the revised
6258
- // artifacts before the interruption — and this gate still grants no
6259
- // second revision, so a FAIL on the re-review below escalates exactly
6260
- // as it does today.
6261
- if(!resumingAtReReview){
6262
- console.log(maxPlanRevisions>0?` FAIL — revising ${label}`:` FAIL — ${label} (plan revision budget is 0)`);
6263
- // Backlog #54: RATE_LIMITED re-check before spending a gate revision —
6264
- // the pre-wiring guard above already escalates, so this never fires in
6265
- // practice; it guarantees no saveReviewFeedback/planner_revision
6266
- // checkpoint write and no runPlanningPhase call for a rate limit.
6267
- escalateIfRateLimited(c,s,"speckit-gate-review",rr,{fatal:die,saveState,escalatePrefix:`SpecKit gate ${step.id} plan review`});
6268
- // The gate reviewer's findings used to be dropped entirely here: the
6269
- // revising Planner was re-run with the SAME prompt as the original
6270
- // attempt and never told what the gate rejected, so it could only
6271
- // guess or go read the review out of runs/. Persist them against the
6272
- // stage about to be revised, so runPlanningPhase() can quote them
6273
- // and a resume mid-revision still has them.
6274
- saveReviewFeedback(s,speckitStageFeedbackKey(gateStage),rr.review?.findings||[]);
6275
- recordPlanReviewRound(s,speckitStageFeedbackKey(gateStage),rr.review?.findings||[],rr.review?.previous);noteEscalationTrigger(s,c,"planner",speckitStageFeedbackKey(gateStage),"plan_reviewer",rr.review?.previous);
6677
+ // Review cycles. A cycle is: FAIL -> Planner revision -> ONE scoped
6678
+ // re-review (as before: a re-review FAIL escalates, no second revision).
6679
+ // A scoped PASS (one that verified earlier findings) is not accepted
6680
+ // until one fresh FULL confirmation review also PASSes; a confirmation
6681
+ // FAIL starts a new cycle and consumes one revision from the budget.
6682
+ let skipRevision=resumingAtReReview;
6683
+ let stubRevisionResume=resumingAtPlannerRevision;
6684
+ while(true){
6685
+ if(rr.status==="completed" && rr.review?.decision==="PASS"){
6686
+ if(kind!=="confirmation" && planReviewPassNeedsConfirmation(s,gateKey,planReviewConverge)){
6687
+ markPlanReviewConfirmationPending(s,gateKey);
6688
+ // initial_review is a reviewer substage (content digest validated
6689
+ // on resume) and the marker routes the resume to the confirmation.
6690
+ writeSpeckitGateCheckpoint(s,{feature:s.speckitFeatureDir,gate:step.id,gateIndex:idx,substage:SPECKIT_INITIAL_REVIEW});
6691
+ saveState(s);
6692
+ kind="confirmation";
6693
+ rr=await runGateConfirmation();
6694
+ continue;
6695
+ }
6696
+ break;
6697
+ }
6698
+ if(kind!=="rereview"){
6699
+ const revisionsUsed=planReviewRevisionCount(s,gateKey);
6700
+ // Initial reviews keep the legacy rule (budget > 0 grants the gate's
6701
+ // revision); a confirmation FAIL needs unspent budget.
6702
+ const revisionAllowed=kind==="confirmation" ? revisionsUsed<maxPlanRevisions : maxPlanRevisions>0;
6703
+ if(!skipRevision){
6704
+ console.log(revisionAllowed?` FAIL — revising ${label}`:` FAIL — ${label} (plan revision budget is ${maxPlanRevisions>0?"spent":"0"})`);
6705
+ // Backlog #54: RATE_LIMITED re-check before spending a gate revision.
6706
+ escalateIfRateLimited(c,s,"speckit-gate-review",rr,{fatal:die,saveState,escalatePrefix:`SpecKit gate ${step.id} plan review`});
6707
+ // Persist the findings against the stage about to be revised, so
6708
+ // runPlanningPhase() quotes them and a resume mid-revision has them.
6709
+ saveReviewFeedback(s,speckitStageFeedbackKey(gateStage),rr.review?.findings||[]);
6710
+ if(rr.deterministic!==true) recordPlanReviewNotes(s,speckitStageFeedbackKey(gateStage),rr.review?.notes);
6711
+ recordPlanReviewRound(s,speckitStageFeedbackKey(gateStage),rr.review?.findings||[],rr.review?.previous,{reviewer:rr.deterministic!==true});noteEscalationTrigger(s,c,"planner",speckitStageFeedbackKey(gateStage),"plan_reviewer",rr.review?.previous);
6712
+ // The confirmation round is over: its FAIL is now ordinary feedback.
6713
+ clearPlanReviewConfirmation(s,gateKey);
6714
+ // A resume at planner_revision re-runs a revision already counted.
6715
+ if(revisionAllowed && !stubRevisionResume) recordPlanReviewRevision(s,gateKey);
6716
+ saveState(s);
6717
+ if(revisionAllowed){
6718
+ // planner_revision: written immediately before the between-reviews
6719
+ // Planner call (writable stage: only version/identity/paths are
6720
+ // validated on resume, so a crash here resumes at the Planner).
6721
+ writeSpeckitGateCheckpoint(s,{feature:s.speckitFeatureDir,gate:step.id,gateIndex:idx,substage:SPECKIT_PLANNER_REVISION});
6722
+ saveState(s);
6723
+ await runPlanningPhase(c,gateStage,request,s,planReviewRevisionCount(s,gateKey));
6724
+ persistDetectedMission(s);
6725
+ s.planningStepIndex=idx; saveState(s);
6726
+ // post_revision_review: a reviewer stage, content digest validated on resume.
6727
+ writeSpeckitGateCheckpoint(s,{feature:s.speckitFeatureDir,gate:step.id,gateIndex:idx,substage:SPECKIT_POST_REVISION_REVIEW});
6728
+ saveState(s);
6729
+ }
6730
+ }
6731
+ if(!revisionAllowed){
6732
+ // Backlog #100: the budget forbids a gate revision, so the findings
6733
+ // persisted above are the last word. Checkpoint like an exhausted
6734
+ // re-review (a resume returns to the Planner position) and stop.
6735
+ if(!s.speckitPlanningCheckpoint) s.speckitPlanningCheckpoint={};
6736
+ s.speckitPlanningCheckpoint={step:step.id,index:idx,failedAt:now()};
6737
+ writeSpeckitGateCheckpoint(s,{feature:s.speckitFeatureDir,gate:step.id,gateIndex:idx,substage:SPECKIT_PLANNER_REVISION});
6738
+ saveState(s);
6739
+ die(`SpecKit gate ${step.id} failed.`);
6740
+ }
6741
+ skipRevision=false;
6742
+ stubRevisionResume=false;
6743
+ // Mission 038 task 1.4 (deterministic tasks-text re-scan of the revised
6744
+ // tasks.md) and the TASK-14 evidence call site 3 (retry / escalate
6745
+ // classification) live in runGateReReview.
6746
+ rr=await runGateReReview(rr.review?.findings||[]);
6747
+ kind="rereview";
6748
+ continue;
6749
+ }
6750
+ // A re-review FAIL is another revision round while plan_revisions
6751
+ // budget remains — the same revise -> re-review cycle as the initial
6752
+ // review, never a one-shot escalation. Only an exhausted budget
6753
+ // escalates. The re-review's findings are the freshest verdict, so
6754
+ // they drive the next Planner revision (and any resume).
6755
+ if(kind==="rereview"){
6756
+ const revisionsUsed=planReviewRevisionCount(s,gateKey);
6757
+ // Only an explicit FAIL verdict earns another revision. A
6758
+ // re-review/retry that produced no usable verdict (output-contract
6759
+ // or infrastructure outcome) escalates exactly as before.
6760
+ const failedVerdict=rr.review?.decision==="FAIL";
6761
+ const revisionAllowed=failedVerdict && revisionsUsed<maxPlanRevisions;
6762
+ const reFindings=rr.review?.findings||[];
6763
+ console.log(revisionAllowed?` FAIL — revising ${label}`:` FAIL — ${label} (plan revision budget is ${maxPlanRevisions>0?"spent":"0"})`);
6764
+ saveReviewFeedback(s,speckitStageFeedbackKey(gateStage),reFindings);
6765
+ if(rr.deterministic!==true) recordPlanReviewNotes(s,speckitStageFeedbackKey(gateStage),rr.review?.notes);
6766
+ recordPlanReviewRound(s,speckitStageFeedbackKey(gateStage),reFindings,rr.review?.previous,{reviewer:rr.deterministic!==true});noteEscalationTrigger(s,c,"planner",speckitStageFeedbackKey(gateStage),"plan_reviewer",rr.review?.previous);
6767
+ clearPlanReviewConfirmation(s,gateKey);
6768
+ if(revisionAllowed) recordPlanReviewRevision(s,gateKey);
6276
6769
  saveState(s);
6277
- if(maxPlanRevisions>0){
6278
- // planner_revision: written immediately before the between-reviews
6279
- // Planner call. Content is NOT validated on resume (writable stage —
6280
- // only version/identity/paths), which is what makes a crash here
6281
- // resume at the Planner instead of jumping to the reviewer.
6770
+ if(revisionAllowed){
6282
6771
  writeSpeckitGateCheckpoint(s,{feature:s.speckitFeatureDir,gate:step.id,gateIndex:idx,substage:SPECKIT_PLANNER_REVISION});
6283
6772
  saveState(s);
6284
- await runPlanningPhase(c,gateStage,request,s);
6773
+ await runPlanningPhase(c,gateStage,request,s,planReviewRevisionCount(s,gateKey));
6285
6774
  persistDetectedMission(s);
6286
6775
  s.planningStepIndex=idx; saveState(s);
6287
- // post_revision_review: written before the re-review call, after the
6288
- // Planner's revision completed — a reviewer stage, so the content
6289
- // digest is recorded and validated on resume.
6290
6776
  writeSpeckitGateCheckpoint(s,{feature:s.speckitFeatureDir,gate:step.id,gateIndex:idx,substage:SPECKIT_POST_REVISION_REVIEW});
6291
6777
  saveState(s);
6778
+ rr=await runGateReReview(reFindings);
6779
+ kind="rereview";
6780
+ continue;
6292
6781
  }
6293
- }
6294
- if(maxPlanRevisions===0){
6295
- // Backlog #100: the configured budget forbids a gate revision, so
6296
- // the findings persisted above are the last word. Checkpoint the
6297
- // failure exactly like an exhausted re-review (so a resume returns
6298
- // to the Planner position without granting a revision) and stop.
6299
6782
  if(!s.speckitPlanningCheckpoint) s.speckitPlanningCheckpoint={};
6300
6783
  s.speckitPlanningCheckpoint={step:step.id,index:idx,failedAt:now()};
6784
+ // 023 Phase 2 task 2.2 (standalone 2.1 twin): on gate exhaustion the
6785
+ // mission escalates; the checkpoint is overwritten to planner_revision
6786
+ // so a same-request resume re-invokes the Planner with the persisted
6787
+ // findings and re-runs gate review, never adopting the rejected
6788
+ // artifacts (only version/identity/paths are validated there).
6301
6789
  writeSpeckitGateCheckpoint(s,{feature:s.speckitFeatureDir,gate:step.id,gateIndex:idx,substage:SPECKIT_PLANNER_REVISION});
6302
6790
  saveState(s);
6303
6791
  die(`SpecKit gate ${step.id} failed.`);
6304
6792
  }
6305
- const reReviewPrompt=`Re-review the SpecKit gate "${step.id}" after revision.\n${rr.review?.findings?.join("\n")||""}${tasksGateNote}\n${planReviewerRoundLines(s,speckitStageFeedbackKey(gateStage),c.workflow?.plan_review_convergence!==false).join("\n")}\n${REVIEWER_ALL_FINDINGS_LINE}`;
6306
- // Mission 038 task 1.4: re-run the deterministic tasks-text scan on
6307
- // the REVISED tasks.md before paying for another reviewer call. A
6308
- // revision that still runs a forbidden command fails
6309
- // deterministically (same string rendering as the initial gate); a
6310
- // clean revision falls through to the re-review above.
6793
+ }
6794
+ // Hoisted function declaration (block scope): the scoped re-review of a
6795
+ // revision cycle, used by the gate loop above it.
6796
+ async function runGateReReview(findings){
6797
+ const reReviewPrompt=`Re-review the SpecKit gate "${step.id}" after revision.\n${(findings||[]).join("\n")}${tasksGateNote}${earlierStageEditsNote(s,gateStage)}\n${planReviewerRoundLines(s,speckitStageFeedbackKey(gateStage),c.workflow?.plan_review_convergence!==false).join("\n")}\n${REVIEWER_ALL_FINDINGS_LINE}`;
6311
6798
  let deterministicRetry=null;
6312
6799
  if(gateStage==="tasks" && s.speckitFeatureDir){
6313
6800
  let revisedText="";
@@ -6315,50 +6802,19 @@ Return PASS or FAIL with findings.`;
6315
6802
  const stillDenied=[...speckitTaskDenylistFindings(revisedText,effectiveVerifyDenylist(c)),...speckitTaskVerifyLengthFindings(revisedText),...speckitTaskVerifyShardFindings(revisedText)];
6316
6803
  if(stillDenied.length){
6317
6804
  const stillDeniedText=stillDenied.map(f=>`${f.location}: ${f.problem} — ${f.correction}`);
6318
- deterministicRetry={status:"completed",review:{decision:"FAIL",findings:stillDeniedText},failure:failure("PLANNING_REJECTED","planning",true,stillDeniedText)};
6805
+ deterministicRetry={status:"completed",review:{decision:"FAIL",findings:stillDeniedText},failure:failure("PLANNING_REJECTED","planning",true,stillDeniedText),deterministic:true};
6319
6806
  }
6320
6807
  }
6321
- let retry=deterministicRetry||await review("plan_reviewer",reReviewPrompt,c,s);
6322
- // Backlog #54: RATE_LIMITED short-circuits here — before the re-review
6323
- // wiring call below — so the planner is never re-invoked for a rate limit.
6808
+ let retry=deterministicRetry||await runCappedReview(reReviewPrompt);
6324
6809
  escalateIfRateLimited(c,s,"speckit-gate-review",retry,{fatal:die,saveState,escalatePrefix:`SpecKit gate ${step.id} plan re-review`});
6325
- // TASK-14 Evidence call site 3 (the post_revision_review position):
6326
- // the same classify-then-record-then-decide contract as call sites
6327
- // 1-2 — retryable adapter/infrastructure failures retry this
6328
- // re-review call only (fresh session; review() already passes
6329
- // resume:false), granting zero additional revisions; non-retryable
6330
- // ones escalate naming class/domain; functional/output-contract
6331
- // outcomes ("passthrough" — no revision left at this position) flow
6332
- // through to the unchanged immediate die() below with nothing
6333
- // recorded.
6334
6810
  retry=await resolvePlanningReviewAttempt(c,s,"speckit-gate-review",retry,
6335
- ()=>review("plan_reviewer",reReviewPrompt,c,s),
6811
+ ()=>runCappedReview(reReviewPrompt),
6336
6812
  {policyMessage:`SpecKit gate ${step.id} reviewer violated read-only relay policy.`,escalatePrefix:`SpecKit gate ${step.id} plan re-review`});
6337
- // Mission 031 Phase 3 task 3.5: INCOMPLETE-as-FAIL (no continuation): speckit-gate-re-review has no continuation loop — an INCOMPLETE decision is treated as FAIL with explicit reason and never consumes a continuation.
6338
- if(retry.review?.decision==="INCOMPLETE") console.log(`SpecKit gate ${step.id} plan re-review INCOMPLETE treated as FAIL (no continuation).`);
6339
- if(retry.status!=="completed" || retry.review?.decision!=="PASS") {
6340
- if(!s.speckitPlanningCheckpoint) s.speckitPlanningCheckpoint={};
6341
- s.speckitPlanningCheckpoint={step:step.id,index:idx,failedAt:now()};
6342
- // Keep the RE-review's findings (fresher than the ones that drove
6343
- // the revision) so the resumed run revises against the latest
6344
- // verdict rather than the superseded one.
6345
- saveReviewFeedback(s,speckitStageFeedbackKey(gateStage),retry.review?.findings||[]);
6346
- recordPlanReviewRound(s,speckitStageFeedbackKey(gateStage),retry.review?.findings||[],retry.review?.previous);noteEscalationTrigger(s,c,"planner",speckitStageFeedbackKey(gateStage),"plan_reviewer",retry.review?.previous);
6347
- // 023 Phase 2 task 2.2 (standalone 2.1 twin): on gate exhaustion
6348
- // the mission escalates instead of returning to the planner in
6349
- // this process — but a same-request resume must still re-invoke
6350
- // the Planner with the persisted findings and re-run gate review,
6351
- // never just re-run the re-review (post_revision_review) or adopt
6352
- // the rejected artifacts as complete. So the checkpoint is
6353
- // overwritten to planner_revision here as well: resume validates
6354
- // only version/identity/paths (never content) and the gate loop
6355
- // restarts at the Planner whose revision-round prompt quotes the
6356
- // findings saved above.
6357
- writeSpeckitGateCheckpoint(s,{feature:s.speckitFeatureDir,gate:step.id,gateIndex:idx,substage:SPECKIT_PLANNER_REVISION});
6358
- saveState(s);
6359
- die(`SpecKit gate ${step.id} failed.`);
6360
- }
6813
+ clearHandoffNote(s.mission,planReviewerTaskRef);
6814
+ return retry;
6361
6815
  }
6816
+ clearPlanReviewConfirmation(s,gateKey);
6817
+ clearPlanReviewRevisions(s,gateKey);
6362
6818
  // Reached only when this gate passed — on the first attempt or after a
6363
6819
  // revision. Both must leave the stage clean. Clearing only on the
6364
6820
  // post-revision PASS was the gap: a gate that failed, was revised, died
@@ -6368,6 +6824,7 @@ Return PASS or FAIL with findings.`;
6368
6824
  // REJECTED an attempt that has since been accepted.
6369
6825
  clearReviewFeedback(s,speckitStageFeedbackKey(gateStage));
6370
6826
  clearPlanReviewHistory(s,speckitStageFeedbackKey(gateStage));
6827
+ delete s.planningEarlierStageEdits;
6371
6828
  clearFindingTasks(s,speckitStageFeedbackKey(gateStage));
6372
6829
  // TASK-14 Phase 6 task 19: cleared the moment THIS gate reaches PASS —
6373
6830
  // not only at the end of the whole planning loop — so a crash after a
@@ -6412,12 +6869,14 @@ function gsdPlanningReviewPrompt(stage, phase, request, findings=""){
6412
6869
  stage === "discuss" ? "Discuss review: verify implementation decisions/constraints are explicit enough to plan without unresolved ambiguity." : "",
6413
6870
  stage === "plan" ? "Plan review: verify every generated PLAN is atomic, executable in a fresh context, has correct dependencies/files/verification, and collectively achieves the phase goal." : "",
6414
6871
  stage === "plan-check" ? "Plan-check review: independently challenge the saved plans and identify any blocking gap before execution." : "",
6872
+ PLAN_REVIEW_SEVERITY_LINE,
6873
+ PLAN_REVIEW_TESTING_RULE,
6415
6874
  findings ? `Prior findings to re-check:\n${findings}` : "",
6416
6875
  "Return exactly one AUTOPILOT_REVIEW JSON sentinel with decision PASS or FAIL and concise findings."
6417
6876
  ].filter(Boolean).join("\n\n");
6418
6877
  }
6419
6878
 
6420
- async function gsdPlanningStage(c,s,{stage,phase,request,resume=false}){
6879
+ async function gsdPlanningStage(c,s,{stage,phase,request,resume=false,planRevision=null}){
6421
6880
  s.phase="planning"; s.step=`gsd-${stage}`; s.gsdPhase=phase||null; saveState(s);
6422
6881
  // Phase 9 task 9.1: absolute sandbox-visible GSD artifact paths cited in
6423
6882
  // the prompt itself, so PLANNER_PATHS_LINE's "read the exact paths above"
@@ -6425,29 +6884,85 @@ async function gsdPlanningStage(c,s,{stage,phase,request,resume=false}){
6425
6884
  const gsdRootAbs=path.join(repo,gsdPhaseRoot(c));
6426
6885
  const gsdPhaseAbs=phase?path.join(repo,gsdPhaseRoot(c),phase):path.join(repo,gsdPhaseRoot(c),"<phase>");
6427
6886
  const gsdPathsLine=`GSD artifact paths (absolute, sandbox-visible): planning dir: ${gsdRootAbs}; target phase dir: ${gsdPhaseAbs}.`;
6428
- const prompt=[
6429
- `Run the GSD planning stage: ${stage}.`,
6430
- phase ? `Target phase: ${phase}` : "Target: current milestone/project planning.",
6431
- "Use the installed GSD Core commands/skills and the project's real .planning artifacts.",
6432
- gsdPathsLine,
6433
- PLANNER_PATHS_LINE,
6434
- stage === "research" ? "Perform the phase research work and produce/update the GSD research artifact. Do not implement production code." : "",
6435
- stage === "discuss" ? "Capture or refine the implementation decisions/context needed by the next planning step. Do not implement production code." : "",
6436
- stage === "plan" ? "Run the GSD plan-phase workflow for the target phase. It must produce the native PLAN.md artifacts and preserve GSD-native structure." : "",
6437
- "Do not commit or push.",
6438
- `Project request/context:\n${request}`
6439
- ].filter(Boolean).join("\n\n");
6440
- const r=await relayAsync("planner",prompt,c,s,{readOnly:false,resume});
6887
+ const {taskRef,contScope}=planningScopeParts("gsd","planner");
6888
+ let finalR=null;
6889
+ for(;;){
6890
+ const {deadEndsText,noteText}=planningContinuationText(s,"gsd","planner");
6891
+ const budgetReminder=planningBudgetReminderFor(s,c,"planner");
6892
+ const prompt=[
6893
+ `Run the GSD planning stage: ${stage}.`,
6894
+ phase ? `Target phase: ${phase}` : "Target: current milestone/project planning.",
6895
+ "Use the installed GSD Core commands/skills and the project's real .planning artifacts.",
6896
+ gsdPathsLine,
6897
+ PLANNER_PATHS_LINE,
6898
+ stage === "research" ? "Perform the phase research work and produce/update the GSD research artifact. Do not implement production code." : "",
6899
+ stage === "discuss" ? "Capture or refine the implementation decisions/context needed by the next planning step. Do not implement production code." : "",
6900
+ stage === "plan" ? "Run the GSD plan-phase workflow for the target phase. It must produce the native PLAN.md artifacts and preserve GSD-native structure." : "",
6901
+ stage === "plan" ? testingPolicyRuleNote(c) : "",
6902
+ "Do not commit or push.",
6903
+ deadEndsText,
6904
+ noteText,
6905
+ `Keep a hand-off note at ${handoffNoteRelPath(s.mission, taskRef)}. OVERWRITE it (never append) at each milestone — after every important finding, before writing the stage artifact — with: 1. Done — files changed (names only; details are in the worktree). 2. Found — facts the next session needs: exact file:line, function names, how the code works there. 3. Next — the exact next edit: file, line, what to change. 4. Dead ends — what you tried that did not work, and why. If your session is stopped, the next session starts from this note.${budgetReminder ? ` ${budgetReminder}` : ""}`,
6906
+ `Project request/context:\n${request}`
6907
+ ].filter(Boolean).join("\n\n");
6908
+ const r=await relayAsync("planner",prompt,c,s,{readOnly:false,resume,capEnabled:true,taskRef,planRevision});
6909
+ if(r.capped){
6910
+ handleCappedPlanningResult({c,s,framework:"gsd",role:"planner",result:r,subject:`GSD planner ${stage}`,touchedFiles:r.touchedFiles||[]});
6911
+ continue;
6912
+ }
6913
+ clearBackstopFirings(s,contScope);
6914
+ if(parsePlanningIncompleteSignal(r.finalMessage ?? r)){
6915
+ handleIncompletePlanningResult({c,s,framework:"gsd",role:"planner",result:r,subject:`GSD planner ${stage}`,touchedFiles:r.touchedFiles||[]});
6916
+ continue;
6917
+ }
6918
+ finalR=r;
6919
+ break;
6920
+ }
6921
+ const r=finalR;
6441
6922
  if(r.status!=="completed" || r.policyViolation)die(`GSD planning stage ${stage} failed or violated relay policy.`);
6923
+ // Item 6: the planner completed this stage uncapped, so its continuation
6924
+ // allowance starts fresh on the next stage/revision.
6925
+ clearPlanningContinuationScope(s,contScope);
6926
+ // Backlog #7: clear the completed planner's hand-off note so a later
6927
+ // revision's rate-limit fallback cannot quote this finished revision as
6928
+ // "continuation context" (mirrors the reviewer path's clearHandoffNote).
6929
+ clearHandoffNote(s.mission,taskRef);
6930
+ saveState(s);
6442
6931
  return r;
6443
6932
  }
6444
6933
 
6445
- async function reviewGsdPlanningStage(c,s,stage,phase,request,previousFindings=""){
6446
- const rr=await relayAsync("plan_reviewer",
6447
- gsdPlanningReviewPrompt(stage,phase,request,previousFindings),
6448
- c,s,{readOnly:true,review:true});
6449
- if(rr.status==="completed" && !rr.policyViolation && rr.review?.decision==="PASS") return rr;
6450
- return rr;
6934
+ async function reviewGsdPlanningStage(c,s,stage,phase,request,previousFindings="",{allowIncompleteContinuation=true}={}){
6935
+ // Record the stage this reviewer works on (observabilityMeta reads s.step);
6936
+ // without this a plan-check review would inherit the preceding plan stage.
6937
+ s.step=`gsd-${stage}`;
6938
+ const {taskRef,contScope}=planningScopeParts("gsd","plan_reviewer");
6939
+ for(;;){
6940
+ const {deadEndsText,noteText}=planningContinuationText(s,"gsd","plan_reviewer");
6941
+ const budgetReminder=planningBudgetReminderFor(s,c,"plan_reviewer");
6942
+ const prompt=[
6943
+ gsdPlanningReviewPrompt(stage,phase,request,previousFindings),
6944
+ deadEndsText,
6945
+ noteText,
6946
+ `Keep a hand-off note at ${scratchHandoffNotePath(SCRATCH_HANDOFF_NOTE_DEFAULT_DIR, taskRef)}. OVERWRITE it (never append) at each milestone — after every important finding and before the review verdict — with: 1. Done — files changed (names only; details are in the worktree). 2. Found — facts the next session needs: exact file:line, function names, how the code works there. 3. Next — the exact next edit: file, line, what to change. 4. Dead ends — what you tried that did not work, and why. If your session is stopped, the next session starts from this note.${budgetReminder ? ` ${budgetReminder}` : ""}`
6947
+ ].filter(Boolean).join("\n\n");
6948
+ const rr=await relayAsync("plan_reviewer",prompt,c,s,{readOnly:true,review:true,capEnabled:true,taskRef});
6949
+ if(rr.capped && acceptCappedFinalVerdict(`GSD ${stage} reviewer`,rr)){ /* verdict already complete */ }
6950
+ if(rr.capped){
6951
+ handleCappedPlanningResult({c,s,framework:"gsd",role:"plan_reviewer",result:rr,subject:`GSD ${stage} reviewer`});
6952
+ continue;
6953
+ }
6954
+ clearBackstopFirings(s,contScope);
6955
+ if(parseIncompleteSignal(rr.review ?? rr)){
6956
+ if(!allowIncompleteContinuation) return rr;
6957
+ handleIncompletePlanningResult({c,s,framework:"gsd",role:"plan_reviewer",result:rr,subject:`GSD ${stage} reviewer`});
6958
+ continue;
6959
+ }
6960
+ // Item 6: this uncapped run produced a verdict, so the reviewer's
6961
+ // continuation allowance starts fresh on the next stage review.
6962
+ if(rr.review) clearPlanningContinuationScope(s,contScope);
6963
+ clearHandoffNote(s.mission,taskRef);
6964
+ return rr;
6965
+ }
6451
6966
  }
6452
6967
 
6453
6968
  async function runGsdPlanning(c,s,request){
@@ -6485,6 +7000,7 @@ async function runGsdPlanning(c,s,request){
6485
7000
  let lastFindings="";
6486
7001
  const stageMaxRevisions=Math.max(1,Number(localPlanningReview.max_revisions||maxRevisions));
6487
7002
  const checkpointKey=`gsd-plan-${stage}-${phase||"project"}`;
7003
+ const notesKey=`gsd-plan-review:${stage}:${phase||"project"}`;
6488
7004
  if(s.planningCheckpoints?.[checkpointKey]?.status==="passed") continue;
6489
7005
 
6490
7006
  while(true){
@@ -6493,7 +7009,7 @@ async function runGsdPlanning(c,s,request){
6493
7009
  // needs is resume-only. Same fix as the standalone plan loop — resuming
6494
7010
  // here would otherwise let one GSD planning session's context grow
6495
7011
  // across every revision for no benefit.
6496
- await gsdPlanningStage(c,s,{stage,phase,request,resume:false});
7012
+ await gsdPlanningStage(c,s,{stage,phase,request,resume:false,planRevision:revision});
6497
7013
 
6498
7014
  if(!planReviewEnabled){
6499
7015
  if(!s.planningCheckpoints)s.planningCheckpoints={};
@@ -6530,12 +7046,14 @@ async function runGsdPlanning(c,s,request){
6530
7046
  if(rr.review?.decision==="PASS"){
6531
7047
  if(!s.planningCheckpoints)s.planningCheckpoints={};
6532
7048
  s.planningCheckpoints[checkpointKey]={status:"passed",stage,phase,at:now()};
7049
+ clearPlanReviewHistory(s,notesKey);
6533
7050
  saveState(s);
6534
7051
  break;
6535
7052
  }
6536
7053
 
6537
7054
  revision++;
6538
7055
  lastFindings=(rr.review?.findings||[]).join("\n") || "Reviewer returned FAIL without findings.";
7056
+ recordPlanReviewNotes(s,notesKey,rr.review?.notes);
6539
7057
  s.gsdPlanningReview={stage,phase,revision,findings:rr.review?.findings||[]};
6540
7058
  s.step=`gsd-${stage}-revision-${revision}`; saveState(s);
6541
7059
  if(revision>=stageMaxRevisions){
@@ -6543,7 +7061,8 @@ async function runGsdPlanning(c,s,request){
6543
7061
  die(`GSD planning stage ${stage} failed review after ${stageMaxRevisions} revision(s).`);
6544
7062
  }
6545
7063
  // The next loop re-runs the same native GSD planning stage with the review feedback in context.
6546
- request=`${request}\n\nGSD ${stage} reviewer findings that MUST be addressed on the next revision:\n${lastFindings}`;
7064
+ const notesText=planReviewNotesText(s,notesKey);
7065
+ request=`${request}\n\nGSD ${stage} reviewer findings that MUST be addressed on the next revision:\n${lastFindings}${notesText?`\n\n${notesText}`:""}`;
6547
7066
  }
6548
7067
  }
6549
7068
 
@@ -6591,14 +7110,34 @@ async function runGsdPlanning(c,s,request){
6591
7110
  }
6592
7111
 
6593
7112
  if(planReviewEnabled){
6594
- const finalReview=await relayAsync("plan_reviewer",
6595
- gsdPlanningReviewPrompt("plan-check",phase,request),
6596
- c,s,{readOnly:true,review:true});
6597
- // Mission 031 Phase 3 task 3.5: INCOMPLETE-as-FAIL (no continuation): gsd-plan-final-review has no continuation loop — an INCOMPLETE decision is treated as FAIL with explicit reason and never consumes a continuation.
6598
- if(finalReview.review?.decision==="INCOMPLETE") console.log(`GSD phase ${phase} plan final review INCOMPLETE treated as FAIL (no continuation).`);
6599
- if(finalReview.review?.decision!=="PASS"){
6600
- s.phase="escalated";s.step="gsd-plan-final-review";s.gsdPlanningReview=finalReview.review;saveState(s);
6601
- die(`GSD phase ${phase} plans failed final planning review.`);
7113
+ const finalMaxRevisions=Math.max(1,Number(localPlanningReview.max_revisions||maxRevisions));
7114
+ const finalNotesKey=`gsd-plan-review:plan-check:${phase||"project"}`;
7115
+ let finalRevision=0;
7116
+ let finalFindings="";
7117
+ for(;;){
7118
+ const finalReview=await reviewGsdPlanningStage(c,s,"plan-check",phase,request,finalFindings,{allowIncompleteContinuation:false});
7119
+ // Mission 031 Phase 3 task 3.5: INCOMPLETE-as-FAIL (no continuation): gsd-plan-final-review has no continuation allowance — an INCOMPLETE decision is treated as FAIL with explicit reason and never consumes a continuation.
7120
+ if(finalReview.review?.decision==="INCOMPLETE") console.log(`GSD phase ${phase} plan final review INCOMPLETE treated as FAIL (no continuation).`);
7121
+ if(finalReview.review?.decision==="PASS"){
7122
+ clearPlanReviewHistory(s,finalNotesKey);
7123
+ saveState(s);
7124
+ break;
7125
+ }
7126
+ finalRevision++;
7127
+ finalFindings=(finalReview.review?.findings||[]).join("\n") || "Reviewer returned FAIL without findings.";
7128
+ recordPlanReviewNotes(s,finalNotesKey,finalReview.review?.notes);
7129
+ s.gsdPlanningReview={stage:"plan-check",phase,revision:finalRevision,findings:finalReview.review?.findings||[]};
7130
+ s.step=`gsd-plan-check-revision-${finalRevision}`; saveState(s);
7131
+ if(finalRevision>=finalMaxRevisions){
7132
+ s.phase="escalated";s.step="gsd-plan-final-review";saveState(s);
7133
+ die(`GSD phase ${phase} plans failed final planning review after ${finalMaxRevisions} revision(s).`);
7134
+ }
7135
+ // A final plan-check FAIL is another revision round while the budget
7136
+ // remains: re-run the native plan stage with the findings, then review
7137
+ // the saved plans again (never a one-shot escalation).
7138
+ const notesText=planReviewNotesText(s,finalNotesKey);
7139
+ const reviseRequest=`${request}\n\nGSD plan-check reviewer findings that MUST be addressed on the next revision:\n${finalFindings}${notesText?`\n\n${notesText}`:""}`;
7140
+ await gsdPlanningStage(c,s,{stage:"plan",phase,request:reviseRequest,resume:false,planRevision:finalRevision});
6602
7141
  }
6603
7142
  }
6604
7143
  }
@@ -6606,6 +7145,7 @@ async function runGsdPlanning(c,s,request){
6606
7145
  s.phase="tasks_ready";s.step="tasks_ready";s.gsdPhase=phase||"current";saveState(s);
6607
7146
  }
6608
7147
  async function runSpec(c,s){
7148
+ recordEntryCommand(s);saveState(s);
6609
7149
  const request=process.env.AUTOPILOT_SPEC || c.project?.spec || "Use the current project request/context.";
6610
7150
  await ensurePlanningReady(c,s,request);
6611
7151
  // Mission 099 task 2.6: planning can END at the plan-approval pause (the
@@ -6943,8 +7483,8 @@ function buildPlanPrompt(phase,u){
6943
7483
  ].join("\n");
6944
7484
  }
6945
7485
  async function relayAsync(roleName,task,c,s,options={}){
6946
- const {cwd=repo,readOnly=false,resume=false,review=false,persistState=true,requirements=[],taskFacts={},taskRef=null,timeoutMs=null,capEnabled=false,contScope=null,planRevision=null,carriedForwardTasks=null}=options;
6947
- return runRelay(roleName,task,c,s,{cwd,readOnly,resume,review,persistState,requirements,taskFacts,taskRef,timeoutMs,alwaysSave:true,fatal:orchestratorFatal,includeError:true,capEnabled,contScope,planRevision,carriedForwardTasks});
7486
+ const {cwd=repo,readOnly=false,resume=false,review=false,persistState=true,requirements=[],taskFacts={},taskRef=null,timeoutMs=null,capEnabled=false,contScope=null,planRevision=null,carriedForwardTasks=null,runNote=null,resultExtra=null}=options;
7487
+ return runRelay(roleName,task,c,s,{cwd,readOnly,resume,review,persistState,requirements,taskFacts,taskRef,timeoutMs,alwaysSave:true,fatal:orchestratorFatal,includeError:true,capEnabled,contScope,planRevision,carriedForwardTasks,runNote,resultExtra});
6948
7488
  }
6949
7489
  // Mission 091 task 2.1: minimal adhoc review entry. Runs one read-only
6950
7490
  // reviewer/verifier relay against an already-built prompt with a synthetic
@@ -7036,7 +7576,9 @@ async function runGsdPlanUnit(c,s,phase,u,waveBaseSha,snapshot){
7036
7576
  "The plan was executed in an isolated worktree.",
7037
7577
  "Do not modify files. Do not commit/push.",
7038
7578
  "",
7039
- `Git diff:\n${planDiff(wt)}`
7579
+ `Git diff:\n${planDiff(wt)}`,
7580
+ "",
7581
+ CODE_REVIEW_REAL_TESTS_RULE
7040
7582
  ].join("\n"),
7041
7583
  c,localState,{cwd:wt,readOnly:true,review:true,persistState:false,requirements:taskRequirements,taskFacts});
7042
7584
 
@@ -7264,7 +7806,7 @@ async function runGsdPhase(c,s,phase){
7264
7806
  // deterministic gsd-tools check, or Verifier may run until the phase
7265
7807
  // Code Reviewer has returned PASS.
7266
7808
  const phaseReview=await relayAsync("code_reviewer",
7267
- `Final code review for GSD phase ${phase}.\n\nGit diff:\n${diff()}\n\nReview against phase plans, requirements, and scope.`,
7809
+ `Final code review for GSD phase ${phase}.\n\nGit diff:\n${diff()}\n\nReview against phase plans, requirements, and scope.\n\n${CODE_REVIEW_REAL_TESTS_RULE}`,
7268
7810
  c,s,{readOnly:true,review:true,requirements:phaseRequirements,taskFacts:phaseTaskFacts});
7269
7811
 
7270
7812
  if(phaseReview.status!=="completed" || phaseReview.policyViolation || phaseReview.review?.decision!=="PASS"){
@@ -7366,7 +7908,7 @@ async function runGsdImplementation(c,s){
7366
7908
  }
7367
7909
  s.phase="final_review";s.step="final_review";saveState(s);
7368
7910
  const final=await relayAsync("code_reviewer",
7369
- `Final repository review across all completed GSD phases.\n\nGit diff:\n${diff()}`,
7911
+ `Final repository review across all completed GSD phases.\n\nGit diff:\n${diff()}\n\n${CODE_REVIEW_REAL_TESTS_RULE}`,
7370
7912
  c,s,{readOnly:true,review:true});
7371
7913
  // Mission 031 Phase 3 task 3.5: INCOMPLETE-as-FAIL (no continuation): gsd-loop-final-review has no continuation loop — an INCOMPLETE decision is treated as FAIL with explicit reason and never consumes a continuation.
7372
7914
  if(final.review?.decision==="INCOMPLETE") console.log("GSD loop final repository review INCOMPLETE treated as FAIL (no continuation).");
@@ -7637,6 +8179,7 @@ async function runStandaloneImplementation(c,s){
7637
8179
  }
7638
8180
  const phaseKey=`standalone-phase:${s.phaseIndex}`;
7639
8181
  const phaseRequirements=phaseTasks.flatMap(t=>t.requiredCapabilities||[]);
8182
+ const phaseTaskFacts=mergeTaskLineFacts(phaseTasks);
7640
8183
  // TASK-20: reviewer-facing task labels — position i is phaseTaskIds[i],
7641
8184
  // labelled 1-based by taskLabel() (never derived inline; a stable task
7642
8185
  // ID's own T segment is 0-based, see finding-task-locality.mjs).
@@ -8396,6 +8939,7 @@ const taskImpl=await relay("implementer",`${promptBuilt.text}${taskRetryFindings
8396
8939
  s.phase="escalated";s.step="relay_policy_violation";s.relayPolicyViolation=rv.policyViolation;saveState(s);
8397
8940
  die(formatRelayPolicyViolation(s.relayPolicyViolation, rv.touchedFiles));
8398
8941
  }
8942
+ if(rv.capped && acceptCappedFinalVerdict("Code review",rv)){ /* verdict already complete */ }
8399
8943
  if(rv.capped){
8400
8944
  // Mission 031 Phase 4 task 4.1: a standalone code_reviewer cap
8401
8945
  // breach is a continuation event, never a failure — failure
@@ -8605,8 +9149,8 @@ const taskImpl=await relay("implementer",`${promptBuilt.text}${taskRetryFindings
8605
9149
  const verifyHandoffNote=readHandoffNote(mission, verifyRef);
8606
9150
  const verifyDeadEndsText=verifyDeadEnds.length?`Prior dead ends (continuation context — do not retry blindly):\n${verifyDeadEnds.map((d)=>`- Generation ${d.generation} (${d.reason} ${d.used}/${d.limit}): ${d.tail}`).join("\n")}`:"";
8607
9151
  const verifyHandoffNoteText=verifyHandoffNote?`Prior verifier handoff note (continuation context — continue from where it stopped):\n${verifyHandoffNote}`:"";
8608
- const vv=await relayAsync("verifier",`${verifyHandoff.text}\n\n${verifyDeadEndsText}\n\n${verifyHandoffNoteText}\n\n${verifyBudget.note}\n${verifierRoundLines(s,codeReviewScopeKey("standalone-phase",s.phaseIndex)).join("\n")}\n${readOnlyHandoffNoteReminder({mission, ref: verifyRef, noteLimit: resolveHandoffNoteMaxChars(c.policy)})}`,
8609
- c,s,{readOnly:true,review:true,requirements:phaseRequirements,taskRef:verifyRef,timeoutMs:verifyBudget.timeoutMs,capEnabled:true});
9152
+ const vv=await relayAsync("verifier",`${verifyHandoff.text}\n\n${verifyDeadEndsText}\n\n${verifyHandoffNoteText}\n\n${verifyBudget.note}${browserVerifierNote(phaseTaskFacts)?`\n${browserVerifierNote(phaseTaskFacts)}`:""}\n${verifierRoundLines(s,codeReviewScopeKey("standalone-phase",s.phaseIndex)).join("\n")}\n${readOnlyHandoffNoteReminder({mission, ref: verifyRef, noteLimit: resolveHandoffNoteMaxChars(c.policy)})}`,
9153
+ c,s,{readOnly:true,review:true,requirements:phaseRequirements,taskFacts:phaseTaskFacts,taskRef:verifyRef,timeoutMs:verifyBudget.timeoutMs,capEnabled:true});
8610
9154
  noteRoleDeadlineOutcome(s,"verifier",vv,verifyBudget.derived);
8611
9155
  {
8612
9156
  // Backlog #91: a red phase gate forces the recorded verdict to FAIL.
@@ -8869,6 +9413,9 @@ const taskImpl=await relay("implementer",`${promptBuilt.text}${taskRetryFindings
8869
9413
  s.phase="escalated";s.step="task_marking_failed";s.taskMarkingError=marked.reason;saveState(s);
8870
9414
  die(`Standalone task checkbox marking failed after verification PASS: ${marked.reason}. STOP — human intervention required.`);
8871
9415
  }
9416
+ if(tasksIncludeE2eSetup(phaseTasks)){
9417
+ try{clearE2eSetupPending(repo);}catch(error){console.log(`[e2e-setup] marker clear skipped (${error?.message||error})`);}
9418
+ }
8872
9419
  for(const t of phaseTasks) t.done=true;
8873
9420
  passed=true;
8874
9421
  s.lastReview={decision:checkpoint.codeReviewer?.decision??"SKIPPED",findings:checkpoint.codeReviewer?.findings??[]};
@@ -9074,6 +9621,15 @@ function maybeInjectCarryCrash(taskId){
9074
9621
  process.exit(137);
9075
9622
  }
9076
9623
 
9624
+ // What the watch report's `Command:` line shows: the entry command typed and
9625
+ // the stage it stops after (null = the full loop). `ap run --stage X` stops
9626
+ // after X; `ap resume` is the same loop as `ap run`.
9627
+ const COMMAND_STOP_AFTER={plan:"planning",spec:"planning",review:"review"};
9628
+ const STAGE_STOP_AFTER={planning:"planning",implement:"implementation",review:"review",verify:"verification"};
9629
+ function recordEntryCommand(s){
9630
+ recordCommand(s,command==="resume"?"run":command,COMMAND_STOP_AFTER[command]??STAGE_STOP_AFTER[activeStage]??null);
9631
+ }
9632
+
9077
9633
  async function runImplementation(c){
9078
9634
  const stage=activeStage;
9079
9635
  // Mission 090 task 1.5: stage plumbing — the run dispatch passes the
@@ -9083,6 +9639,12 @@ async function runImplementation(c){
9083
9639
  // frameworks refuse them (planning allowed) at their own dispatch.
9084
9640
  let s=loadState();
9085
9641
 
9642
+ // Record which skill copy this run executes from (derived from the running
9643
+ // module's location, never cwd) so the watch report prints it.
9644
+ recordSkillCopy(s);
9645
+ recordEntryCommand(s);
9646
+ saveState(s);
9647
+
9086
9648
  // TASK 14 CORRECTION: reconcile any SpecKit checkbox snapshot left
9087
9649
  // pending by a previous process that crashed between the Implementer
9088
9650
  // writing tasks.md and this same reconciliation running normally (see
@@ -9391,6 +9953,7 @@ async function runImplementation(c){
9391
9953
  // ID's own T segment is 0-based, see finding-task-locality.mjs).
9392
9954
  const scope=phaseTasks.map((t,i)=>`- [${taskLabel(i)}] ${t.text} (${t.file}:${t.line})`).join("\n");
9393
9955
  const phaseRequirements=phaseTasks.flatMap(task=>task.requiredCapabilities||[]);
9956
+ const phaseTaskFacts=mergeTaskLineFacts(phaseTasks);
9394
9957
  console.log(`\n Phase ${s.phaseIndex+1}/${phaseList.length}: ${phase.name} — ${phaseTasks.length} task(s)`);
9395
9958
  // Per-phase baseline: captured once at this phase's first attempt
9396
9959
  // (idempotent — a retry after a FAILED attempt, or a resume mid-phase,
@@ -10091,6 +10654,7 @@ const taskImpl=await relay("implementer",`${promptBuilt.text}${taskRetryFindings
10091
10654
  s.phase="escalated";s.step="relay_policy_violation";s.relayPolicyViolation=rv.policyViolation;saveState(s);
10092
10655
  die(formatRelayPolicyViolation(s.relayPolicyViolation, rv.touchedFiles));
10093
10656
  }
10657
+ if(rv.capped && acceptCappedFinalVerdict("Code review",rv)){ /* verdict already complete */ }
10094
10658
  if(rv.capped){
10095
10659
  // Mission 031 Phase 5 task 5.1: a SpecKit code_reviewer cap
10096
10660
  // breach is a continuation event, never a failure — failure
@@ -10299,8 +10863,8 @@ const taskImpl=await relay("implementer",`${promptBuilt.text}${taskRetryFindings
10299
10863
  const verifyHandoffNoteText=verifyHandoffNote?`Prior verifier handoff note (continuation context — continue from where it stopped):\n${verifyHandoffNote}`:"";
10300
10864
  const vv=await relayAsync("verifier",
10301
10865
  `${`Verify this phase after implementation and code review.${gateSection?" The full test suite already ran as the phase gate; review the recorded gate result below — do not re-run it.":""}\nPhase: ${phase.name}\n\nTasks:\n${scope}\n\nScoped implementation delta for THIS PHASE only (pre-existing repository changes made before this mission started, and changes from earlier phases, are excluded — do not evaluate or flag anything outside this delta; an empty delta means this phase made no repository changes):\n${scopedDeltaBeginMarker("PHASE")}\n${speckitBoundedDelta(repo,phaseBaselineKey,"PHASE_REVIEW")}\n${SCOPED_DELTA_END_MARKER}`}${gateSection?`\n\n${gateSection}`:""}${verifyDurations?`\n\n${verifyDurations}`:""}${heavyNoParallelVerify?`
10302
- \n\n${heavyNoParallelVerify}`:""}\n\n${verifyDeadEndsText}\n\n${verifyHandoffNoteText}\n\n${verifyBudget.note}\n${verifierRoundLines(s,codeReviewScopeKey("speckit-phase",s.phaseIndex)).join("\n")}\n${readOnlyHandoffNoteReminder({mission: missionIdentity, ref: verifyRef, noteLimit: resolveHandoffNoteMaxChars(c.policy)})}`,
10303
- c,s,{readOnly:true,review:true,requirements:phaseRequirements,taskRef:verifyRef,timeoutMs:verifyBudget.timeoutMs,capEnabled:true});
10866
+ \n\n${heavyNoParallelVerify}`:""}\n\n${verifyDeadEndsText}\n\n${verifyHandoffNoteText}\n\n${verifyBudget.note}${browserVerifierNote(phaseTaskFacts)?`\n${browserVerifierNote(phaseTaskFacts)}`:""}\n${verifierRoundLines(s,codeReviewScopeKey("speckit-phase",s.phaseIndex)).join("\n")}\n${readOnlyHandoffNoteReminder({mission: missionIdentity, ref: verifyRef, noteLimit: resolveHandoffNoteMaxChars(c.policy)})}`,
10867
+ c,s,{readOnly:true,review:true,requirements:phaseRequirements,taskFacts:phaseTaskFacts,taskRef:verifyRef,timeoutMs:verifyBudget.timeoutMs,capEnabled:true});
10304
10868
  noteRoleDeadlineOutcome(s,"verifier",vv,verifyBudget.derived);
10305
10869
  {
10306
10870
  // Backlog #91: a red phase gate forces the recorded verdict to FAIL.
@@ -10644,6 +11208,7 @@ function setup(){
10644
11208
  return run("node",[p,"--repo",repo]);
10645
11209
  }
10646
11210
  async function reviewPlanCommand(c,s){
11211
+ recordEntryCommand(s);saveState(s);
10647
11212
  const state=detectPlanningState(c,s);
10648
11213
  if(state.status==="missing") die("No planning artifacts exist yet. Run ap run to let Autopilot plan and continue automatically.");
10649
11214
  if(state.status==="ambiguous") die(`Planning state is ambiguous: ${state.reason||"insufficient evidence"}. Run ap run again after repairing the planning artifacts.`);
@@ -10730,7 +11295,7 @@ async function main(){
10730
11295
  process.exitCode=code;
10731
11296
  return code;
10732
11297
  }
10733
- const c=cfg();preflightConfiguredRoles(c);const s=loadState();
11298
+ const c=cfg();await preflightConfiguredRoles(c);const s=loadState();
10734
11299
  if(command==="plan"||command==="spec")return runSpec(c,s);
10735
11300
  if(command==="review")return reviewPlanCommand(c,s);
10736
11301
  if(command==="implement"||command==="run"||command==="resume"||command==="full") {