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
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ import { normalizeFindings } from "./review-feedback.mjs";
5
+
4
6
  // Mission 094 Phase 1 task 1.1 — the core report model.
5
7
  //
6
8
  // `buildReportModel` normalizes the three live data sources of a watch report
@@ -65,16 +67,20 @@ function attemptNumberOf(run) {
65
67
  function readRunExtras(dir) {
66
68
  const extras = {
67
69
  findings: [],
70
+ notes: [],
68
71
  carriedForwardTasks: null,
69
72
  sessionNumber: null,
70
73
  configuredModel: null,
71
74
  resolvedModel: null,
72
75
  modelMismatch: null,
76
+ reasoning: null,
77
+ variant: null,
73
78
  snapshotBeforeStartedAt: null,
74
79
  snapshotBeforeAt: null,
75
80
  snapshotAfterStartedAt: null,
76
81
  snapshotAfterAt: null,
77
82
  planRevision: null,
83
+ planningStage: null,
78
84
  note: null,
79
85
  policyViolation: null,
80
86
  failureClass: null,
@@ -90,12 +96,15 @@ function readRunExtras(dir) {
90
96
  if (st.carriedForwardTasks !== undefined) extras.carriedForwardTasks = st.carriedForwardTasks;
91
97
  if (st.configuredModel !== undefined) extras.configuredModel = st.configuredModel;
92
98
  if (st.resolvedModel !== undefined) extras.resolvedModel = st.resolvedModel;
99
+ if (typeof st.reasoning === "string" && st.reasoning) extras.reasoning = st.reasoning;
100
+ if (typeof st.variant === "string" && st.variant) extras.variant = st.variant;
93
101
  if (st.modelMismatch !== undefined) extras.modelMismatch = st.modelMismatch;
94
102
  if (st.snapshotBeforeStartedAt !== undefined) extras.snapshotBeforeStartedAt = st.snapshotBeforeStartedAt;
95
103
  if (st.snapshotBeforeAt !== undefined) extras.snapshotBeforeAt = st.snapshotBeforeAt;
96
104
  if (st.snapshotAfterStartedAt !== undefined) extras.snapshotAfterStartedAt = st.snapshotAfterStartedAt;
97
105
  if (st.snapshotAfterAt !== undefined) extras.snapshotAfterAt = st.snapshotAfterAt;
98
106
  if (st.planRevision !== undefined) extras.planRevision = st.planRevision;
107
+ if (st.planningStage !== undefined) extras.planningStage = st.planningStage;
99
108
  if (st.note !== undefined) extras.note = st.note;
100
109
  if (st.interrupted_at !== undefined) extras.interrupted_at = st.interrupted_at;
101
110
  if (st.interruption_reason !== undefined) extras.interruption_reason = st.interruption_reason;
@@ -104,10 +113,20 @@ function readRunExtras(dir) {
104
113
  try {
105
114
  const rs = JSON.parse(fs.readFileSync(path.join(dir, "result.json"), "utf8"));
106
115
  if (rs && typeof rs === "object") {
107
- if (Array.isArray(rs.review?.findings)) extras.findings = rs.review.findings.map(String);
108
- else if (Array.isArray(rs.findings)) extras.findings = rs.findings.map(String);
116
+ if (extras.planningStage == null && rs.planningStage !== undefined) extras.planningStage = rs.planningStage;
117
+ if (Array.isArray(rs.review?.findings) && rs.review.findings.length) extras.findings = rs.review.findings.map(String);
118
+ else if (Array.isArray(rs.findings) && rs.findings.length) extras.findings = rs.findings.map(String);
119
+ // A plan-review FAIL can record its findings only on verdict.findings
120
+ // (review.findings is null/empty); the watch summary must still show
121
+ // the reason, so fall back to the verdict array.
122
+ else if (Array.isArray(rs.verdict?.findings) && rs.verdict.findings.length) extras.findings = normalizeFindings(rs.verdict.findings);
123
+ // Backlog #8: minor notes never fail a plan review; show their count.
124
+ if (Array.isArray(rs.review?.notes) && rs.review.notes.length) extras.notes = rs.review.notes.map(String);
125
+ else if (Array.isArray(rs.verdict?.notes) && rs.verdict.notes.length) extras.notes = rs.verdict.notes.map(String);
109
126
  if (extras.carriedForwardTasks == null && Array.isArray(rs.carriedForwardTasks)) extras.carriedForwardTasks = rs.carriedForwardTasks.slice();
110
127
  if (extras.resolvedModel == null && typeof rs.resolvedModel === "string") extras.resolvedModel = rs.resolvedModel;
128
+ if (extras.reasoning == null && typeof rs.reasoning === "string" && rs.reasoning) extras.reasoning = rs.reasoning;
129
+ if (extras.variant == null && typeof rs.variant === "string" && rs.variant) extras.variant = rs.variant;
111
130
  extras.policyViolation = (typeof rs.policyViolation === "string" && rs.policyViolation)
112
131
  || (typeof rs.policy?.violation === "string" && rs.policy.violation)
113
132
  || null;
@@ -126,7 +145,7 @@ function readRunExtras(dir) {
126
145
  // (e.g. `resolvedModel`) silently dropped those extras (backlog #4).
127
146
  const RUN_EXTRA_KEYS = [
128
147
  "carriedForwardTasks", "sessionNumber", "resolvedModel", "configuredModel",
129
- "modelMismatch", "snapshotBeforeStartedAt", "snapshotBeforeAt", "snapshotAfterStartedAt", "snapshotAfterAt", "planRevision", "note",
148
+ "modelMismatch", "reasoning", "variant", "snapshotBeforeStartedAt", "snapshotBeforeAt", "snapshotAfterStartedAt", "snapshotAfterAt", "planRevision", "planningStage", "note", "notes",
130
149
  "policyViolation", "failureClass", "escalated", "interrupted_at", "interruption_reason",
131
150
  ];
132
151
  function enrichRun(run) {
@@ -459,6 +478,32 @@ function buildPlanningRuns(runs, { status: missionStatus } = {}) {
459
478
  };
460
479
  }
461
480
 
481
+ // The revision counter the planning header shows. Standalone keeps the
482
+ // planner-run `planRevision` high-water mark; SpecKit and GSD gate loops keep
483
+ // their own counters in `state.planReviewRevisions` (keys such as
484
+ // `speckit-stage:specify`, or `gsd-*`), so read those too. GSD also records
485
+ // the latest stage revision in `state.gsdPlanningReview.revision`. Each gate
486
+ // has its own budget, so the header reports the highest counter used, never a
487
+ // sum that could exceed `max_plan_revisions`.
488
+ function statePlanningRevision(state, framework) {
489
+ if (framework !== "speckit" && framework !== "gsd") return null;
490
+ const values = [];
491
+ const revisions = state?.planReviewRevisions;
492
+ if (revisions && typeof revisions === "object") {
493
+ for (const [key, value] of Object.entries(revisions)) {
494
+ if (!Number.isInteger(value) || value < 0) continue;
495
+ if (framework === "speckit" && !key.startsWith("speckit-")) continue;
496
+ if (framework === "gsd" && !key.startsWith("gsd")) continue;
497
+ values.push(value);
498
+ }
499
+ }
500
+ if (framework === "gsd") {
501
+ const latest = state?.gsdPlanningReview?.revision;
502
+ if (Number.isInteger(latest) && latest >= 0) values.push(latest);
503
+ }
504
+ return values.length ? Math.max(...values) : null;
505
+ }
506
+
462
507
  // Final review rounds: each code_reviewer/verifier opens a round; fix-pass
463
508
  // implementer runs between reviews belong to the preceding round.
464
509
  function buildFinalReview(runs, { status: missionStatus, skipped } = {}) {
@@ -502,19 +547,32 @@ function isFinalReviewRun(run, phaseCount) {
502
547
  return true;
503
548
  }
504
549
 
505
- function classifyStage(state, planning, phases, finalReview) {
550
+ function classifyStage(state, planning, phases, finalReview, runs = []) {
506
551
  const statePhase = typeof state?.phase === "string" ? state.phase : "";
552
+ const stateStep = typeof state?.step === "string" ? state.step : "";
507
553
  // The recorded state wins when it names a stage: a report rendered after a
508
554
  // final review run finished must still read IMPLEMENTATION while the mission
509
- // is still implementing.
555
+ // is still implementing. A plan-phase step (standalone-plan, plan_review, …)
556
+ // is a real stage signal even before state.phase has been written.
510
557
  if (statePhase === "final_review") return "FINAL REVIEW";
511
- if (statePhase === "planning" || statePhase === "plan_approval" || /planning/.test(String(state?.step || ""))) return "PLANNING";
558
+ if (statePhase === "planning" || statePhase === "plan_approval" || /plan/i.test(stateStep)) return "PLANNING";
512
559
  if (statePhase === "implementing" || statePhase === "verifying" || statePhase === "tasks_ready") return "IMPLEMENTATION";
513
560
  if (finalReview.status === "skipped") return "FINAL REVIEW";
514
561
  if (finalReview.runs.length > 0 && (finalReview.status === "in_progress" || finalReview.status === "passed")) return "FINAL REVIEW";
515
562
  const anyImpl = phases.some((p) => p.runs.length > 0);
516
- if (!anyImpl && planning.runs.length > 0) return "PLANNING";
517
- return "IMPLEMENTATION";
563
+ if (anyImpl) return "IMPLEMENTATION";
564
+ if (planning.runs.length > 0) return "PLANNING";
565
+ // Last resort before STARTING: the newest run's role says what the mission
566
+ // is doing even when state.json has no phase/step yet (the first seconds of
567
+ // a run). An impl/verify/review role is known work, not an unknown state.
568
+ const latest = [...(Array.isArray(runs) ? runs : [])]
569
+ .sort((a, b) => (Date.parse(a?.started_at ?? a?.startedAt) || 0) - (Date.parse(b?.started_at ?? b?.startedAt) || 0))
570
+ .pop();
571
+ const role = roleOf(latest);
572
+ if (PLANNING_ROLES.has(role)) return "PLANNING";
573
+ if (IMPLEMENTATION_ROLES.has(role) || REVIEW_ROLES.has(role)) return "IMPLEMENTATION";
574
+ // Nothing has started yet: never pretend the mission is implementing.
575
+ return "STARTING";
518
576
  }
519
577
 
520
578
  // ── Mission 094 task 1.2 — enrichment helpers ───────────────────────────────
@@ -589,6 +647,7 @@ export function modelDisplay(run) {
589
647
  export function providerTroubleLabel(text) {
590
648
  const s = String(text ?? "");
591
649
  if (/ProviderHeaderTimeout/.test(s)) return "ProviderHeaderTimeout";
650
+ if (/free usage limit reached|usage limit reached/i.test(s) || /insufficient[_\s-]?quota|quota[_\s-]?(?:exceeded|exhausted)/i.test(s)) return "Free quota used up";
592
651
  if (/Rate limit exceeded/i.test(s) || /rate_limit_exceeded/.test(s)) return "Rate limit exceeded";
593
652
  const silent = s.match(/silent\s+(\d+)\s*m[,\s]+no child process/i);
594
653
  if (silent) return `silent ${silent[1]}m, no child process`;
@@ -883,6 +942,10 @@ function decodeProgressEntry(raw) {
883
942
  const text = String(m[2] ?? "").split("\n")[0].trim();
884
943
  return { at, kind, text: text || m[1] };
885
944
  }
945
+ if (/^PREPARE \|/.test(rest)) {
946
+ const text = rest.replace(/^PREPARE \|\s*/, "").trim();
947
+ return { at, kind: "PREP", text: text || "preparing" };
948
+ }
886
949
  const prep = rest.match(/^PREP\b\s*(.*)$/);
887
950
  if (prep) return { at, kind: "PREP", text: prep[1] || "pre-run snapshot started" };
888
951
  const warn = rest.match(/^WARN\b\s*(.*)$/);
@@ -992,6 +1055,8 @@ export function detectCurrent({ runs = [], gates = [], status = "RUNNING", gateL
992
1055
  attempt: attemptNumberOf(r),
993
1056
  status: "running",
994
1057
  model: r.displayModel ?? modelDisplay(r),
1058
+ reasoning: typeof r.reasoning === "string" && r.reasoning ? r.reasoning : null,
1059
+ variant: typeof r.variant === "string" && r.variant ? r.variant : null,
995
1060
  sessionNumber: r.sessionNumber ?? null,
996
1061
  session: r.sessionLabel ?? null,
997
1062
  pid: r.pid ?? null,
@@ -1001,6 +1066,7 @@ export function detectCurrent({ runs = [], gates = [], status = "RUNNING", gateL
1001
1066
  snapshotAfterAt: r.snapshotAfterAt ?? null,
1002
1067
  usage: r.usage ?? null,
1003
1068
  note: r.note ?? null,
1069
+ lastAction: r.last_action ?? null,
1004
1070
  };
1005
1071
  }
1006
1072
 
@@ -1145,7 +1211,7 @@ function missionStartedAt(state, runs) {
1145
1211
  return earliest;
1146
1212
  }
1147
1213
 
1148
- export function buildReportModel({ state = null, runs = [], events = [], phases = [], policy = {}, layout = null, progress = [], gateLog = null, now = null, skill = null, missionFallback = null, ownerAlive } = {}) {
1214
+ export function buildReportModel({ state = null, runs = [], events = [], phases = [], policy = {}, layout = null, progress = [], gateLog = null, now = null, skill = null, command = null, missionFallback = null, ownerAlive } = {}) {
1149
1215
  const eventList = Array.isArray(events) ? events : [];
1150
1216
  const progressList = Array.isArray(progress) ? progress : [];
1151
1217
  const phaseDefs = normalizePhases(phases);
@@ -1264,6 +1330,11 @@ export function buildReportModel({ state = null, runs = [], events = [], phases
1264
1330
 
1265
1331
  const mission = typeof state?.mission === "string" && state.mission ? state.mission : null;
1266
1332
  const framework = state?.planningDetection?.framework ?? null;
1333
+ const storedRevision = statePlanningRevision(state, framework);
1334
+ if (storedRevision != null) {
1335
+ const runRevision = typeof planning.revision === "number" ? planning.revision : 0;
1336
+ planning.revision = Math.max(runRevision, storedRevision);
1337
+ }
1267
1338
 
1268
1339
  // For ended missions, compute the end time from statusInfo.at (cancel/stop/escalation)
1269
1340
  // or from the last run's end time for COMPLETED missions.
@@ -1308,7 +1379,7 @@ export function buildReportModel({ state = null, runs = [], events = [], phases
1308
1379
  status: statusInfo.status,
1309
1380
  statusInfo,
1310
1381
  stopLine: statusInfo.stopLine,
1311
- stage: classifyStage(state, planning, phasesOut, finalReview),
1382
+ stage: classifyStage(state, planning, phasesOut, finalReview, enrichedRuns),
1312
1383
  phaseIndex,
1313
1384
  phaseNumber: phaseIndex != null ? phaseIndex + 1 : null,
1314
1385
  phaseName: phaseIndex != null ? (phasesOut[phaseIndex]?.name ?? null) : null,
@@ -1321,7 +1392,8 @@ export function buildReportModel({ state = null, runs = [], events = [], phases
1321
1392
  missionStartedAt: missionStartedAt(state, enrichedRuns),
1322
1393
  missionEndedAt,
1323
1394
  missionFallback,
1324
- skill: skill && typeof skill === "object" ? skill : null,
1395
+ skill: skill && typeof skill === "object" ? skill : (state?.skill && typeof state.skill === "object" ? state.skill : null),
1396
+ command: command ?? state?.command ?? null,
1325
1397
  continuationAllowance: allowance,
1326
1398
  gateCommand,
1327
1399
  gates,
@@ -1598,6 +1670,8 @@ function planningNote(run, model) {
1598
1670
  } else if (roleOf(run) === "plan_reviewer") {
1599
1671
  const n = Array.isArray(run.findings) ? run.findings.length : 0;
1600
1672
  parts.push(`${n} finding${n === 1 ? "" : "s"}`);
1673
+ const notes = Array.isArray(run.notes) ? run.notes.length : 0;
1674
+ if (notes) parts.push(`notes: ${notes} minor`);
1601
1675
  }
1602
1676
  const failureReason = failureReasonText(run, model?.runs);
1603
1677
  if (failureReason) parts.push(failureReason);
@@ -1665,14 +1739,40 @@ function renderRows(rows, { indent = " ", widths: sharedWidths = null, collec
1665
1739
  return lines;
1666
1740
  }
1667
1741
 
1668
- // A planning run as a shared-table row: both planning roles write and review
1669
- // PLAN.md + TASKS.md, so their TASK is always `plan/tasks`.
1742
+ // The planning TASK cell. Standalone planning keeps the historical
1743
+ // `plan/tasks` label (its planner writes and its reviewer reads both files).
1744
+ // SpecKit rows name the stage the run worked on (`spec`/`plan`/`tasks`) and
1745
+ // GSD rows their stage (`discuss`/`research`/`plan`/`plan-check`), both from
1746
+ // the stage recorded on the run at launch. A legacy run without that field
1747
+ // keeps the old `plan/tasks` label rather than inventing a stage.
1748
+ const PLANNING_STAGE_LABELS = {
1749
+ specify: "spec", spec: "spec", "review-spec": "spec", "spec-review": "spec",
1750
+ plan: "plan", "review-plan": "plan", "plan-review": "plan",
1751
+ tasks: "tasks", "review-tasks": "tasks", "tasks-review": "tasks",
1752
+ discuss: "discuss", research: "research",
1753
+ "plan-check": "plan-check", "review-plan-check": "plan-check",
1754
+ };
1755
+ function planningStageLabel(run) {
1756
+ const raw = run?.planningStage ?? run?.stage ?? run?.planningStep ?? null;
1757
+ if (typeof raw !== "string" || !raw.trim()) return null;
1758
+ const key = raw.trim().toLowerCase()
1759
+ .replace(/^(?:speckit|gsd)-/, "")
1760
+ .replace(/-revision-\d+$/, "")
1761
+ .replace(/-review-failed$/, "");
1762
+ return PLANNING_STAGE_LABELS[key] ?? key;
1763
+ }
1764
+ function planningTaskLabel(run) {
1765
+ return planningStageLabel(run) ?? "plan/tasks";
1766
+ }
1767
+
1768
+ // A planning run as a shared-table row: standalone keeps `plan/tasks`, while
1769
+ // SpecKit/GSD rows show the stage the run actually worked on.
1670
1770
  function planningRow(run, model) {
1671
1771
  return {
1672
1772
  running: isRunning(run),
1673
1773
  role: run?.dirName || run?.id || "-",
1674
1774
  model: run?.displayModel ?? modelDisplay(run),
1675
- task: "plan/tasks",
1775
+ task: planningTaskLabel(run),
1676
1776
  status: runStatusCell(run),
1677
1777
  duration: durationCell(run, model?.now ?? null),
1678
1778
  snapshot: snapshotCell(run),
@@ -1836,6 +1936,7 @@ export function stageText(model) {
1836
1936
  const status = String(model?.status ?? "").toUpperCase();
1837
1937
  if (status === "COMPLETED" || status === "CANCELLED") return "done";
1838
1938
  const stage = model?.stage;
1939
+ if (stage === "STARTING") return "STARTING";
1839
1940
  if (stage === "PLANNING") return "PLANNING";
1840
1941
  if (stage === "FINAL REVIEW") {
1841
1942
  const limit = Number(model?.policy?.final_review_fix_rounds);
@@ -1845,6 +1946,7 @@ export function stageText(model) {
1845
1946
  }
1846
1947
  return "FINAL REVIEW";
1847
1948
  }
1949
+ if (stage !== "IMPLEMENTATION") return "STARTING";
1848
1950
  const phase = model?.phaseNumber;
1849
1951
  const total = Array.isArray(model?.phases) ? model.phases.length : 0;
1850
1952
  const name = stripPhasePrefix(model?.phaseName);
@@ -2038,29 +2140,42 @@ export function capsText(model) {
2038
2140
  return result;
2039
2141
  }
2040
2142
 
2041
- // `Skill:` names the skill copy the orchestrator runs. No skill-copy/sync value
2042
- // is recorded anywhere (PLAN.md:50), so this degrades to a best-effort value:
2043
- // the passed `model.skill` when available, else an explicit not-recorded label.
2143
+ // `Skill:` names the skill copy the orchestrator runs. autopilot records it on
2144
+ // the mission state (`skill-copy.mjs`) at run start as {version, mode, path};
2145
+ // older runs have no record and keep the explicit not-recorded label.
2044
2146
  export function skillText(model) {
2045
2147
  const skill = model?.skill;
2046
- if (skill && (skill.mode || skill.path)) {
2148
+ if (skill && (skill.version || skill.mode || skill.path)) {
2047
2149
  const mode = skill.mode || "production";
2048
2150
  const at = skill.syncedAt ? formatStamp(skill.syncedAt).slice(0, 16) : null;
2049
- return `${mode} · ${skill.path || "-"}${at ? ` (synced ${at})` : ""}`;
2151
+ const head = [skill.version, mode, skill.path || "-"].filter((value) => value != null && value !== "").join(" · ");
2152
+ return `${head}${at ? ` (synced ${at})` : ""}`;
2050
2153
  }
2051
2154
  return "unknown · skill copy location not recorded";
2052
2155
  }
2053
2156
 
2157
+ // `Command:` names the entry command that started the run. autopilot records
2158
+ // it on the mission state (`skill-copy.mjs` recordCommand) at run start as
2159
+ // {name, stopAfter}; older runs have no record and print nothing.
2160
+ export function commandText(model) {
2161
+ const name = model?.command?.name;
2162
+ if (!name) return null;
2163
+ const stopAfter = model.command.stopAfter;
2164
+ return `ap ${name} (${stopAfter ? `stops after ${stopAfter}` : "full loop"})`;
2165
+ }
2166
+
2054
2167
  // `=== [<stamp> — ]PIPELINE STATE ===` block (watcher.md Sections): mission,
2055
- // status, stage, budgets used/limit, retries, totals, caps, skill copy and the Events
2168
+ // status, command, stage, budgets used/limit, retries, totals, caps, skill copy and the Events
2056
2169
  // line. No current-run detail leaks in here.
2057
2170
  export function renderPipelineState(model, { stamp = null } = {}) {
2058
2171
  const head = stamp ? `=== ${stamp} — PIPELINE STATE ===` : "=== PIPELINE STATE ===";
2059
2172
  const missionName = model?.mission ?? model?.missionFallback ?? "-";
2173
+ const commandLine = commandText(model);
2060
2174
  const lines = [
2061
2175
  head,
2062
2176
  `Mission: ${missionName}`,
2063
2177
  label("Status") + statusText(model),
2178
+ ...(commandLine ? [label("Command") + commandLine] : []),
2064
2179
  label("Stage") + stageText(model),
2065
2180
  label("Budgets") + budgetsText(model),
2066
2181
  label("Retries") + retriesText(model),
@@ -2107,6 +2222,7 @@ const FAILURE_CLASS_LABELS = {
2107
2222
  COMMAND_FAILED: "command failed",
2108
2223
  SERVER_UNAVAILABLE: "server unavailable",
2109
2224
  RATE_LIMITED: "rate limited",
2225
+ AUTH_REQUIRED: "authentication required (log in)",
2110
2226
  PROVIDER_ERROR: "provider error",
2111
2227
  IDLE_HANG: "idle hang",
2112
2228
  IMPLEMENTATION_REJECTED: "implementation rejected",
@@ -2624,6 +2740,7 @@ function capForRole(model, role) {
2624
2740
  // those). FINAL REVIEW may add its fix round; PLANNING is bare.
2625
2741
  export function currentStageText(model) {
2626
2742
  const stage = model?.stage;
2743
+ if (stage === "STARTING") return "STARTING";
2627
2744
  if (stage === "PLANNING") return "PLANNING";
2628
2745
  if (stage === "FINAL REVIEW") {
2629
2746
  const limit = Number(model?.policy?.final_review_fix_rounds);
@@ -2703,6 +2820,10 @@ export function renderCurrentRun(model) {
2703
2820
  if (current.role) roleParts.push(current.role);
2704
2821
  if (current.taskRef) roleParts.push(`task ${current.taskRef}`);
2705
2822
  if (current.model && current.model !== "-") roleParts.push(`model ${current.model}`);
2823
+ if (current.model && current.model !== "-") {
2824
+ if (current.reasoning) roleParts.push(`reasoning ${current.reasoning}`);
2825
+ if (current.variant && current.variant !== current.reasoning) roleParts.push(`variant ${current.variant}`);
2826
+ }
2706
2827
  if (current.session) roleParts.push(current.session);
2707
2828
  if (roleParts.length) out.push(label("role") + roleParts.join(" · "));
2708
2829
 
@@ -2710,8 +2831,12 @@ export function renderCurrentRun(model) {
2710
2831
  if (current.pid != null) {
2711
2832
  status += ` · pid ${current.pid} (alive)`;
2712
2833
  } else {
2834
+ const preparing = typeof current.lastAction === "string" && /^preparing:\s*/.test(current.lastAction)
2835
+ ? current.lastAction.replace(/^preparing:\s*/, "")
2836
+ : null;
2837
+ if (preparing) status += ` · PREPARING ${preparing} (no CLI process yet)`;
2713
2838
  const silenceMs = current.updatedAt && now ? Date.parse(now) - Date.parse(current.updatedAt) : null;
2714
- if (Number.isFinite(silenceMs) && silenceMs >= 10 * 60 * 1000) {
2839
+ if (!preparing && Number.isFinite(silenceMs) && silenceMs >= 10 * 60 * 1000) {
2715
2840
  status += ` · ⚠ silent ${Math.floor(silenceMs / 60000)}m, no child process`;
2716
2841
  }
2717
2842
  }
@@ -7,6 +7,7 @@ import {
7
7
  readWatchEvents,
8
8
  readRunMeta,
9
9
  } from "./run-history.mjs";
10
+ import { normalizeFindings } from "./review-feedback.mjs";
10
11
 
11
12
  // Mission 094 Phase 3 task 3.1 — the compact watch-summary cache.
12
13
  //
@@ -30,7 +31,7 @@ import {
30
31
  // unchanged, so a run that finishes (its status.json is rewritten) or a new
31
32
  // event forces a rebuild.
32
33
 
33
- export const SUMMARY_VERSION = 4;
34
+ export const SUMMARY_VERSION = 7;
34
35
 
35
36
  // Mission-scoped cache path, with the flat legacy fallback used elsewhere in
36
37
  // run-history.mjs (watchRunsDir).
@@ -108,16 +109,20 @@ function stampMapFresh(stored, current, skipName, skipEvents) {
108
109
  function readRunExtras(dir) {
109
110
  const extras = {
110
111
  findings: [],
112
+ notes: [],
111
113
  carriedForwardTasks: null,
112
114
  sessionNumber: null,
113
115
  resolvedModel: null,
114
116
  configuredModel: null,
115
117
  modelMismatch: null,
118
+ reasoning: null,
119
+ variant: null,
116
120
  snapshotBeforeStartedAt: null,
117
121
  snapshotBeforeAt: null,
118
122
  snapshotAfterStartedAt: null,
119
123
  snapshotAfterAt: null,
120
124
  planRevision: null,
125
+ planningStage: null,
121
126
  note: null,
122
127
  policyViolation: null,
123
128
  failureClass: null,
@@ -133,12 +138,15 @@ function readRunExtras(dir) {
133
138
  if (st.carriedForwardTasks !== undefined && st.carriedForwardTasks !== null) extras.carriedForwardTasks = st.carriedForwardTasks;
134
139
  if (st.configuredModel !== undefined && st.configuredModel !== null) extras.configuredModel = st.configuredModel;
135
140
  if (st.resolvedModel !== undefined && st.resolvedModel !== null) extras.resolvedModel = st.resolvedModel;
141
+ if (typeof st.reasoning === "string" && st.reasoning) extras.reasoning = st.reasoning;
142
+ if (typeof st.variant === "string" && st.variant) extras.variant = st.variant;
136
143
  if (st.modelMismatch !== undefined && st.modelMismatch !== null) extras.modelMismatch = st.modelMismatch;
137
144
  if (st.snapshotBeforeStartedAt !== undefined) extras.snapshotBeforeStartedAt = st.snapshotBeforeStartedAt;
138
145
  if (st.snapshotBeforeAt !== undefined) extras.snapshotBeforeAt = st.snapshotBeforeAt;
139
146
  if (st.snapshotAfterStartedAt !== undefined) extras.snapshotAfterStartedAt = st.snapshotAfterStartedAt;
140
147
  if (st.snapshotAfterAt !== undefined) extras.snapshotAfterAt = st.snapshotAfterAt;
141
148
  if (st.planRevision !== undefined) extras.planRevision = st.planRevision;
149
+ if (st.planningStage !== undefined) extras.planningStage = st.planningStage;
142
150
  if (st.note !== undefined) extras.note = st.note;
143
151
  if (st.interrupted_at !== undefined) extras.interrupted_at = st.interrupted_at;
144
152
  if (st.interruption_reason !== undefined) extras.interruption_reason = st.interruption_reason;
@@ -147,10 +155,17 @@ function readRunExtras(dir) {
147
155
  try {
148
156
  const rs = JSON.parse(fs.readFileSync(path.join(dir, "result.json"), "utf8"));
149
157
  if (rs && typeof rs === "object") {
150
- if (Array.isArray(rs.review?.findings)) extras.findings = rs.review.findings.map(String);
151
- else if (Array.isArray(rs.findings)) extras.findings = rs.findings.map(String);
158
+ if (extras.planningStage == null && rs.planningStage !== undefined) extras.planningStage = rs.planningStage;
159
+ if (Array.isArray(rs.review?.findings) && rs.review.findings.length) extras.findings = rs.review.findings.map(String);
160
+ else if (Array.isArray(rs.findings) && rs.findings.length) extras.findings = rs.findings.map(String);
161
+ // Plan-review FAILs can carry their findings only on verdict.findings.
162
+ else if (Array.isArray(rs.verdict?.findings) && rs.verdict.findings.length) extras.findings = normalizeFindings(rs.verdict.findings);
163
+ if (Array.isArray(rs.review?.notes) && rs.review.notes.length) extras.notes = rs.review.notes.map(String);
164
+ else if (Array.isArray(rs.verdict?.notes) && rs.verdict.notes.length) extras.notes = rs.verdict.notes.map(String);
152
165
  if (extras.carriedForwardTasks == null && Array.isArray(rs.carriedForwardTasks)) extras.carriedForwardTasks = rs.carriedForwardTasks.slice();
153
166
  if (extras.resolvedModel == null && typeof rs.resolvedModel === "string") extras.resolvedModel = rs.resolvedModel;
167
+ if (extras.reasoning == null && typeof rs.reasoning === "string" && rs.reasoning) extras.reasoning = rs.reasoning;
168
+ if (extras.variant == null && typeof rs.variant === "string" && rs.variant) extras.variant = rs.variant;
154
169
  extras.policyViolation = (typeof rs.policyViolation === "string" && rs.policyViolation)
155
170
  || (typeof rs.policy?.violation === "string" && rs.policy.violation)
156
171
  || null;
@@ -170,16 +185,20 @@ function compactRun(run) {
170
185
  return {
171
186
  ...base,
172
187
  findings: Array.isArray(base.findings) ? base.findings.map(String) : extras.findings,
188
+ notes: Array.isArray(base.notes) ? base.notes.map(String) : extras.notes,
173
189
  carriedForwardTasks: base.carriedForwardTasks !== undefined ? base.carriedForwardTasks : extras.carriedForwardTasks,
174
190
  sessionNumber: base.sessionNumber !== undefined ? base.sessionNumber : extras.sessionNumber,
175
191
  resolvedModel: base.resolvedModel !== undefined ? base.resolvedModel : extras.resolvedModel,
176
192
  configuredModel: base.configuredModel !== undefined ? base.configuredModel : extras.configuredModel,
177
193
  modelMismatch: base.modelMismatch !== undefined ? base.modelMismatch : extras.modelMismatch,
194
+ reasoning: base.reasoning !== undefined ? base.reasoning : extras.reasoning,
195
+ variant: base.variant !== undefined ? base.variant : extras.variant,
178
196
  snapshotBeforeStartedAt: base.snapshotBeforeStartedAt !== undefined ? base.snapshotBeforeStartedAt : extras.snapshotBeforeStartedAt,
179
197
  snapshotBeforeAt: base.snapshotBeforeAt !== undefined ? base.snapshotBeforeAt : extras.snapshotBeforeAt,
180
198
  snapshotAfterStartedAt: base.snapshotAfterStartedAt !== undefined ? base.snapshotAfterStartedAt : extras.snapshotAfterStartedAt,
181
199
  snapshotAfterAt: base.snapshotAfterAt !== undefined ? base.snapshotAfterAt : extras.snapshotAfterAt,
182
200
  planRevision: base.planRevision !== undefined ? base.planRevision : extras.planRevision,
201
+ planningStage: base.planningStage !== undefined ? base.planningStage : extras.planningStage,
183
202
  note: base.note !== undefined ? base.note : extras.note,
184
203
  policyViolation: base.policyViolation !== undefined ? base.policyViolation : extras.policyViolation,
185
204
  failureClass: base.failureClass !== undefined ? base.failureClass : extras.failureClass,
@@ -192,16 +211,20 @@ function compactRun(run) {
192
211
  function normalizeRecord(record) {
193
212
  const r = compactRun(record);
194
213
  if (!Array.isArray(r.findings)) r.findings = [];
214
+ if (!Array.isArray(r.notes)) r.notes = [];
195
215
  if (r.carriedForwardTasks === undefined) r.carriedForwardTasks = null;
196
216
  if (r.sessionNumber === undefined) r.sessionNumber = null;
197
217
  if (r.resolvedModel === undefined) r.resolvedModel = null;
198
218
  if (r.configuredModel === undefined) r.configuredModel = null;
199
219
  if (r.modelMismatch === undefined) r.modelMismatch = null;
220
+ if (r.reasoning === undefined) r.reasoning = null;
221
+ if (r.variant === undefined) r.variant = null;
200
222
  if (r.snapshotBeforeStartedAt === undefined) r.snapshotBeforeStartedAt = null;
201
223
  if (r.snapshotBeforeAt === undefined) r.snapshotBeforeAt = null;
202
224
  if (r.snapshotAfterStartedAt === undefined) r.snapshotAfterStartedAt = null;
203
225
  if (r.snapshotAfterAt === undefined) r.snapshotAfterAt = null;
204
226
  if (r.planRevision === undefined) r.planRevision = null;
227
+ if (r.planningStage === undefined) r.planningStage = null;
205
228
  if (r.note === undefined) r.note = null;
206
229
  if (r.policyViolation === undefined) r.policyViolation = null;
207
230
  if (r.failureClass === undefined) r.failureClass = null;
@@ -1,27 +0,0 @@
1
- // Backlog #108: Claude Code runs read-only (--permission-mode plan) sessions on
2
- // Sonnet when the session model is Haiku. Pure helpers, warn-only.
3
- export const READ_ONLY_ROLES = ["plan_reviewer", "code_reviewer", "verifier"];
4
-
5
- export function isHaikuOnReadOnlyClaudeRole(role, adapter, model) {
6
- if (!READ_ONLY_ROLES.includes(role)) return false;
7
- if (adapter !== "claude") return false;
8
- const m = String(model || "").trim().toLowerCase();
9
- return m === "haiku" || /^claude-haiku-/.test(m) || /^anthropic\/claude-haiku-/.test(m);
10
- }
11
-
12
- export function planModeModelNote(role) {
13
- return `Note: Claude Code runs read-only (plan-mode) sessions on Sonnet when Haiku is selected — role ${role} will actually run as Sonnet and use Sonnet usage.`;
14
- }
15
-
16
- // One line per read-only Claude role (primary and each fallback) using Haiku.
17
- export function planModeModelWarnings(roles) {
18
- const out = [];
19
- for (const [name, r] of Object.entries(roles || {})) {
20
- if (!r) continue;
21
- if (isHaikuOnReadOnlyClaudeRole(name, r.adapter, r.model)) out.push(planModeModelNote(name));
22
- for (const fb of Array.isArray(r.fallbacks) ? r.fallbacks : []) {
23
- if (isHaikuOnReadOnlyClaudeRole(name, fb?.adapter, fb?.model)) out.push(planModeModelNote(name));
24
- }
25
- }
26
- return out;
27
- }