@kendoo.agentdesk/agentdesk 0.32.1 → 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.
@@ -10,11 +10,12 @@
10
10
  import { readFileSync } from "fs";
11
11
  import { dirname, join } from "path";
12
12
  import { fileURLToPath } from "url";
13
- import { wrapUntrusted, PROMPT_SECURITY_HEADER, MEMORY_INSTRUCTIONS, loadProjectMemory } from "../prompt.mjs";
13
+ import { wrapUntrusted, PROMPT_SECURITY_HEADER, LEGACY_NOTES_HEADER } from "../prompt.mjs";
14
14
  import { generateContext } from "../detect.mjs";
15
15
  import { formatFindingsForRetry } from "./verdict.mjs";
16
16
  import { formatEvidenceForPrompt } from "./evidence.mjs";
17
17
  import { renderOpenItems } from "./handoff.mjs";
18
+ import { renderLessonsSection } from "./lessons.mjs";
18
19
 
19
20
  const here = dirname(fileURLToPath(import.meta.url));
20
21
 
@@ -40,6 +41,16 @@ export function renderTemplate(text, { flags = new Set(), vars = {} } = {}) {
40
41
  return out;
41
42
  }
42
43
 
44
+ // Engine-provided project knowledge: active lessons in scope, then the
45
+ // hand-written notes from before the ledger existed (read-only).
46
+ function projectKnowledge({ lessons = [], projectNotes = "" }) {
47
+ let out = "";
48
+ const block = renderLessonsSection(lessons);
49
+ if (block) out += `\n\n${block}`;
50
+ if (projectNotes) out += `\n\n${LEGACY_NOTES_HEADER}\n\n${projectNotes}`;
51
+ return out;
52
+ }
53
+
43
54
  function trackerSection(tracker, phases, vars) {
44
55
  if (!tracker) return "";
45
56
  let text;
@@ -67,7 +78,7 @@ function soloSearchAndCreate({ tracker, config }) {
67
78
  // tools, no lead and no review gate. Ported from the legacy buildSoloPrompt.
68
79
  export function renderSoloPrompt({
69
80
  agent, taskId, taskLink, description, tracker, config = {}, project = {},
70
- sessionUrl, cwd, childStrategy,
81
+ sessionUrl, childStrategy, lessons = [], projectNotes = "",
71
82
  }) {
72
83
  const hasRealTaskId = !!taskId && !String(taskId).startsWith("new-") && !String(taskId).startsWith("task-");
73
84
  const vars = {
@@ -104,9 +115,7 @@ export function renderSoloPrompt({
104
115
  }
105
116
  if (config.instructions) body += `\n\n## ADDITIONAL INSTRUCTIONS\n\n${config.instructions}`;
106
117
 
107
- body += `\n\n${MEMORY_INSTRUCTIONS}`;
108
- const memory = loadProjectMemory(cwd);
109
- if (memory) body += `\n\n### Current memory\n\n${memory}`;
118
+ body += projectKnowledge({ lessons, projectNotes });
110
119
 
111
120
  const context = generateContext(project);
112
121
  const now = new Date();
@@ -135,8 +144,8 @@ function createTaskSection({ tracker, config, description }) {
135
144
  // Returns the full user prompt for one phase's query().
136
145
  export function renderPhasePrompt({
137
146
  phase, taskId, taskLink, description, createTask, tracker, config = {}, project = {},
138
- sessionUrl, cwd, sessionMemory = "", retryVerdict = null, evidence = null, openItems = [],
139
- roster = null, profile = null, handoffRetry = false,
147
+ sessionUrl, sessionMemory = "", retryVerdict = null, evidence = null, openItems = [],
148
+ roster = null, profile = null, handoffRetry = false, lessons = [], projectNotes = "",
140
149
  }) {
141
150
  const vars = {
142
151
  TASK_ID: taskId,
@@ -187,9 +196,7 @@ export function renderPhasePrompt({
187
196
  body += `\n\n## SESSION MEMORY (previous phases)\n\n${sessionMemory}`;
188
197
  }
189
198
 
190
- body += `\n\n${MEMORY_INSTRUCTIONS}`;
191
- const memory = loadProjectMemory(cwd);
192
- if (memory) body += `\n\n### Current memory\n\n${memory}`;
199
+ body += projectKnowledge({ lessons, projectNotes });
193
200
 
194
201
  if (config.projectAgents?.length) {
195
202
  project.configAgents = config.projectAgents.map(a => ({ ...a, type: a.type || "declared", source: ".agentdesk.json" }));
@@ -0,0 +1,101 @@
1
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { PHASE_OUTPUT_SCHEMAS } from "./schemas.mjs";
5
+
6
+ export function checkpointStore(directory, sessionId, { resume = false } = {}) {
7
+ mkdirSync(directory, { recursive: true });
8
+ const path = join(directory, `recovery-${createHash("sha256").update(sessionId).digest("hex").slice(0, 24)}.json`);
9
+ let data = { version: 1, sessionId, queue: null, receipts: {}, outcomes: [], instructions: [], transcript: [], totals: null };
10
+ if (resume) {
11
+ if (!existsSync(path)) throw new Error("No recovery checkpoint exists for this session. Start a separately reviewed continuation instead.");
12
+ data = JSON.parse(readFileSync(path, "utf8"));
13
+ if (data.version !== 1 || data.sessionId !== sessionId) throw new Error("Recovery checkpoint does not match this session.");
14
+ }
15
+ const save = patch => {
16
+ data = { ...data, ...patch };
17
+ writeFileSync(`${path}.tmp`, JSON.stringify(data), { mode: 0o600 });
18
+ renameSync(`${path}.tmp`, path);
19
+ };
20
+ return { get data() { return data; }, save, path };
21
+ }
22
+
23
+ export function validHandoff(phase, value) {
24
+ const matches = (schema, v) => {
25
+ if (schema.type === "object") return !!v && typeof v === "object" && !Array.isArray(v)
26
+ && (schema.required || []).every(k => k in v)
27
+ && Object.entries(v).every(([k, item]) => schema.properties?.[k] ? matches(schema.properties[k], item) : schema.additionalProperties !== false);
28
+ if (schema.type === "array") return Array.isArray(v) && v.every(item => matches(schema.items, item));
29
+ if (schema.type === "string") return typeof v === "string" && (!schema.enum || schema.enum.includes(v));
30
+ if (schema.type === "integer") return Number.isInteger(v);
31
+ return true;
32
+ };
33
+ return !!PHASE_OUTPUT_SCHEMAS[phase] && matches(PHASE_OUTPUT_SCHEMAS[phase], value);
34
+ }
35
+
36
+ // Conservative journal for externally visible commands. Exact repeats are
37
+ // refused; uncertain responses require reconciliation, never automatic replay.
38
+ export function externalActionKey(tool, input = {}) {
39
+ const command = String(input.command || "");
40
+ const external = tool.startsWith("mcp__") && /create|update|delete|transition|comment|post|send/i.test(tool) || (tool === "Bash" && (
41
+ /\bgh\s+(?:pr|issue)\s+(?:create|comment|edit|merge|close|reopen)\b/.test(command)
42
+ || /\bcurl\b/.test(command) && /(?:--data|-d\b|-X\s*(?:POST|PUT|PATCH|DELETE)|--request\s+(?:POST|PUT|PATCH|DELETE))/.test(command)
43
+ ));
44
+ if (!external) return null;
45
+ const canonical = value => Array.isArray(value) ? value.map(canonical)
46
+ : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])])) : value;
47
+ // Bash descriptions/timeouts are presentation, not the external action.
48
+ return createHash("sha256").update(JSON.stringify([tool, tool === "Bash" ? command.trim() : canonical(input)])).digest("hex");
49
+ }
50
+
51
+ export function journalExternalActions(options, recovery) {
52
+ const previousPre = options.hooks.PreToolUse[0].hooks[0];
53
+ options.hooks.PreToolUse[0].hooks[0] = async input => {
54
+ const decision = await previousPre(input);
55
+ if (decision.hookSpecificOutput?.permissionDecision === "deny") return decision;
56
+ const key = externalActionKey(input.tool_name, input.tool_input);
57
+ if (!key) return decision;
58
+ const receipt = recovery.data.receipts[key];
59
+ if (receipt) return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny",
60
+ permissionDecisionReason: `This external action is recorded as ${receipt.state}. Do not repeat or reword it to bypass this guard. Inspect the provider to reconcile its result; preserve the existing PR/comment.` } };
61
+ recovery.save({ receipts: { ...recovery.data.receipts, [key]: { state: "pending", tool: input.tool_name, at: Date.now() } } });
62
+ return decision;
63
+ };
64
+ const previousPost = options.hooks.PostToolUse[0].hooks[0];
65
+ options.hooks.PostToolUse[0].hooks[0] = async input => {
66
+ const result = await previousPost(input);
67
+ const key = externalActionKey(input.tool_name, input.tool_input);
68
+ if (key && recovery.data.receipts[key]) {
69
+ // A tool returning is not proof of an external write succeeding. Only
70
+ // the provider-specific outcome parser can produce a confirmed receipt.
71
+ recovery.save({ receipts: { ...recovery.data.receipts, [key]: { ...recovery.data.receipts[key], state: "response-received" } } });
72
+ }
73
+ return result;
74
+ };
75
+ }
76
+
77
+ export function repairQueryOptions(options) {
78
+ return { ...options, agents: {}, agent: undefined, allowedTools: ["StructuredOutput"], tools: ["StructuredOutput"], mcpServers: {},
79
+ hooks: { PreToolUse: [{ hooks: [async input => input.tool_name === "StructuredOutput" ? {} : {
80
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Handoff repair cannot execute tools." },
81
+ }] }] }, maxTurns: 2 };
82
+ }
83
+
84
+ export function trackerAccessDenied(denials) {
85
+ return denials.some(d => /mcp__.*(?:jira|atlassian|linear|github)/i.test(d.tool_name || "")
86
+ || d.tool_name === "Bash" && /\bcurl\b|\bgh\s+(?:issue|pr)\s+(?:view|list)\b/.test(d.tool_input?.command || ""));
87
+ }
88
+
89
+ export function recoveryBrief(data, tree) {
90
+ return [
91
+ "## Authoritative task and user instructions",
92
+ `Original task: ${data.taskId || ""}\n${data.description || ""}`,
93
+ "Stay within this objective. Propose unrelated discoveries separately; do not expand scope to repair lost context.",
94
+ ...data.instructions.map((i, n) => `${n + 1}. ${i.text}`),
95
+ "Latest user corrections override earlier plans. Acknowledge their effect before working.",
96
+ `Current revision: ${tree.revision || "unknown"}; working tree clean: ${tree.clean}. Preserve unfinished edits. Inspect the diff and existing PR before continuing. Prior verification must be re-established.`,
97
+ `Recorded external actions: ${JSON.stringify(data.receipts)}`,
98
+ `Confirmed provider outcomes: ${JSON.stringify(data.outcomes || [])}`,
99
+ "Do not repeat confirmed actions. A pending or response-received entry is uncertain: reconcile it against the provider first. Never reword commands to bypass the replay guard. Never claim an action failed merely because this phase cannot access the provider.",
100
+ ].join("\n\n");
101
+ }
@@ -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
  });
@@ -41,6 +41,10 @@ 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;
@@ -299,7 +331,7 @@ async function executeSession({
299
331
  // --- handoff ledger (engine-owned) ---------------------------------------
300
332
  // One entry per phase run; the open-items list travels into the next prompt.
301
333
  const ledger = createLedger(ledgerPath);
302
- let openItems = [];
334
+ let openItems = resumeSession ? (prior.openItems || []) : [];
303
335
 
304
336
  const reportEvidence = (evidence, evidencePhase = "REVIEW") => {
305
337
  emit({ type: "session:evidence", phase: evidencePhase, revision: evidence.revision, clean: evidence.clean,
@@ -363,18 +395,34 @@ async function executeSession({
363
395
 
364
396
  // --- team profile (INTAKE assesses the task; config may force) -----------
365
397
  let profile = teamProfileFor({ intake: null, config });
366
- 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 });
367
412
 
368
413
  try {
369
414
  while (queue.length > 0) {
370
415
  if (abortController.signal.aborted) { aborted = true; break; }
371
- if (++phaseRuns > MAX_PHASE_RUNS) {
416
+ phaseRuns++;
417
+ if (++invocationRuns > MAX_PHASE_RUNS) {
372
418
  emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Phase ceiling reached (${MAX_PHASE_RUNS} runs) — ending session for review.` });
373
419
  break;
374
420
  }
375
421
 
376
422
  const phase = queue.shift();
377
423
  lastPhase = phase;
424
+ checkpoint([phase, ...queue]);
425
+ if (!(resumeSession && prior.phase === phase && invocationRuns === 1)) recovery.save({ transcript: [] });
378
426
  const run = { index: phaseRuns, startedAt: Date.now(), revisionBefore: headNow() };
379
427
  const finishRun = ({ output = null, evidence: runEvidence = null, status }) => {
380
428
  const entry = { phase, run: run.index, startedAt: run.startedAt, durationMs: Date.now() - run.startedAt,
@@ -382,6 +430,7 @@ async function executeSession({
382
430
  ...(run.audit ? { audit: run.audit } : {}) };
383
431
  ledger.record(entry);
384
432
  emit({ type: "session:handoff", ...entry });
433
+ checkpoint(status === "ok" || status === "not-approved" || status === "checks-failed" ? queue : [phase, ...queue]);
385
434
  };
386
435
  // The dashboard knows the five team phases; solo shows as EXECUTION.
387
436
  const model = modelForPhase(phase === "SOLO" ? "EXECUTION" : phase, config.phaseModels);
@@ -419,11 +468,11 @@ async function executeSession({
419
468
  ? soloDefinition(solo)
420
469
  : agentsForPhase({ phase, team, phaseModels: config.phaseModels, profile });
421
470
  let prompt = solo
422
- ? 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 })
423
472
  : renderPhasePrompt({
424
473
  phase, taskId, taskLink, description,
425
474
  createTask: phase === "INTAKE" ? createTask : false,
426
- tracker, config, project, sessionUrl, cwd,
475
+ tracker, config, project, sessionUrl,
427
476
  sessionMemory: memoryText(),
428
477
  retryVerdict: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? lastVerdict : null,
429
478
  evidence,
@@ -432,10 +481,15 @@ async function executeSession({
432
481
  roster: Object.keys(agents).filter(name => name !== lead),
433
482
  profile,
434
483
  handoffRetry: (handoffRetries.get(phase) || 0) > 0,
484
+ lessons: lessonsForPrompt(), projectNotes,
435
485
  });
436
486
  if (workspaceRecord) {
437
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`;
438
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
+ }
439
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`;
440
494
  if (["SUMMARY", "SOLO"].includes(phase)) {
441
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`;
@@ -477,6 +531,7 @@ async function executeSession({
477
531
  branch: workspaceRecord?.branch || (result.tool === "Bash" && /^gh\s+pr\s+create\b/.test(result.input?.command || "") ? currentBranch(cwd) : null), tracker, config });
478
532
  if (outcome && !outcomeIds.has(outcome.id)) {
479
533
  outcomeIds.add(outcome.id);
534
+ recovery.save({ outcomes: [...(recovery.data.outcomes || []), outcome] });
480
535
  emit({ type: "session:outcome", project: project?.name || null, outcome });
481
536
  }
482
537
  },
@@ -494,9 +549,19 @@ async function executeSession({
494
549
  },
495
550
  });
496
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
+
497
556
  let thrown = null;
498
557
  try {
499
- 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
+ }
500
565
  } catch (err) {
501
566
  if (!abortController.signal.aborted) thrown = err;
502
567
  }
@@ -506,12 +571,19 @@ async function executeSession({
506
571
  totals.inputTokens += summary.inputTokens;
507
572
  totals.outputTokens += summary.outputTokens;
508
573
  totals.costUsd += summary.costUsd;
574
+ checkpoint([phase, ...queue]);
509
575
  emit({ type: "session:usage", inputTokens: totals.inputTokens, outputTokens: totals.outputTokens });
510
576
 
511
577
  if (abortController.signal.aborted) { aborted = true; break; }
512
578
 
513
- if (phaseFailed({ exitCode: thrown || summary.isError ? 1 : 0, aborted: false })) {
514
- 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);
515
587
  emit({ type: "session:error", code: "PHASE_FAILED", message: `${phase} failed (${detail}) — session incomplete.` });
516
588
  handoff = true;
517
589
  writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
@@ -530,26 +602,46 @@ async function executeSession({
530
602
  }
531
603
 
532
604
  // Other phases: the structured output IS the handoff. Without it the
533
- // next phase would start from nothing run the phase again once, then
534
- // 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.
535
607
  if (!summary.structuredOutput && phase !== "SOLO") {
608
+ let repairError = null;
536
609
  const attempt = (handoffRetries.get(phase) || 0) + 1;
537
610
  handoffRetries.set(phase, attempt);
538
611
  finishRun({ status: "missing-output" });
539
612
  if (attempt <= MAX_HANDOFF_RETRIES) {
540
- emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} ended without its structured handoff — running it again (retry ${attempt}/${MAX_HANDOFF_RETRIES}).` });
541
- queue.unshift(phase);
542
- 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 });
543
627
  }
544
- emit({ type: "session:error", code: "HANDOFF_INVALID", message: `${phase} ended without its structured handoff again — ending session for human review.` });
545
- handoff = true;
546
- writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
547
- 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 } });
548
637
  }
549
638
  if (!summary.structuredOutput) {
550
639
  emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
551
640
  }
552
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
+ }
553
645
  if (phase === "EXECUTION") {
554
646
  const after = gitState(cwd, workspaceGitEnv);
555
647
  const missing = noCommitItem({ phase, revisionBefore: run.revisionBefore, revisionAfter: after.revision, clean: after.clean, output: summary.structuredOutput });
@@ -572,6 +664,7 @@ async function executeSession({
572
664
  const areas = profile.touches.length ? ` (${profile.touches.join(", ")})` : "";
573
665
  emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Team for this task: ${profile.size}${areas} — ${names}.${profile.size === "small" ? " Skipping PLAN." : ""}` });
574
666
  }
667
+ checkpoint(queue);
575
668
  }
576
669
  } finally {
577
670
  abortSignal?.removeEventListener?.("abort", onExternalAbort);
@@ -586,6 +679,27 @@ async function executeSession({
586
679
 
587
680
  const duration = seconds(startedAt);
588
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
+
589
703
  const resumePath = join(cwd, ".agentdesk-resume.md");
590
704
  const endFields = { duration, steps: totals.steps, inputTokens: totals.inputTokens, outputTokens: totals.outputTokens };
591
705
 
@@ -606,5 +720,6 @@ async function executeSession({
606
720
  approvedRevision: reviewResolved ? approvedRevision : null,
607
721
  openItems,
608
722
  profile,
723
+ lessons: lessonCounts,
609
724
  };
610
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
  });