@kendoo.agentdesk/agentdesk 0.30.0 → 0.31.1

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,21 @@ 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.31.1] — 2026-09-21
12
+
13
+ ### Fixed
14
+ - `[Both]` Saving project settings without credentials (which the CLI's config sync does by design) no longer erases the stored Jira, Linear or GitHub credentials, and no longer resets settings the write did not mention. 0.31.0 made the CLI re-sync settings on every run, which wiped tracker credentials for affected projects — if pre-flight says your tracker email or token is missing, re-enter it once in project settings.
15
+
16
+ ## [0.31.0] — 2026-09-21
17
+
18
+ ### Changed
19
+ - `[CLI]` The team now follows the task. `INTAKE` assesses the scope — `small` or `standard`, and which areas the change touches (UI, copy, docs, API, data). A small task skips `PLAN` (Dennis states the approach at the start of `EXECUTION`) and is reviewed by Bart and Vera; Sam's architecture audit still gates the PR. Luna, Mark and Nora join only when the task touches their area, and the phase instructions only name the agents who are actually on the team. Set `"teamProfile": "small"` or `"standard"` in `.agentdesk.json` to force it.
20
+ - `[CLI]` The engine keeps a handoff ledger (`.agentdesk/handoffs.jsonl`): one record per phase run with the commit before and after, the phase's output, the verification evidence and what is still open. Unresolved findings and deferred items are carried into the next phase's prompt as open items, so nothing is quietly dropped or quietly picked up.
21
+ - `[CLI]` An `EXECUTION` that reports implemented work without making a commit is now flagged to the reviewers and in the session feed.
22
+
23
+ ### Fixed
24
+ - `[CLI]` The engine's own runtime files (`.agentdesk/`, `.agentdesk-resume.md`) no longer count as uncommitted changes when deciding whether a tree is clean.
25
+
11
26
  ## [0.30.0] — 2026-09-21
12
27
 
13
28
  ### Changed
package/README.md CHANGED
@@ -267,6 +267,10 @@ The `REVIEW` phase is a read-only completeness check (no code changes) — it ve
267
267
 
268
268
  Before the reviewers are asked, the engine runs the project's own checks itself — `commands.test`, `commands.build` and `commands.lint` from project settings, or the `test`/`build`/`lint` scripts in `package.json` when those are unset — inside the session sandbox, at the current commit. A failing check goes straight back to `EXECUTION` with the real output as findings; the reviewers are only asked once the checks pass. An approval is pinned to that commit: if the code changes afterwards, the approval no longer counts and the session ends for human review instead of reporting success. In a session worktree, uncommitted changes block review too — an approval refers to a committed revision. `SUMMARY` cannot commit, move `HEAD`, or push; it writes messages only.
269
269
 
270
+ The team follows the task. `INTAKE` assesses the scope — `small` or `standard`, and which areas the change touches (`ui`, `copy`, `docs`, `api`, `data`). A small task skips `PLAN` (Dennis states the approach at the start of `EXECUTION`) and is reviewed by Bart and Vera; Sam's architecture audit still gates the PR inside `EXECUTION`. Luna, Mark and Nora join only when the task touches UI, user-facing copy or docs respectively. Set `"teamProfile": "small"` or `"standard"` in `.agentdesk.json` to force it (`"auto"`, the default, lets `INTAKE` decide).
271
+
272
+ Every phase run is also recorded by the engine in `.agentdesk/handoffs.jsonl` (revision before and after, the phase's structured output, the verification evidence, and what is still open). Unresolved findings and deferred items are carried into the next phase's prompt as open items, and an `EXECUTION` that reports work without making a commit is flagged to the reviewers.
273
+
270
274
  ## How It Works
271
275
 
272
276
  All agents collaborate in a single Claude process — each with distinct roles, ground rules, and areas of expertise.
package/cli/config.mjs CHANGED
@@ -27,6 +27,11 @@ const DEFAULTS = {
27
27
  // Missing entry or "default" falls back to the phase default
28
28
  // (sonnet for INTAKE/PLAN/EXECUTION, haiku for REVIEW/SUMMARY).
29
29
  phaseModels: {},
30
+ // Team composition: "auto" lets INTAKE assess the task (small tasks skip
31
+ // PLAN and run with implementer + reviewers; specialists join only for the
32
+ // areas the task touches); "small" | "standard" force it. Local-only for
33
+ // now — not synced to the server.
34
+ teamProfile: "auto",
30
35
  instructions: null,
31
36
  // Optional display name shown wherever AgentDesk identifies itself (UI,
32
37
  // future tracker-comment prefixes). Leave null to fall back to agent
@@ -129,13 +134,19 @@ export async function loadConfig(dir, opts = {}) {
129
134
  // True when `local` has any non-null leaf that's null/missing on `server`.
130
135
  // Walked recursively for nested blocks (linear, jira, github). Used to
131
136
  // decide whether it's worth pushing a heal sync.
132
- function hasFieldsServerLacks(local, server) {
137
+ //
138
+ // Only fields the server actually accepts (SETTINGS_FIELDS) count at the top
139
+ // level: a local-only field such as teamProfile can never be "healed", and
140
+ // treating it as a gap made every run push a credential-free payload —
141
+ // which is what a heal push is, by design — forever.
142
+ function hasFieldsServerLacks(local, server, top = true) {
133
143
  const isObj = v => v && typeof v === "object" && !Array.isArray(v);
134
144
  for (const [key, v] of Object.entries(local || {})) {
145
+ if (top && !SETTINGS_FIELDS.includes(key)) continue;
135
146
  if (v === null || v === undefined) continue;
136
147
  const s = server?.[key];
137
148
  if (isObj(v)) {
138
- if (!isObj(s) || hasFieldsServerLacks(v, s)) return true;
149
+ if (!isObj(s) || hasFieldsServerLacks(v, s, false)) return true;
139
150
  } else {
140
151
  if (s === null || s === undefined) return true;
141
152
  }
@@ -29,6 +29,27 @@ export const PHASE_ROSTER = Object.freeze({
29
29
  SUMMARY: { Dennis: RO_BASH },
30
30
  });
31
31
 
32
+ // Specialists join only when INTAKE says the task touches their area.
33
+ export const SPECIALISTS = Object.freeze({ Luna: "ui", Mark: "copy", Nora: "docs" });
34
+ // A small task is reviewed by the QA and test engineers; the architecture
35
+ // audit still gates publishing inside EXECUTION.
36
+ const SMALL_REVIEW = Object.freeze(["Bart", "Vera"]);
37
+
38
+ // The built-in roster for one phase under a team profile (team-profile.mjs).
39
+ // No profile means the legacy full roster.
40
+ export function rosterFor(phase, profile = null) {
41
+ const base = PHASE_ROSTER[phase] || {};
42
+ if (!profile) return base;
43
+ const touches = Array.isArray(profile.touches) ? profile.touches : [];
44
+ const roster = {};
45
+ for (const [name, tools] of Object.entries(base)) {
46
+ if (SPECIALISTS[name] && !touches.includes(SPECIALISTS[name])) continue;
47
+ if (profile.size === "small" && phase === "REVIEW" && !SMALL_REVIEW.includes(name)) continue;
48
+ roster[name] = tools;
49
+ }
50
+ return roster;
51
+ }
52
+
32
53
  // Phases where project-defined custom agents (config.projectAgents) join.
33
54
  const CUSTOM_AGENT_PHASES = new Set(["PLAN", "EXECUTION"]);
34
55
 
@@ -108,9 +129,10 @@ export function soloDefinition(agent) {
108
129
  // team — resolveTeam(config) output (array of { name, role, description, ... })
109
130
  // phase — one of PHASES
110
131
  // phaseModels — config.phaseModels
132
+ // profile — teamProfileFor() output; omitted → the full roster
111
133
  // Returns { agents: Record<name, AgentDefinition>, allowedTools: string[], lead: "Jane" }
112
- export function agentsForPhase({ phase, team, phaseModels = {} }) {
113
- const roster = PHASE_ROSTER[phase] || {};
134
+ export function agentsForPhase({ phase, team, phaseModels = {}, profile = null }) {
135
+ const roster = rosterFor(phase, profile);
114
136
  const subagentModel = (phase === "REVIEW" || phase === "SUMMARY") ? modelForPhase(phase, phaseModels) : "inherit";
115
137
 
116
138
  const agents = {};
@@ -51,12 +51,23 @@ function git(cwd, args, extra) {
51
51
  return execFileSync("git", args, { cwd, env: gitEnv(extra), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 30000 }).trim();
52
52
  }
53
53
 
54
+ // The engine's own runtime files (session memory, ledger, findings, resume
55
+ // note) live inside the project when no worktree is used. They are never the
56
+ // agents' uncommitted work and must not make a tree look dirty.
57
+ const ENGINE_FILES = /(^|\/)(\.agentdesk\/|\.agentdesk-resume\.md$)/;
58
+ function isEngineFile(statusLine) {
59
+ return ENGINE_FILES.test(statusLine.slice(3).replace(/^"|"$/g, ""));
60
+ }
61
+
54
62
  // Independent lookups: a repository with an unborn branch has no HEAD yet
55
63
  // but can still have a dirty tree.
56
64
  export function gitState(cwd, extraEnv = {}) {
57
65
  let revision = null, clean = null;
58
66
  try { revision = git(cwd, ["rev-parse", "HEAD"], extraEnv) || null; } catch {}
59
- try { clean = git(cwd, ["status", "--porcelain"], extraEnv) === ""; } catch {}
67
+ try {
68
+ const lines = git(cwd, ["status", "--porcelain"], extraEnv).split("\n").filter(Boolean);
69
+ clean = lines.every(isEngineFile);
70
+ } catch {}
60
71
  return { revision, clean };
61
72
  }
62
73
 
@@ -0,0 +1,75 @@
1
+ // The handoff ledger: what each phase run actually did, at which revision,
2
+ // with what evidence, and what is still open — written by the engine, not
3
+ // by the agents, so the next phase starts from a record instead of a recap.
4
+ //
5
+ // `.agentdesk/handoffs.jsonl` is append-only, one entry per phase run. The
6
+ // open-items list is the part that travels forward into the next prompt.
7
+
8
+ import { appendFileSync, readFileSync } from "node:fs";
9
+ import { shortRev } from "./evidence.mjs";
10
+
11
+ export function createLedger(path) {
12
+ return {
13
+ path,
14
+ record(entry) {
15
+ try { appendFileSync(path, `${JSON.stringify(entry)}\n`); } catch {}
16
+ return entry;
17
+ },
18
+ entries() {
19
+ try {
20
+ return readFileSync(path, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line));
21
+ } catch { return []; }
22
+ },
23
+ };
24
+ }
25
+
26
+ function location(f) {
27
+ return f.file ? ` (${f.file}${f.line ? `:${f.line}` : ""})` : "";
28
+ }
29
+
30
+ // Open items after a review has been settled. Review and engine items are
31
+ // unresolved work; deferred items are carried so the summary can list them
32
+ // and nobody quietly picks them up.
33
+ export function openItemsFrom({ verdict = null } = {}) {
34
+ const items = [];
35
+ if (!verdict) return items;
36
+ if (!verdict.approved) {
37
+ const source = verdict.engineOnly ? "engine" : "review";
38
+ for (const f of verdict.findings || []) items.push({ source, title: `${f.title}${location(f)}`, detail: f.detail || "", reviewer: f.reviewer });
39
+ for (const claim of verdict.unverifiedClaims || []) items.push({ source: "review", title: "Unverified claim", detail: String(claim) });
40
+ if (verdict.outcome === "APPROVED" && verdict.approvalReason) {
41
+ items.push({ source: "engine", title: "Approval not accepted", detail: verdict.approvalReason });
42
+ }
43
+ }
44
+ for (const d of verdict.deferred || []) items.push({ source: "deferred", title: String(d), detail: "" });
45
+ return items;
46
+ }
47
+
48
+ // An EXECUTION that reports implemented work while HEAD did not move and the
49
+ // tree is clean has nothing to review. Surfaced, not blocked: the reviewers
50
+ // decide, with the fact in front of them.
51
+ export function noCommitItem({ phase, revisionBefore, revisionAfter, clean, output }) {
52
+ if (phase !== "EXECUTION" || !revisionBefore || revisionAfter !== revisionBefore || clean !== true) return null;
53
+ const implemented = Array.isArray(output?.implemented) ? output.implemented : [];
54
+ if (implemented.length === 0) return null;
55
+ return {
56
+ source: "engine",
57
+ title: "EXECUTION reported work but made no commits",
58
+ detail: `HEAD is still ${shortRev(revisionBefore)} and the tree is clean; the ${implemented.length} implemented item(s) are not backed by a commit.`,
59
+ };
60
+ }
61
+
62
+ export function renderOpenItems(items = []) {
63
+ if (!items.length) return "";
64
+ const lines = [
65
+ "## OPEN ITEMS",
66
+ "",
67
+ "Carried from earlier phases by the engine. Resolve the review and engine items. Deferred items are explicitly out of scope — do not address them, but make sure the final summary lists them.",
68
+ "",
69
+ ];
70
+ for (const item of items) {
71
+ const detail = item.detail ? ` — ${item.detail}` : "";
72
+ lines.push(item.source === "deferred" ? `- [deferred] ${item.title}` : `- [${item.source}] **${item.title}**${detail}`);
73
+ }
74
+ return lines.join("\n");
75
+ }
@@ -15,7 +15,7 @@ The reviewers returned the following. Work from this list; do not re-derive it.
15
15
 
16
16
  - Follow CLAUDE.md conventions (if present). Do not modify files unrelated to the task.
17
17
  - **Sam's audit is a blocking gate.** After Dennis implements, Sam must read every changed file and run his full checklist, citing file:line for every finding — "looks clean" without evidence is invalid. Dennis fixes every violation before Bart creates the PR. Publishing is refused while findings are open.
18
- - Nora's sign-off is a gate too: Bart cannot create the PR until Nora reports either "No doc impact — skipped" or "Docs updated: [files]".
18
+ {{#HAS_NORA}}- Nora's sign-off is a gate too: Bart cannot create the PR until Nora reports either "No doc impact — skipped" or "Docs updated: [files]".{{/HAS_NORA}}
19
19
  - Do NOT post the final tracker summary or transition the task here — SUMMARY owns all final tracker writes.
20
20
 
21
21
  {{#SCREENSHOTS_ENABLED}}
@@ -33,12 +33,12 @@ Screenshots are **disabled** for this project. Do not capture any unless the use
33
33
 
34
34
  ## Your mission
35
35
 
36
- Drive the plan from session memory step by step. Delegate each step with the Agent tool, give the agent the exact step and the relevant decisions, and require an observation for every claim ("tests pass" means the test output, "endpoint works" means the response). In order:
36
+ {{#NO_PLAN}}This task was assessed as small, so there was no PLAN phase. First have Dennis state the approach in two or three lines — files to change, the risk, how it will be verified — and confirm it; that is the plan. Then drive it step by step.{{/NO_PLAN}}{{#HAS_PLAN}}Drive the plan from session memory step by step.{{/HAS_PLAN}} Delegate each step with the Agent tool, give the agent the exact step and the relevant decisions, and require an observation for every claim ("tests pass" means the test output, "endpoint works" means the response). In order:
37
37
 
38
38
  1. **Dennis implements** — create the branch, implement per the plan, run linter and build, commit. Report files changed and technical decisions.
39
39
  2. **Sam audits** — every changed file, full checklist (feature envy, separation of concerns, clear interfaces, layering, god files), file:line for each finding. If there are violations, send Dennis back to fix them, then have Sam re-audit.
40
40
  3. **Vera tests** — unit/regression tests for the changed code, run and verified, committed.
41
- 4. **Luna / Mark / Nora** — only where applicable (UI, user-facing copy, user-facing behaviour). Each proposes exact changes; Dennis applies them.
41
+ {{#HAS_SPECIALISTS}}4. **{{SPECIALISTS}}** — only where applicable (UI, user-facing copy, user-facing behaviour). Each proposes exact changes; Dennis applies them.{{/HAS_SPECIALISTS}}
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.
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
 
@@ -18,9 +18,9 @@ Task: {{TASK_ID}}
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.
20
20
  - Vera: test plan — which functions need coverage, regression cases.
21
- - Luna (only if the task touches UI): visual impact, accessibility, and a screenshot plan (pages, viewports).
22
- - Mark (only if user-facing text changes): copy audit.
23
- - Nora (only if user-facing behaviour changes): which docs/README/help surfaces must change.
21
+ {{#HAS_LUNA}} - Luna (only if the task touches UI): visual impact, accessibility, and a screenshot plan (pages, viewports).{{/HAS_LUNA}}
22
+ {{#HAS_MARK}} - Mark (only if user-facing text changes): copy audit.{{/HAS_MARK}}
23
+ {{#HAS_NORA}} - Nora (only if user-facing behaviour changes): which docs/README/help surfaces must change.{{/HAS_NORA}}
24
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.
25
25
 
26
26
  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.
@@ -11,9 +11,10 @@ Task: {{TASK_ID}}
11
11
 
12
12
  ## Your mission
13
13
 
14
- 1. **Delegate to three reviewers in parallel.** Each should inspect the actual changes themselves (`git diff <base>...HEAD` against the branch the work started from, and read the changed files) and report findings with file:line:
15
- - **Sam** — does the code match the PLAN? Partially-implemented helpers, dead branches, TODOs, error paths not wired. Hidden cross-cutting concerns: docs, changelog, config schema, migrations, dependent callers. **Verification audit:** 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.
14
+ 1. **Delegate to the reviewers in parallel.** Each should inspect the actual changes themselves (`git diff <base>...HEAD` against the branch the work started from, and read the changed files) and report findings with file:line:
15
+ {{#HAS_SAM}} - **Sam** — does the code match the PLAN? Partially-implemented helpers, dead branches, TODOs, error paths not wired. Hidden cross-cutting concerns: docs, changelog, config schema, migrations, dependent callers. **Verification audit:** 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.{{/HAS_SAM}}
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
+ {{#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}}
17
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?
18
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.
19
20
  3. Decide: `APPROVED` or `NEEDS_MORE_WORK`.
@@ -14,6 +14,7 @@ import { wrapUntrusted, PROMPT_SECURITY_HEADER, MEMORY_INSTRUCTIONS, loadProject
14
14
  import { generateContext } from "../detect.mjs";
15
15
  import { formatFindingsForRetry } from "./verdict.mjs";
16
16
  import { formatEvidenceForPrompt } from "./evidence.mjs";
17
+ import { renderOpenItems } from "./handoff.mjs";
17
18
 
18
19
  const here = dirname(fileURLToPath(import.meta.url));
19
20
 
@@ -134,7 +135,8 @@ function createTaskSection({ tracker, config, description }) {
134
135
  // Returns the full user prompt for one phase's query().
135
136
  export function renderPhasePrompt({
136
137
  phase, taskId, taskLink, description, createTask, tracker, config = {}, project = {},
137
- sessionUrl, cwd, sessionMemory = "", retryVerdict = null, evidence = null,
138
+ sessionUrl, cwd, sessionMemory = "", retryVerdict = null, evidence = null, openItems = [],
139
+ roster = null, profile = null,
138
140
  }) {
139
141
  const vars = {
140
142
  TASK_ID: taskId,
@@ -150,6 +152,17 @@ export function renderPhasePrompt({
150
152
  if (tracker) flags.add(tracker.toUpperCase()); else flags.add("NO_TRACKER");
151
153
  if (retryVerdict) flags.add("RETRY");
152
154
 
155
+ // The prompt names only the agents that exist in this phase (agents/index.mjs
156
+ // rosterFor): a gate or a step that names an absent agent would send the lead
157
+ // chasing someone she cannot delegate to. No roster → the legacy full team.
158
+ const names = Array.isArray(roster) ? roster : ["Dennis", "Sam", "Vera", "Bart", "Luna", "Mark", "Nora"];
159
+ for (const n of ["Sam", "Luna", "Mark", "Nora"]) if (names.includes(n)) flags.add(`HAS_${n.toUpperCase()}`);
160
+ if (!names.includes("Sam")) flags.add("NO_SAM");
161
+ const specialists = ["Luna", "Mark", "Nora"].filter(n => names.includes(n));
162
+ if (specialists.length) flags.add("HAS_SPECIALISTS");
163
+ vars.SPECIALISTS = specialists.join(" / ");
164
+ flags.add(profile?.size === "small" ? "NO_PLAN" : "HAS_PLAN");
165
+
153
166
  vars.TRACKER_SECTION = trackerSection(tracker, phase, vars);
154
167
 
155
168
  let body = renderTemplate(template(`phases/${phase}.md`), { flags, vars }).trim();
@@ -166,6 +179,7 @@ export function renderPhasePrompt({
166
179
  if (evidence) {
167
180
  body += `\n\n## ENGINE VERIFICATION\n\nThe engine ran the project's own checks before this phase. Build on these observations; do not re-run the whole suite blind.\n\n${formatEvidenceForPrompt(evidence)}`;
168
181
  }
182
+ if (openItems.length) body += `\n\n${renderOpenItems(openItems)}`;
169
183
  if (sessionMemory) {
170
184
  body += `\n\n## SESSION MEMORY (previous phases)\n\n${sessionMemory}`;
171
185
  }
@@ -15,7 +15,7 @@ export const PHASE_OUTPUT_SCHEMAS = Object.freeze({
15
15
  INTAKE: {
16
16
  type: "object",
17
17
  additionalProperties: false,
18
- required: ["title", "taskSummary", "requirements", "assessment", "subtasks", "nextPhaseFocus"],
18
+ required: ["title", "taskSummary", "requirements", "assessment", "subtasks", "nextPhaseFocus", "scope"],
19
19
  properties: {
20
20
  title: { type: "string", description: "4-8 word session title" },
21
21
  taskSummary: { type: "string" },
@@ -23,6 +23,16 @@ export const PHASE_OUTPUT_SCHEMAS = Object.freeze({
23
23
  assessment: { ...strList, description: "existing branches/PRs, code patterns, resume context" },
24
24
  subtasks: { ...strList, description: "subtasks created, if the task was decomposed; else empty" },
25
25
  nextPhaseFocus: strList,
26
+ scope: {
27
+ type: "object",
28
+ additionalProperties: false,
29
+ required: ["size", "touches"],
30
+ description: "how big the change is and which areas it touches — decides the team and phases",
31
+ properties: {
32
+ size: { type: "string", enum: ["small", "standard"], description: "small: a contained change one implementer and independent reviewers can handle without a planning round; standard: multi-area or user-facing work" },
33
+ touches: { type: "array", items: { type: "string", enum: ["ui", "copy", "docs", "api", "data"] }, description: "areas the change touches; specialists join only for the areas listed" },
34
+ },
35
+ },
26
36
  },
27
37
  },
28
38
  PLAN: {
@@ -93,6 +103,7 @@ export function renderMemorySection(phase, out) {
93
103
  "## Task",
94
104
  `- Title: ${out.title || ""}`,
95
105
  `- Summary: ${out.taskSummary || ""}`,
106
+ ...(out.scope?.size ? [`- Scope: ${out.scope.size}${Array.isArray(out.scope.touches) && out.scope.touches.length ? ` (${out.scope.touches.join(", ")})` : ""}`] : []),
96
107
  "",
97
108
  "## Requirements",
98
109
  bullets(out.requirements),
@@ -17,14 +17,14 @@ import { fileURLToPath } from "url";
17
17
  import { createScratchHome } from "../session-sandbox.mjs";
18
18
  import { resolveGitHubCreds, assertPushable, PreflightError } from "../session-preflight.mjs";
19
19
  import {
20
- PHASES, MAX_REVIEW_RETRIES, MAX_PHASE_RUNS, phaseFailed, finalStatus, archiveStaleMemory,
20
+ MAX_REVIEW_RETRIES, MAX_PHASE_RUNS, phaseFailed, finalStatus, archiveStaleMemory,
21
21
  } from "../phase-loop.mjs";
22
22
  import { buildChildEnv } from "./env.mjs";
23
23
  import { loadDotEnv } from "../dotenv.mjs";
24
24
  import { checkClaudeAuth } from "./claude-auth.mjs";
25
25
  import { createEventMapper, timestamp } from "./events.mjs";
26
26
  import { captureOutcome, githubRepository, currentBranch, replyMarker } from "./outcome.mjs";
27
- import { agentsForPhase, modelForPhase, soloDefinition } from "./agents/index.mjs";
27
+ import { agentsForPhase, modelForPhase, soloDefinition, rosterFor } from "./agents/index.mjs";
28
28
  import { renderPhasePrompt, renderSoloPrompt } from "./prompts.mjs";
29
29
  import { renderMemorySection } from "./schemas.mjs";
30
30
  import { verdictFromResult } from "./verdict.mjs";
@@ -33,6 +33,8 @@ import {
33
33
  renderEvidenceSection, gitState, checkTimeoutMs, shortRev,
34
34
  } from "./evidence.mjs";
35
35
  import { spawnSandboxedCommand } from "./spawn.mjs";
36
+ import { createLedger, openItemsFrom, noCommitItem } from "./handoff.mjs";
37
+ import { teamProfileFor, phasesFor } from "./team-profile.mjs";
36
38
  import { buildQueryOptions, defaultRunQuery } from "./query.mjs";
37
39
  import { armPublishGate, onSubagentStopped } from "./hooks.mjs";
38
40
  import { prepareWorkspace } from "../worktrees.mjs";
@@ -215,10 +217,12 @@ async function executeSession({
215
217
  const stateDir = workspaceStateDir || join(cwd, ".agentdesk");
216
218
  const memoryPath = join(stateDir, "session-memory.md");
217
219
  const findingsPath = join(stateDir, "review-findings.json");
220
+ const ledgerPath = join(stateDir, "handoffs.jsonl");
218
221
  try { mkdirSync(stateDir, { recursive: true }); } catch {}
219
222
  if (!resumingWorkspace || !existsSync(memoryPath)) {
220
223
  if (archiveStaleMemory(memoryPath)) console.error("[agentdesk] archived stale session-memory.md from a previous run");
221
224
  try { if (existsSync(findingsPath)) unlinkSync(findingsPath); } catch {}
225
+ try { if (existsSync(ledgerPath)) unlinkSync(ledgerPath); } catch {}
222
226
  writeFileSync(memoryPath, `# Session Memory\n\n## Task\n- ID: ${taskId}\n${taskLink ? `- Link: ${taskLink}\n` : ""}\n`);
223
227
  } else {
224
228
  appendFileSync(memoryPath, `\n## Resumed session\n${sessionUrl || sessionId}\n`);
@@ -247,6 +251,11 @@ async function executeSession({
247
251
  const headNow = () => gitState(cwd, workspaceGitEnv).revision;
248
252
  let approvedRevision = null;
249
253
 
254
+ // --- handoff ledger (engine-owned) ---------------------------------------
255
+ // One entry per phase run; the open-items list travels into the next prompt.
256
+ const ledger = createLedger(ledgerPath);
257
+ let openItems = [];
258
+
250
259
  const reportEvidence = evidence => {
251
260
  emit({ type: "session:evidence", phase: "REVIEW", revision: evidence.revision, clean: evidence.clean,
252
261
  passed: evidence.passed, checked: evidence.checked, results: evidence.results });
@@ -271,6 +280,7 @@ async function executeSession({
271
280
  reviewResolved = verdict.approved === true;
272
281
  if (reviewResolved) approvedRevision = verdict.headNow || verdict.revision || null;
273
282
  state.openFindings = reviewResolved ? [] : verdict.findings;
283
+ openItems = openItemsFrom({ verdict });
274
284
  try { writeFileSync(findingsPath, JSON.stringify(verdict, null, 2)); } catch {}
275
285
  if (reviewResolved) return;
276
286
  if (verdict.outcome === "MISSING") {
@@ -294,13 +304,16 @@ async function executeSession({
294
304
  const invalidateApproval = now => {
295
305
  reviewResolved = false;
296
306
  emit({ type: "session:error", code: "REVIEW_STALE", message: `Code changed after approval (${shortRev(approvedRevision)} → ${shortRev(now)}) — the approval no longer applies; ending for human review.` });
307
+ openItems = [...openItems, { source: "engine", title: "Approval invalidated", detail: `Code changed after approval (${shortRev(approvedRevision)} → ${shortRev(now)}).` }];
297
308
  if (lastVerdict) {
298
309
  lastVerdict = { ...lastVerdict, approved: false, approvalReason: "code changed after approval", headNow: now };
299
310
  try { writeFileSync(findingsPath, JSON.stringify(lastVerdict, null, 2)); } catch {}
300
311
  }
301
312
  };
302
313
 
303
- const queue = solo ? ["SOLO"] : [...PHASES];
314
+ // --- team profile (INTAKE assesses the task; config may force) -----------
315
+ let profile = teamProfileFor({ intake: null, config });
316
+ const queue = solo ? ["SOLO"] : phasesFor(profile);
304
317
 
305
318
  try {
306
319
  while (queue.length > 0) {
@@ -312,6 +325,13 @@ async function executeSession({
312
325
 
313
326
  const phase = queue.shift();
314
327
  lastPhase = phase;
328
+ const run = { index: phaseRuns, startedAt: Date.now(), revisionBefore: headNow() };
329
+ const finishRun = ({ output = null, evidence: runEvidence = null, status }) => {
330
+ const entry = { phase, run: run.index, startedAt: run.startedAt, durationMs: Date.now() - run.startedAt,
331
+ revisionBefore: run.revisionBefore, revisionAfter: headNow(), output, evidence: runEvidence, openItems: [...openItems], status };
332
+ ledger.record(entry);
333
+ emit({ type: "session:handoff", ...entry });
334
+ };
315
335
  // The dashboard knows the five team phases; solo shows as EXECUTION.
316
336
  const model = modelForPhase(phase === "SOLO" ? "EXECUTION" : phase, config.phaseModels);
317
337
  emit({ type: "phase:change", phase: phase === "SOLO" ? "EXECUTION" : phase, model: model || "default" });
@@ -329,6 +349,7 @@ async function executeSession({
329
349
  if (!evidence.passed) {
330
350
  settleReview({ outcome: "NEEDS_MORE_WORK", findings: evidenceFindings(evidence), deferred: [], unverifiedClaims: [], reason: null,
331
351
  revision: evidence.revision, headNow: evidence.revision, evidence, approved: false, approvalReason: "engine checks did not pass", engineOnly: true });
352
+ finishRun({ evidence, status: "checks-failed" });
332
353
  continue;
333
354
  }
334
355
  }
@@ -339,7 +360,7 @@ async function executeSession({
339
360
 
340
361
  const { agents, allowedTools, lead } = solo
341
362
  ? soloDefinition(solo)
342
- : agentsForPhase({ phase, team, phaseModels: config.phaseModels });
363
+ : agentsForPhase({ phase, team, phaseModels: config.phaseModels, profile });
343
364
  let prompt = solo
344
365
  ? renderSoloPrompt({ agent: solo, taskId, taskLink, description, tracker, config, project, sessionUrl, cwd, childStrategy })
345
366
  : renderPhasePrompt({
@@ -349,6 +370,10 @@ async function executeSession({
349
370
  sessionMemory: memoryText(),
350
371
  retryVerdict: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? lastVerdict : null,
351
372
  evidence,
373
+ // A retry already carries the verdict's findings; open items feed the other phases.
374
+ openItems: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? [] : openItems,
375
+ roster: Object.keys(agents).filter(name => name !== lead),
376
+ profile,
352
377
  });
353
378
  if (workspaceRecord) {
354
379
  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`;
@@ -410,6 +435,7 @@ async function executeSession({
410
435
  emit({ type: "session:error", code: "PHASE_FAILED", message: `${phase} failed (${detail}) — session incomplete.` });
411
436
  handoff = true;
412
437
  writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
438
+ finishRun({ status: "failed" });
413
439
  break;
414
440
  }
415
441
 
@@ -419,6 +445,7 @@ async function executeSession({
419
445
  const approval = evaluateApproval({ verdict, evidence, headNow: now });
420
446
  appendMemory(renderMemorySection("REVIEW", summary.structuredOutput));
421
447
  settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now, evidence, approved: approval.approved, approvalReason: approval.reason });
448
+ finishRun({ output: summary.structuredOutput ?? null, evidence, status: lastVerdict.approved ? "ok" : "not-approved" });
422
449
  continue;
423
450
  }
424
451
 
@@ -427,9 +454,28 @@ async function executeSession({
427
454
  emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
428
455
  }
429
456
  appendMemory(renderMemorySection(phase, summary.structuredOutput));
457
+ if (phase === "EXECUTION") {
458
+ const after = gitState(cwd, workspaceGitEnv);
459
+ const missing = noCommitItem({ phase, revisionBefore: run.revisionBefore, revisionAfter: after.revision, clean: after.clean, output: summary.structuredOutput });
460
+ if (missing) {
461
+ openItems = [...openItems, missing];
462
+ emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `${missing.title} — ${missing.detail}` });
463
+ }
464
+ }
465
+ finishRun({ output: summary.structuredOutput ?? null, status: summary.structuredOutput ? "ok" : "missing-output" });
430
466
  if (phase === "INTAKE" && summary.structuredOutput?.title) {
431
467
  emit({ type: "session:update", title: String(summary.structuredOutput.title).slice(0, 60) });
432
468
  }
469
+ if (phase === "INTAKE") {
470
+ profile = teamProfileFor({ intake: summary.structuredOutput, config });
471
+ if (profile.size === "small") {
472
+ const at = queue.indexOf("PLAN");
473
+ if (at >= 0) queue.splice(at, 1);
474
+ }
475
+ const names = Object.keys(rosterFor("EXECUTION", profile)).join(", ");
476
+ const areas = profile.touches.length ? ` (${profile.touches.join(", ")})` : "";
477
+ emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Team for this task: ${profile.size}${areas} — ${names}.${profile.size === "small" ? " Skipping PLAN." : ""}` });
478
+ }
433
479
  }
434
480
  } finally {
435
481
  abortSignal?.removeEventListener?.("abort", onExternalAbort);
@@ -462,5 +508,7 @@ async function executeSession({
462
508
  aborted, status, reviewResolved, lastPhase,
463
509
  verdict: lastVerdict,
464
510
  approvedRevision: reviewResolved ? approvedRevision : null,
511
+ openItems,
512
+ profile,
465
513
  };
466
514
  }
@@ -0,0 +1,30 @@
1
+ // Team composition follows the task.
2
+ //
3
+ // INTAKE assesses the scope (size + areas touched); the engine turns that
4
+ // into a profile that decides which phases run and which specialists join.
5
+ // A missing or malformed assessment falls back to the full team — the safe
6
+ // direction. config.teamProfile ("small" | "standard") overrides the
7
+ // assessment; "auto" (default) lets INTAKE decide.
8
+
9
+ import { PHASES } from "../phase-loop.mjs";
10
+
11
+ export const PROFILES = Object.freeze(["small", "standard"]);
12
+ export const TOUCH_AREAS = Object.freeze(["ui", "copy", "docs", "api", "data"]);
13
+
14
+ export function teamProfileFor({ intake = null, config = {} } = {}) {
15
+ const override = config?.teamProfile;
16
+ const assessed = intake?.scope?.size;
17
+ // No usable assessment of the touched areas → every specialist joins.
18
+ const touches = Array.isArray(intake?.scope?.touches)
19
+ ? [...new Set(intake.scope.touches.filter(t => TOUCH_AREAS.includes(t)))]
20
+ : [...TOUCH_AREAS];
21
+ if (PROFILES.includes(override)) return { size: override, touches, source: "config" };
22
+ if (PROFILES.includes(assessed)) return { size: assessed, touches, source: "intake" };
23
+ return { size: "standard", touches, source: "default" };
24
+ }
25
+
26
+ // A small task has no separate planning phase: the implementer states the
27
+ // approach at the start of EXECUTION instead.
28
+ export function phasesFor(profile) {
29
+ return profile?.size === "small" ? PHASES.filter(p => p !== "PLAN") : [...PHASES];
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
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,7 @@
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-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",
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-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",
26
26
  "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
27
  "lint": "eslint .",
28
28
  "lint:fix": "eslint . --fix",