@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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,29 @@ All user-facing changes to AgentDesk. Each entry is tagged:
8
8
 
9
9
  Internal refactors, infrastructure changes, and architectural notes are not listed here.
10
10
 
11
+ ## [0.34.0] — 2026-09-21
12
+
13
+ ### Added
14
+ - `[CLI]` Project lessons with provenance. SUMMARY and solo sessions propose lessons with evidence and a scope; the engine records them in `.agentdesk/lessons.json` with the session, task, phase, agent and revision they came from. Lessons from a session that ended complete are active at once; lessons from an interrupted session stay proposed until a later complete session confirms them. Prompts receive the active lessons in scope, newest first, and the team can retire a lesson by id when it proves wrong. Solo sessions have no review gate, so their lessons stay proposed until a team session that ends complete confirms them.
15
+
16
+ ### Changed
17
+ - `[Both]` Jane now leads delivery explicitly: she sets priorities, assigns an owner and required evidence for each step, resolves disagreements, follows up on incomplete work, and carries decisions into the next phase. Her reports distinguish verified outcomes from blockers and unconfirmed tracker writes; unmet requirements cannot be silently deferred or described as ready.
18
+ - `[CLI]` `.agentdesk/memory.md` is no longer written by the team. It is still shown as read-only legacy notes.
19
+
20
+ ### Fixed
21
+ - `[CLI]` Team and solo agents can now submit their structured handoffs. The required submission tool was missing from their allowed tool lists, so intake could finish its research and Jira startup comment but stop before implementation or a final tracker report. Handoff repair also has access to the submission tool without execution tools.
22
+ - `[CLI]` When the model exhausts its structured-output retries, a solo session now ends as a handoff instead of reporting itself complete without a summary, and a review gets the same one-shot repair as the other phases instead of sending the team back into execution blindly. The underlying error is reported when repair fails too.
23
+ - `[CLI]` Issue label updates on GitHub are best-effort again: a missing label is not reported as a failed tracker write.
24
+
25
+ ## [0.33.0] — 2026-09-21
26
+
27
+ ### Added
28
+ - `[Both]` New dashboard sessions support pausing, saving task corrections, and resuming the same conversation and workspace after workers stop. Saved instructions and recovery status survive server restarts; the daemon retains the task checkpoint and checks access again before continuing.
29
+ - `[UI]` A recovery panel explains access failures, shows when intervention is needed, and offers instructions for reconnecting the model provider.
30
+
31
+ ### Changed
32
+ - `[CLI]` Missing phase handoffs get one repair attempt from saved findings with execution tools disabled. Failed recovery preserves the work for intervention. Recognized external writes are recorded before dispatch, and exact repeats are blocked during continuation.
33
+
11
34
  ## [0.32.1] — 2026-09-21
12
35
 
13
36
  ### Fixed
package/README.md CHANGED
@@ -46,7 +46,7 @@ AgentDesk spawns a team of AI agents inside Claude Code that collaborate on your
46
46
 
47
47
  | Agent | Role |
48
48
  |-------|------|
49
- | Jane | Product Lead — facilitates, clarifies requirements, coordinates the team |
49
+ | Jane | Team Lead — owns priorities and delivery, assigns work, challenges evidence, and resolves disagreements |
50
50
  | Dennis | Senior Developer — implements the solution |
51
51
  | Sam | Architecture Auditor — code structure, separation of concerns |
52
52
  | Bart | QA Engineer — edge cases, risks, acceptance criteria, screenshots |
@@ -288,14 +288,32 @@ When working on Jira or Linear tasks, agents automatically download and review a
288
288
 
289
289
  ### Handoff & Resume
290
290
 
291
- If a session hits Claude's rate or context limit, AgentDesk saves a resume snapshot (`.agentdesk-resume.md`) and marks the session as **Handoff** in the dashboard. When you run the same task again, agents pick up where the previous session left off skipping completed work and continuing from the last phase.
291
+ The phase lead submits each handoff through the SDK's `StructuredOutput` tool. It must be included in both the selected agent's tool list and the unattended tool permissions; chat text alone cannot advance the phase. To check this against the real SDK after changes to tool configuration, run `npm run test:sdk-handoff`. This opt-in diagnostic requires a Claude login and uses model tokens. It checks every phase, solo mode, and handoff repair with fixed fixtures and execution tools blocked.
292
+
293
+ Run `npm run test:sdk-leadership` to check Jane's decisions against supplied reports: a superseded scope deferral, a completion claim missing visual evidence, and a failed Jira comment. This opt-in behavioral check also requires a Claude login and uses model tokens; delegation and external actions are blocked. It checks decisions and named follow-up owners using the production prompts.
294
+
295
+ Sessions started from the dashboard with an updated daemon can be paused and resumed in the same conversation and workspace. Use **Pause / check workers**, or enter a correction and choose **Pause & save**. Instructions are saved immediately; **Verify & resume** becomes available after the previous workers have stopped and released the workspace. A quarantined workspace remains blocked until its workers exit.
296
+
297
+ Resume restores the original task, discovered tracker task ID, pending phase, saved findings, user corrections, token totals, and recorded external actions. Corrections trigger replanning against the existing work. Access is checked again before continuing; a failed login check preserves both the checkpoint and the pending correction. For a revoked model login, reconnect Claude on the daemon computer using the same account profile, then choose **Verify & resume**. Session controls require the session owner and a connected daemon that supports recovery; older sessions need a separate continuation.
298
+
299
+ A missing phase handoff gets one repair attempt using the preserved findings with execution tools disabled. If repair fails, or required tracker access is denied during intake or planning, work pauses for intervention. Recognized external write commands are fingerprinted before dispatch and exact repeats are blocked. An uncertain result must be reconciled with the provider before continuing. This is a conservative replay guard, not general deduplication of equivalent commands or arbitrary scripts.
300
+
301
+ The recovery checkpoint lives on the daemon computer. The dashboard retains instructions and recovery status across server restarts, but cannot resume without the local checkpoint and workspace. It never silently creates a new task when a required checkpoint is missing.
302
+
303
+ Terminal sessions also save a resume snapshot (`.agentdesk-resume.md`) when interrupted. To start a continuation from the terminal, use the command printed in that snapshot, including `--resume-worktree` when present:
292
304
 
293
305
  ```bash
294
306
  # Session hits limit → "HANDOFF" shown in terminal
295
- # Resume when ready:
296
- agentdesk team KEN-517
307
+ # Continue in the retained workspace:
308
+ agentdesk team KEN-517 --resume-worktree SESSION-ID
297
309
  ```
298
310
 
311
+ ### Project lessons
312
+
313
+ The team's cross-session memory is an engine-owned ledger, `.agentdesk/lessons.json` in the project directory (local, gitignored, shared by every session worktree). At the end of a session the SUMMARY handoff (or the solo agent's) may propose lessons — a setup step, seed data, an environment quirk — each with evidence and a scope (`project`, `area:<ui|copy|docs|api|data>`, or `path:<prefix>`). The engine records them with where they came from (session, task, phase, agent, revision). A lesson from a session that ended **complete** (verified approval) is active immediately; one from a session that ended in handoff stays proposed until a later complete session proposes the same lesson. Solo sessions have no review gate, so their lessons stay proposed until a team session that ends complete confirms them. Every phase prompt receives the active lessons in scope, newest first (at most 30). A team that finds a lesson wrong or obsolete retires it by id in the same handoff; retired lessons are kept for the record and never shown again.
314
+
315
+ `.agentdesk/memory.md`, the hand-written notes file from earlier versions, is still shown to the team as read-only legacy notes but is no longer written.
316
+
299
317
  ### Session protocol
300
318
 
301
319
  At the end of each session, Jane posts a structured summary on the tracker covering:
package/cli/agents.mjs CHANGED
@@ -3,16 +3,16 @@
3
3
  export const BUILT_IN_AGENTS = {
4
4
  Jane: {
5
5
  badge: "●● JANE ●●",
6
- role: "Product Analyst / Team Lead",
7
- description: "leads the session, clarifies requirements, coordinates the team, dictates tracker comments (Dennis executes), decomposes large tasks into subtasks",
8
- groundRules: "Jane is strictly non-technical and NEVER calls tools. She MUST NEVER: call Read, Write, Edit, Bash, Grep, Glob, or any other tool; read or reference code; mention file names, paths, function names, variable names, class names, imports, code snippets, terminal commands, error messages, or stack traces; write or modify code; execute tracker API calls herself. Her language is product-only: user stories, acceptance criteria, scope, priorities, stakeholder impact. During INTAKE she evaluates task complexity and decomposes large features into subtasks (basic vs deferred). When she needs technical information she asks Dennis or Sam (\"Dennis, can you check...\"). When tracker comments or status transitions are needed, Jane DICTATES the exact text and Dennis runs the command. If you are about to call a tool while the current speaker is Jane, STOP and switch to Dennis instead.",
9
- planning: "Requirements: what we're building, acceptance criteria, scope. Flags UI tasks for Luna. (No code, no files, no tool calls.)",
6
+ role: "Team Lead",
7
+ description: "owns delivery against the user's requirements, sets priorities, makes decisions, assigns work, challenges unsupported reports, resolves disagreements, and decomposes large tasks into accountable steps",
8
+ groundRules: "Jane owns the outcome: she sets acceptance criteria, decomposes work, assigns an owner and expected evidence, and follows up until each requirement is verified or explicitly blocked. The latest user correction takes precedence over earlier plans and agent-proposed deferrals. She makes routine decisions within the authorized scope and asks the user only for a consequential choice or missing information the team cannot resolve. In team mode she delegates implementation and tracker operations; she does not edit code or run commands herself. She can discuss technical evidence, file references and tradeoffs when needed to judge a report. She never overrides a failed check or required review gate, invents evidence, or treats an agent's unsupported claim as completion. She keeps unrelated improvements out of scope and reports unresolved blockers honestly.",
9
+ planning: "Set the delivery target and acceptance criteria; choose the approach from the team's evidence; resolve tradeoffs; assign owners, dependencies and verification for each step.",
10
10
  execution: {
11
- step: "Jane wraps up (dictates, Dennis executes)",
11
+ step: "Jane leads delivery and closes the loop",
12
12
  tasks: [
13
- "Jane dictates the final summary text (product outcome, what was delivered, any scope decisions).",
14
- "Dennis transitions the task to 'In Review' and posts the final summary comment on the tracker.",
15
- "Jane verifies the PR description reflects the product outcome (reads Dennis's report, does not open files).",
13
+ "Track each acceptance criterion, its owner, and the evidence still owed; send incomplete work back with a specific next action.",
14
+ "Resolve disagreements using requirements, project conventions and observations; preserve all required audit and verification gates.",
15
+ "Dictate an accurate final tracker summary and verify the posting receipt; distinguish delivered, unverified, blocked and user-approved deferred work.",
16
16
  ],
17
17
  order: 99,
18
18
  },
package/cli/daemon.mjs CHANGED
@@ -17,6 +17,7 @@ import { fileURLToPath } from "url";
17
17
  import { dirname } from "path";
18
18
  import { repositoryInfo, listWorkspaces, findWorkspacesByTask, inspectWorkspace, cleanupWorkspace, recoverWorkspaceLocks } from "./worktrees.mjs";
19
19
  import { createSessionQueue, sessionLimit, confirmWithAbort } from "./session-queue.mjs";
20
+ import { classifyFailure } from "../shared/recovery.mjs";
20
21
 
21
22
  const __dirname_daemon = dirname(fileURLToPath(import.meta.url));
22
23
  const DAEMON_VERSION = JSON.parse(readFileSync(join(__dirname_daemon, "../package.json"), "utf-8")).version;
@@ -280,17 +281,20 @@ export async function runDaemon() {
280
281
  let confirmationQueue = Promise.resolve();
281
282
  const maxSessions = sessionLimit();
282
283
  const sessionQueue = createSessionQueue({ limit: maxSessions, run: handleStartSession,
283
- onQueued: job => sendBuffered(job.sessionId, { type: "session:update", status: "queued" }),
284
- onError: job => send({ type: "daemon:error", sessionId: job.sessionId, error: "Unable to start queued session" }),
284
+ onQueued: job => sendBuffered(job.sessionId, { type: "session:update", status: "queued", runId: job.runId }),
285
+ onSettled: job => {
286
+ if (job.recoveryCompletion) sendBuffered(job.sessionId, { type: "session:recovery", runId: job.runId, recovery: job.recoveryCompletion });
287
+ },
288
+ onError: job => send({ type: "daemon:error", sessionId: job.sessionId, runId: job.runId, error: "Unable to start queued session" }),
285
289
  });
286
290
  function enqueueSession(job) {
287
291
  const project = projects.find(p => p.id === job.projectId);
288
- if (!project) { send({ type: "daemon:error", sessionId: job.sessionId, error: "Unknown project" }); return; }
292
+ if (!project) { send({ type: "daemon:error", sessionId: job.sessionId, runId: job.runId, error: "Unknown project" }); return; }
289
293
  try {
290
294
  let key = job.workspace?.resumeId ? `workspace:${job.workspace.resumeId}` : null;
291
295
  if (job.workspace?.enabled === false) key = `repo:${repositoryInfo(project.path).repo}`;
292
296
  sessionQueue.enqueue({ ...job, key });
293
- } catch (error) { send({ type: "daemon:error", sessionId: job.sessionId, error: error.message }); }
297
+ } catch (error) { send({ type: "daemon:error", sessionId: job.sessionId, runId: job.runId, error: error.message }); }
294
298
  }
295
299
 
296
300
  function send(data) {
@@ -332,6 +336,7 @@ export async function runDaemon() {
332
336
  type: "daemon:connect",
333
337
  version: DAEMON_VERSION,
334
338
  worktrees: true,
339
+ recovery: true,
335
340
  maxSessions,
336
341
  projects: projects.map(p => ({ id: p.id, name: p.name })),
337
342
  });
@@ -366,6 +371,13 @@ export async function runDaemon() {
366
371
  if (msg.type === "daemon:cancel-session") {
367
372
  handleCancelSession(msg);
368
373
  }
374
+ if (msg.type === "daemon:pause-session") {
375
+ try { handlePauseSession(msg); }
376
+ catch {
377
+ sendBuffered(msg.sessionId, { type: "session:recovery", runId: msg.runId,
378
+ recovery: { state: "paused", kind: "restart", ready: false, message: "Could not verify workspace ownership. Check the daemon and try checking workers again." } });
379
+ }
380
+ }
369
381
  if (msg.type === "daemon:workspace-request") handleWorkspaceRequest(msg);
370
382
  });
371
383
 
@@ -453,12 +465,13 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
453
465
  }
454
466
  }
455
467
 
456
- async function handleStartSession({ sessionId, projectId, taskId: remoteTaskId, prompt, screenshots: screenshotsOverride, workspace }) {
468
+ async function handleStartSession(job) {
469
+ const { sessionId, projectId, taskId: remoteTaskId, prompt, screenshots: screenshotsOverride, workspace, runId, resumeSession, instructions = [] } = job;
457
470
  // Validate project against local allowlist
458
471
  const project = projects.find(p => p.id === projectId);
459
472
  if (!project) {
460
473
  console.log(` ${red}Rejected:${reset} unknown project ${dim}${projectId}${reset}`);
461
- send({ type: "daemon:error", sessionId, error: `Project "${projectId}" not registered with this daemon` });
474
+ send({ type: "daemon:error", sessionId, runId, error: `Project "${projectId}" not registered with this daemon` });
462
475
  return;
463
476
  }
464
477
 
@@ -471,6 +484,7 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
471
484
  if (activeSessions.has(sessionId)) return;
472
485
  const activeSession = {
473
486
  sessionId, projectId,
487
+ runId,
474
488
  child: null,
475
489
  // Cancelling must stop the whole phase pipeline, not just the child that
476
490
  // happens to be running: killing one phase's child would otherwise let
@@ -531,6 +545,8 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
531
545
  sessionId,
532
546
  cwd: project.path,
533
547
  workspace,
548
+ resumeSession, instructions,
549
+ onWorkspace: record => { activeSession.workspaceId = record?.id; },
534
550
  apiKey,
535
551
  serverUrl: agentdeskServer,
536
552
  abortSignal: sessionAbort.signal,
@@ -564,6 +580,10 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
564
580
  }
565
581
 
566
582
  return (event) => {
583
+ event = { ...event, runId };
584
+ if (event.type === "session:start") activeSession.startedExecution = true;
585
+ if (event.type === "session:recovery") activeSession.recovery = event.recovery;
586
+ if (event.type === "session:workspace" && event.quarantined) activeSession.quarantined = true;
567
587
  // Confirmed accomplishments may arrive during cancellation teardown.
568
588
  // They do not revive the session or emit a second terminal event.
569
589
  if (["session:outcome", "session:workspace", "session:usage"].includes(event.type)) { sendBuffered(sessionId, event); return; }
@@ -594,6 +614,7 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
594
614
  };
595
615
  })(),
596
616
  });
617
+ activeSession.quarantined ||= result.quarantined;
597
618
 
598
619
  const outcome = result.status || (result.handoff ? "handoff" : "complete");
599
620
  const clean = outcome === "complete";
@@ -616,10 +637,42 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
616
637
  } catch (err) {
617
638
  console.log(` ${red}Failed to start session:${reset} ${err.message}`);
618
639
  if (activeSession.cancelled) return;
640
+ activeSession.recovery = classifyFailure(err.message);
619
641
  // Send generic error to server — don't leak internal details (paths, config, etc.)
620
- send({ type: "daemon:error", sessionId, error: "Failed to start session on daemon" });
642
+ send({ type: "daemon:error", sessionId, runId, error: "Failed to start session on daemon" });
621
643
  } finally {
622
644
  if (activeSessions.get(sessionId) === activeSession) activeSessions.delete(sessionId);
645
+ if (activeSession.paused || activeSession.recovery?.state !== "running" && activeSession.recovery) {
646
+ job.recoveryCompletion = activeSession.paused
647
+ ? { state: "paused", kind: activeSession.startedExecution || resumeSession ? "user" : "queued", message: "Work preserved. Your saved instructions will be applied when you resume." }
648
+ : activeSession.recovery;
649
+ job.recoveryCompletion = { ...job.recoveryCompletion, ready: !activeSession.quarantined };
650
+ if (activeSession.quarantined) job.recoveryCompletion.message = "Waiting for previous workers to exit. The workspace is quarantined; it cannot be resumed yet.";
651
+ }
652
+ }
653
+ }
654
+
655
+ function handlePauseSession({ sessionId, runId, workspaceId }) {
656
+ const job = sessionQueue.get(sessionId);
657
+ if (job && job.runId !== runId) return;
658
+ const session = activeSessions.get(sessionId);
659
+ if (session && session.runId !== runId) return;
660
+ if (session) {
661
+ session.paused = true;
662
+ session.cancelled = true;
663
+ stopSessionWork(session);
664
+ } else if (sessionQueue.cancel(sessionId)) {
665
+ sendBuffered(sessionId, { type: "session:recovery", runId, recovery: { state: "paused", kind: job?.resumeSession ? "user" : "queued", ready: true,
666
+ message: "Paused before starting. Resume to begin with your saved instructions." } });
667
+ } else {
668
+ // After a daemon restart, no local worker owns the session. Workspace
669
+ // acquisition on resume still checks surviving child PIDs and leases.
670
+ recoverWorkspaceLocks();
671
+ const record = workspaceId ? listWorkspaces({ projectPaths: projects.map(p => p.path) }).find(r => r.id === workspaceId) : null;
672
+ const busy = sessionQueue.activeIds.includes(sessionId) || (record ? inspectWorkspace(record).active : !!workspaceId);
673
+ if (record && !busy) sendBuffered(sessionId, { type: "session:workspace", runId, quarantined: false });
674
+ sendBuffered(sessionId, { type: "session:recovery", runId, recovery: { state: "paused", kind: "restart", ready: !busy,
675
+ message: busy ? "The workspace is still busy or unavailable. Check workers again after they exit." : "No worker is running on this daemon. Resume will check the saved workspace lease and checkpoint." } });
623
676
  }
624
677
  }
625
678
 
@@ -633,8 +686,9 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
633
686
  }
634
687
 
635
688
  function handleCancelSession({ sessionId }) {
689
+ const job = sessionQueue.get(sessionId);
636
690
  if (sessionQueue.cancel(sessionId)) {
637
- sendBuffered(sessionId, { type: "session:end", status: "stopped", duration: "0s" });
691
+ sendBuffered(sessionId, { type: "session:end", runId: job?.runId, status: "stopped", duration: "0s" });
638
692
  return;
639
693
  }
640
694
  const activeSession = activeSessions.get(sessionId);
@@ -649,7 +703,7 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
649
703
 
650
704
  // The `cancelled` flag also suppresses the orchestrator's own
651
705
  // session:end, so this stays the single terminal event for the session.
652
- sendBuffered(sessionId, { type: "session:end", duration, steps: 0, inputTokens: 0, outputTokens: 0, status: "stopped" });
706
+ sendBuffered(sessionId, { type: "session:end", runId: activeSession.runId, duration, steps: 0, inputTokens: 0, outputTokens: 0, status: "stopped" });
653
707
  }
654
708
  }
655
709
 
@@ -3,8 +3,8 @@
3
3
  // The team (cli/agents.mjs BUILT_IN_AGENTS + project customisations via
4
4
  // resolveTeam) becomes, per phase:
5
5
  // - one lead definition — Jane — that the main thread runs *as* (Options.agent),
6
- // with the Agent tool and nothing else. She cannot touch code because the
7
- // tools do not exist in her session.
6
+ // with Agent for delegation and StructuredOutput for the phase handoff.
7
+ // She cannot touch code because those tools do not exist in her session.
8
8
  // - one AgentDefinition per engineering role, with the tool list that role
9
9
  // needs in that phase and nothing more.
10
10
  //
@@ -104,16 +104,23 @@ export function leadSystemPrompt(roster, phase) {
104
104
  const lines = Object.entries(roster).map(([n, d]) => rosterLine(n, d));
105
105
  return [
106
106
  `You are ${LEAD}, ${jane.role}. ${jane.description}.`,
107
+ `Leadership responsibilities: ${jane.groundRules}`,
107
108
  "",
108
- "You coordinate; you do not build. You have exactly one tool Agent and you use it to delegate to the team. You never read files, run commands, or edit anything yourself; when you need technical information, ask an agent for it.",
109
+ "You coordinate; you do not build. Use Agent to delegate to the team and StructuredOutput to submit the phase handoff when the work is done. You never read files, run commands, or edit anything yourself; when you need technical information, ask an agent for it.",
110
+ "Finish by calling StructuredOutput with the required schema. A closing chat message alone does not submit the handoff. Do not repeat the JSON in chat.",
109
111
  "Always delegate with run_in_background: false. A backgrounded agent's own tool calls cannot be approved, so it will be unable to do anything.",
110
- "Your language is product-only: user stories, acceptance criteria, scope, priorities, stakeholder impact.",
112
+ "Explain decisions in plain language tied to the user's outcome. Use technical details when they help assign work, challenge a claim, or explain a tradeoff.",
111
113
  "",
112
114
  `Team available in phase ${phase}:`,
113
115
  ...lines,
114
116
  "",
115
- "When you delegate, give the agent everything it needs in the prompt: the task, the relevant decisions so far, and exactly what to report back. Agents start with no memory of this conversation.",
116
- "When an agent reports, relay the substance in one to three lines and move on. Never claim a result you did not receive from an agent.",
117
+ "Start each phase by stating its objective, the outstanding acceptance criteria, and who owns the next action. On a resumed task, work from the existing implementation and latest user feedback; reopen only what that feedback or current evidence requires.",
118
+ "When you delegate, name one owner and provide the objective, relevant context and user corrections, scope boundaries, dependencies, and the exact evidence needed to accept the result. Agents start with no memory of this conversation. Use only agents available in this phase.",
119
+ "When an agent reports, assess it against the assignment. Accept with evidence, return a specific gap to its owner, or identify a real blocker. Follow each assessment with your decision and the next action; do not merely relay the report. Never claim a result you did not receive or treat a proposed action as an executed one.",
120
+ "Resolve disagreements by comparing observations with the user's requirements and project conventions. Request a focused check when facts conflict, then make the call and explain why. An unsupported rejection needs evidence; a valid blocking finding must be fixed and rechecked, never voted away. Do not re-run broad discovery when a targeted check will settle the issue.",
121
+ "Keep a concise account of requirements, owners, accepted evidence and unresolved blockers in the phase's existing handoff fields. Carry your decisions and next actions forward. A prior agent's deferral does not authorize skipping a current user requirement; do not silently expand scope either.",
122
+ "If blocked, assign a bounded diagnostic or recovery step to an available agent. If it requires user access or a consequential scope decision, state what is blocked, what was tried, and the smallest input needed; never weaken acceptance criteria to call the task done. Continue independent authorized work only where the phase's gates permit it.",
123
+ "Report meaningful decisions, resolved blockers, and the next owner/action concisely. Avoid repeating long agent reports, narrating every delegation, or requesting permission for routine work already authorized.",
117
124
  "Announce a new task id, if you create one, on its own line as `TASK_ID: <id>`, and a short session title as `SESSION_TITLE: <title>`.",
118
125
  ].join("\n");
119
126
  }
@@ -124,10 +131,10 @@ export function leadSystemPrompt(roster, phase) {
124
131
  export function soloDefinition(agent) {
125
132
  const def = {
126
133
  description: `${agent.role}: ${agent.description}`,
127
- prompt: `You are ${agent.name}, ${agent.role}. You work alone on the task given to you, end to end.`,
128
- tools: [...FULL],
134
+ prompt: `You are ${agent.name}, ${agent.role}. You work alone on the task given to you, end to end. Finish by calling StructuredOutput with the required schema; a chat message alone does not submit the handoff.`,
135
+ tools: [...FULL, "StructuredOutput"],
129
136
  };
130
- return { agents: { [agent.name]: def }, allowedTools: [...FULL], lead: agent.name };
137
+ return { agents: { [agent.name]: def }, allowedTools: [...def.tools], lead: agent.name };
131
138
  }
132
139
 
133
140
  // team — resolveTeam(config) output (array of { name, role, description, ... })
@@ -168,7 +175,9 @@ export function agentsForPhase({ phase, team, phaseModels = {}, profile = null }
168
175
  sdkAgents[LEAD] = {
169
176
  description: `${BUILT_IN_AGENTS.Jane.role}: ${BUILT_IN_AGENTS.Jane.description}`,
170
177
  prompt: leadSystemPrompt(agents, phase),
171
- tools: ["Agent"],
178
+ // The SDK filters even its schema tool against the selected agent's tools.
179
+ // Without this, outputFormat yields success with no structured_output.
180
+ tools: ["Agent", "StructuredOutput"],
172
181
  };
173
182
 
174
183
  const allowed = new Set(["Agent"]);
@@ -0,0 +1,171 @@
1
+ // Project lessons with provenance — an engine-owned ledger.
2
+ //
3
+ // `.agentdesk/memory.md` was free text the agents edited themselves: no
4
+ // source, no scope, no way to correct or retire an entry, and in a worktree
5
+ // session it was read from the worktree, where it never existed. This ledger
6
+ // lives in the *source* project directory (shared by every worktree; local,
7
+ // gitignored). Agents never write it: SUMMARY and SOLO propose lessons and
8
+ // retirements in their structured handoff, the engine records them with
9
+ // where they came from, and the engine's own verdict on the session decides
10
+ // whether a lesson is `active` (session ended `complete`) or only `proposed`.
11
+
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { randomBytes } from "node:crypto";
15
+ import { TOUCH_AREAS } from "./team-profile.mjs";
16
+ import { shortRev } from "./evidence.mjs";
17
+
18
+ export const LESSON_STATUSES = Object.freeze(["proposed", "active", "retired"]);
19
+ export const MAX_LESSONS = 500; // ledger size; oldest retired, then oldest proposed, go first
20
+ export const MAX_LESSON_TEXT = 1000; // stored text
21
+ export const MAX_LESSON_NOTE = 500; // stored evidence / retirement reason
22
+ export const INJECT_LIMIT = 30; // entries per prompt
23
+ export const INJECT_TEXT_LIMIT = 300; // characters per entry in a prompt
24
+
25
+ export function lessonsPath(sourceCwd) {
26
+ return join(sourceCwd, ".agentdesk", "lessons.json");
27
+ }
28
+
29
+ const EMPTY = () => ({ version: 1, lessons: [] });
30
+
31
+ export function readLessons(path) {
32
+ try {
33
+ if (!existsSync(path)) return EMPTY();
34
+ const data = JSON.parse(readFileSync(path, "utf8"));
35
+ if (!data || data.version !== 1 || !Array.isArray(data.lessons)) return { version: 1, lessons: [], unreadable: true };
36
+ return {
37
+ version: 1,
38
+ lessons: data.lessons
39
+ .filter(l => l && typeof l.id === "string" && typeof l.text === "string")
40
+ .map(l => ({
41
+ ...l,
42
+ confirmedBy: Array.isArray(l.confirmedBy) ? l.confirmedBy.filter(s => typeof s === "string") : [],
43
+ status: LESSON_STATUSES.includes(l.status) ? l.status : "proposed",
44
+ scope: normalizeScope(l.scope),
45
+ evidence: typeof l.evidence === "string" ? l.evidence : "",
46
+ createdAt: typeof l.createdAt === "string" ? l.createdAt : "",
47
+ source: l.source && typeof l.source === "object" ? l.source : {},
48
+ })),
49
+ };
50
+ } catch { return { version: 1, lessons: [], unreadable: true }; }
51
+ }
52
+
53
+ export function writeLessons(path, data) {
54
+ mkdirSync(dirname(path), { recursive: true });
55
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
56
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
57
+ renameSync(tmp, path);
58
+ }
59
+
60
+ // "project" | "area:<ui|copy|docs|api|data>" | "path:<relative prefix>".
61
+ // Anything else widens to "project" — the safe direction for a hint.
62
+ export function normalizeScope(scope) {
63
+ const s = String(scope ?? "").trim();
64
+ if (s === "project") return s;
65
+ const area = s.match(/^area:([a-z]+)$/);
66
+ if (area) return TOUCH_AREAS.includes(area[1]) ? s : "project";
67
+ const path = s.match(/^path:(.+)$/);
68
+ if (path) {
69
+ const prefix = path[1].trim().replace(/^\.\//, "");
70
+ if (prefix && prefix.length <= 200 && !prefix.startsWith("/") && !prefix.split("/").includes("..")) return `path:${prefix}`;
71
+ }
72
+ return "project";
73
+ }
74
+
75
+ const clean = (text, max) => String(text ?? "").replace(/\s+/g, " ").trim().slice(0, max);
76
+ const textKey = text => clean(text, MAX_LESSON_TEXT).toLowerCase();
77
+ const newId = () => randomBytes(4).toString("hex");
78
+ const byAge = (a, b) => String(a.createdAt).localeCompare(String(b.createdAt));
79
+
80
+ // When the ledger outgrows MAX_LESSONS, drop the oldest retired entries, then
81
+ // the oldest proposed ones. Active lessons are never dropped here.
82
+ function bounded(list) {
83
+ const keep = [...list];
84
+ for (const status of ["retired", "proposed"]) {
85
+ while (keep.length > MAX_LESSONS) {
86
+ const victim = keep.filter(l => l.status === status).sort(byAge)[0];
87
+ if (!victim) break;
88
+ keep.splice(keep.indexOf(victim), 1);
89
+ }
90
+ }
91
+ return keep;
92
+ }
93
+
94
+ // Apply a single proposal to the lessons list, mutating list and counts.
95
+ function applyProposal({ list, proposal, source, complete, at, counts }) {
96
+ const text = clean(proposal?.text, MAX_LESSON_TEXT);
97
+ if (!text) return;
98
+ const key = textKey(text);
99
+ const existing = list.find(l => l.status !== "retired" && textKey(l.text) === key);
100
+ if (existing) {
101
+ if (!complete) return; // an unverified session cannot confirm anything
102
+ if (!existing.confirmedBy.includes(source.sessionId)) existing.confirmedBy.push(source.sessionId);
103
+ if (existing.status === "proposed") { existing.status = "active"; counts.activated++; } else counts.confirmed++;
104
+ return;
105
+ }
106
+ list.push({
107
+ id: newId(), text, scope: normalizeScope(proposal?.scope), evidence: clean(proposal?.evidence, MAX_LESSON_NOTE),
108
+ source: { sessionId: source.sessionId, taskId: source.taskId || null, phase: source.phase, agent: source.agent, revision: source.revision || null },
109
+ createdAt: at, status: complete ? "active" : "proposed", confirmedBy: complete ? [source.sessionId] : [],
110
+ });
111
+ if (complete) counts.activated++; else counts.proposed++;
112
+ }
113
+
114
+ // Apply a single retirement to the lessons list, mutating list and counts.
115
+ function applyRetirement({ list, retirement, source, at, counts }) {
116
+ const index = list.findIndex(l => l.id === retirement?.id);
117
+ if (index < 0 || list[index].status === "retired") return;
118
+ if (list[index].source?.sessionId === source.sessionId) { list.splice(index, 1); counts.dropped++; return; }
119
+ list[index] = { ...list[index], status: "retired", retired: { sessionId: source.sessionId, reason: clean(retirement?.reason, MAX_LESSON_NOTE), at } };
120
+ counts.retired++;
121
+ }
122
+
123
+ // Records what a session's SUMMARY/SOLO handed back. `complete` is the
124
+ // engine's verdict on the whole session (verified approval), not the agents'.
125
+ export function recordLessons({ path, proposals = [], retirements = [], source, complete = false, now = () => new Date().toISOString() }) {
126
+ const data = readLessons(path);
127
+ if (data.unreadable) throw new Error("lessons.json exists but is not a version-1 ledger; not overwriting it");
128
+ const list = data.lessons;
129
+ const at = now();
130
+ const counts = { activated: 0, proposed: 0, confirmed: 0, retired: 0, dropped: 0 };
131
+
132
+ for (const p of Array.isArray(proposals) ? proposals : []) {
133
+ applyProposal({ list, proposal: p, source, complete, at, counts });
134
+ }
135
+
136
+ for (const r of Array.isArray(retirements) ? retirements : []) {
137
+ applyRetirement({ list, retirement: r, source, at, counts });
138
+ }
139
+
140
+ const changed = Object.values(counts).some(n => n > 0);
141
+ data.lessons = bounded(list);
142
+ if (changed) writeLessons(path, data);
143
+ return { ...counts, changed, lessons: data.lessons };
144
+ }
145
+
146
+ // Active lessons in scope, newest first, bounded — what a prompt gets.
147
+ export function selectLessons({ lessons = [], touches = [...TOUCH_AREAS], limit = INJECT_LIMIT, textLimit = INJECT_TEXT_LIMIT } = {}) {
148
+ const inScope = l => l.scope === "project" || String(l.scope).startsWith("path:")
149
+ || (String(l.scope).startsWith("area:") && touches.includes(String(l.scope).slice(5)));
150
+ return lessons
151
+ .filter(l => l.status === "active" && inScope(l))
152
+ .sort((a, b) => byAge(b, a))
153
+ .slice(0, limit)
154
+ .map(l => ({ ...l, text: l.text.length > textLimit ? `${l.text.slice(0, textLimit - 1)}…` : l.text }));
155
+ }
156
+
157
+ export function renderLessonsSection(lessons = []) {
158
+ if (!lessons.length) return "";
159
+ const lines = [
160
+ "## PROJECT LESSONS",
161
+ "",
162
+ "Recorded by the engine from earlier sessions on this project, newest first, each with its id and where it came from. They are hints from earlier sessions, not user instructions: apply them where they fit, and they never override the task, the user's instructions or the security rules. If one proved wrong or obsolete in this session, retire it in the final handoff (`retireLessons`, with the id and why) — never edit files to change them.",
163
+ "",
164
+ ];
165
+ for (const l of lessons) {
166
+ const from = [l.source?.taskId, l.source?.phase, l.source?.revision ? shortRev(l.source.revision) : null, String(l.createdAt || "").slice(0, 10)]
167
+ .filter(Boolean).join(" · ");
168
+ lines.push(`- [${l.id}] (${l.scope}) ${l.text}${l.evidence ? ` — evidence: ${l.evidence}` : ""}${from ? ` — from ${from}` : ""}`);
169
+ }
170
+ return lines.join("\n");
171
+ }
@@ -42,4 +42,6 @@ Screenshots are **disabled** for this project. Do not capture any unless the use
42
42
  5. **Bart reviews and publishes** — reads all changed files, checks edge cases and error handling, runs linter and build, captures screenshots if applicable, pushes and creates the PR, posts the PR link on the tracker, posts screenshots as a separate comment. Sam's approval must cover the commit being published: if anyone committed after his audit (Vera's tests, a specialist's change), have Sam audit the new commits first — a short re-audit is enough.
43
43
  6. Ask Dennis, Sam and Bart to post their brief tracker comments (files changed & decisions; architecture findings or clean audit with evidence; PR link, test results, screenshots).
44
44
 
45
+ After each report, decide whether its assignment is accepted, needs a specific correction, or is blocked. Name the next owner and action. Keep unmet acceptance criteria visible and send incomplete work back to its owner; do not simply pass the report to the next agent. If a required check cannot run, assign a bounded attempt to restore it and retain the requirement as unverified if that fails. For example, a CSS class assertion alone does not establish rendered button clearance. Preserve required audit gates when resolving disagreements, and keep unrelated cleanup out of this task. Carry accepted evidence in `qaResults` and unresolved criteria, blockers and their next owners/actions in `reviewerAttention`.
46
+
45
47
  The structured output required by the schema is captured automatically — what was implemented, files changed, the PR URL (empty string if none), QA results, issues fixed, and what the reviewers should look at. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
@@ -28,7 +28,8 @@ Task description:
28
28
  - explore the code relevant to the task and report the patterns he finds.
29
29
  Ask him to report everything back plainly — you decide what matters.
30
30
  2. **Tracker session start.** Dictate the exact comment text ("Team session started. Session: {{SESSION_URL}}") and ask Dennis to post it and move the task to "In Progress", confirming with the command output.
31
- 3. **Assess scope.** Restate the task as user outcomes and acceptance criteria. If it is too large for one session, decompose it into subtasks (basic vs deferred) in product terms and have Dennis create them in the tracker.
31
+ 3. **Set the delivery target.** Reconcile the original task, latest user corrections, and existing work. State what remains to deliver and what evidence will close each acceptance criterion. A previous agent's deferral is not permission to omit something the user now explicitly requires. Make routine scope interpretations yourself; ask only when missing information or a consequential choice cannot be resolved by the team. Decompose large work into owned steps without silently deferring requested scope; obtain a user decision if completing the request requires changing that scope.
32
32
  4. Announce `SESSION_TITLE: <4-8 word title>` on its own line.
33
+ 5. **Hand over a direction.** Put the current acceptance criteria in `requirements`, accepted facts and scope decisions in `assessment`, and the next owner, action and required evidence in `nextPhaseFocus`. Distinguish confirmed work from claims still needing verification.
33
34
 
34
35
  The structured output required by the schema is captured automatically — title, task summary, requirements, assessment (branches, PRs, patterns, resume context), subtasks, and what PLAN should focus on. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
@@ -7,13 +7,13 @@ Task: {{TASK_ID}}
7
7
 
8
8
  - Follow CLAUDE.md conventions (if present). Do not modify files unrelated to the task.
9
9
  - No code and no file changes in this phase. Plan only.
10
- - Sam's audit is a blocking gate in EXECUTION: the plan must leave room for it. The PR cannot be created until Sam signs off with file:line evidence.
10
+ - Order the work correctly: PLAN chooses the approach; EXECUTION starts with implementation and tests; Sam then audits the resulting changes inside EXECUTION; approval permits publishing. Sam's code audit gates publishing, not entry into EXECUTION. Do not create a circular dependency by requiring an audit of unwritten changes before implementation can start.
11
11
 
12
12
  {{TRACKER_SECTION}}
13
13
 
14
14
  ## Your mission
15
15
 
16
- 1. Restate the task in product terms — what the user gets, acceptance criteria, scope boundaries. No file names, no jargon.
16
+ 1. Set the delivery target — what the user gets, acceptance criteria, scope boundaries, and the highest-priority unresolved requirement. Explain technical constraints when they affect the decision.
17
17
  2. **Delegate in parallel** to the team, each with the task summary and requirements from session memory. Ask each to verify assumptions with tools (Glob, Grep, Read) — no guessing — and to report:
18
18
  - Dennis: implementation plan — files to modify, approach, complexity (S/M/L).
19
19
  - Sam: architecture review — existing patterns, module boundaries, whether the approach keeps concerns separated.
@@ -21,6 +21,7 @@ Task: {{TASK_ID}}
21
21
  {{#HAS_LUNA}} - Luna (only if the task touches UI): visual impact, accessibility, and a screenshot plan (pages, viewports).{{/HAS_LUNA}}
22
22
  {{#HAS_MARK}} - Mark (only if user-facing text changes): copy audit.{{/HAS_MARK}}
23
23
  {{#HAS_NORA}} - Nora (only if user-facing behaviour changes): which docs/README/help surfaces must change.{{/HAS_NORA}}
24
- 3. Relay the substance of each report in a few lines. Ask for objections once. Resolve them and declare the plan final do not brainstorm beyond two rounds.
24
+ 3. **Choose the plan.** Compare the proposals against acceptance criteria, existing project conventions and evidence. Ask for objections once; resolve factual disagreements with a focused check and record the decision and reason. Make routine implementation choices within the authorized scope without sending them back to the user. Do not brainstorm beyond two rounds.
25
+ 4. **Assign accountable work.** Each step needs one owner, dependencies, a bounded deliverable and the evidence that will demonstrate acceptance. Put these in `assignments` and `steps`; put tradeoffs and rationale in `decisions`. Include every required acceptance criterion and identify real blockers in `risks`.
25
26
 
26
27
  The structured output required by the schema is captured automatically — approach, files to modify, decisions, risks, agent assignments, and the ordered implementation steps. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
@@ -16,7 +16,7 @@ Task: {{TASK_ID}}
16
16
  - **Bart** — does the implementation meet the acceptance criteria from INTAKE? Is any requirement missed or silently deferred? Is the PR description accurate, does it reference the task, are screenshots attached where expected?
17
17
  {{#NO_SAM}} - **Bart** also runs the verification audit in Sam's place: for every claim EXECUTION made (deployed, tests pass, endpoint works, migration ran), confirm it is backed by an observation he can reproduce; anything resting on inference is a finding.{{/NO_SAM}}
18
18
  - **Vera** — run the test suite and report the real output; is the changed code covered; do the new tests exercise the behaviour that changed?
19
- 2. Weigh the reports. Be strict but not pedantic: only actual gaps against the task requirements and the plan — not stylistic preferences or speculative refactors.
20
- 3. Decide: `APPROVED` or `NEEDS_MORE_WORK`.
19
+ 2. **Own the acceptance decision.** Reconcile conflicting reports against the current user requirements and direct observations. Request a focused check when evidence conflicts. Be strict but not pedantic: only actual gaps against the task requirements and the plan — not stylistic preferences or speculative refactors. An agent saying "done" or a majority vote cannot override a failed check, a missing observation, or a required acceptance criterion.
20
+ 3. Decide: `APPROVED` only when required outcomes have evidence and no blocking finding remains; otherwise `NEEDS_MORE_WORK`. For each finding, write `Owner: <agent name>; Action: <specific corrective work>; Accept when: <required observation>` in its `detail` so EXECUTION can act immediately. Assign implementation fixes to Dennis, test gaps to Vera, and missing user-flow or rendered acceptance checks to Bart. The reviewer field identifies who found the gap and does not replace the corrective owner. Reserve `deferred` for work explicitly outside the authorized scope or deferred by the user; a previous agent's convenience deferral does not remove a requirement.
21
21
 
22
22
  The structured output required by the schema is captured automatically — the verdict, the findings (reviewer, title, detail, file, line), items explicitly out of scope, and any claims that were not backed by an observation. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
@@ -112,4 +112,8 @@ This keeps everything in one branch — no conflicts, one PR to review.
112
112
  If the task has no child items, just work on it normally as a single task.
113
113
  {{/CHILD_TASKS}}
114
114
 
115
- The structured output required by the schema is captured automatically — a summary of what was done, files changed, the PR URL (empty string if none), deferred items, and manual steps. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
115
+ ## Lessons for future sessions
116
+
117
+ The handoff has `lessons` and `retireLessons`. Propose a lesson only for something non-obvious that cost you time and would cost the next session too — a setup step, seed data, an environment quirk, a deployment step. Each needs the evidence (the command and its outcome) and a scope: `project`, `area:<ui|copy|docs|api|data>`, or `path:<file or directory prefix>`. Never store secrets — name the env var instead. Do not restate what README, CLAUDE.md or the code already says. If an entry under PROJECT LESSONS proved wrong or obsolete, retire it by its id with the reason. Empty arrays are the normal case.
118
+
119
+ The structured output required by the schema is captured automatically — a summary of what was done, files changed, the PR URL (empty string if none), deferred items, manual steps, lessons, and retirements. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.