@kendoo.agentdesk/agentdesk 0.29.3 → 0.31.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 +17 -0
- package/README.md +7 -1
- package/cli/config.mjs +5 -0
- package/cli/engine/agents/index.mjs +24 -2
- package/cli/engine/evidence.mjs +193 -0
- package/cli/engine/handoff.mjs +75 -0
- package/cli/engine/hooks.mjs +18 -0
- package/cli/engine/phases/EXECUTION.md +3 -3
- package/cli/engine/phases/PLAN.md +3 -3
- package/cli/engine/phases/REVIEW.md +3 -2
- package/cli/engine/prompts.mjs +19 -1
- package/cli/engine/schemas.mjs +12 -1
- package/cli/engine/session.mjs +159 -22
- package/cli/engine/spawn.mjs +29 -11
- package/cli/engine/team-profile.mjs +30 -0
- package/cli/engine/verdict.mjs +4 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,23 @@ 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.0] — 2026-09-21
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `[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.
|
|
15
|
+
- `[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.
|
|
16
|
+
- `[CLI]` An `EXECUTION` that reports implemented work without making a commit is now flagged to the reviewers and in the session feed.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- `[CLI]` The engine's own runtime files (`.agentdesk/`, `.agentdesk-resume.md`) no longer count as uncommitted changes when deciding whether a tree is clean.
|
|
20
|
+
|
|
21
|
+
## [0.30.0] — 2026-09-21
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- `[CLI]` A review approval now refers to one verified commit. Before the reviewers are asked, the engine itself runs the project's `test`/`build`/`lint` commands (from project settings, or the `package.json` scripts) inside the session sandbox. A failing check goes straight back to EXECUTION with the real output as findings; the reviewers only see work whose checks pass, and their prompt shows what the engine observed. If the code changes after an approval, the approval no longer counts and the session ends for human review instead of reporting success. In a session worktree, uncommitted changes block review.
|
|
25
|
+
- `[CLI]` The SUMMARY phase is now enforced read-only: it can no longer edit files, commit, move `HEAD`, push, or open a PR.
|
|
26
|
+
- `[UI]` The session feed shows each verification command the engine ran and its result.
|
|
27
|
+
|
|
11
28
|
## [0.29.3] — 2026-09-21
|
|
12
29
|
|
|
13
30
|
### Fixed
|
package/README.md
CHANGED
|
@@ -265,6 +265,12 @@ Valid values: `"default"`, `"opus"`, `"sonnet"`, `"haiku"`. `"default"` resolves
|
|
|
265
265
|
|
|
266
266
|
The `REVIEW` phase is a read-only completeness check (no code changes) — it verifies the implementation meets requirements, flags missed documentation updates or silently-deferred scope. If gaps are found, the orchestrator loops back to `EXECUTION` once before moving on. The `SUMMARY` phase writes the final tracker comments and session protocol.
|
|
267
267
|
|
|
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
|
+
|
|
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
|
+
|
|
268
274
|
## How It Works
|
|
269
275
|
|
|
270
276
|
All agents collaborate in a single Claude process — each with distinct roles, ground rules, and areas of expertise.
|
|
@@ -317,7 +323,7 @@ Once running, a "Run Team" button appears on [agentdesk.live](https://agentdesk.
|
|
|
317
323
|
- **Credentials prerequisite** — scoped sessions push code over HTTPS using a per-project GitHub token. `agentdesk init` saves it to the project's `.env` as `GITHUB_TOKEN`; if you skip that step, the session refuses to start with an actionable message. SSH-only remotes (`git@github.com:...`) are also rejected — the sandbox intentionally isolates `~/.ssh`. Switch the origin to `https://github.com/<owner>/<repo>.git` or run without scoped isolation.
|
|
318
324
|
- **Outbound only** — no ports opened on your machine
|
|
319
325
|
- **Project allowlist** — only runs on projects registered via `agentdesk init`
|
|
320
|
-
- **No arbitrary commands** —
|
|
326
|
+
- **No arbitrary commands** — spawns Claude with a fixed set of allowed tools, plus the project's own configured `test`/`build`/`lint` commands (run inside the same sandbox, before each review) — nothing the server can push
|
|
321
327
|
- **Metadata-only logs** — session logs in `~/.agentdesk/logs/` contain timestamps and file paths, never sensitive data
|
|
322
328
|
- **Fail closed** — unknown projects or exceeded session limits are rejected
|
|
323
329
|
|
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
|
|
@@ -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 =
|
|
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 = {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The engine's own evidence for an approval.
|
|
2
|
+
//
|
|
3
|
+
// REVIEW's verdict is an agent's structured claim. Before the engine asks for
|
|
4
|
+
// that claim it runs the project's own checks (test/build/lint) at a known
|
|
5
|
+
// revision, and afterwards it only accepts "APPROVED" if the checks passed and
|
|
6
|
+
// the code has not moved since. Discipline belongs in the system; judgement
|
|
7
|
+
// belongs with the agents.
|
|
8
|
+
//
|
|
9
|
+
// Pure decisions (resolveChecks, evaluateApproval, evidenceFindings, the
|
|
10
|
+
// renderers) are unit-tested without spawning anything. runChecks takes an
|
|
11
|
+
// injectable runner so the session loop is testable with scripted results.
|
|
12
|
+
|
|
13
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
14
|
+
import { killTree } from "../proc.mjs";
|
|
15
|
+
|
|
16
|
+
export const ENGINE_REVIEWER = "AgentDesk";
|
|
17
|
+
export const CHECK_ORDER = Object.freeze(["test", "build", "lint"]);
|
|
18
|
+
export const DEFAULT_CHECK_TIMEOUT_MS = 15 * 60 * 1000;
|
|
19
|
+
export const OUTPUT_TAIL_BYTES = 4096;
|
|
20
|
+
|
|
21
|
+
export const shortRev = revision => String(revision || "").slice(0, 7);
|
|
22
|
+
|
|
23
|
+
// config.commands.{test,build,lint} win; the project's detected commands fill
|
|
24
|
+
// the gaps; a slot with neither is simply not a check.
|
|
25
|
+
export function resolveChecks({ config = {}, project = {} } = {}) {
|
|
26
|
+
const detected = { test: project.testCommand, build: project.buildCommand, lint: project.lintCommand };
|
|
27
|
+
const checks = [];
|
|
28
|
+
for (const name of CHECK_ORDER) {
|
|
29
|
+
const configured = config.commands?.[name];
|
|
30
|
+
const command = typeof configured === "string" && configured.trim() ? configured.trim() : (detected[name] || null);
|
|
31
|
+
if (command) checks.push({ name, command });
|
|
32
|
+
}
|
|
33
|
+
return checks;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function checkTimeoutMs(config = {}) {
|
|
37
|
+
const v = Number(config.checkTimeoutMs);
|
|
38
|
+
return Number.isFinite(v) && v > 0 ? v : DEFAULT_CHECK_TIMEOUT_MS;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Worktree sessions commit into a private Git dir (worktree-git.mjs); the
|
|
42
|
+
// engine must look where the agents commit, so the session's GIT_* env is
|
|
43
|
+
// overlaid on a scrubbed environment.
|
|
44
|
+
function gitEnv(extra = {}) {
|
|
45
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
|
|
46
|
+
for (const key of Object.keys(env)) if (/^GIT_(DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|CONFIG_COUNT|CONFIG_KEY_\d+|CONFIG_VALUE_\d+|CONFIG_PARAMETERS)$/.test(key)) delete env[key];
|
|
47
|
+
return { ...env, ...extra };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function git(cwd, args, extra) {
|
|
51
|
+
return execFileSync("git", args, { cwd, env: gitEnv(extra), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 30000 }).trim();
|
|
52
|
+
}
|
|
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
|
+
|
|
62
|
+
// Independent lookups: a repository with an unborn branch has no HEAD yet
|
|
63
|
+
// but can still have a dirty tree.
|
|
64
|
+
export function gitState(cwd, extraEnv = {}) {
|
|
65
|
+
let revision = null, clean = null;
|
|
66
|
+
try { revision = git(cwd, ["rev-parse", "HEAD"], extraEnv) || null; } catch {}
|
|
67
|
+
try {
|
|
68
|
+
const lines = git(cwd, ["status", "--porcelain"], extraEnv).split("\n").filter(Boolean);
|
|
69
|
+
clean = lines.every(isEngineFile);
|
|
70
|
+
} catch {}
|
|
71
|
+
return { revision, clean };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function plainSpawnCommand({ command, cwd, env }) {
|
|
75
|
+
return spawn("/bin/sh", ["-c", command], { cwd, env, stdio: ["ignore", "pipe", "pipe"], shell: false, detached: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// spawnCommand({ command, cwd, env }) → ChildProcess with piped stdout/stderr.
|
|
79
|
+
export function createCheckRunner({ spawnCommand, tailBytes = OUTPUT_TAIL_BYTES } = {}) {
|
|
80
|
+
return function runCheck({ command, cwd, env, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, signal }) {
|
|
81
|
+
return new Promise(resolve => {
|
|
82
|
+
const startedAt = Date.now();
|
|
83
|
+
let child;
|
|
84
|
+
try { child = spawnCommand({ command, cwd, env }); }
|
|
85
|
+
catch (error) { return resolve({ exitCode: null, output: "", timedOut: false, durationMs: 0, error: error.message }); }
|
|
86
|
+
let tail = "";
|
|
87
|
+
const append = chunk => { tail = (tail + chunk.toString()).slice(-tailBytes); };
|
|
88
|
+
child.stdout?.on("data", append);
|
|
89
|
+
child.stderr?.on("data", append);
|
|
90
|
+
let timedOut = false, settled = false;
|
|
91
|
+
const timer = setTimeout(() => { timedOut = true; killTree(child, { graceMs: 1000 }); }, timeoutMs);
|
|
92
|
+
const onAbort = () => killTree(child, { graceMs: 0 });
|
|
93
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
94
|
+
const finish = (exitCode, error) => {
|
|
95
|
+
if (settled) return;
|
|
96
|
+
settled = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
signal?.removeEventListener("abort", onAbort);
|
|
99
|
+
resolve({ exitCode, output: tail.trim(), timedOut, durationMs: Date.now() - startedAt, ...(error && { error }) });
|
|
100
|
+
};
|
|
101
|
+
child.once("error", err => finish(null, err.message));
|
|
102
|
+
child.once("close", (code, sig) => finish(code, sig && !timedOut ? `terminated by ${sig}` : undefined));
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Runs the checks in order at the current revision. `runCheck` is the
|
|
108
|
+
// per-check runner (createCheckRunner or a scripted stand-in).
|
|
109
|
+
export async function runChecks({ checks = [], cwd, env = {}, gitEnv: extraGitEnv = {}, runCheck, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, signal, requireClean = false }) {
|
|
110
|
+
const startedAt = Date.now();
|
|
111
|
+
const { revision, clean } = gitState(cwd, extraGitEnv);
|
|
112
|
+
const results = [];
|
|
113
|
+
let aborted = false;
|
|
114
|
+
for (const check of checks) {
|
|
115
|
+
if (signal?.aborted) { aborted = true; break; }
|
|
116
|
+
const r = await runCheck({ ...check, cwd, env, timeoutMs, signal });
|
|
117
|
+
results.push({
|
|
118
|
+
name: check.name, command: check.command,
|
|
119
|
+
exitCode: r.exitCode ?? null, durationMs: r.durationMs ?? 0, timedOut: !!r.timedOut,
|
|
120
|
+
output: String(r.output || "").slice(-OUTPUT_TAIL_BYTES),
|
|
121
|
+
...(r.error && { error: r.error }),
|
|
122
|
+
});
|
|
123
|
+
if (signal?.aborted) { aborted = true; break; }
|
|
124
|
+
}
|
|
125
|
+
const checksPassed = results.every(r => r.exitCode === 0 && !r.timedOut);
|
|
126
|
+
const dirty = requireClean && clean === false;
|
|
127
|
+
return {
|
|
128
|
+
revision, clean, requireClean, checked: checks.length > 0,
|
|
129
|
+
startedAt, durationMs: Date.now() - startedAt, results, aborted,
|
|
130
|
+
passed: !aborted && checksPassed && !dirty,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// An approval stands only on: an explicit APPROVED, passing checks, and the
|
|
135
|
+
// same revision the checks and reviewers saw.
|
|
136
|
+
export function evaluateApproval({ verdict, evidence, headNow }) {
|
|
137
|
+
if (!verdict || verdict.outcome !== "APPROVED") return { approved: false, reason: "review did not approve" };
|
|
138
|
+
if (evidence && !evidence.passed) return { approved: false, reason: evidence.aborted ? "verification was cancelled" : "engine checks did not pass" };
|
|
139
|
+
if (evidence?.revision && headNow && evidence.revision !== headNow) {
|
|
140
|
+
return { approved: false, reason: `code changed after verification (${shortRev(evidence.revision)} → ${shortRev(headNow)})` };
|
|
141
|
+
}
|
|
142
|
+
return { approved: true, reason: null };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const seconds = ms => `${(Number(ms || 0) / 1000).toFixed(1)}s`;
|
|
146
|
+
|
|
147
|
+
function describeResult(r) {
|
|
148
|
+
if (r.timedOut) return `TIMED OUT after ${seconds(r.durationMs)}`;
|
|
149
|
+
if (r.exitCode === 0) return `passed in ${seconds(r.durationMs)}`;
|
|
150
|
+
return `FAILED (${r.exitCode == null ? r.error || "no exit code" : `exit ${r.exitCode}`}) in ${seconds(r.durationMs)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Findings in the REVIEW verdict shape, so a failed check flows through the
|
|
154
|
+
// same retry path as a reviewer's finding.
|
|
155
|
+
export function evidenceFindings(evidence) {
|
|
156
|
+
const findings = [];
|
|
157
|
+
if (!evidence) return findings;
|
|
158
|
+
if (evidence.requireClean && evidence.clean === false) {
|
|
159
|
+
findings.push({ reviewer: ENGINE_REVIEWER, title: "Uncommitted changes in the session worktree", detail: "Commit or discard them before review — an approval refers to a committed revision." });
|
|
160
|
+
}
|
|
161
|
+
for (const r of evidence.results || []) {
|
|
162
|
+
if (r.exitCode === 0 && !r.timedOut) continue;
|
|
163
|
+
const title = r.timedOut ? `\`${r.command}\` timed out after ${seconds(r.durationMs)}` : `\`${r.command}\` failed (${r.exitCode == null ? r.error || "no exit code" : `exit ${r.exitCode}`})`;
|
|
164
|
+
findings.push({ reviewer: ENGINE_REVIEWER, title, detail: r.output || "(no output)" });
|
|
165
|
+
}
|
|
166
|
+
return findings;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function formatEvidenceForPrompt(evidence) {
|
|
170
|
+
if (!evidence) return "";
|
|
171
|
+
const where = evidence.revision ? ` at ${shortRev(evidence.revision)}` : "";
|
|
172
|
+
const tree = evidence.clean == null ? "" : ` (tree ${evidence.clean ? "clean" : "has uncommitted changes"})`;
|
|
173
|
+
if (!evidence.checked) {
|
|
174
|
+
return `No verification commands are configured for this project (commands.test/build/lint in .agentdesk.json)${where}${tree} — reviewers must run the checks themselves and report the output.`;
|
|
175
|
+
}
|
|
176
|
+
const lines = [`Engine verification${where}${tree}:`];
|
|
177
|
+
for (const r of evidence.results) {
|
|
178
|
+
lines.push(`- \`${r.command}\` — ${describeResult(r)}`);
|
|
179
|
+
if (r.exitCode !== 0 || r.timedOut) for (const l of String(r.output || "(no output)").split("\n")) lines.push(` ${l}`);
|
|
180
|
+
}
|
|
181
|
+
if (evidence.aborted) lines.push("- (verification was cancelled before every check ran)");
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function renderEvidenceSection(evidence) {
|
|
186
|
+
if (!evidence) return "";
|
|
187
|
+
const lines = ["## Evidence", `- Revision: ${evidence.revision ? shortRev(evidence.revision) : "(no repository)"}`];
|
|
188
|
+
if (evidence.clean != null) lines.push(`- Tree: ${evidence.clean ? "clean" : "uncommitted changes"}`);
|
|
189
|
+
if (!evidence.checked) lines.push("- Checks: none configured");
|
|
190
|
+
for (const r of evidence.results || []) lines.push(`- ${r.name}: \`${r.command}\` — ${describeResult(r)}`);
|
|
191
|
+
lines.push(`- Result: ${evidence.passed ? "passed" : "not passed"}`, "");
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
}
|
|
@@ -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
|
+
}
|
package/cli/engine/hooks.mjs
CHANGED
|
@@ -19,6 +19,14 @@ export function isPublishCommand(command) {
|
|
|
19
19
|
return PUBLISH_RE.test(String(command || ""));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Commands that move HEAD or rewrite the tree. SUMMARY reports on an approved
|
|
23
|
+
// revision; it must not be able to change which revision that is.
|
|
24
|
+
const HISTORY_RE = /(^|[;&|]\s*)git\s+(commit|merge|rebase|reset|checkout|switch|cherry-pick|revert|am|apply|stash|restore|clean)\b/;
|
|
25
|
+
|
|
26
|
+
export function isHistoryCommand(command) {
|
|
27
|
+
return HISTORY_RE.test(String(command || ""));
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
// The auditor whose sign-off gates publishing. "Sam's audit is a blocking
|
|
23
31
|
// gate — not advisory" used to be prose; now it is the observable fact that a
|
|
24
32
|
// Sam subagent finished in this EXECUTION phase (SubagentStop).
|
|
@@ -62,6 +70,16 @@ export function decidePreToolUse({ phase, input, state = {} }) {
|
|
|
62
70
|
return { decision: "deny", reason: "REVIEW is read-only: report findings; fixes happen back in EXECUTION." };
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
// The approval refers to one revision. SUMMARY may read it and talk about
|
|
74
|
+
// it; it may not edit, commit, move HEAD, or publish.
|
|
75
|
+
if (phase === "SUMMARY") {
|
|
76
|
+
if (MUTATING_TOOLS.has(tool)) return { decision: "deny", reason: "SUMMARY writes messages only — the approved revision must not change." };
|
|
77
|
+
const command = input?.tool_input?.command;
|
|
78
|
+
if (tool === "Bash" && (isPublishCommand(command) || isHistoryCommand(command))) {
|
|
79
|
+
return { decision: "deny", reason: "SUMMARY writes messages only — it cannot commit, move HEAD, or publish; the approved revision must not change." };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
65
83
|
if (phase === "EXECUTION" && tool === "Bash" && isPublishCommand(input?.tool_input?.command)) {
|
|
66
84
|
const open = Array.isArray(state.openFindings) ? state.openFindings : [];
|
|
67
85
|
if (state.awaitingAudit || open.length > 0) {
|
|
@@ -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. **
|
|
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
|
|
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`.
|
package/cli/engine/prompts.mjs
CHANGED
|
@@ -13,6 +13,8 @@ import { fileURLToPath } from "url";
|
|
|
13
13
|
import { wrapUntrusted, PROMPT_SECURITY_HEADER, MEMORY_INSTRUCTIONS, loadProjectMemory } from "../prompt.mjs";
|
|
14
14
|
import { generateContext } from "../detect.mjs";
|
|
15
15
|
import { formatFindingsForRetry } from "./verdict.mjs";
|
|
16
|
+
import { formatEvidenceForPrompt } from "./evidence.mjs";
|
|
17
|
+
import { renderOpenItems } from "./handoff.mjs";
|
|
16
18
|
|
|
17
19
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
20
|
|
|
@@ -133,7 +135,8 @@ function createTaskSection({ tracker, config, description }) {
|
|
|
133
135
|
// Returns the full user prompt for one phase's query().
|
|
134
136
|
export function renderPhasePrompt({
|
|
135
137
|
phase, taskId, taskLink, description, createTask, tracker, config = {}, project = {},
|
|
136
|
-
sessionUrl, cwd, sessionMemory = "", retryVerdict = null,
|
|
138
|
+
sessionUrl, cwd, sessionMemory = "", retryVerdict = null, evidence = null, openItems = [],
|
|
139
|
+
roster = null, profile = null,
|
|
137
140
|
}) {
|
|
138
141
|
const vars = {
|
|
139
142
|
TASK_ID: taskId,
|
|
@@ -149,6 +152,17 @@ export function renderPhasePrompt({
|
|
|
149
152
|
if (tracker) flags.add(tracker.toUpperCase()); else flags.add("NO_TRACKER");
|
|
150
153
|
if (retryVerdict) flags.add("RETRY");
|
|
151
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
|
+
|
|
152
166
|
vars.TRACKER_SECTION = trackerSection(tracker, phase, vars);
|
|
153
167
|
|
|
154
168
|
let body = renderTemplate(template(`phases/${phase}.md`), { flags, vars }).trim();
|
|
@@ -162,6 +176,10 @@ export function renderPhasePrompt({
|
|
|
162
176
|
if (config.instructions) {
|
|
163
177
|
body += `\n\n## ADDITIONAL INSTRUCTIONS\n\n${config.instructions}`;
|
|
164
178
|
}
|
|
179
|
+
if (evidence) {
|
|
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)}`;
|
|
181
|
+
}
|
|
182
|
+
if (openItems.length) body += `\n\n${renderOpenItems(openItems)}`;
|
|
165
183
|
if (sessionMemory) {
|
|
166
184
|
body += `\n\n## SESSION MEMORY (previous phases)\n\n${sessionMemory}`;
|
|
167
185
|
}
|
package/cli/engine/schemas.mjs
CHANGED
|
@@ -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),
|
package/cli/engine/session.mjs
CHANGED
|
@@ -17,17 +17,24 @@ 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
|
-
|
|
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";
|
|
31
|
+
import {
|
|
32
|
+
resolveChecks, runChecks, createCheckRunner, evaluateApproval, evidenceFindings,
|
|
33
|
+
renderEvidenceSection, gitState, checkTimeoutMs, shortRev,
|
|
34
|
+
} from "./evidence.mjs";
|
|
35
|
+
import { spawnSandboxedCommand } from "./spawn.mjs";
|
|
36
|
+
import { createLedger, openItemsFrom, noCommitItem } from "./handoff.mjs";
|
|
37
|
+
import { teamProfileFor, phasesFor } from "./team-profile.mjs";
|
|
31
38
|
import { buildQueryOptions, defaultRunQuery } from "./query.mjs";
|
|
32
39
|
import { armPublishGate, onSubagentStopped } from "./hooks.mjs";
|
|
33
40
|
import { prepareWorkspace } from "../worktrees.mjs";
|
|
@@ -132,6 +139,7 @@ async function executeSession({
|
|
|
132
139
|
onEvent, apiKey, serverUrl, sessionId, onChild, abortSignal,
|
|
133
140
|
soloAgent = null, childStrategy = null,
|
|
134
141
|
runQuery = defaultRunQuery,
|
|
142
|
+
runCheck = null,
|
|
135
143
|
authCheck = checkClaudeAuth,
|
|
136
144
|
sourceCwd = cwd, workspaceRecord, workspaceStateDir, resumingWorkspace = false,
|
|
137
145
|
workspaceGitEnv = {},
|
|
@@ -209,10 +217,12 @@ async function executeSession({
|
|
|
209
217
|
const stateDir = workspaceStateDir || join(cwd, ".agentdesk");
|
|
210
218
|
const memoryPath = join(stateDir, "session-memory.md");
|
|
211
219
|
const findingsPath = join(stateDir, "review-findings.json");
|
|
220
|
+
const ledgerPath = join(stateDir, "handoffs.jsonl");
|
|
212
221
|
try { mkdirSync(stateDir, { recursive: true }); } catch {}
|
|
213
222
|
if (!resumingWorkspace || !existsSync(memoryPath)) {
|
|
214
223
|
if (archiveStaleMemory(memoryPath)) console.error("[agentdesk] archived stale session-memory.md from a previous run");
|
|
215
224
|
try { if (existsSync(findingsPath)) unlinkSync(findingsPath); } catch {}
|
|
225
|
+
try { if (existsSync(ledgerPath)) unlinkSync(ledgerPath); } catch {}
|
|
216
226
|
writeFileSync(memoryPath, `# Session Memory\n\n## Task\n- ID: ${taskId}\n${taskLink ? `- Link: ${taskLink}\n` : ""}\n`);
|
|
217
227
|
} else {
|
|
218
228
|
appendFileSync(memoryPath, `\n## Resumed session\n${sessionUrl || sessionId}\n`);
|
|
@@ -226,7 +236,84 @@ async function executeSession({
|
|
|
226
236
|
let reviewRetries = 0, phaseRuns = 0, lastVerdict = null, lastPhase = null;
|
|
227
237
|
let isolationLogged = false;
|
|
228
238
|
|
|
229
|
-
|
|
239
|
+
// --- evidence (engine-owned) ---------------------------------------------
|
|
240
|
+
// The engine runs the project's checks itself before every REVIEW and pins
|
|
241
|
+
// any approval to the revision they ran at. In a session worktree the tree
|
|
242
|
+
// must be clean too: an approval refers to a committed revision.
|
|
243
|
+
const checks = resolveChecks({ config, project });
|
|
244
|
+
const requireClean = !!workspaceRecord;
|
|
245
|
+
const runOneCheck = runCheck || createCheckRunner({
|
|
246
|
+
spawnCommand: ({ command, cwd: checkCwd, env }) => spawnSandboxedCommand({
|
|
247
|
+
command, cwd: checkCwd, env, sandbox, onChild,
|
|
248
|
+
extraWritePaths: workspaceStateDir ? [workspaceStateDir, workspaceRecord.tree || cwd] : [],
|
|
249
|
+
}),
|
|
250
|
+
});
|
|
251
|
+
const headNow = () => gitState(cwd, workspaceGitEnv).revision;
|
|
252
|
+
let approvedRevision = null;
|
|
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
|
+
|
|
259
|
+
const reportEvidence = evidence => {
|
|
260
|
+
emit({ type: "session:evidence", phase: "REVIEW", revision: evidence.revision, clean: evidence.clean,
|
|
261
|
+
passed: evidence.passed, checked: evidence.checked, results: evidence.results });
|
|
262
|
+
const at = evidence.revision ? ` at ${shortRev(evidence.revision)}` : "";
|
|
263
|
+
if (!evidence.checked) {
|
|
264
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `No verification commands configured (commands.test/build/lint) — review relies on the reviewers alone${at}.` });
|
|
265
|
+
}
|
|
266
|
+
for (const r of evidence.results) {
|
|
267
|
+
const took = `${(r.durationMs / 1000).toFixed(1)}s`;
|
|
268
|
+
const status = r.timedOut ? `timed out after ${took}` : r.exitCode === 0 ? `passed (${took})` : `FAILED (exit ${r.exitCode ?? "?"}, ${took})`;
|
|
269
|
+
emit({ type: "agent:message", agent: "Jane", tag: "ACT", message: `Ran \`${r.command}\`${at} — ${status}` });
|
|
270
|
+
}
|
|
271
|
+
if (evidence.requireClean && evidence.clean === false) {
|
|
272
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: "Uncommitted changes in the session worktree — review cannot approve an uncommitted state." });
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// One place decides what a REVIEW outcome means for the loop, whether the
|
|
277
|
+
// verdict came from the reviewers or was synthesised from failed checks.
|
|
278
|
+
function settleReview(verdict) {
|
|
279
|
+
lastVerdict = verdict;
|
|
280
|
+
reviewResolved = verdict.approved === true;
|
|
281
|
+
if (reviewResolved) approvedRevision = verdict.headNow || verdict.revision || null;
|
|
282
|
+
state.openFindings = reviewResolved ? [] : verdict.findings;
|
|
283
|
+
openItems = openItemsFrom({ verdict });
|
|
284
|
+
try { writeFileSync(findingsPath, JSON.stringify(verdict, null, 2)); } catch {}
|
|
285
|
+
if (reviewResolved) return;
|
|
286
|
+
if (verdict.outcome === "MISSING") {
|
|
287
|
+
emit({ type: "session:error", code: "REVIEW_VERDICT_MISSING", message: `REVIEW ended without a verdict (${verdict.reason}) — treating as not approved.` });
|
|
288
|
+
} else if (verdict.outcome === "APPROVED") {
|
|
289
|
+
emit({ type: "session:error", code: "REVIEW_STALE", message: `Review approved ${shortRev(verdict.revision)} but the code is now at ${shortRev(verdict.headNow)} — the approval does not carry over.` });
|
|
290
|
+
}
|
|
291
|
+
const count = `${verdict.findings.length} finding(s)`;
|
|
292
|
+
if (reviewRetries < MAX_REVIEW_RETRIES) {
|
|
293
|
+
reviewRetries++;
|
|
294
|
+
const why = verdict.engineOnly ? `Verification failed (${count})` : `Review did not approve (${verdict.outcome}, ${count})`;
|
|
295
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `${why} — returning to EXECUTION (retry ${reviewRetries}/${MAX_REVIEW_RETRIES}).` });
|
|
296
|
+
queue.unshift("EXECUTION", "REVIEW");
|
|
297
|
+
} else {
|
|
298
|
+
const why = verdict.engineOnly ? "Verification still failing" : "Review still unresolved";
|
|
299
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `${why} after ${reviewRetries} retry — ending session for human review.` });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// An approval is void the moment the code moves past the reviewed revision.
|
|
304
|
+
const invalidateApproval = now => {
|
|
305
|
+
reviewResolved = false;
|
|
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)}).` }];
|
|
308
|
+
if (lastVerdict) {
|
|
309
|
+
lastVerdict = { ...lastVerdict, approved: false, approvalReason: "code changed after approval", headNow: now };
|
|
310
|
+
try { writeFileSync(findingsPath, JSON.stringify(lastVerdict, null, 2)); } catch {}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// --- team profile (INTAKE assesses the task; config may force) -----------
|
|
315
|
+
let profile = teamProfileFor({ intake: null, config });
|
|
316
|
+
const queue = solo ? ["SOLO"] : phasesFor(profile);
|
|
230
317
|
|
|
231
318
|
try {
|
|
232
319
|
while (queue.length > 0) {
|
|
@@ -238,13 +325,42 @@ async function executeSession({
|
|
|
238
325
|
|
|
239
326
|
const phase = queue.shift();
|
|
240
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
|
+
};
|
|
241
335
|
// The dashboard knows the five team phases; solo shows as EXECUTION.
|
|
242
336
|
const model = modelForPhase(phase === "SOLO" ? "EXECUTION" : phase, config.phaseModels);
|
|
243
337
|
emit({ type: "phase:change", phase: phase === "SOLO" ? "EXECUTION" : phase, model: model || "default" });
|
|
244
338
|
|
|
339
|
+
// Verification runs before the reviewers are asked. If it fails, the
|
|
340
|
+
// reviewers are not asked at all: the failing output goes straight back
|
|
341
|
+
// to EXECUTION as findings, through the same retry path.
|
|
342
|
+
let evidence = null;
|
|
343
|
+
if (phase === "REVIEW") {
|
|
344
|
+
evidence = await runChecks({ checks, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }),
|
|
345
|
+
gitEnv: workspaceGitEnv, runCheck: runOneCheck, timeoutMs: checkTimeoutMs(config), signal: abortController.signal, requireClean });
|
|
346
|
+
if (abortController.signal.aborted) { aborted = true; break; }
|
|
347
|
+
reportEvidence(evidence);
|
|
348
|
+
appendMemory(renderEvidenceSection(evidence));
|
|
349
|
+
if (!evidence.passed) {
|
|
350
|
+
settleReview({ outcome: "NEEDS_MORE_WORK", findings: evidenceFindings(evidence), deferred: [], unverifiedClaims: [], reason: null,
|
|
351
|
+
revision: evidence.revision, headNow: evidence.revision, evidence, approved: false, approvalReason: "engine checks did not pass", engineOnly: true });
|
|
352
|
+
finishRun({ evidence, status: "checks-failed" });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (phase === "SUMMARY" && reviewResolved && approvedRevision) {
|
|
357
|
+
const now = headNow();
|
|
358
|
+
if (now !== approvedRevision) invalidateApproval(now);
|
|
359
|
+
}
|
|
360
|
+
|
|
245
361
|
const { agents, allowedTools, lead } = solo
|
|
246
362
|
? soloDefinition(solo)
|
|
247
|
-
: agentsForPhase({ phase, team, phaseModels: config.phaseModels });
|
|
363
|
+
: agentsForPhase({ phase, team, phaseModels: config.phaseModels, profile });
|
|
248
364
|
let prompt = solo
|
|
249
365
|
? renderSoloPrompt({ agent: solo, taskId, taskLink, description, tracker, config, project, sessionUrl, cwd, childStrategy })
|
|
250
366
|
: renderPhasePrompt({
|
|
@@ -252,7 +368,12 @@ async function executeSession({
|
|
|
252
368
|
createTask: phase === "INTAKE" ? createTask : false,
|
|
253
369
|
tracker, config, project, sessionUrl, cwd,
|
|
254
370
|
sessionMemory: memoryText(),
|
|
255
|
-
retryVerdict: phase === "EXECUTION" && lastVerdict && lastVerdict.
|
|
371
|
+
retryVerdict: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? lastVerdict : null,
|
|
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,
|
|
256
377
|
});
|
|
257
378
|
if (workspaceRecord) {
|
|
258
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`;
|
|
@@ -314,29 +435,17 @@ async function executeSession({
|
|
|
314
435
|
emit({ type: "session:error", code: "PHASE_FAILED", message: `${phase} failed (${detail}) — session incomplete.` });
|
|
315
436
|
handoff = true;
|
|
316
437
|
writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
|
|
438
|
+
finishRun({ status: "failed" });
|
|
317
439
|
break;
|
|
318
440
|
}
|
|
319
441
|
|
|
320
442
|
if (phase === "REVIEW") {
|
|
321
443
|
const verdict = verdictFromResult({ is_error: summary.isError, subtype: summary.subtype, structured_output: summary.structuredOutput });
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
state.openFindings = reviewResolved ? [] : verdict.findings;
|
|
444
|
+
const now = headNow();
|
|
445
|
+
const approval = evaluateApproval({ verdict, evidence, headNow: now });
|
|
325
446
|
appendMemory(renderMemorySection("REVIEW", summary.structuredOutput));
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (!reviewResolved) {
|
|
329
|
-
if (verdict.outcome === "MISSING") {
|
|
330
|
-
emit({ type: "session:error", code: "REVIEW_VERDICT_MISSING", message: `REVIEW ended without a verdict (${verdict.reason}) — treating as not approved.` });
|
|
331
|
-
}
|
|
332
|
-
if (reviewRetries < MAX_REVIEW_RETRIES) {
|
|
333
|
-
reviewRetries++;
|
|
334
|
-
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Review did not approve (${verdict.outcome}, ${verdict.findings.length} finding(s)) — returning to EXECUTION (retry ${reviewRetries}/${MAX_REVIEW_RETRIES}).` });
|
|
335
|
-
queue.unshift("EXECUTION", "REVIEW");
|
|
336
|
-
} else {
|
|
337
|
-
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Review still unresolved after ${reviewRetries} retry — ending session for human review.` });
|
|
338
|
-
}
|
|
339
|
-
}
|
|
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" });
|
|
340
449
|
continue;
|
|
341
450
|
}
|
|
342
451
|
|
|
@@ -345,15 +454,40 @@ async function executeSession({
|
|
|
345
454
|
emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
|
|
346
455
|
}
|
|
347
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" });
|
|
348
466
|
if (phase === "INTAKE" && summary.structuredOutput?.title) {
|
|
349
467
|
emit({ type: "session:update", title: String(summary.structuredOutput.title).slice(0, 60) });
|
|
350
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
|
+
}
|
|
351
479
|
}
|
|
352
480
|
} finally {
|
|
353
481
|
abortSignal?.removeEventListener?.("abort", onExternalAbort);
|
|
354
482
|
sandbox.cleanup();
|
|
355
483
|
}
|
|
356
484
|
|
|
485
|
+
// Belt to SUMMARY's braces: nothing may have moved the approved revision.
|
|
486
|
+
if (!aborted && reviewResolved && approvedRevision) {
|
|
487
|
+
const now = headNow();
|
|
488
|
+
if (now !== approvedRevision) invalidateApproval(now);
|
|
489
|
+
}
|
|
490
|
+
|
|
357
491
|
const duration = seconds(startedAt);
|
|
358
492
|
const status = finalStatus({ aborted, crashed: handoff, reviewResolved });
|
|
359
493
|
const resumePath = join(cwd, ".agentdesk-resume.md");
|
|
@@ -373,5 +507,8 @@ async function executeSession({
|
|
|
373
507
|
handoff: status !== "complete",
|
|
374
508
|
aborted, status, reviewResolved, lastPhase,
|
|
375
509
|
verdict: lastVerdict,
|
|
510
|
+
approvedRevision: reviewResolved ? approvedRevision : null,
|
|
511
|
+
openItems,
|
|
512
|
+
profile,
|
|
376
513
|
};
|
|
377
514
|
}
|
package/cli/engine/spawn.mjs
CHANGED
|
@@ -35,6 +35,34 @@ export function bundledClaudeDir() {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// One isolation policy for everything a session runs — Claude itself and the
|
|
39
|
+
// engine's own verification commands. Shared Git metadata is exposed
|
|
40
|
+
// read-only and the worktree's private Git state is protected, so a check
|
|
41
|
+
// command has exactly the reach a Bash tool call inside the session has.
|
|
42
|
+
function wrapForSession({ cmd, args, cwd, env = {}, sandbox, extraReadPaths = [], extraWritePaths = [] }) {
|
|
43
|
+
const sharedGit = workspaceGitPaths(cwd);
|
|
44
|
+
const wrapped = wrapIsolatedSpawn({
|
|
45
|
+
cmd, args, cwd,
|
|
46
|
+
scratchHome: sandbox.home,
|
|
47
|
+
extraReadPaths: [bundledClaudeDir(), ...sharedGit, ...extraReadPaths].filter(Boolean),
|
|
48
|
+
extraWritePaths,
|
|
49
|
+
protectedPaths: sharedGit.length ? [...sharedGit, join(env.GIT_WORK_TREE || cwd, ".git"), ...(env.GIT_DIR ? [join(dirname(env.GIT_DIR), "..", "git-pending")] : [])] : [],
|
|
50
|
+
});
|
|
51
|
+
if (sharedGit.length && wrapped.isolation.policy !== "strict") throw new Error("Worktree sessions require strict kernel isolation; shared Git metadata cannot be exposed writable.");
|
|
52
|
+
return wrapped;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Run one shell command under the session's isolation, in its own process
|
|
56
|
+
// group, tracked for teardown. Used by the engine's verification checks.
|
|
57
|
+
export function spawnSandboxedCommand({ command, cwd, env, sandbox, extraReadPaths = [], extraWritePaths = [], onChild }) {
|
|
58
|
+
installExitGuards();
|
|
59
|
+
const wrapped = wrapForSession({ cmd: "/bin/sh", args: ["-c", command], cwd, env, sandbox, extraReadPaths, extraWritePaths });
|
|
60
|
+
const child = spawn(wrapped.cmd, wrapped.args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], shell: false, detached: true });
|
|
61
|
+
trackChild(child);
|
|
62
|
+
onChild?.(child);
|
|
63
|
+
return child;
|
|
64
|
+
}
|
|
65
|
+
|
|
38
66
|
// Build the `spawnClaudeCodeProcess` callback for one session.
|
|
39
67
|
//
|
|
40
68
|
// sandbox — result of createScratchHome() (its `home` hosts the profile)
|
|
@@ -46,17 +74,7 @@ export function createSandboxedSpawn({ sandbox, onChild, extraReadPaths = [], ex
|
|
|
46
74
|
let reported = false;
|
|
47
75
|
|
|
48
76
|
return function spawnClaudeCodeProcess(opts) {
|
|
49
|
-
const
|
|
50
|
-
const wrapped = wrapIsolatedSpawn({
|
|
51
|
-
cmd: opts.command,
|
|
52
|
-
args: opts.args,
|
|
53
|
-
cwd: opts.cwd,
|
|
54
|
-
scratchHome: sandbox.home,
|
|
55
|
-
extraReadPaths: [bundledClaudeDir(), ...sharedGit, ...extraReadPaths].filter(Boolean),
|
|
56
|
-
extraWritePaths,
|
|
57
|
-
protectedPaths: sharedGit.length ? [...sharedGit, join(opts.env.GIT_WORK_TREE || opts.cwd, ".git"), ...(opts.env.GIT_DIR ? [join(dirname(opts.env.GIT_DIR), "..", "git-pending")] : [])] : [],
|
|
58
|
-
});
|
|
59
|
-
if (sharedGit.length && wrapped.isolation.policy !== "strict") throw new Error("Worktree sessions require strict kernel isolation; shared Git metadata cannot be exposed writable.");
|
|
77
|
+
const wrapped = wrapForSession({ cmd: opts.command, args: opts.args, cwd: opts.cwd, env: opts.env, sandbox, extraReadPaths, extraWritePaths });
|
|
60
78
|
|
|
61
79
|
if (!reported) {
|
|
62
80
|
reported = true;
|
|
@@ -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/cli/engine/verdict.mjs
CHANGED
|
@@ -64,7 +64,10 @@ export function verdictFromResult(result) {
|
|
|
64
64
|
// reviewers' concrete list rather than re-deriving it.
|
|
65
65
|
export function formatFindingsForRetry(verdict) {
|
|
66
66
|
const lines = ["## Review findings to resolve", ""];
|
|
67
|
-
if (verdict.
|
|
67
|
+
if (verdict.approvalReason && verdict.outcome === "APPROVED") {
|
|
68
|
+
lines.push(`- **The reviewers approved, but the engine could not accept it** — ${verdict.approvalReason}. Re-verify at the current revision.`);
|
|
69
|
+
}
|
|
70
|
+
if (verdict.findings.length === 0 && verdict.unverifiedClaims.length === 0 && !verdict.approvalReason) {
|
|
68
71
|
lines.push("- (review did not approve but listed no findings — re-verify every claim from the last execution)");
|
|
69
72
|
}
|
|
70
73
|
for (const f of verdict.findings) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kendoo.agentdesk/agentdesk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,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",
|
|
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",
|
|
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",
|