@kendoo.agentdesk/agentdesk 0.32.1 → 0.34.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.
@@ -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);
261
+ const projectNotes = loadProjectMemory(sourceCwd);
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,22 @@ 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 handoff failure the engine can try to repair — in
582
+ // every phase but SOLO, which has no repair path and must fail closed:
583
+ // a solo run with no summary is a handoff, never "complete".
584
+ const outputFailure = !sourceDenied && summary.subtype === "error_max_structured_output_retries";
585
+ const repairable = outputFailure && phase !== "SOLO";
586
+ if (!repairable && phaseFailed({ exitCode: thrown || summary.isError || sourceDenied ? 1 : 0, aborted: false })) {
587
+ const detail = sourceDenied ? "Tracker permission denied. Restore access before planning from unverified requirements."
588
+ : thrown?.message || summary.errors?.join("\n") || summary.resultText || summary.subtype || "error";
589
+ block(detail, "PHASE_FAILED", phase);
515
590
  emit({ type: "session:error", code: "PHASE_FAILED", message: `${phase} failed (${detail}) — session incomplete.` });
516
591
  handoff = true;
517
592
  writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
@@ -519,6 +594,61 @@ async function executeSession({
519
594
  break;
520
595
  }
521
596
 
597
+ // The structured output IS the handoff. Without it the next phase would
598
+ // start from nothing: repair from saved evidence with no tools, then fail
599
+ // closed. Solo mode keeps its single-run behaviour. A REVIEW that simply
600
+ // returned no verdict is "not approved" and goes back to EXECUTION
601
+ // (settleReview); only a REVIEW whose verdict the SDK gave up on gets the
602
+ // repair — a transport error is not a reason to re-implement anything.
603
+ if (!summary.structuredOutput && phase !== "SOLO" && (phase !== "REVIEW" || repairable)) {
604
+ let repairError = null;
605
+ const attempt = (handoffRetries.get(phase) || 0) + 1;
606
+ handoffRetries.set(phase, attempt);
607
+ finishRun({ status: "missing-output" });
608
+ if (attempt <= MAX_HANDOFF_RETRIES) {
609
+ emit({ type: "session:recovery", recovery: { state: "recovering", kind: "handoff", phase,
610
+ message: "Recovering the phase summary from preserved findings. Completed actions will not be repeated.", ready: false } });
611
+ const repair = createEventMapper({ leadAgent: lead });
612
+ try {
613
+ const repairOptions = repairQueryOptions(options);
614
+ const verdictRule = phase === "REVIEW" ? " For REVIEW, report NEEDS_MORE_WORK with every finding and unverified claim the evidence supports; a recovered verdict cannot grant approval — the reviewers approve again on the next run." : "";
615
+ 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.${verdictRule}\n${recoveryBrief(recovery.data, treeNow())}\n${memoryText()}\n${recovery.data.transcript.join("\n")}\n${summary.resultText || ""}`;
616
+ for await (const msg of runQuery({ prompt: repairPrompt, options: repairOptions })) repair.handle(msg);
617
+ const repaired = repair.finish();
618
+ totals.inputTokens += repaired.inputTokens; totals.outputTokens += repaired.outputTokens; totals.costUsd += repaired.costUsd;
619
+ if (!repaired.isError && validHandoff(phase, repaired.structuredOutput)) {
620
+ // The repaired handoff stands in for the failed result from here on.
621
+ summary.structuredOutput = repaired.structuredOutput;
622
+ summary.isError = false;
623
+ summary.subtype = repaired.subtype;
624
+ // A recovered verdict is the engine's reconstruction, not a
625
+ // reviewer's word: it carries the findings forward so the retry
626
+ // is informed, but it can never grant the approval itself.
627
+ if (phase === "REVIEW" && summary.structuredOutput.verdict === "APPROVED") {
628
+ summary.structuredOutput = { ...summary.structuredOutput, verdict: "NEEDS_MORE_WORK",
629
+ unverifiedClaims: [...(summary.structuredOutput.unverifiedClaims || []), "Approval was reconstructed after the SDK gave up on the verdict; the reviewers must approve this revision again."] };
630
+ }
631
+ } else if (repaired.isError) repairError = repaired.errors.join("\n") || repaired.resultText || repaired.subtype;
632
+ else repairError = "repair produced no valid handoff";
633
+ } catch (error) { repairError = error.message; }
634
+ checkpoint([phase, ...queue]);
635
+ emit({ type: "session:usage", inputTokens: totals.inputTokens, outputTokens: totals.outputTokens });
636
+ }
637
+ if (abortController.signal.aborted) { aborted = true; break; }
638
+ if (!summary.structuredOutput) {
639
+ // Both failures are reported: what the repair said, and what the
640
+ // SDK threw or returned in the first place.
641
+ const original = thrown?.message || (summary.isError ? summary.errors?.join("\n") || summary.subtype : null);
642
+ const cause = [repairError, original].filter(Boolean).join("; ") || "missing structured handoff";
643
+ block(cause, "HANDOFF_INVALID", phase);
644
+ emit({ type: "session:error", code: "HANDOFF_INVALID", message: `${phase} handoff recovery failed (${cause}) — work preserved for intervention.` });
645
+ handoff = true;
646
+ writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
647
+ break;
648
+ }
649
+ emit({ type: "session:recovery", recovery: { state: "running", kind: "handoff", phase, message: "Phase summary recovered.", ready: false } });
650
+ }
651
+
522
652
  if (phase === "REVIEW") {
523
653
  const verdict = verdictFromResult({ is_error: summary.isError, subtype: summary.subtype, structured_output: summary.structuredOutput });
524
654
  const now = treeNow();
@@ -529,27 +659,13 @@ async function executeSession({
529
659
  continue;
530
660
  }
531
661
 
532
- // 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.
535
- if (!summary.structuredOutput && phase !== "SOLO") {
536
- const attempt = (handoffRetries.get(phase) || 0) + 1;
537
- handoffRetries.set(phase, attempt);
538
- finishRun({ status: "missing-output" });
539
- 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;
543
- }
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;
548
- }
549
662
  if (!summary.structuredOutput) {
550
663
  emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
551
664
  }
552
665
  appendMemory(renderMemorySection(phase, summary.structuredOutput));
666
+ if (["SUMMARY", "SOLO"].includes(phase) && summary.structuredOutput) {
667
+ lessonHandoff = { phase, agent: lead, lessons: summary.structuredOutput.lessons, retireLessons: summary.structuredOutput.retireLessons };
668
+ }
553
669
  if (phase === "EXECUTION") {
554
670
  const after = gitState(cwd, workspaceGitEnv);
555
671
  const missing = noCommitItem({ phase, revisionBefore: run.revisionBefore, revisionAfter: after.revision, clean: after.clean, output: summary.structuredOutput });
@@ -572,6 +688,7 @@ async function executeSession({
572
688
  const areas = profile.touches.length ? ` (${profile.touches.join(", ")})` : "";
573
689
  emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Team for this task: ${profile.size}${areas} — ${names}.${profile.size === "small" ? " Skipping PLAN." : ""}` });
574
690
  }
691
+ checkpoint(queue);
575
692
  }
576
693
  } finally {
577
694
  abortSignal?.removeEventListener?.("abort", onExternalAbort);
@@ -586,6 +703,33 @@ async function executeSession({
586
703
 
587
704
  const duration = seconds(startedAt);
588
705
  const status = finalStatus({ aborted, crashed: handoff, reviewResolved });
706
+ if (status === "handoff" && !handoff) block("Verification or review remains unresolved", "REVIEW_UNRESOLVED", lastPhase);
707
+
708
+ // The engine's verdict on the session decides whether a lesson is active
709
+ // (verified approval) or only proposed; the agents' say-so never does.
710
+ let lessonCounts = null;
711
+ if (lessonHandoff) {
712
+ try {
713
+ const recorded = recordLessons({ path: lessonsFile, proposals: lessonHandoff.lessons, retirements: lessonHandoff.retireLessons,
714
+ source: { sessionId, taskId, phase: lessonHandoff.phase, agent: lessonHandoff.agent, revision: headNow() },
715
+ complete: status === "complete" && !solo });
716
+ const { activated, proposed, confirmed, retired, dropped } = recorded;
717
+ lessonCounts = { activated, proposed, confirmed, retired, dropped };
718
+ if (recorded.changed) {
719
+ emit({ type: "session:lessons", ...lessonCounts });
720
+ const parts = [
721
+ activated && `${activated} recorded as active`,
722
+ proposed && `${proposed} proposed (activates when a later session that ends complete confirms it)`,
723
+ confirmed && `${confirmed} confirmed`, retired && `${retired} retired`, dropped && `${dropped} dropped`,
724
+ ].filter(Boolean);
725
+ emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Project lessons: ${parts.join(", ")}.` });
726
+ }
727
+ } catch (err) {
728
+ lessonCounts = null;
729
+ emit({ type: "session:error", code: "LESSONS_NOT_RECORDED", message: `Project lessons were not recorded: ${err.message}` });
730
+ }
731
+ }
732
+
589
733
  const resumePath = join(cwd, ".agentdesk-resume.md");
590
734
  const endFields = { duration, steps: totals.steps, inputTokens: totals.inputTokens, outputTokens: totals.outputTokens };
591
735
 
@@ -606,5 +750,6 @@ async function executeSession({
606
750
  approvedRevision: reviewResolved ? approvedRevision : null,
607
751
  openItems,
608
752
  profile,
753
+ lessons: lessonCounts,
609
754
  };
610
755
  }
@@ -5,7 +5,7 @@
5
5
  {{/COMMON}}
6
6
  {{#INTAKE}}
7
7
  - Fetch: `gh issue view {{TASK_ID}} --json title,body,state,comments,labels`
8
- - Session start: post "Team session started. Session: {{SESSION_URL}}" and add the "in progress" label.
8
+ - Session start: post "Team session started. Session: {{SESSION_URL}}" and add the "in progress" label — best-effort: `gh issue edit {{TASK_ID}} --add-label "in progress" 2>/dev/null || true` (a label that does not exist in the repository is not a failed write).
9
9
  {{/INTAKE}}
10
10
  {{#EXECUTION}}
11
11
  - PR created (Bart): reference the issue in the PR body ("Closes #{{TASK_ID}}") and post a comment with the PR link.
@@ -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" 2>/dev/null || true`. Otherwise leave them unchanged. Labels are best-effort — a label that does not exist in the repository is not a failed write. A denied comment or edit is; report it 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
  });
package/cli/prompt.mjs CHANGED
@@ -42,16 +42,9 @@ export function loadProjectMemory(cwd) {
42
42
  return "";
43
43
  }
44
44
 
45
- export const MEMORY_INSTRUCTIONS = `
46
- ## Project Memory
45
+ // Notes earlier teams wrote by hand into .agentdesk/memory.md. Still shown,
46
+ // no longer written: lessons are recorded by the engine from the SUMMARY
47
+ // handoff (cli/engine/lessons.mjs), each with its source and a status.
48
+ export const LEGACY_NOTES_HEADER = `## LEGACY PROJECT NOTES (read-only)
47
49
 
48
- The team has a shared memory file at \`.agentdesk/memory.md\` for storing learnings that should persist across sessions. This is LOCAL and gitignoredit never leaves this machine.
49
-
50
- **When to save**: After discovering something non-obvious that cost time (test setup steps, required seed data, login credentials for test env, workarounds, environment quirks, deployment steps). If you had to figure it out, save it so you don't have to next time.
51
-
52
- **When NOT to save**: Code patterns, architecture, or anything derivable from the codebase. Don't duplicate what's already in README or CLAUDE.md.
53
-
54
- **Format**: Use clear markdown sections. Update existing sections rather than appending duplicates. Never store real secrets — reference env vars instead (e.g. \`$TEST_ADMIN_PASSWORD\`).
55
-
56
- **How**: Use the Edit or Write tool on \`.agentdesk/memory.md\`. Create the file if it doesn't exist.
57
- `.trim();
50
+ Notes earlier teams left by hand in \`.agentdesk/memory.md\`. Treat them as hints to verify, not as facts. Do not edit that filelessons are now recorded by the engine from the SUMMARY handoff (\`lessons\` / \`retireLessons\`).`;
@@ -7,7 +7,7 @@ export function sessionLimit(value = process.env.AGENTDESK_MAX_SESSIONS) {
7
7
 
8
8
  // Capacity belongs to the daemon, not the network connection or UI status.
9
9
  // Hold reservations through confirmation, cancellation and engine teardown.
10
- export function createSessionQueue({ limit = sessionLimit(), run, onQueued = () => {}, onError = () => {} }) {
10
+ export function createSessionQueue({ limit = sessionLimit(), run, onQueued = () => {}, onError = () => {}, onSettled = () => {} }) {
11
11
  const active = new Map();
12
12
  const waiting = [];
13
13
  let closed = false;
@@ -20,6 +20,7 @@ export function createSessionQueue({ limit = sessionLimit(), run, onQueued = ()
20
20
  active.set(job.sessionId, job);
21
21
  Promise.resolve().then(() => run(job)).catch(error => onError(job, error)).finally(() => {
22
22
  active.delete(job.sessionId);
23
+ onSettled(job);
23
24
  drain();
24
25
  });
25
26
  }
@@ -39,6 +40,7 @@ export function createSessionQueue({ limit = sessionLimit(), run, onQueued = ()
39
40
  waiting.splice(index, 1);
40
41
  return true;
41
42
  },
43
+ get(sessionId) { return active.get(sessionId) || waiting.find(job => job.sessionId === sessionId); },
42
44
  get activeIds() { return [...active.keys()]; },
43
45
  get queuedIds() { return waiting.map(job => job.sessionId); },
44
46
  close() { closed = true; waiting.length = 0; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.32.1",
3
+ "version": "0.34.0",
4
4
  "description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,9 @@
22
22
  "server": "node server/index.mjs",
23
23
  "build": "vite build",
24
24
  "preview": "vite preview",
25
- "test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs tests/projects-registry.test.mjs tests/phase-loop.test.mjs tests/proc.test.mjs tests/crypto.test.mjs tests/dotenv.test.mjs tests/update-check.test.mjs tests/setup-helpers.test.mjs tests/project-key.test.mjs tests/tracker-project.test.mjs tests/tracker-check.test.mjs tests/config.test.mjs tests/engine-env.test.mjs tests/engine-events.test.mjs tests/engine-verdict.test.mjs tests/engine-hooks.test.mjs tests/engine-commands.test.mjs tests/engine-agents.test.mjs tests/session-isolation.test.mjs tests/engine-session.test.mjs tests/engine-prompts.test.mjs tests/engine-schemas.test.mjs tests/engine-claude-auth.test.mjs tests/worktrees.test.mjs tests/workspaces-api.test.mjs tests/engine-outcome.test.mjs tests/session-outcomes.test.mjs tests/outcome-hydration.test.mjs tests/mobile-outcomes.test.mjs tests/session-queue.test.mjs tests/useFollowScroll.test.mjs tests/session-usage.test.mjs tests/task-lookup.test.mjs tests/engine-evidence.test.mjs tests/engine-handoff.test.mjs tests/engine-team-profile.test.mjs tests/project-settings.test.mjs tests/delivery.test.mjs tests/feed-view.test.mjs",
25
+ "test:sdk-handoff": "node --test --test-concurrency=1 --test-reporter=spec tests/engine-sdk-handoff.smoke.mjs",
26
+ "test:sdk-leadership": "node --test --test-concurrency=1 --test-reporter=spec tests/engine-sdk-leadership.smoke.mjs",
27
+ "test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs tests/projects-registry.test.mjs tests/phase-loop.test.mjs tests/proc.test.mjs tests/crypto.test.mjs tests/dotenv.test.mjs tests/update-check.test.mjs tests/setup-helpers.test.mjs tests/project-key.test.mjs tests/tracker-project.test.mjs tests/tracker-check.test.mjs tests/config.test.mjs tests/engine-env.test.mjs tests/engine-events.test.mjs tests/engine-verdict.test.mjs tests/engine-hooks.test.mjs tests/engine-commands.test.mjs tests/engine-agents.test.mjs tests/session-isolation.test.mjs tests/engine-session.test.mjs tests/engine-prompts.test.mjs tests/engine-schemas.test.mjs tests/engine-claude-auth.test.mjs tests/worktrees.test.mjs tests/workspaces-api.test.mjs tests/engine-outcome.test.mjs tests/session-outcomes.test.mjs tests/outcome-hydration.test.mjs tests/mobile-outcomes.test.mjs tests/session-queue.test.mjs tests/useFollowScroll.test.mjs tests/session-usage.test.mjs tests/task-lookup.test.mjs tests/engine-evidence.test.mjs tests/engine-handoff.test.mjs tests/engine-team-profile.test.mjs tests/project-settings.test.mjs tests/delivery.test.mjs tests/feed-view.test.mjs tests/recovery.test.mjs tests/session-recovery-api.test.mjs tests/engine-lessons.test.mjs",
26
28
  "test:coverage": "node --test --experimental-test-coverage --test-coverage-include='cli/**' --test-coverage-include='server/**' --test-coverage-lines=60 --test-coverage-branches=62 tests/*.test.mjs",
27
29
  "lint": "eslint .",
28
30
  "lint:fix": "eslint . --fix",
@@ -0,0 +1,28 @@
1
+ // Public recovery state contains no credentials or raw tool responses.
2
+ export const RECOVERY_STATES = new Set(["running", "recovering", "pausing", "paused", "waiting_access", "waiting_input"]);
3
+
4
+ export function classifyFailure(text = "", code = "") {
5
+ const detail = `${code} ${text}`;
6
+ if (/401|oauth.*revoked|failed to authenticate|not.logged.in|authentication|invalid.api.key/i.test(detail)) {
7
+ return { state: "waiting_access", kind: "authentication", message: "The model connection needs attention. Work has been preserved. Reconnect on the daemon machine, then verify and resume." };
8
+ }
9
+ if (/permission|not.allowed|denied|403/i.test(detail)) {
10
+ return { state: "waiting_access", kind: "permission", message: "A required operation was denied. Check project access or correct the instructions, then retry." };
11
+ }
12
+ if (/handoff|structured/i.test(detail)) {
13
+ return { state: "waiting_input", kind: "handoff", message: "The phase handoff could not be recovered. Review the preserved findings before retrying." };
14
+ }
15
+ return { state: "waiting_input", kind: "execution", message: "Work paused after an execution failure. Review the details, correct instructions or the environment, then retry." };
16
+ }
17
+
18
+ export function normalizeRecovery(value) {
19
+ if (!value || !RECOVERY_STATES.has(value.state)) return null;
20
+ return {
21
+ state: value.state,
22
+ kind: String(value.kind || "execution").slice(0, 60),
23
+ message: String(value.message || "").slice(0, 1500),
24
+ phase: String(value.phase || "").slice(0, 30),
25
+ ready: value.ready === true,
26
+ updatedAt: Date.now(),
27
+ };
28
+ }
@@ -1,6 +1,6 @@
1
1
  // Running consumes execution capacity. Open also includes queued work and a
2
2
  // human handoff: neither may disappear through generic inactive cleanup.
3
3
  export const RUNNING_SESSION_STATUSES = new Set(["active", "stale"]);
4
- export const OPEN_SESSION_STATUSES = new Set(["queued", "active", "stale", "handoff"]);
4
+ export const OPEN_SESSION_STATUSES = new Set(["queued", "active", "stale", "handoff", "paused"]);
5
5
  export const isRunningSession = status => RUNNING_SESSION_STATUSES.has(status);
6
6
  export const isOpenSession = status => OPEN_SESSION_STATUSES.has(status);