@kendoo.agentdesk/agentdesk 0.33.0 → 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,13 +8,19 @@ 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
- ## [Unreleased]
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.
12
15
 
13
16
  ### Changed
14
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.
15
19
 
16
20
  ### Fixed
17
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.
18
24
 
19
25
  ## [0.33.0] — 2026-09-21
20
26
 
package/README.md CHANGED
@@ -308,6 +308,12 @@ Terminal sessions also save a resume snapshot (`.agentdesk-resume.md`) when inte
308
308
  agentdesk team KEN-517 --resume-worktree SESSION-ID
309
309
  ```
310
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
+
311
317
  ### Session protocol
312
318
 
313
319
  At the end of each session, Jane posts a structured summary on the tracker covering:
@@ -32,15 +32,29 @@ export function readLessons(path) {
32
32
  try {
33
33
  if (!existsSync(path)) return EMPTY();
34
34
  const data = JSON.parse(readFileSync(path, "utf8"));
35
- if (!data || data.version !== 1 || !Array.isArray(data.lessons)) return EMPTY();
36
- return { version: 1, lessons: data.lessons.filter(l => l && typeof l.id === "string" && typeof l.text === "string") };
37
- } catch { return EMPTY(); }
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 }; }
38
51
  }
39
52
 
40
53
  export function writeLessons(path, data) {
41
54
  mkdirSync(dirname(path), { recursive: true });
42
- writeFileSync(`${path}.tmp`, JSON.stringify(data, null, 2), { mode: 0o600 });
43
- renameSync(`${path}.tmp`, path);
55
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
56
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
57
+ renameSync(tmp, path);
44
58
  }
45
59
 
46
60
  // "project" | "area:<ui|copy|docs|api|data>" | "path:<relative prefix>".
@@ -110,6 +124,7 @@ function applyRetirement({ list, retirement, source, at, counts }) {
110
124
  // engine's verdict on the whole session (verified approval), not the agents'.
111
125
  export function recordLessons({ path, proposals = [], retirements = [], source, complete = false, now = () => new Date().toISOString() }) {
112
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");
113
128
  const list = data.lessons;
114
129
  const at = now();
115
130
  const counts = { activated: 0, proposed: 0, confirmed: 0, retired: 0, dropped: 0 };
@@ -144,7 +159,7 @@ export function renderLessonsSection(lessons = []) {
144
159
  const lines = [
145
160
  "## PROJECT LESSONS",
146
161
  "",
147
- "Recorded by the engine from earlier sessions on this project, newest first, each with its id and where it came from. Apply them. If one proved wrong or obsolete in this session, retire it in the SUMMARY handoff (`retireLessons`, with the id and why) — never edit files to change them.",
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.",
148
163
  "",
149
164
  ];
150
165
  for (const l of lessons) {
@@ -74,11 +74,19 @@ export function journalExternalActions(options, recovery) {
74
74
  };
75
75
  }
76
76
 
77
+ // A PreToolUse matcher that lets only the SDK's schema tool through and denies
78
+ // everything else with the given reason. Shared by the engine's handoff repair
79
+ // and the opt-in SDK smoke tests, so the production guard and the diagnostics
80
+ // cannot drift apart.
81
+ export function structuredOutputOnlyHook(reason) {
82
+ return { hooks: [async input => input.tool_name === "StructuredOutput" ? {} : {
83
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
84
+ }] };
85
+ }
86
+
77
87
  export function repairQueryOptions(options) {
78
88
  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 };
89
+ hooks: { PreToolUse: [structuredOutputOnlyHook("Handoff repair cannot execute tools.")] }, maxTurns: 2 };
82
90
  }
83
91
 
84
92
  export function trackerAccessDenied(denials) {
@@ -257,8 +257,8 @@ async function executeSession({
257
257
 
258
258
  // --- project lessons (engine-owned, shared by every worktree) ------------
259
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);
260
+ const lessonsFile = lessonsPath(sourceCwd);
261
+ const projectNotes = loadProjectMemory(sourceCwd);
262
262
  const lessonsForPrompt = () => selectLessons({ lessons: readLessons(lessonsFile).lessons, touches: profile.touches });
263
263
  let lessonHandoff = null; // { phase, agent, lessons, retireLessons } from SUMMARY/SOLO
264
264
 
@@ -578,9 +578,12 @@ async function executeSession({
578
578
 
579
579
  const sourceDenied = ["INTAKE", "PLAN"].includes(phase) && trackerAccessDenied(summary.permissionDenials);
580
580
  // The SDK can yield its error result and then throw. The yielded subtype
581
- // still identifies a repairable handoff failure in that case.
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".
582
584
  const outputFailure = !sourceDenied && summary.subtype === "error_max_structured_output_retries";
583
- if (!outputFailure && phaseFailed({ exitCode: thrown || summary.isError || sourceDenied ? 1 : 0, aborted: false })) {
585
+ const repairable = outputFailure && phase !== "SOLO";
586
+ if (!repairable && phaseFailed({ exitCode: thrown || summary.isError || sourceDenied ? 1 : 0, aborted: false })) {
584
587
  const detail = sourceDenied ? "Tracker permission denied. Restore access before planning from unverified requirements."
585
588
  : thrown?.message || summary.errors?.join("\n") || summary.resultText || summary.subtype || "error";
586
589
  block(detail, "PHASE_FAILED", phase);
@@ -591,20 +594,13 @@ async function executeSession({
591
594
  break;
592
595
  }
593
596
 
594
- if (phase === "REVIEW") {
595
- const verdict = verdictFromResult({ is_error: summary.isError, subtype: summary.subtype, structured_output: summary.structuredOutput });
596
- const now = treeNow();
597
- const approval = evaluateApproval({ verdict, evidence, headNow: now.revision, cleanNow: now.clean });
598
- appendMemory(renderMemorySection("REVIEW", summary.structuredOutput));
599
- settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now.revision, evidence, approved: approval.approved, approvalReason: approval.reason });
600
- finishRun({ output: summary.structuredOutput ?? null, evidence, status: lastVerdict.approved ? "ok" : "not-approved" });
601
- continue;
602
- }
603
-
604
- // Other phases: the structured output IS the handoff. Without it the
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.
607
- if (!summary.structuredOutput && phase !== "SOLO") {
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)) {
608
604
  let repairError = null;
609
605
  const attempt = (handoffRetries.get(phase) || 0) + 1;
610
606
  handoffRetries.set(phase, attempt);
@@ -615,26 +611,54 @@ async function executeSession({
615
611
  const repair = createEventMapper({ leadAgent: lead });
616
612
  try {
617
613
  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 || ""}`;
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 || ""}`;
619
616
  for await (const msg of runQuery({ prompt: repairPrompt, options: repairOptions })) repair.handle(msg);
620
617
  const repaired = repair.finish();
621
618
  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;
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";
624
633
  } catch (error) { repairError = error.message; }
625
634
  checkpoint([phase, ...queue]);
626
635
  emit({ type: "session:usage", inputTokens: totals.inputTokens, outputTokens: totals.outputTokens });
627
636
  }
628
637
  if (abortController.signal.aborted) { aborted = true; break; }
629
638
  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.` });
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.` });
632
645
  handoff = true;
633
646
  writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
634
647
  break;
635
648
  }
636
649
  emit({ type: "session:recovery", recovery: { state: "running", kind: "handoff", phase, message: "Phase summary recovered.", ready: false } });
637
650
  }
651
+
652
+ if (phase === "REVIEW") {
653
+ const verdict = verdictFromResult({ is_error: summary.isError, subtype: summary.subtype, structured_output: summary.structuredOutput });
654
+ const now = treeNow();
655
+ const approval = evaluateApproval({ verdict, evidence, headNow: now.revision, cleanNow: now.clean });
656
+ appendMemory(renderMemorySection("REVIEW", summary.structuredOutput));
657
+ settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now.revision, evidence, approved: approval.approved, approvalReason: approval.reason });
658
+ finishRun({ output: summary.structuredOutput ?? null, evidence, status: lastVerdict.approved ? "ok" : "not-approved" });
659
+ continue;
660
+ }
661
+
638
662
  if (!summary.structuredOutput) {
639
663
  emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
640
664
  }
@@ -685,18 +709,24 @@ async function executeSession({
685
709
  // (verified approval) or only proposed; the agents' say-so never does.
686
710
  let lessonCounts = null;
687
711
  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(", ")}.` });
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}` });
700
730
  }
701
731
  }
702
732
 
@@ -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
- - 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.
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}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.33.0",
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": {