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
@@ -22,6 +22,7 @@
22
22
  export const RATE_LIMITED_FAILURE_CLASS = "RATE_LIMITED";
23
23
  export const IDLE_HANG_FAILURE_CLASS = "IDLE_HANG";
24
24
  export const PROVIDER_ERROR_FAILURE_CLASS = "PROVIDER_ERROR";
25
+ export const AUTH_REQUIRED_FAILURE_CLASS = "AUTH_REQUIRED";
25
26
 
26
27
  function isRecord(value) {
27
28
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -175,8 +176,13 @@ export function skipExhaustedRoleModel({role, roleConfig, activeModel = null, ex
175
176
  // fallback left: run the same run at once on `next` (fresh copy).
176
177
  // - `{action: "escalate", reason}` — RATE_LIMITED with no fallback left
177
178
  // (or no fallbacks configured): keep today's escalate-and-stop.
178
- // - `{action: "none", reason}` — any non-RATE_LIMITED class: no switch,
179
- // caller behavior unchanged.
179
+ // - `{action: "none", reason}` — any other class: no switch, caller
180
+ // behavior unchanged.
181
+ //
182
+ // AUTH_REQUIRED (the CLI is logged out) switches only to a fallback on a
183
+ // DIFFERENT adapter: another model on the same adapter shares the same dead
184
+ // credentials, so it is never retried. With no other-adapter fallback it
185
+ // escalates at once with the log-in instruction.
180
186
  //
181
187
  // IDLE_HANG with `noOutput: true` (the run produced 0 bytes for its whole
182
188
  // life) is treated like RATE_LIMITED for the switch (sticky, next untried
@@ -187,21 +193,24 @@ export function decideRoleFallback({role, roleConfig, activeModel = null, failur
187
193
  const name = typeof role === "string" && role ? role : "unknown";
188
194
  const silentHang = failureClass === IDLE_HANG_FAILURE_CLASS && noOutput === true;
189
195
  const providerError = failureClass === PROVIDER_ERROR_FAILURE_CLASS;
190
- if (failureClass !== RATE_LIMITED_FAILURE_CLASS && !silentHang && !providerError) {
196
+ const authRequired = failureClass === AUTH_REQUIRED_FAILURE_CLASS;
197
+ if (failureClass !== RATE_LIMITED_FAILURE_CLASS && !silentHang && !providerError && !authRequired) {
191
198
  return {
192
199
  action: "none",
193
200
  reason: `${name}: failure class ${String(failureClass)} is not RATE_LIMITED; no fallback switch`,
194
201
  };
195
202
  }
196
203
  const fallbacks = fallbackListOf(roleConfig);
197
- const label = silentHang ? "IDLE_HANG (no output)" : providerError ? "PROVIDER_ERROR" : "RATE_LIMITED";
204
+ const label = silentHang ? "IDLE_HANG (no output)" : authRequired ? "AUTH_REQUIRED" : providerError ? "PROVIDER_ERROR" : "RATE_LIMITED";
198
205
  if (fallbacks.length === 0) {
199
206
  if (silentHang) {
200
207
  return {action: "none", reason: `${name}: ${label} with no fallbacks configured; normal retry`};
201
208
  }
202
209
  return {
203
210
  action: "escalate",
204
- reason: `${name}: ${label} with no fallbacks configured; human intervention required`,
211
+ reason: authRequired
212
+ ? `${name}: ${label} with no fallback on another adapter; log in required`
213
+ : `${name}: ${label} with no fallbacks configured; human intervention required`,
205
214
  };
206
215
  }
207
216
  // Sticky order: the current position is the active entry when it matches
@@ -218,17 +227,23 @@ export function decideRoleFallback({role, roleConfig, activeModel = null, failur
218
227
  currentIndex = found === -1 ? 0 : found + 1;
219
228
  }
220
229
  }
221
- const next = fallbacks.slice(currentIndex).find((entry) => !isExhausted(exhausted, entry, policy, nowMs)) ?? null;
230
+ const from = currentIndex === 0 ? primary : fallbacks[currentIndex - 1];
231
+ // AUTH_REQUIRED is scoped to the CLI's own credentials, so a fallback model
232
+ // on the SAME adapter cannot help — only a different adapter (another CLI)
233
+ // can. Never retry the logged-out CLI, even through a configured sibling.
234
+ const next = fallbacks.slice(currentIndex).find((entry) =>
235
+ (!authRequired || entry.adapter !== from?.adapter) && !isExhausted(exhausted, entry, policy, nowMs)) ?? null;
222
236
  if (!next) {
223
237
  if (silentHang) {
224
238
  return {action: "none", reason: `${name}: ${label} with all ${fallbacks.length} fallback(s) exhausted; normal retry`};
225
239
  }
226
240
  return {
227
241
  action: "escalate",
228
- reason: `${name}: ${label} with all ${fallbacks.length} fallback(s) exhausted; human intervention required`,
242
+ reason: authRequired
243
+ ? `${name}: ${label} with no untried fallback on another adapter; log in required`
244
+ : `${name}: ${label} with all ${fallbacks.length} fallback(s) exhausted; human intervention required`,
229
245
  };
230
246
  }
231
- const from = currentIndex === 0 ? primary : fallbacks[currentIndex - 1];
232
247
  return {
233
248
  action: "switch",
234
249
  next: copyModelEntry(next),
@@ -8,6 +8,7 @@ import { defaultIsPidAlive } from "./run-recovery.mjs";
8
8
  import { growingTerm } from "./per-task-budget.mjs";
9
9
  import { readOwnerPid, ownerPidAlive } from "./owner-pid.mjs";
10
10
  import { buildReportModel, renderReport } from "./watch-report.mjs";
11
+ import { detectUiProject } from "./e2e-detect.mjs";
11
12
  import { phases as standalonePhases } from "../adapters/standalone.mjs";
12
13
 
13
14
  // true = alive, false = provably gone, null = could not tell (no/invalid pid, or
@@ -196,6 +197,9 @@ export function readRunMeta(dir, id) {
196
197
  // only and may be stale or null (see observabilityMeta in
197
198
  // autopilot.mjs). result.json first (#110), status.json fallback for old runs.
198
199
  taskRef: result?.taskRef ?? status?.taskRef ?? null,
200
+ // The SpecKit/GSD planning stage this run worked on, recorded at launch.
201
+ // Null for non-planning runs and for standalone planning (`plan/tasks`).
202
+ planningStage: status?.planningStage ?? result?.planningStage ?? null,
199
203
  attempt: status?.attempt ?? result?.attempt ?? null,
200
204
  // Backlog #26 Step B: wrap-up marker (status.json only). Renders as a
201
205
  // NOTE suffix on the run line; runs without it read null and render
@@ -537,6 +541,30 @@ function sameResetEvent(a, b) {
537
541
  // same displayStatusLabel the old layout used (a run left "running" by a dead
538
542
  // orchestrator reads as the stale label, never RUNNING), and a cancelled
539
543
  // mission has no current run. All optional inputs are injectable for tests.
544
+ // Final-report note: shown only when tests.e2e is none/unset and a UI was detected.
545
+ export const NO_E2E_REPORT_NOTE = "no E2E — UI checked by component tests only";
546
+ export function noE2eReportNote(repo, layout) {
547
+ let raw = layout;
548
+ if (raw === undefined) {
549
+ try { raw = JSON.parse(fs.readFileSync(path.join(repo, ".autopilot", "config.json"), "utf8")); } catch { raw = null; }
550
+ }
551
+ const e2e = raw?.tests?.e2e;
552
+ if (e2e && e2e !== "none") return null;
553
+ try { return detectUiProject(repo) ? NO_E2E_REPORT_NOTE : null; } catch { return null; }
554
+ }
555
+
556
+ // Append the no-E2E note to a mission REPORT.md once (idempotent); returns true when written.
557
+ export function appendNoE2eNoteToReport(repo, file) {
558
+ try {
559
+ const note = noE2eReportNote(repo);
560
+ if (!note || !fs.existsSync(file)) return false;
561
+ const cur = fs.readFileSync(file, "utf8");
562
+ if (cur.includes(note)) return false;
563
+ fs.writeFileSync(file, `${cur.replace(/\n+$/, "")}\n\nNote: ${note}\n`);
564
+ return true;
565
+ } catch { return false; }
566
+ }
567
+
540
568
  export function renderWatchReport(repo, mission, { tail = 40, isOwnerAlive, isAlive, phases, policy, events, now, skill, gateLog } = {}) {
541
569
  const d = new Date();
542
570
  const p = (n) => String(n).padStart(2, "0");
@@ -623,10 +651,13 @@ export function renderWatchReport(repo, mission, { tail = 40, isOwnerAlive, isAl
623
651
  gateLog: gateLogText,
624
652
  now: now ?? d.toISOString(),
625
653
  skill: skill ?? null,
654
+ command: state?.command ?? null,
626
655
  missionFallback: mission ?? null,
627
656
  ownerAlive,
628
657
  });
629
- return renderReport(model, { stamp });
658
+ const e2eNote = noE2eReportNote(repo, configResult?.layout ?? null);
659
+ const out = renderReport(model, { stamp });
660
+ return e2eNote ? `${out.replace(/\n+$/, "")}\nNote: ${e2eNote}\n` : out;
630
661
  }
631
662
 
632
663
  // Direct-write the watch report file (same principle as progress.log: the
@@ -169,6 +169,10 @@ export function initRun(dir, meta = {}) {
169
169
  // This is the field to read; `taskGroup` above is grouping-only (see
170
170
  // observabilityMeta in autopilot.mjs) and may be stale or null.
171
171
  taskRef: meta.taskRef ?? null,
172
+ // SpecKit/GSD planning stage this run worked on (`spec`/`plan`/`tasks` or
173
+ // `discuss`/`research`/`plan`/`plan-check`). Null for every other run and
174
+ // for standalone planning, whose report label stays `plan/tasks`.
175
+ planningStage: meta.planningStage ?? null,
172
176
  attempt: meta.attempt ?? null,
173
177
  // Backlog #26 Step B: wrap-up marker (`note: "wrap-up"` on the forced
174
178
  // hand-off run). Shown in the watch report so the extra run after a cap
@@ -182,6 +186,11 @@ export function initRun(dir, meta = {}) {
182
186
  configuredModel: meta.configuredModel ?? null,
183
187
  resolvedModel: meta.resolvedModel ?? null,
184
188
  modelMismatch: meta.modelMismatch ?? null,
189
+ // Backlog #7: reasoning level / opencode variant of the model actually
190
+ // launched (the active fallback/escalation entry's own values, never the
191
+ // primary's). Null when the CLI default applies. Additive.
192
+ reasoning: meta.reasoning ?? null,
193
+ variant: meta.variant ?? null,
185
194
  // Mission 093 Part 1 task 2.3: run-scoped layout fields. snapshotBeforeAt
186
195
  // and snapshotAfterAt are the end wall-clock times of the relay's safety
187
196
  // snapshots; the matching *StartedAt fields let the report print the
@@ -207,10 +216,20 @@ export function initRun(dir, meta = {}) {
207
216
  // list; a non-list or empty list degrades to null here. Additive — every
208
217
  // reader above is untouched.
209
218
  carriedForwardTasks: (Array.isArray(meta.carriedForwardTasks) && meta.carriedForwardTasks.length > 0 ? meta.carriedForwardTasks : null),
210
- last_action: "started",
219
+ // The pre-run seed call defers the START line until the child process
220
+ // actually exists (onStart re-inits with the real pid). Until then the
221
+ // run is visibly PREPARING, never a fake START with PID=-.
222
+ last_action: meta.last_action ?? (meta.deferStart === true ? "preparing" : "started"),
211
223
  };
212
224
  fs.writeFileSync(path.join(dir, "status.json"), JSON.stringify(status, null, 2));
213
- appendProgress(dir, `START | ROLE=${meta.role ?? "-"} | PHASE=${meta.phase ?? "-"} | TASK=${meta.taskRef ?? "-"} | ATTEMPT=${meta.attempt ?? "-"} | PID=${meta.pid ?? "-"}`);
225
+ if (meta.deferStart !== true) {
226
+ appendProgress(dir, `START | ROLE=${meta.role ?? "-"} | PHASE=${meta.phase ?? "-"} | TASK=${meta.taskRef ?? "-"} | ATTEMPT=${meta.attempt ?? "-"} | PID=${meta.pid ?? "-"}`);
227
+ } else {
228
+ // Seed an empty progress.log so readers can tail it while the pre-run
229
+ // steps write their PREPARE lines; no fake START with PID=-.
230
+ const progressFile = path.join(dir, "progress.log");
231
+ if (!fs.existsSync(progressFile)) fs.writeFileSync(progressFile, "");
232
+ }
214
233
  // Mission 094 Phase 3 task 3.2: keep the compact watch-summary cache current
215
234
  // at the run-write points so a later render never re-reads every run dir.
216
235
  // Best-effort (a failed cache write must never break a run); a dir that is
@@ -219,6 +238,31 @@ export function initRun(dir, meta = {}) {
219
238
  return status;
220
239
  }
221
240
 
241
+ // Mark a run as PREPARING (before its CLI exists) so status.json, and therefore
242
+ // the watch report, names the setup step that is running instead of showing a
243
+ // running run with pid null. Best-effort like every observability write.
244
+ export function setRunPreparing(dir, action) {
245
+ const name = String(action || "").trim();
246
+ if (!name) return false;
247
+ updateStatus(dir, { updated_at: localIso(), last_action: `preparing: ${name}` });
248
+ try { updateSummaryForRunDir(dir); } catch { /* ignore */ }
249
+ return true;
250
+ }
251
+
252
+ // Append one `PREPARE | <action>[ (<detail>)] | DURATION=<h:mm:ss>` line for a
253
+ // completed pre-run step (git guard, snapshot-before, …). The START line stays
254
+ // the only line that names the real PID, and keeps its existing format.
255
+ export function prepareRun(dir, { action, detail = null, durationMs = null } = {}) {
256
+ const name = String(action || "").trim();
257
+ if (!name) return false;
258
+ const suffix = detail ? ` (${detail})` : "";
259
+ const duration = Number.isFinite(Number(durationMs)) && Number(durationMs) >= 0 ? ` | DURATION=${formatDuration(Number(durationMs))}` : "";
260
+ appendProgress(dir, `PREPARE | ${name}${suffix}${duration}`);
261
+ updateStatus(dir, { updated_at: localIso(), last_action: `preparing: ${name}` });
262
+ try { updateSummaryForRunDir(dir); } catch { /* ignore */ }
263
+ return true;
264
+ }
265
+
222
266
  export function observe(dir, { stream = "stdout", bytes = 0, minIntervalMs = 1000, last } = {}) {
223
267
  // Backlog #6a: a chunk carrying a real tool action writes its OWN progress
224
268
  // line (tool + command/file, extracted before the ACTIVITY snippet is
@@ -1,11 +1,12 @@
1
1
  import { discoverAgentContract } from './agent-contract.mjs';
2
2
  import { discoverCapabilities } from './model-discovery.mjs';
3
3
 
4
- export async function buildDiscoveryCaches(availableIds, agentsMap, discoverFn = discoverAgentContract) {
4
+ export async function buildDiscoveryCaches(availableIds, agentsMap, discoverFn = discoverAgentContract, {onStart = null} = {}) {
5
5
  const capabilityCache = new Map();
6
6
  const contractCache = new Map();
7
7
  for (const id of availableIds) {
8
8
  try {
9
+ if (typeof onStart === "function") onStart(id, agentsMap[id]);
9
10
  const contract = await discoverFn(id, agentsMap[id]);
10
11
  contractCache.set(id, contract);
11
12
  capabilityCache.set(id, contract?.capabilities || await discoverCapabilities(agentsMap[id]));