@kendoo.agentdesk/agentdesk 0.29.2 → 0.30.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 +9 -2
- package/README.md +3 -1
- package/cli/engine/evidence.mjs +182 -0
- package/cli/engine/hooks.mjs +18 -0
- package/cli/engine/prompts.mjs +5 -1
- package/cli/engine/session.mjs +107 -18
- package/cli/engine/spawn.mjs +29 -11
- package/cli/engine/verdict.mjs +4 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,10 +8,17 @@ 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.
|
|
11
|
+
## [0.30.0] — 2026-09-21
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `[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.
|
|
15
|
+
- `[CLI]` The SUMMARY phase is now enforced read-only: it can no longer edit files, commit, move `HEAD`, push, or open a PR.
|
|
16
|
+
- `[UI]` The session feed shows each verification command the engine ran and its result.
|
|
17
|
+
|
|
18
|
+
## [0.29.3] — 2026-09-21
|
|
12
19
|
|
|
13
20
|
### Fixed
|
|
14
|
-
- `[CLI]` The dashboard's new task-lookup (which recommends resuming, continuing, or starting fresh for a task id) could miss a workspace that already had unfinished local work, then try to check out its branch a second time and fail with `fatal: '...' is already used by worktree at ...`. Lookups now match by the repo's actual path instead of a locally-derived project label that could go stale.
|
|
21
|
+
- `[CLI]` The dashboard's new task-lookup (which recommends resuming, continuing, or starting fresh for a task id) could miss a workspace that already had unfinished local work, then try to check out its branch a second time and fail with `fatal: '...' is already used by worktree at ...`. Lookups now match by the repo's actual path instead of a locally-derived project label that could go stale. (Published as 0.29.3 — 0.29.2 got stuck mid-publish on the registry.)
|
|
15
22
|
|
|
16
23
|
## [0.29.1] — 2026-09-20
|
|
17
24
|
|
package/README.md
CHANGED
|
@@ -265,6 +265,8 @@ 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
|
+
|
|
268
270
|
## How It Works
|
|
269
271
|
|
|
270
272
|
All agents collaborate in a single Claude process — each with distinct roles, ground rules, and areas of expertise.
|
|
@@ -317,7 +319,7 @@ Once running, a "Run Team" button appears on [agentdesk.live](https://agentdesk.
|
|
|
317
319
|
- **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
320
|
- **Outbound only** — no ports opened on your machine
|
|
319
321
|
- **Project allowlist** — only runs on projects registered via `agentdesk init`
|
|
320
|
-
- **No arbitrary commands** —
|
|
322
|
+
- **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
323
|
- **Metadata-only logs** — session logs in `~/.agentdesk/logs/` contain timestamps and file paths, never sensitive data
|
|
322
324
|
- **Fail closed** — unknown projects or exceeded session limits are rejected
|
|
323
325
|
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
// Independent lookups: a repository with an unborn branch has no HEAD yet
|
|
55
|
+
// but can still have a dirty tree.
|
|
56
|
+
export function gitState(cwd, extraEnv = {}) {
|
|
57
|
+
let revision = null, clean = null;
|
|
58
|
+
try { revision = git(cwd, ["rev-parse", "HEAD"], extraEnv) || null; } catch {}
|
|
59
|
+
try { clean = git(cwd, ["status", "--porcelain"], extraEnv) === ""; } catch {}
|
|
60
|
+
return { revision, clean };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function plainSpawnCommand({ command, cwd, env }) {
|
|
64
|
+
return spawn("/bin/sh", ["-c", command], { cwd, env, stdio: ["ignore", "pipe", "pipe"], shell: false, detached: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// spawnCommand({ command, cwd, env }) → ChildProcess with piped stdout/stderr.
|
|
68
|
+
export function createCheckRunner({ spawnCommand, tailBytes = OUTPUT_TAIL_BYTES } = {}) {
|
|
69
|
+
return function runCheck({ command, cwd, env, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, signal }) {
|
|
70
|
+
return new Promise(resolve => {
|
|
71
|
+
const startedAt = Date.now();
|
|
72
|
+
let child;
|
|
73
|
+
try { child = spawnCommand({ command, cwd, env }); }
|
|
74
|
+
catch (error) { return resolve({ exitCode: null, output: "", timedOut: false, durationMs: 0, error: error.message }); }
|
|
75
|
+
let tail = "";
|
|
76
|
+
const append = chunk => { tail = (tail + chunk.toString()).slice(-tailBytes); };
|
|
77
|
+
child.stdout?.on("data", append);
|
|
78
|
+
child.stderr?.on("data", append);
|
|
79
|
+
let timedOut = false, settled = false;
|
|
80
|
+
const timer = setTimeout(() => { timedOut = true; killTree(child, { graceMs: 1000 }); }, timeoutMs);
|
|
81
|
+
const onAbort = () => killTree(child, { graceMs: 0 });
|
|
82
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
83
|
+
const finish = (exitCode, error) => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
signal?.removeEventListener("abort", onAbort);
|
|
88
|
+
resolve({ exitCode, output: tail.trim(), timedOut, durationMs: Date.now() - startedAt, ...(error && { error }) });
|
|
89
|
+
};
|
|
90
|
+
child.once("error", err => finish(null, err.message));
|
|
91
|
+
child.once("close", (code, sig) => finish(code, sig && !timedOut ? `terminated by ${sig}` : undefined));
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Runs the checks in order at the current revision. `runCheck` is the
|
|
97
|
+
// per-check runner (createCheckRunner or a scripted stand-in).
|
|
98
|
+
export async function runChecks({ checks = [], cwd, env = {}, gitEnv: extraGitEnv = {}, runCheck, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, signal, requireClean = false }) {
|
|
99
|
+
const startedAt = Date.now();
|
|
100
|
+
const { revision, clean } = gitState(cwd, extraGitEnv);
|
|
101
|
+
const results = [];
|
|
102
|
+
let aborted = false;
|
|
103
|
+
for (const check of checks) {
|
|
104
|
+
if (signal?.aborted) { aborted = true; break; }
|
|
105
|
+
const r = await runCheck({ ...check, cwd, env, timeoutMs, signal });
|
|
106
|
+
results.push({
|
|
107
|
+
name: check.name, command: check.command,
|
|
108
|
+
exitCode: r.exitCode ?? null, durationMs: r.durationMs ?? 0, timedOut: !!r.timedOut,
|
|
109
|
+
output: String(r.output || "").slice(-OUTPUT_TAIL_BYTES),
|
|
110
|
+
...(r.error && { error: r.error }),
|
|
111
|
+
});
|
|
112
|
+
if (signal?.aborted) { aborted = true; break; }
|
|
113
|
+
}
|
|
114
|
+
const checksPassed = results.every(r => r.exitCode === 0 && !r.timedOut);
|
|
115
|
+
const dirty = requireClean && clean === false;
|
|
116
|
+
return {
|
|
117
|
+
revision, clean, requireClean, checked: checks.length > 0,
|
|
118
|
+
startedAt, durationMs: Date.now() - startedAt, results, aborted,
|
|
119
|
+
passed: !aborted && checksPassed && !dirty,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// An approval stands only on: an explicit APPROVED, passing checks, and the
|
|
124
|
+
// same revision the checks and reviewers saw.
|
|
125
|
+
export function evaluateApproval({ verdict, evidence, headNow }) {
|
|
126
|
+
if (!verdict || verdict.outcome !== "APPROVED") return { approved: false, reason: "review did not approve" };
|
|
127
|
+
if (evidence && !evidence.passed) return { approved: false, reason: evidence.aborted ? "verification was cancelled" : "engine checks did not pass" };
|
|
128
|
+
if (evidence?.revision && headNow && evidence.revision !== headNow) {
|
|
129
|
+
return { approved: false, reason: `code changed after verification (${shortRev(evidence.revision)} → ${shortRev(headNow)})` };
|
|
130
|
+
}
|
|
131
|
+
return { approved: true, reason: null };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const seconds = ms => `${(Number(ms || 0) / 1000).toFixed(1)}s`;
|
|
135
|
+
|
|
136
|
+
function describeResult(r) {
|
|
137
|
+
if (r.timedOut) return `TIMED OUT after ${seconds(r.durationMs)}`;
|
|
138
|
+
if (r.exitCode === 0) return `passed in ${seconds(r.durationMs)}`;
|
|
139
|
+
return `FAILED (${r.exitCode == null ? r.error || "no exit code" : `exit ${r.exitCode}`}) in ${seconds(r.durationMs)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Findings in the REVIEW verdict shape, so a failed check flows through the
|
|
143
|
+
// same retry path as a reviewer's finding.
|
|
144
|
+
export function evidenceFindings(evidence) {
|
|
145
|
+
const findings = [];
|
|
146
|
+
if (!evidence) return findings;
|
|
147
|
+
if (evidence.requireClean && evidence.clean === false) {
|
|
148
|
+
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." });
|
|
149
|
+
}
|
|
150
|
+
for (const r of evidence.results || []) {
|
|
151
|
+
if (r.exitCode === 0 && !r.timedOut) continue;
|
|
152
|
+
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}`})`;
|
|
153
|
+
findings.push({ reviewer: ENGINE_REVIEWER, title, detail: r.output || "(no output)" });
|
|
154
|
+
}
|
|
155
|
+
return findings;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function formatEvidenceForPrompt(evidence) {
|
|
159
|
+
if (!evidence) return "";
|
|
160
|
+
const where = evidence.revision ? ` at ${shortRev(evidence.revision)}` : "";
|
|
161
|
+
const tree = evidence.clean == null ? "" : ` (tree ${evidence.clean ? "clean" : "has uncommitted changes"})`;
|
|
162
|
+
if (!evidence.checked) {
|
|
163
|
+
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.`;
|
|
164
|
+
}
|
|
165
|
+
const lines = [`Engine verification${where}${tree}:`];
|
|
166
|
+
for (const r of evidence.results) {
|
|
167
|
+
lines.push(`- \`${r.command}\` — ${describeResult(r)}`);
|
|
168
|
+
if (r.exitCode !== 0 || r.timedOut) for (const l of String(r.output || "(no output)").split("\n")) lines.push(` ${l}`);
|
|
169
|
+
}
|
|
170
|
+
if (evidence.aborted) lines.push("- (verification was cancelled before every check ran)");
|
|
171
|
+
return lines.join("\n");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function renderEvidenceSection(evidence) {
|
|
175
|
+
if (!evidence) return "";
|
|
176
|
+
const lines = ["## Evidence", `- Revision: ${evidence.revision ? shortRev(evidence.revision) : "(no repository)"}`];
|
|
177
|
+
if (evidence.clean != null) lines.push(`- Tree: ${evidence.clean ? "clean" : "uncommitted changes"}`);
|
|
178
|
+
if (!evidence.checked) lines.push("- Checks: none configured");
|
|
179
|
+
for (const r of evidence.results || []) lines.push(`- ${r.name}: \`${r.command}\` — ${describeResult(r)}`);
|
|
180
|
+
lines.push(`- Result: ${evidence.passed ? "passed" : "not passed"}`, "");
|
|
181
|
+
return lines.join("\n");
|
|
182
|
+
}
|
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) {
|
package/cli/engine/prompts.mjs
CHANGED
|
@@ -13,6 +13,7 @@ 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";
|
|
16
17
|
|
|
17
18
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
|
|
@@ -133,7 +134,7 @@ function createTaskSection({ tracker, config, description }) {
|
|
|
133
134
|
// Returns the full user prompt for one phase's query().
|
|
134
135
|
export function renderPhasePrompt({
|
|
135
136
|
phase, taskId, taskLink, description, createTask, tracker, config = {}, project = {},
|
|
136
|
-
sessionUrl, cwd, sessionMemory = "", retryVerdict = null,
|
|
137
|
+
sessionUrl, cwd, sessionMemory = "", retryVerdict = null, evidence = null,
|
|
137
138
|
}) {
|
|
138
139
|
const vars = {
|
|
139
140
|
TASK_ID: taskId,
|
|
@@ -162,6 +163,9 @@ export function renderPhasePrompt({
|
|
|
162
163
|
if (config.instructions) {
|
|
163
164
|
body += `\n\n## ADDITIONAL INSTRUCTIONS\n\n${config.instructions}`;
|
|
164
165
|
}
|
|
166
|
+
if (evidence) {
|
|
167
|
+
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
|
+
}
|
|
165
169
|
if (sessionMemory) {
|
|
166
170
|
body += `\n\n## SESSION MEMORY (previous phases)\n\n${sessionMemory}`;
|
|
167
171
|
}
|
package/cli/engine/session.mjs
CHANGED
|
@@ -28,6 +28,11 @@ import { agentsForPhase, modelForPhase, soloDefinition } from "./agents/index.mj
|
|
|
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";
|
|
31
36
|
import { buildQueryOptions, defaultRunQuery } from "./query.mjs";
|
|
32
37
|
import { armPublishGate, onSubagentStopped } from "./hooks.mjs";
|
|
33
38
|
import { prepareWorkspace } from "../worktrees.mjs";
|
|
@@ -132,6 +137,7 @@ async function executeSession({
|
|
|
132
137
|
onEvent, apiKey, serverUrl, sessionId, onChild, abortSignal,
|
|
133
138
|
soloAgent = null, childStrategy = null,
|
|
134
139
|
runQuery = defaultRunQuery,
|
|
140
|
+
runCheck = null,
|
|
135
141
|
authCheck = checkClaudeAuth,
|
|
136
142
|
sourceCwd = cwd, workspaceRecord, workspaceStateDir, resumingWorkspace = false,
|
|
137
143
|
workspaceGitEnv = {},
|
|
@@ -226,6 +232,74 @@ async function executeSession({
|
|
|
226
232
|
let reviewRetries = 0, phaseRuns = 0, lastVerdict = null, lastPhase = null;
|
|
227
233
|
let isolationLogged = false;
|
|
228
234
|
|
|
235
|
+
// --- evidence (engine-owned) ---------------------------------------------
|
|
236
|
+
// The engine runs the project's checks itself before every REVIEW and pins
|
|
237
|
+
// any approval to the revision they ran at. In a session worktree the tree
|
|
238
|
+
// must be clean too: an approval refers to a committed revision.
|
|
239
|
+
const checks = resolveChecks({ config, project });
|
|
240
|
+
const requireClean = !!workspaceRecord;
|
|
241
|
+
const runOneCheck = runCheck || createCheckRunner({
|
|
242
|
+
spawnCommand: ({ command, cwd: checkCwd, env }) => spawnSandboxedCommand({
|
|
243
|
+
command, cwd: checkCwd, env, sandbox, onChild,
|
|
244
|
+
extraWritePaths: workspaceStateDir ? [workspaceStateDir, workspaceRecord.tree || cwd] : [],
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
const headNow = () => gitState(cwd, workspaceGitEnv).revision;
|
|
248
|
+
let approvedRevision = null;
|
|
249
|
+
|
|
250
|
+
const reportEvidence = evidence => {
|
|
251
|
+
emit({ type: "session:evidence", phase: "REVIEW", revision: evidence.revision, clean: evidence.clean,
|
|
252
|
+
passed: evidence.passed, checked: evidence.checked, results: evidence.results });
|
|
253
|
+
const at = evidence.revision ? ` at ${shortRev(evidence.revision)}` : "";
|
|
254
|
+
if (!evidence.checked) {
|
|
255
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `No verification commands configured (commands.test/build/lint) — review relies on the reviewers alone${at}.` });
|
|
256
|
+
}
|
|
257
|
+
for (const r of evidence.results) {
|
|
258
|
+
const took = `${(r.durationMs / 1000).toFixed(1)}s`;
|
|
259
|
+
const status = r.timedOut ? `timed out after ${took}` : r.exitCode === 0 ? `passed (${took})` : `FAILED (exit ${r.exitCode ?? "?"}, ${took})`;
|
|
260
|
+
emit({ type: "agent:message", agent: "Jane", tag: "ACT", message: `Ran \`${r.command}\`${at} — ${status}` });
|
|
261
|
+
}
|
|
262
|
+
if (evidence.requireClean && evidence.clean === false) {
|
|
263
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: "Uncommitted changes in the session worktree — review cannot approve an uncommitted state." });
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// One place decides what a REVIEW outcome means for the loop, whether the
|
|
268
|
+
// verdict came from the reviewers or was synthesised from failed checks.
|
|
269
|
+
function settleReview(verdict) {
|
|
270
|
+
lastVerdict = verdict;
|
|
271
|
+
reviewResolved = verdict.approved === true;
|
|
272
|
+
if (reviewResolved) approvedRevision = verdict.headNow || verdict.revision || null;
|
|
273
|
+
state.openFindings = reviewResolved ? [] : verdict.findings;
|
|
274
|
+
try { writeFileSync(findingsPath, JSON.stringify(verdict, null, 2)); } catch {}
|
|
275
|
+
if (reviewResolved) return;
|
|
276
|
+
if (verdict.outcome === "MISSING") {
|
|
277
|
+
emit({ type: "session:error", code: "REVIEW_VERDICT_MISSING", message: `REVIEW ended without a verdict (${verdict.reason}) — treating as not approved.` });
|
|
278
|
+
} else if (verdict.outcome === "APPROVED") {
|
|
279
|
+
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.` });
|
|
280
|
+
}
|
|
281
|
+
const count = `${verdict.findings.length} finding(s)`;
|
|
282
|
+
if (reviewRetries < MAX_REVIEW_RETRIES) {
|
|
283
|
+
reviewRetries++;
|
|
284
|
+
const why = verdict.engineOnly ? `Verification failed (${count})` : `Review did not approve (${verdict.outcome}, ${count})`;
|
|
285
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `${why} — returning to EXECUTION (retry ${reviewRetries}/${MAX_REVIEW_RETRIES}).` });
|
|
286
|
+
queue.unshift("EXECUTION", "REVIEW");
|
|
287
|
+
} else {
|
|
288
|
+
const why = verdict.engineOnly ? "Verification still failing" : "Review still unresolved";
|
|
289
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `${why} after ${reviewRetries} retry — ending session for human review.` });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// An approval is void the moment the code moves past the reviewed revision.
|
|
294
|
+
const invalidateApproval = now => {
|
|
295
|
+
reviewResolved = false;
|
|
296
|
+
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.` });
|
|
297
|
+
if (lastVerdict) {
|
|
298
|
+
lastVerdict = { ...lastVerdict, approved: false, approvalReason: "code changed after approval", headNow: now };
|
|
299
|
+
try { writeFileSync(findingsPath, JSON.stringify(lastVerdict, null, 2)); } catch {}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
229
303
|
const queue = solo ? ["SOLO"] : [...PHASES];
|
|
230
304
|
|
|
231
305
|
try {
|
|
@@ -242,6 +316,27 @@ async function executeSession({
|
|
|
242
316
|
const model = modelForPhase(phase === "SOLO" ? "EXECUTION" : phase, config.phaseModels);
|
|
243
317
|
emit({ type: "phase:change", phase: phase === "SOLO" ? "EXECUTION" : phase, model: model || "default" });
|
|
244
318
|
|
|
319
|
+
// Verification runs before the reviewers are asked. If it fails, the
|
|
320
|
+
// reviewers are not asked at all: the failing output goes straight back
|
|
321
|
+
// to EXECUTION as findings, through the same retry path.
|
|
322
|
+
let evidence = null;
|
|
323
|
+
if (phase === "REVIEW") {
|
|
324
|
+
evidence = await runChecks({ checks, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }),
|
|
325
|
+
gitEnv: workspaceGitEnv, runCheck: runOneCheck, timeoutMs: checkTimeoutMs(config), signal: abortController.signal, requireClean });
|
|
326
|
+
if (abortController.signal.aborted) { aborted = true; break; }
|
|
327
|
+
reportEvidence(evidence);
|
|
328
|
+
appendMemory(renderEvidenceSection(evidence));
|
|
329
|
+
if (!evidence.passed) {
|
|
330
|
+
settleReview({ outcome: "NEEDS_MORE_WORK", findings: evidenceFindings(evidence), deferred: [], unverifiedClaims: [], reason: null,
|
|
331
|
+
revision: evidence.revision, headNow: evidence.revision, evidence, approved: false, approvalReason: "engine checks did not pass", engineOnly: true });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (phase === "SUMMARY" && reviewResolved && approvedRevision) {
|
|
336
|
+
const now = headNow();
|
|
337
|
+
if (now !== approvedRevision) invalidateApproval(now);
|
|
338
|
+
}
|
|
339
|
+
|
|
245
340
|
const { agents, allowedTools, lead } = solo
|
|
246
341
|
? soloDefinition(solo)
|
|
247
342
|
: agentsForPhase({ phase, team, phaseModels: config.phaseModels });
|
|
@@ -252,7 +347,8 @@ async function executeSession({
|
|
|
252
347
|
createTask: phase === "INTAKE" ? createTask : false,
|
|
253
348
|
tracker, config, project, sessionUrl, cwd,
|
|
254
349
|
sessionMemory: memoryText(),
|
|
255
|
-
retryVerdict: phase === "EXECUTION" && lastVerdict && lastVerdict.
|
|
350
|
+
retryVerdict: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? lastVerdict : null,
|
|
351
|
+
evidence,
|
|
256
352
|
});
|
|
257
353
|
if (workspaceRecord) {
|
|
258
354
|
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`;
|
|
@@ -319,24 +415,10 @@ async function executeSession({
|
|
|
319
415
|
|
|
320
416
|
if (phase === "REVIEW") {
|
|
321
417
|
const verdict = verdictFromResult({ is_error: summary.isError, subtype: summary.subtype, structured_output: summary.structuredOutput });
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
state.openFindings = reviewResolved ? [] : verdict.findings;
|
|
418
|
+
const now = headNow();
|
|
419
|
+
const approval = evaluateApproval({ verdict, evidence, headNow: now });
|
|
325
420
|
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
|
-
}
|
|
421
|
+
settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now, evidence, approved: approval.approved, approvalReason: approval.reason });
|
|
340
422
|
continue;
|
|
341
423
|
}
|
|
342
424
|
|
|
@@ -354,6 +436,12 @@ async function executeSession({
|
|
|
354
436
|
sandbox.cleanup();
|
|
355
437
|
}
|
|
356
438
|
|
|
439
|
+
// Belt to SUMMARY's braces: nothing may have moved the approved revision.
|
|
440
|
+
if (!aborted && reviewResolved && approvedRevision) {
|
|
441
|
+
const now = headNow();
|
|
442
|
+
if (now !== approvedRevision) invalidateApproval(now);
|
|
443
|
+
}
|
|
444
|
+
|
|
357
445
|
const duration = seconds(startedAt);
|
|
358
446
|
const status = finalStatus({ aborted, crashed: handoff, reviewResolved });
|
|
359
447
|
const resumePath = join(cwd, ".agentdesk-resume.md");
|
|
@@ -373,5 +461,6 @@ async function executeSession({
|
|
|
373
461
|
handoff: status !== "complete",
|
|
374
462
|
aborted, status, reviewResolved, lastPhase,
|
|
375
463
|
verdict: lastVerdict,
|
|
464
|
+
approvedRevision: reviewResolved ? approvedRevision : null,
|
|
376
465
|
};
|
|
377
466
|
}
|
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;
|
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.30.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",
|
|
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",
|