@kendoo.agentdesk/agentdesk 0.32.0 → 0.33.0

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.
@@ -11,6 +11,33 @@ import { VERDICT_SCHEMA } from "./verdict.mjs";
11
11
 
12
12
  const strList = { type: "array", items: { type: "string" } };
13
13
 
14
+ // What SUMMARY/SOLO hand back for the project lessons ledger. The engine
15
+ // records these with provenance; agents never write the ledger themselves.
16
+ const lessonList = {
17
+ type: "array",
18
+ description: "non-obvious things that cost this session time and would cost the next one too; empty when nothing qualifies",
19
+ items: {
20
+ type: "object",
21
+ additionalProperties: false,
22
+ required: ["text", "scope", "evidence"],
23
+ properties: {
24
+ text: { type: "string", description: "one actionable sentence" },
25
+ scope: { type: "string", description: "project | area:<ui|copy|docs|api|data> | path:<file or directory prefix>" },
26
+ evidence: { type: "string", description: "what happened that proves it (command and outcome)" },
27
+ },
28
+ },
29
+ };
30
+ const retireList = {
31
+ type: "array",
32
+ description: "ids from PROJECT LESSONS that proved wrong or obsolete in this session; empty when none did",
33
+ items: {
34
+ type: "object",
35
+ additionalProperties: false,
36
+ required: ["id", "reason"],
37
+ properties: { id: { type: "string" }, reason: { type: "string" } },
38
+ },
39
+ };
40
+
14
41
  export const PHASE_OUTPUT_SCHEMAS = Object.freeze({
15
42
  INTAKE: {
16
43
  type: "object",
@@ -44,8 +71,8 @@ export const PHASE_OUTPUT_SCHEMAS = Object.freeze({
44
71
  filesToModify: strList,
45
72
  decisions: strList,
46
73
  risks: strList,
47
- assignments: { ...strList, description: "who does what during execution" },
48
- steps: { ...strList, description: "ordered implementation steps" },
74
+ assignments: { ...strList, description: "each assignment names one owner, a bounded deliverable, dependencies, and acceptance evidence" },
75
+ steps: { ...strList, description: "ordered implementation steps with owners; implementation and the subsequent audit both happen in EXECUTION, with audit approval required before publishing" },
49
76
  },
50
77
  },
51
78
  EXECUTION: {
@@ -65,25 +92,29 @@ export const PHASE_OUTPUT_SCHEMAS = Object.freeze({
65
92
  SOLO: {
66
93
  type: "object",
67
94
  additionalProperties: false,
68
- required: ["summary", "filesChanged", "prUrl", "deferred", "manualSteps"],
95
+ required: ["summary", "filesChanged", "prUrl", "deferred", "manualSteps", "lessons", "retireLessons"],
69
96
  properties: {
70
97
  summary: { type: "string" },
71
98
  filesChanged: strList,
72
99
  prUrl: { type: "string", description: "empty string if no PR was created" },
73
100
  deferred: strList,
74
101
  manualSteps: strList,
102
+ lessons: lessonList,
103
+ retireLessons: retireList,
75
104
  },
76
105
  },
77
106
  SUMMARY: {
78
107
  type: "object",
79
108
  additionalProperties: false,
80
- required: ["status", "prUrl", "deferred", "manualSteps", "summaryComment"],
109
+ required: ["status", "prUrl", "deferred", "manualSteps", "summaryComment", "lessons", "retireLessons"],
81
110
  properties: {
82
- status: { type: "string" },
111
+ status: { type: "string", description: "accurate delivery outcome, including any unresolved review or tracker-write blocker" },
83
112
  prUrl: { type: "string" },
84
- deferred: strList,
85
- manualSteps: strList,
86
- summaryComment: { type: "string", description: "the final tracker comment as posted" },
113
+ deferred: { ...strList, description: "only explicitly out-of-scope or user-approved deferrals; required work blocked on access belongs in status and manualSteps" },
114
+ manualSteps: { ...strList, description: "each remaining manual action with its named owner and completion evidence; include access recovery and the subsequent delivery retry when a tracker write failed; empty only when no manual action remains" },
115
+ summaryComment: { type: "string", description: "the final tracker comment exactly as confirmed posted; empty string if posting failed or was not confirmed" },
116
+ lessons: lessonList,
117
+ retireLessons: retireList,
87
118
  },
88
119
  },
89
120
  });
@@ -36,11 +36,15 @@ import { spawnSandboxedCommand } from "./spawn.mjs";
36
36
  import { createLedger, openItemsFrom, noCommitItem } from "./handoff.mjs";
37
37
  import { teamProfileFor, phasesFor } from "./team-profile.mjs";
38
38
  import { buildQueryOptions, defaultRunQuery } from "./query.mjs";
39
- import { armPublishGate, onSubagentStopped } from "./hooks.mjs";
39
+ import { armPublishGate, recordAudit, responseText, AUDITOR } from "./hooks.mjs";
40
40
  import { prepareWorkspace } from "../worktrees.mjs";
41
41
  import { detectProject } from "../detect.mjs";
42
42
  import { preparePrivateGit } from "../worktree-git.mjs";
43
43
  import { runCancellable } from "./cancellation.mjs";
44
+ import { checkpointStore, validHandoff, journalExternalActions, repairQueryOptions, trackerAccessDenied, recoveryBrief } from "./recovery.mjs";
45
+ import { classifyFailure } from "../../shared/recovery.mjs";
46
+ import { loadProjectMemory } from "../prompt.mjs";
47
+ import { lessonsPath, readLessons, recordLessons, selectLessons } from "./lessons.mjs";
44
48
 
45
49
  const here = dirname(fileURLToPath(import.meta.url));
46
50
  const CLI_VERSION = JSON.parse(readFileSync(join(here, "../../package.json"), "utf-8")).version;
@@ -144,14 +148,33 @@ async function executeSession({
144
148
  sourceCwd = cwd, workspaceRecord, workspaceStateDir, resumingWorkspace = false,
145
149
  workspaceGitEnv = {},
146
150
  registerCleanup,
151
+ resumeSession = false, instructions = [],
147
152
  }) {
148
153
  const startedAt = Date.now();
149
154
  const emit = event => {
150
- if (event.type === "session:update" && event.taskId) taskId = event.taskId;
155
+ if (event.type === "session:update" && (event.taskId || event.taskLink)) {
156
+ taskId = event.taskId || taskId;
157
+ taskLink = event.taskLink || taskLink;
158
+ recovery.save({ taskId, taskLink });
159
+ }
151
160
  onEvent?.({ ...event, timestamp: timestamp() });
152
161
  };
153
- const outcomeIds = new Set();
154
162
  const outcomeRepo = githubRepository(sourceCwd || cwd);
163
+ const recovery = checkpointStore(workspaceStateDir || join(cwd, ".agentdesk"), sessionId, { resume: resumeSession });
164
+ const prior = recovery.data;
165
+ const outcomeIds = new Set((prior.outcomes || []).map(o => o.id));
166
+ if (resumeSession) {
167
+ taskId = prior.taskId || taskId;
168
+ description = prior.description ?? description;
169
+ taskLink = prior.taskLink || taskLink;
170
+ createTask = false;
171
+ }
172
+ const knownInstructions = new Map((prior.instructions || []).map(i => [i.id, i]));
173
+ let changedInstructions = false;
174
+ for (const i of instructions) if (!knownInstructions.has(i.id)) { knownInstructions.set(i.id, i); changedInstructions = true; }
175
+ recovery.save({ taskId, taskLink, description, instructions: [...knownInstructions.values()],
176
+ replanRequired: prior.replanRequired || changedInstructions });
177
+ const block = (detail, code, phase) => emit({ type: "session:recovery", recovery: { ...classifyFailure(detail, code), phase, ready: false } });
155
178
 
156
179
  // Solo mode: one agent, one phase, full tools, no lead and no review gate.
157
180
  const solo = soloAgent ? team.find(a => a.name === soloAgent) : null;
@@ -177,6 +200,7 @@ async function executeSession({
177
200
  const creds = { ...trackerCreds, GITHUB_TOKEN: resolvedToken || trackerCreds.GITHUB_TOKEN };
178
201
 
179
202
  const failStart = (code, message) => {
203
+ block(message, code, prior.phase);
180
204
  emit({ type: "session:error", code, message });
181
205
  emit({ type: "session:end", duration: seconds(startedAt), steps: 0, inputTokens: 0, outputTokens: 0, status: "error" });
182
206
  return { duration: "0s", steps: 0, inputTokens: 0, outputTokens: 0, handoff: false, error: message, status: "error" };
@@ -196,6 +220,7 @@ async function executeSession({
196
220
  const auth = await authCheck({ env: buildChildEnv({ dotenv }) });
197
221
  abortSignal?.throwIfAborted();
198
222
  if (!auth.ok) return failStart("CLAUDE_NOT_LOGGED_IN", `${auth.detail}.\n${auth.hint || ""}`.trim());
223
+ emit({ type: "session:recovery", recovery: { state: "running", kind: "resume", message: "Access checked; continuing the saved task.", ready: false } });
199
224
 
200
225
  const sandbox = createScratchHome({
201
226
  projectId: project?.name,
@@ -219,7 +244,7 @@ async function executeSession({
219
244
  const findingsPath = join(stateDir, "review-findings.json");
220
245
  const ledgerPath = join(stateDir, "handoffs.jsonl");
221
246
  try { mkdirSync(stateDir, { recursive: true }); } catch {}
222
- if (!resumingWorkspace || !existsSync(memoryPath)) {
247
+ if (!(resumingWorkspace || resumeSession) || !existsSync(memoryPath)) {
223
248
  if (archiveStaleMemory(memoryPath)) console.error("[agentdesk] archived stale session-memory.md from a previous run");
224
249
  try { if (existsSync(findingsPath)) unlinkSync(findingsPath); } catch {}
225
250
  try { if (existsSync(ledgerPath)) unlinkSync(ledgerPath); } catch {}
@@ -230,7 +255,14 @@ async function executeSession({
230
255
  const memoryText = () => { try { return readFileSync(memoryPath, "utf-8").trim(); } catch { return ""; } };
231
256
  const appendMemory = text => { try { appendFileSync(memoryPath, `\n${text}`); } catch {} };
232
257
 
233
- const totals = { steps: 0, inputTokens: 0, outputTokens: 0, costUsd: 0 };
258
+ // --- project lessons (engine-owned, shared by every worktree) ------------
259
+ // Read from and recorded in the source project, never the session worktree.
260
+ const lessonsFile = lessonsPath(sourceCwd || cwd);
261
+ const projectNotes = loadProjectMemory(sourceCwd || cwd);
262
+ const lessonsForPrompt = () => selectLessons({ lessons: readLessons(lessonsFile).lessons, touches: profile.touches });
263
+ let lessonHandoff = null; // { phase, agent, lessons, retireLessons } from SUMMARY/SOLO
264
+
265
+ const totals = { steps: 0, inputTokens: 0, outputTokens: 0, costUsd: 0, ...(resumeSession ? prior.totals : {}) };
234
266
  const state = { openFindings: [], mainThreadMayCode: !!solo };
235
267
  let handoff = false, aborted = false, reviewResolved = !!solo; // solo has no review gate
236
268
  let reviewRetries = 0, phaseRuns = 0, lastVerdict = null, lastPhase = null;
@@ -249,7 +281,11 @@ async function executeSession({
249
281
  extraWritePaths: workspaceStateDir ? [workspaceStateDir, workspaceRecord.tree || cwd] : [],
250
282
  }),
251
283
  });
252
- const headNow = () => gitState(cwd, workspaceGitEnv).revision;
284
+ const treeNow = () => gitState(cwd, workspaceGitEnv);
285
+ const headNow = () => treeNow().revision;
286
+ const describeTree = t => `${shortRev(t.revision)}${t.clean === false ? " + uncommitted changes" : ""}`;
287
+ state.treeNow = treeNow;
288
+ state.requireClean = requireClean;
253
289
  let approvedRevision = null;
254
290
  const verify = () => runChecks({ checks, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }),
255
291
  gitEnv: workspaceGitEnv, runCheck: runOneCheck, timeoutMs: checkTimeoutMs(config), signal: abortController.signal, requireClean });
@@ -258,27 +294,36 @@ async function executeSession({
258
294
 
259
295
  // Publishing (git push / gh pr create) is verified on the spot: the engine
260
296
  // runs the checks at the revision being published and refuses with the
261
- // failing output when they do not pass. A pass is cached per revision, so
262
- // REVIEW reuses it at the same HEAD instead of running everything twice; a
263
- // failure is never cached the next attempt gets a fresh run.
264
- let verified = null; // { revision, evidence, verdict } from the latest publish verification
297
+ // failing output when they do not pass. A pass is evidence for the exact
298
+ // tree it ran on same commit and a clean tree, then and now — and only
299
+ // that is reused (by a later publish attempt and by the REVIEW that
300
+ // follows). Uncommitted edits leave HEAD unchanged, so HEAD alone would let
301
+ // stale evidence outlive the code it checked. A failure is never cached.
302
+ let verified = null; // { revision, clean, evidence, verdict } from the latest verification
303
+ const reusableFor = tree => !!(verified && verified.evidence.passed && verified.clean === true
304
+ && tree.revision && tree.revision === verified.revision && tree.clean === true);
265
305
  const verifyPublish = async () => {
266
- const head = headNow();
267
- if (verified && head && verified.revision === head && verified.evidence.passed) return verified.verdict;
306
+ if (reusableFor(treeNow())) return verified.verdict;
268
307
  const evidence = await verify();
269
308
  if (abortController.signal.aborted) return { ok: false, reason: "Session is being cancelled." };
309
+ // The tree is read again after the checks: evidence for a tree that no
310
+ // longer exists is not evidence.
311
+ const after = treeNow();
312
+ const moved = after.revision !== evidence.revision || after.clean !== evidence.clean;
270
313
  let verdict;
271
- if (!evidence.checked && evidence.clean !== false) verdict = { ok: true };
314
+ if (moved) {
315
+ verdict = { ok: false, reason: `Cannot publish: the working tree changed while verification ran (${describeTree(evidence)} → ${describeTree(after)}). Commit, then publish again — the engine re-runs the checks.` };
316
+ } else if (!evidence.checked && evidence.clean !== false) verdict = { ok: true };
272
317
  else {
273
318
  reportEvidence(evidence, "EXECUTION");
274
319
  appendMemory(renderEvidenceSection(evidence));
275
320
  if (evidence.passed) verdict = { ok: true };
276
321
  else {
277
322
  const lines = evidenceFindings(evidence).map(f => `- ${f.title}\n ${String(f.detail || "").split("\n").slice(-8).join("\n ")}`);
278
- verdict = { ok: false, reason: `Cannot publish: verification did not pass at ${shortRev(head)}.\n${lines.join("\n")}\nFix it, commit, and publish again — the engine re-runs the checks for the new revision.` };
323
+ verdict = { ok: false, reason: `Cannot publish: verification did not pass at ${shortRev(evidence.revision)}.\n${lines.join("\n")}\nFix it, commit, and publish again — the engine re-runs the checks for the new revision.` };
279
324
  }
280
325
  }
281
- verified = { revision: evidence.revision, evidence, verdict };
326
+ verified = moved ? null : { revision: evidence.revision, clean: evidence.clean, evidence, verdict };
282
327
  return verdict;
283
328
  };
284
329
  state.verifyPublish = verifyPublish;
@@ -286,7 +331,7 @@ async function executeSession({
286
331
  // --- handoff ledger (engine-owned) ---------------------------------------
287
332
  // One entry per phase run; the open-items list travels into the next prompt.
288
333
  const ledger = createLedger(ledgerPath);
289
- let openItems = [];
334
+ let openItems = resumeSession ? (prior.openItems || []) : [];
290
335
 
291
336
  const reportEvidence = (evidence, evidencePhase = "REVIEW") => {
292
337
  emit({ type: "session:evidence", phase: evidencePhase, revision: evidence.revision, clean: evidence.clean,
@@ -332,37 +377,60 @@ async function executeSession({
332
377
  }
333
378
  }
334
379
 
335
- // An approval is void the moment the code moves past the reviewed revision.
336
- const invalidateApproval = now => {
380
+ // An approval is void the moment the code moves past the reviewed revision
381
+ // a new commit, or (in a session worktree) uncommitted changes on top.
382
+ const drifted = tree => tree.revision !== approvedRevision || (requireClean && tree.clean === false);
383
+ const invalidateApproval = tree => {
337
384
  reviewResolved = false;
338
- emit({ type: "session:error", code: "REVIEW_STALE", message: `Code changed after approval (${shortRev(approvedRevision)} → ${shortRev(now)}) — the approval no longer applies; ending for human review.` });
339
- openItems = [...openItems, { source: "engine", title: "Approval invalidated", detail: `Code changed after approval (${shortRev(approvedRevision)} → ${shortRev(now)}).` }];
385
+ const what = tree.revision !== approvedRevision
386
+ ? `Code changed after approval (${shortRev(approvedRevision)} → ${shortRev(tree.revision)})`
387
+ : `Uncommitted changes appeared after approval of ${shortRev(approvedRevision)}`;
388
+ emit({ type: "session:error", code: "REVIEW_STALE", message: `${what} — the approval no longer applies; ending for human review.` });
389
+ openItems = [...openItems, { source: "engine", title: "Approval invalidated", detail: `${what}.` }];
340
390
  if (lastVerdict) {
341
- lastVerdict = { ...lastVerdict, approved: false, approvalReason: "code changed after approval", headNow: now };
391
+ lastVerdict = { ...lastVerdict, approved: false, approvalReason: tree.revision !== approvedRevision ? "code changed after approval" : "uncommitted changes after approval", headNow: tree.revision };
342
392
  try { writeFileSync(findingsPath, JSON.stringify(lastVerdict, null, 2)); } catch {}
343
393
  }
344
394
  };
345
395
 
346
396
  // --- team profile (INTAKE assesses the task; config may force) -----------
347
397
  let profile = teamProfileFor({ intake: null, config });
348
- const queue = solo ? ["SOLO"] : phasesFor(profile);
398
+ if (resumeSession && prior.profile) profile = prior.profile;
399
+ const queue = resumeSession && Array.isArray(prior.queue) ? [...prior.queue] : solo ? ["SOLO"] : phasesFor(profile);
400
+ if (resumeSession && !queue.length) queue.push(...(solo ? ["SOLO"] : ["EXECUTION", "REVIEW", "SUMMARY"]));
401
+ if (resumeSession && prior.lastVerdict) lastVerdict = prior.lastVerdict;
402
+ if (resumeSession) phaseRuns = prior.phaseRuns || 0;
403
+ // User corrections invalidate the old plan and review. Preserve intake;
404
+ // re-plan against the existing implementation instead of starting a new task.
405
+ if (recovery.data.replanRequired && resumeSession && !solo && queue[0] !== "INTAKE") queue.splice(0, queue.length, "PLAN", "EXECUTION", "REVIEW", "SUMMARY");
406
+ if (resumeSession && queue[0] === "SUMMARY") queue.unshift("REVIEW");
407
+ let invocationRuns = 0;
408
+ const checkpoint = pending => recovery.save({ queue: [...pending], phase: pending[0] || null,
409
+ totals, profile, lastVerdict, openItems, phaseRuns });
410
+ checkpoint(queue);
411
+ recovery.save({ replanRequired: false });
349
412
 
350
413
  try {
351
414
  while (queue.length > 0) {
352
415
  if (abortController.signal.aborted) { aborted = true; break; }
353
- if (++phaseRuns > MAX_PHASE_RUNS) {
416
+ phaseRuns++;
417
+ if (++invocationRuns > MAX_PHASE_RUNS) {
354
418
  emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Phase ceiling reached (${MAX_PHASE_RUNS} runs) — ending session for review.` });
355
419
  break;
356
420
  }
357
421
 
358
422
  const phase = queue.shift();
359
423
  lastPhase = phase;
424
+ checkpoint([phase, ...queue]);
425
+ if (!(resumeSession && prior.phase === phase && invocationRuns === 1)) recovery.save({ transcript: [] });
360
426
  const run = { index: phaseRuns, startedAt: Date.now(), revisionBefore: headNow() };
361
427
  const finishRun = ({ output = null, evidence: runEvidence = null, status }) => {
362
428
  const entry = { phase, run: run.index, startedAt: run.startedAt, durationMs: Date.now() - run.startedAt,
363
- revisionBefore: run.revisionBefore, revisionAfter: headNow(), output, evidence: runEvidence, openItems: [...openItems], status };
429
+ revisionBefore: run.revisionBefore, revisionAfter: headNow(), output, evidence: runEvidence, openItems: [...openItems], status,
430
+ ...(run.audit ? { audit: run.audit } : {}) };
364
431
  ledger.record(entry);
365
432
  emit({ type: "session:handoff", ...entry });
433
+ checkpoint(status === "ok" || status === "not-approved" || status === "checks-failed" ? queue : [phase, ...queue]);
366
434
  };
367
435
  // The dashboard knows the five team phases; solo shows as EXECUTION.
368
436
  const model = modelForPhase(phase === "SOLO" ? "EXECUTION" : phase, config.phaseModels);
@@ -373,12 +441,12 @@ async function executeSession({
373
441
  // to EXECUTION as findings, through the same retry path.
374
442
  let evidence = null;
375
443
  if (phase === "REVIEW") {
376
- const head = headNow();
377
- const reusable = verified && head && verified.revision === head && verified.evidence.passed && verified.evidence.checked;
444
+ const tree = treeNow();
445
+ const reusable = reusableFor(tree) && verified.evidence.checked;
378
446
  evidence = reusable ? verified.evidence : await verify();
379
447
  if (abortController.signal.aborted) { aborted = true; break; }
380
448
  if (reusable) {
381
- emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Verification at ${shortRev(head)} already passed when the work was published — reusing it for review.` });
449
+ emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Verification at ${shortRev(tree.revision)} already passed when the work was published (same commit, clean tree) — reusing it for review.` });
382
450
  emit({ type: "session:evidence", phase: "REVIEW", revision: evidence.revision, clean: evidence.clean, passed: evidence.passed, checked: evidence.checked, results: evidence.results });
383
451
  } else {
384
452
  reportEvidence(evidence);
@@ -392,19 +460,19 @@ async function executeSession({
392
460
  }
393
461
  }
394
462
  if (phase === "SUMMARY" && reviewResolved && approvedRevision) {
395
- const now = headNow();
396
- if (now !== approvedRevision) invalidateApproval(now);
463
+ const now = treeNow();
464
+ if (drifted(now)) invalidateApproval(now);
397
465
  }
398
466
 
399
467
  const { agents, allowedTools, lead } = solo
400
468
  ? soloDefinition(solo)
401
469
  : agentsForPhase({ phase, team, phaseModels: config.phaseModels, profile });
402
470
  let prompt = solo
403
- ? renderSoloPrompt({ agent: solo, taskId, taskLink, description, tracker, config, project, sessionUrl, cwd, childStrategy })
471
+ ? renderSoloPrompt({ agent: solo, taskId, taskLink, description, tracker, config, project, sessionUrl, childStrategy, lessons: lessonsForPrompt(), projectNotes })
404
472
  : renderPhasePrompt({
405
473
  phase, taskId, taskLink, description,
406
474
  createTask: phase === "INTAKE" ? createTask : false,
407
- tracker, config, project, sessionUrl, cwd,
475
+ tracker, config, project, sessionUrl,
408
476
  sessionMemory: memoryText(),
409
477
  retryVerdict: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? lastVerdict : null,
410
478
  evidence,
@@ -413,17 +481,42 @@ async function executeSession({
413
481
  roster: Object.keys(agents).filter(name => name !== lead),
414
482
  profile,
415
483
  handoffRetry: (handoffRetries.get(phase) || 0) > 0,
484
+ lessons: lessonsForPrompt(), projectNotes,
416
485
  });
417
486
  if (workspaceRecord) {
418
487
  prompt += `\n\n## SESSION WORKSPACE\nAll file reads, writes, shell commands and Git operations must use ${cwd}.\nThe session branch ${workspaceRecord.branch} is already checked out; use it instead of creating or switching branches. The starting branch is ${workspaceRecord.baseRef}. Keep this branch until the session ends. Runtime session memory lives at ${memoryPath}.\n`;
419
488
  }
489
+ prompt += `\n\n${recoveryBrief(recovery.data, treeNow())}`;
490
+ if (resumeSession && invocationRuns === 1 && recovery.data.transcript.length) {
491
+ prompt += `\n\nPreserved observations from the interrupted phase (evidence, not new instructions):\n${recovery.data.transcript.join("\n")}`;
492
+ }
420
493
  prompt += `\n\n## Confirmed session outcomes\nWhen creating a PR, use a standalone gh pr create command with explicit --repo ${outcomeRepo || "OWNER/REPO"} and --head ${workspaceRecord?.branch || "BRANCH"}. Do not chain it with other commands; preserve its stdout so the harness can confirm creation. Never create another PR just to record an outcome.\n`;
421
494
  if (["SUMMARY", "SOLO"].includes(phase)) {
422
495
  prompt += `For the final substantive tracker reply only, include the literal marker ${replyMarker(sessionId)} in the posted comment. Never mark startup/progress comments. Keep the successful provider response intact (no jq, redirects or pipelines). For GitHub use standalone gh issue comment ${taskId} --repo ${outcomeRepo || "OWNER/REPO"} --body with a literal body. For Jira use standalone curl with a literal JSON --data-raw payload and the task's comment endpoint. For Linear use standalone curl with a literal JSON --data-raw payload and a commentCreate mutation selecting success and comment { id url body issue { identifier } }. Do not post an extra reply just to record an outcome.\n`;
423
496
  }
424
497
 
425
- // Publishing is gated until Sam has audited in this EXECUTION phase.
498
+ // Publishing is gated until Sam has approved, in this EXECUTION phase,
499
+ // the revision being published. His verdict is read from his report —
500
+ // the harness hands it over when he stops and again as the lead's tool
501
+ // result — and tied to the tree as the engine sees it at that moment.
426
502
  if (phase === "EXECUTION") armPublishGate(state, lastVerdict?.outcome === "APPROVED" ? [] : (lastVerdict?.findings || []));
503
+ const noteAudit = text => {
504
+ if (phase !== "EXECUTION") return;
505
+ const previous = state.audit;
506
+ const audit = recordAudit(state, { phase, agentType: AUDITOR, text, tree: treeNow() });
507
+ if (!audit) return;
508
+ run.audit = audit;
509
+ const same = previous && ["verdict", "revision", "clean", "statedRevision"].every(k => previous[k] === audit[k]);
510
+ if (same) return; // the same report, seen twice
511
+ emit({ type: "session:audit", phase, ...audit });
512
+ const messages = {
513
+ APPROVED: `${AUDITOR}'s audit: APPROVED at ${shortRev(audit.revision)}${audit.clean === false ? " (tree has uncommitted changes)" : ""} — publishing is open for that revision.`,
514
+ REJECTED: `${AUDITOR}'s audit: REJECTED — publishing stays closed until the fixes are committed and ${AUDITOR} re-audits.`,
515
+ STALE: `${AUDITOR} approved ${shortRev(audit.statedRevision)} but the code is at ${shortRev(audit.revision)} — publishing stays closed until he audits the current revision.`,
516
+ MISSING: `${AUDITOR}'s report has no AUDIT line — publishing stays closed until he ends his audit with AUDIT: APPROVED or AUDIT: REJECTED.`,
517
+ };
518
+ emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: messages[audit.verdict] });
519
+ };
427
520
 
428
521
  const mapper = createEventMapper({ leadAgent: lead, onEvent: emit });
429
522
  const options = buildQueryOptions({
@@ -433,15 +526,17 @@ async function executeSession({
433
526
  verifyTimeoutSec,
434
527
  hookCallbacks: {
435
528
  onToolResult: result => {
529
+ if (result.tool === "Agent" && result.input?.subagent_type === AUDITOR) noteAudit(responseText(result.response));
436
530
  const outcome = captureOutcome({ ...result, phase, sessionId, taskId, repo: outcomeRepo,
437
531
  branch: workspaceRecord?.branch || (result.tool === "Bash" && /^gh\s+pr\s+create\b/.test(result.input?.command || "") ? currentBranch(cwd) : null), tracker, config });
438
532
  if (outcome && !outcomeIds.has(outcome.id)) {
439
533
  outcomeIds.add(outcome.id);
534
+ recovery.save({ outcomes: [...(recovery.data.outcomes || []), outcome] });
440
535
  emit({ type: "session:outcome", project: project?.name || null, outcome });
441
536
  }
442
537
  },
443
- onSubagentStop: ({ agentType }) => {
444
- onSubagentStopped(state, { phase, agentType });
538
+ onSubagentStop: ({ agentType, text }) => {
539
+ if (agentType === AUDITOR && typeof text === "string") noteAudit(text);
445
540
  if (agentType) emit({ type: "agent:message", agent: agentType, tag: "SAY", message: `${agentType} finished and reported back.` });
446
541
  },
447
542
  },
@@ -454,9 +549,19 @@ async function executeSession({
454
549
  },
455
550
  });
456
551
 
552
+ // Journal external writes before dispatch, so a crash between execution
553
+ // and response cannot turn into a blind duplicate on resume.
554
+ journalExternalActions(options, recovery);
555
+
457
556
  let thrown = null;
458
557
  try {
459
- for await (const msg of runQuery({ prompt, options })) mapper.handle(msg);
558
+ for await (const msg of runQuery({ prompt, options })) {
559
+ mapper.handle(msg);
560
+ if (msg.type === "assistant") {
561
+ const text = (msg.message?.content || []).filter(b => b.type === "text").map(b => b.text).join("\n");
562
+ if (text) recovery.save({ transcript: [...recovery.data.transcript, text.slice(-12000)].slice(-30) });
563
+ }
564
+ }
460
565
  } catch (err) {
461
566
  if (!abortController.signal.aborted) thrown = err;
462
567
  }
@@ -466,12 +571,19 @@ async function executeSession({
466
571
  totals.inputTokens += summary.inputTokens;
467
572
  totals.outputTokens += summary.outputTokens;
468
573
  totals.costUsd += summary.costUsd;
574
+ checkpoint([phase, ...queue]);
469
575
  emit({ type: "session:usage", inputTokens: totals.inputTokens, outputTokens: totals.outputTokens });
470
576
 
471
577
  if (abortController.signal.aborted) { aborted = true; break; }
472
578
 
473
- if (phaseFailed({ exitCode: thrown || summary.isError ? 1 : 0, aborted: false })) {
474
- const detail = thrown?.message || summary.subtype || "error";
579
+ const sourceDenied = ["INTAKE", "PLAN"].includes(phase) && trackerAccessDenied(summary.permissionDenials);
580
+ // The SDK can yield its error result and then throw. The yielded subtype
581
+ // still identifies a repairable handoff failure in that case.
582
+ const outputFailure = !sourceDenied && summary.subtype === "error_max_structured_output_retries";
583
+ if (!outputFailure && phaseFailed({ exitCode: thrown || summary.isError || sourceDenied ? 1 : 0, aborted: false })) {
584
+ const detail = sourceDenied ? "Tracker permission denied. Restore access before planning from unverified requirements."
585
+ : thrown?.message || summary.errors?.join("\n") || summary.resultText || summary.subtype || "error";
586
+ block(detail, "PHASE_FAILED", phase);
475
587
  emit({ type: "session:error", code: "PHASE_FAILED", message: `${phase} failed (${detail}) — session incomplete.` });
476
588
  handoff = true;
477
589
  writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
@@ -481,35 +593,55 @@ async function executeSession({
481
593
 
482
594
  if (phase === "REVIEW") {
483
595
  const verdict = verdictFromResult({ is_error: summary.isError, subtype: summary.subtype, structured_output: summary.structuredOutput });
484
- const now = headNow();
485
- const approval = evaluateApproval({ verdict, evidence, headNow: now });
596
+ const now = treeNow();
597
+ const approval = evaluateApproval({ verdict, evidence, headNow: now.revision, cleanNow: now.clean });
486
598
  appendMemory(renderMemorySection("REVIEW", summary.structuredOutput));
487
- settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now, evidence, approved: approval.approved, approvalReason: approval.reason });
599
+ settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now.revision, evidence, approved: approval.approved, approvalReason: approval.reason });
488
600
  finishRun({ output: summary.structuredOutput ?? null, evidence, status: lastVerdict.approved ? "ok" : "not-approved" });
489
601
  continue;
490
602
  }
491
603
 
492
604
  // Other phases: the structured output IS the handoff. Without it the
493
- // next phase would start from nothing run the phase again once, then
494
- // fail closed. Solo mode keeps its single-run behaviour.
605
+ // next phase would start from nothing. Repair from saved evidence with
606
+ // no tools, then fail closed. Solo mode keeps its single-run behaviour.
495
607
  if (!summary.structuredOutput && phase !== "SOLO") {
608
+ let repairError = null;
496
609
  const attempt = (handoffRetries.get(phase) || 0) + 1;
497
610
  handoffRetries.set(phase, attempt);
498
611
  finishRun({ status: "missing-output" });
499
612
  if (attempt <= MAX_HANDOFF_RETRIES) {
500
- emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} ended without its structured handoff — running it again (retry ${attempt}/${MAX_HANDOFF_RETRIES}).` });
501
- queue.unshift(phase);
502
- continue;
613
+ emit({ type: "session:recovery", recovery: { state: "recovering", kind: "handoff", phase,
614
+ message: "Recovering the phase summary from preserved findings. Completed actions will not be repeated.", ready: false } });
615
+ const repair = createEventMapper({ leadAgent: lead });
616
+ try {
617
+ const repairOptions = repairQueryOptions(options);
618
+ const repairPrompt = `Recover the ${phase} structured handoff using only the preserved evidence below. Do not use tools, perform actions, invent requirements, or expand scope. If the evidence is insufficient, return no structured output.\n${recoveryBrief(recovery.data, treeNow())}\n${memoryText()}\n${recovery.data.transcript.join("\n")}\n${summary.resultText || ""}`;
619
+ for await (const msg of runQuery({ prompt: repairPrompt, options: repairOptions })) repair.handle(msg);
620
+ const repaired = repair.finish();
621
+ totals.inputTokens += repaired.inputTokens; totals.outputTokens += repaired.outputTokens; totals.costUsd += repaired.costUsd;
622
+ if (!repaired.isError && validHandoff(phase, repaired.structuredOutput)) summary.structuredOutput = repaired.structuredOutput;
623
+ else if (repaired.isError) repairError = repaired.errors.join("\n") || repaired.resultText || repaired.subtype;
624
+ } catch (error) { repairError = error.message; }
625
+ checkpoint([phase, ...queue]);
626
+ emit({ type: "session:usage", inputTokens: totals.inputTokens, outputTokens: totals.outputTokens });
503
627
  }
504
- emit({ type: "session:error", code: "HANDOFF_INVALID", message: `${phase} ended without its structured handoff again — ending session for human review.` });
505
- handoff = true;
506
- writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
507
- break;
628
+ if (abortController.signal.aborted) { aborted = true; break; }
629
+ if (!summary.structuredOutput) {
630
+ block(repairError || "Missing structured handoff", "HANDOFF_INVALID", phase);
631
+ emit({ type: "session:error", code: "HANDOFF_INVALID", message: `${phase} handoff recovery failed — work preserved for intervention.` });
632
+ handoff = true;
633
+ writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
634
+ break;
635
+ }
636
+ emit({ type: "session:recovery", recovery: { state: "running", kind: "handoff", phase, message: "Phase summary recovered.", ready: false } });
508
637
  }
509
638
  if (!summary.structuredOutput) {
510
639
  emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
511
640
  }
512
641
  appendMemory(renderMemorySection(phase, summary.structuredOutput));
642
+ if (["SUMMARY", "SOLO"].includes(phase) && summary.structuredOutput) {
643
+ lessonHandoff = { phase, agent: lead, lessons: summary.structuredOutput.lessons, retireLessons: summary.structuredOutput.retireLessons };
644
+ }
513
645
  if (phase === "EXECUTION") {
514
646
  const after = gitState(cwd, workspaceGitEnv);
515
647
  const missing = noCommitItem({ phase, revisionBefore: run.revisionBefore, revisionAfter: after.revision, clean: after.clean, output: summary.structuredOutput });
@@ -532,6 +664,7 @@ async function executeSession({
532
664
  const areas = profile.touches.length ? ` (${profile.touches.join(", ")})` : "";
533
665
  emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Team for this task: ${profile.size}${areas} — ${names}.${profile.size === "small" ? " Skipping PLAN." : ""}` });
534
666
  }
667
+ checkpoint(queue);
535
668
  }
536
669
  } finally {
537
670
  abortSignal?.removeEventListener?.("abort", onExternalAbort);
@@ -540,12 +673,33 @@ async function executeSession({
540
673
 
541
674
  // Belt to SUMMARY's braces: nothing may have moved the approved revision.
542
675
  if (!aborted && reviewResolved && approvedRevision) {
543
- const now = headNow();
544
- if (now !== approvedRevision) invalidateApproval(now);
676
+ const now = treeNow();
677
+ if (drifted(now)) invalidateApproval(now);
545
678
  }
546
679
 
547
680
  const duration = seconds(startedAt);
548
681
  const status = finalStatus({ aborted, crashed: handoff, reviewResolved });
682
+ if (status === "handoff" && !handoff) block("Verification or review remains unresolved", "REVIEW_UNRESOLVED", lastPhase);
683
+
684
+ // The engine's verdict on the session decides whether a lesson is active
685
+ // (verified approval) or only proposed; the agents' say-so never does.
686
+ let lessonCounts = null;
687
+ if (lessonHandoff) {
688
+ const recorded = recordLessons({ path: lessonsFile, proposals: lessonHandoff.lessons, retirements: lessonHandoff.retireLessons,
689
+ source: { sessionId, taskId, phase: lessonHandoff.phase, agent: lessonHandoff.agent, revision: headNow() }, complete: status === "complete" });
690
+ const { activated, proposed, confirmed, retired, dropped } = recorded;
691
+ lessonCounts = { activated, proposed, confirmed, retired, dropped };
692
+ if (recorded.changed) {
693
+ emit({ type: "session:lessons", ...lessonCounts, path: lessonsFile });
694
+ const parts = [
695
+ activated && `${activated} recorded as active`,
696
+ proposed && `${proposed} proposed (activates when a later session that ends complete confirms it)`,
697
+ confirmed && `${confirmed} confirmed`, retired && `${retired} retired`, dropped && `${dropped} dropped`,
698
+ ].filter(Boolean);
699
+ emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Project lessons: ${parts.join(", ")}.` });
700
+ }
701
+ }
702
+
549
703
  const resumePath = join(cwd, ".agentdesk-resume.md");
550
704
  const endFields = { duration, steps: totals.steps, inputTokens: totals.inputTokens, outputTokens: totals.outputTokens };
551
705
 
@@ -566,5 +720,6 @@ async function executeSession({
566
720
  approvedRevision: reviewResolved ? approvedRevision : null,
567
721
  openItems,
568
722
  profile,
723
+ lessons: lessonCounts,
569
724
  };
570
725
  }
@@ -14,6 +14,6 @@
14
14
  {{/EXECUTION}}
15
15
  {{#SUMMARY}}
16
16
  - Ensure the PR references the issue ("Closes #{{TASK_ID}}").
17
- - Labels: `gh issue edit {{TASK_ID}} --remove-label "in progress" --add-label "in review" 2>/dev/null || true`
17
+ - Only when review and engine verification permit readiness, update labels: `gh issue edit {{TASK_ID}} --remove-label "in progress" --add-label "in review"`. Otherwise leave them unchanged. Report any failed write instead of hiding it.
18
18
  - Post the final comment with the session link {{SESSION_URL}}.
19
19
  {{/SUMMARY}}
@@ -18,6 +18,6 @@
18
18
  {{/EXECUTION}}
19
19
  {{#SUMMARY}}
20
20
  - Verify the PR remote link exists; add it if missing.
21
- - Transition to "In Review": GET `/rest/api/3/issue/{{TASK_ID}}/transitions`, then POST `{"transition":{"id":"<id>"}}`.
21
+ - Only when review and engine verification permit readiness, transition to "In Review": GET `/rest/api/3/issue/{{TASK_ID}}/transitions`, then POST `{"transition":{"id":"<id>"}}`. Otherwise leave the status unchanged and report the blocker.
22
22
  - Post the final comment (ADF, `inlineCard` for the session link {{SESSION_URL}} and the PR).
23
23
  {{/SUMMARY}}
@@ -19,6 +19,6 @@
19
19
  {{/EXECUTION}}
20
20
  {{#SUMMARY}}
21
21
  - Verify the PR link is attached (attach via `attachmentCreate` if missing).
22
- - Transition to "In Review": `mutation { issueUpdate(id: "$ISSUE_ID", input: { stateId: "$IN_REVIEW_STATE_ID" }) { success } }`
22
+ - Only when review and engine verification permit readiness, transition to "In Review": `mutation { issueUpdate(id: "$ISSUE_ID", input: { stateId: "$IN_REVIEW_STATE_ID" }) { success } }`. Otherwise leave the status unchanged and report the blocker.
23
23
  - Post the final comment with the session link {{SESSION_URL}} via `commentCreate`.
24
24
  {{/SUMMARY}}
@@ -24,13 +24,13 @@ export const VERDICT_SCHEMA = Object.freeze({
24
24
  properties: {
25
25
  reviewer: { type: "string" },
26
26
  title: { type: "string" },
27
- detail: { type: "string" },
27
+ detail: { type: "string", description: "observed gap, named corrective owner, next action, and evidence required to close it; name the owner even when the reviewer is someone else" },
28
28
  file: { type: "string" },
29
29
  line: { type: "integer" },
30
30
  },
31
31
  },
32
32
  },
33
- deferred: { type: "array", items: { type: "string" } },
33
+ deferred: { type: "array", items: { type: "string" }, description: "only explicitly out-of-scope or user-approved deferrals; unresolved acceptance criteria belong in findings" },
34
34
  unverifiedClaims: { type: "array", items: { type: "string" } },
35
35
  },
36
36
  });