@kendoo.agentdesk/agentdesk 0.31.1 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,21 @@ All user-facing changes to AgentDesk. Each entry is tagged:
8
8
 
9
9
  Internal refactors, infrastructure changes, and architectural notes are not listed here.
10
10
 
11
+ ## [0.32.1] — 2026-09-21
12
+
13
+ ### Fixed
14
+ - `[CLI]` The publish and history gates now understand the command they are looking at. `git -C . push`, `env git push`, a `git push` on a second line, `sh -c 'git push'`, `/usr/bin/git push` and similar spellings used to slip past both Sam's audit gate and the verification; the engine now parses the shell command and classifies what it actually runs, and refuses anything it cannot model when the text hints at publishing.
15
+ - `[CLI]` A cached verification pass is only reused for the exact tree it ran on — same commit *and* a clean working tree, then and now. Uncommitted edits left `HEAD` unchanged, so later publish attempts and the review could reuse stale evidence; they now run the checks again. An approval is also invalidated by uncommitted changes appearing after it in a session worktree.
16
+ - `[CLI]` Sam finishing his audit no longer counts as approving it. Sam ends his report with `AUDIT: APPROVED` or `AUDIT: REJECTED — <why>`; the engine reads that line, ties an approval to the commit he audited, and refuses to publish after a rejection, without the line, or once a newer commit exists — until he audits again. The verdict shows in the feed and in the handoff ledger.
17
+
18
+ ## [0.32.0] — 2026-09-21
19
+
20
+ ### Changed
21
+ - `[CLI]` Publishing is verified on the spot. When the team runs `git push` or `gh pr create`, the engine runs the project's `test`/`build`/`lint` at the commit being published and refuses with the failing output until a new commit passes; the review that follows reuses a passing run instead of running everything twice. Reviewers can no longer push or change history either.
22
+ - `[CLI]` A phase that ends without its structured handoff is run again once; if it misses again the session ends for human review instead of letting the next phase start from nothing.
23
+ - `[UI]` A Delivery strip above the conversation shows the engine's verification results (each command, pass/fail, failing output), the open items carried between phases, and the phase-by-phase handoff timeline with commit changes — live, and after a restart.
24
+ - `[UI]` The conversation defaults to a product view: the lead's narrative stays inline, the team's reports fold into one expandable row, and tool calls and status notes are hidden. Switch to Full in the session row to see everything; the choice is remembered.
25
+
11
26
  ## [0.31.1] — 2026-09-21
12
27
 
13
28
  ### Fixed
package/README.md CHANGED
@@ -265,7 +265,7 @@ 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.
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. Publishing is verified the same way, on the spot: when the team runs `git push` or `gh pr create` — however the command is spelled (`git -C . push`, `env git push`, on a later line, inside `sh -c`) — the engine runs the checks at the revision being published and refuses with the failing output until a new commit passes. A passing run is evidence for one tree only: the same commit with a clean working tree, then and now. It is reused by the review that follows only while that still holds; uncommitted changes force a fresh run. Publishing also needs Sam's explicit verdict: he ends his audit with `AUDIT: APPROVED` or `AUDIT: REJECTED`, the engine reads that line and ties an approval to the commit he read — a rejection, a report without the line, or a commit made after his approval keeps publishing closed until he audits again. `REVIEW` and `SUMMARY` cannot commit, move `HEAD`, or push; they read and write messages only.
269
269
 
270
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
271
 
@@ -12,6 +12,7 @@
12
12
  // belt to these braces.
13
13
 
14
14
  import { BUILT_IN_AGENTS } from "../../agents.mjs";
15
+ import { AUDITOR, AUDIT_INSTRUCTION } from "../hooks.mjs";
15
16
 
16
17
  export const LEAD = "Jane";
17
18
  export const READ_ONLY = Object.freeze(["Read", "Grep", "Glob"]);
@@ -85,6 +86,9 @@ export function agentSystemPrompt(a, phase) {
85
86
  "",
86
87
  PHASE_GUIDANCE[phase] || "",
87
88
  phase === "EXECUTION" ? executionTasks(a) : "",
89
+ // The audit verdict is read by the engine, so the instruction lives here,
90
+ // not in a customisable task list.
91
+ phase === "EXECUTION" && a.name === AUDITOR ? `\n${AUDIT_INSTRUCTION}\n` : "",
88
92
  "",
89
93
  "No announcement without observation: never claim something is done, passes, or works unless you ran the check and read its output. If the observation is out of reach, say so plainly.",
90
94
  `Report back to ${LEAD} concisely. You may prefix a message with [THINK], [ACT], [ARGUE] or [AGREE] to make your stance clear.`,
@@ -0,0 +1,162 @@
1
+ // Shell command classification for the publish and history gates.
2
+ //
3
+ // A regex over the raw text is not enforcement: `git -C . push`, `env git
4
+ // push`, a newline before `git push`, `sh -c 'git push'`, `/usr/bin/git
5
+ // push` all publish. This tokenizes the command the way a POSIX shell
6
+ // would, splits it into simple commands, unwraps environment prefixes and
7
+ // wrapper programs, and classifies each simple command by its effective
8
+ // program and subcommand. Constructs it does not model (command
9
+ // substitution, backticks, heredocs, process substitution) are marked
10
+ // unparseable and fail closed whenever the text hints at publishing.
11
+
12
+ const OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")", "{", "}"];
13
+ const WRAPPERS_NO_ARGS = new Set(["command", "exec", "nohup", "time", "builtin", "caffeinate", "chronic", "nocorrect", "noglob"]);
14
+ const PUBLISH_GIT = new Set(["push", "send-pack"]);
15
+ const HISTORY_GIT = new Set(["commit", "merge", "rebase", "reset", "checkout", "switch", "cherry-pick", "revert", "am", "apply", "stash", "restore", "clean"]);
16
+ const PUBLISH_GH_PR = new Set(["create", "merge", "ready", "reopen", "edit"]);
17
+ const GIT_GLOBAL_WITH_ARG = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--super-prefix", "--config-env", "--list-cmds", "--attr-source"]);
18
+ const PUBLISH_HINT = /\b(push|send-pack|pr\s+(create|merge|ready|reopen|edit)|release\s+create|repo\s+sync|gh\s+api)\b/i;
19
+
20
+ // POSIX-ish tokenizer: words with quote/escape handling, operators, and a
21
+ // flag for constructs we do not model.
22
+ export function tokenize(command) {
23
+ const text = String(command || "");
24
+ const tokens = [];
25
+ let word = "", inWord = false, quote = null, unparseable = false;
26
+ const push = () => { if (inWord) { tokens.push({ word }); word = ""; inWord = false; } };
27
+ for (let i = 0; i < text.length; i++) {
28
+ const ch = text[i];
29
+ if (quote === "'") { if (ch === "'") quote = null; else word += ch; continue; }
30
+ if (quote === '"') {
31
+ if (ch === '"') { quote = null; continue; }
32
+ if (ch === "\\" && i + 1 < text.length && /["\\$`\n]/.test(text[i + 1])) { word += text[++i]; continue; }
33
+ if (ch === "`" || (ch === "$" && text[i + 1] === "(")) unparseable = true;
34
+ word += ch; continue;
35
+ }
36
+ if (ch === "\\") { if (i + 1 < text.length) { word += text[++i]; inWord = true; } continue; }
37
+ if (ch === "'" || ch === '"') { quote = ch; inWord = true; continue; }
38
+ if (ch === "`" || (ch === "$" && text[i + 1] === "(") || (ch === "<" && text[i + 1] === "(") || (ch === ">" && text[i + 1] === "(")) { unparseable = true; word += ch; inWord = true; continue; }
39
+ if (ch === "<" && text[i + 1] === "<") { unparseable = true; word += ch; inWord = true; continue; }
40
+ if (/\s/.test(ch) && ch !== "\n") { push(); continue; }
41
+ const op = OPERATORS.find(o => text.startsWith(o, i));
42
+ if (op) { push(); tokens.push({ op }); i += op.length - 1; continue; }
43
+ word += ch; inWord = true;
44
+ }
45
+ if (quote) unparseable = true;
46
+ push();
47
+ return { tokens, unparseable };
48
+ }
49
+
50
+ // Splits tokens into simple commands (lists of words); operators and
51
+ // grouping punctuation only separate.
52
+ export function splitCommands(tokens) {
53
+ const commands = [];
54
+ let current = [];
55
+ for (const t of tokens) {
56
+ if (t.op !== undefined) { if (current.length) commands.push(current); current = []; }
57
+ else current.push(t.word);
58
+ }
59
+ if (current.length) commands.push(current);
60
+ return commands;
61
+ }
62
+
63
+ const basename = word => String(word).split("/").pop();
64
+ const isAssignment = word => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
65
+
66
+ // Returns the effective simple command with prefixes and wrappers removed,
67
+ // or a list of nested commands when a wrapper carries a shell string.
68
+ export function unwrap(words, depth = 0) {
69
+ const rest = [...words];
70
+ for (let guard = 0; guard < 16 && rest.length; guard++) {
71
+ while (rest.length && isAssignment(rest[0])) rest.shift();
72
+ if (!rest.length) return { words: [] };
73
+ const prog = basename(rest[0]);
74
+ if (WRAPPERS_NO_ARGS.has(prog)) { rest.shift(); continue; }
75
+ if (prog === "env") {
76
+ rest.shift();
77
+ while (rest.length) {
78
+ if (rest[0] === "-S" || rest[0] === "--split-string") return { unparseable: true, words: rest };
79
+ if (rest[0] === "-u" || rest[0] === "-C" || rest[0] === "--unset" || rest[0] === "--chdir") { rest.splice(0, 2); continue; }
80
+ if (rest[0].startsWith("-") && rest[0] !== "--") { rest.shift(); continue; }
81
+ if (rest[0] === "--") { rest.shift(); break; }
82
+ if (isAssignment(rest[0])) { rest.shift(); continue; }
83
+ break;
84
+ }
85
+ continue;
86
+ }
87
+ if (prog === "sudo" || prog === "doas") {
88
+ rest.shift();
89
+ while (rest.length && rest[0].startsWith("-")) { const flag = rest.shift(); if (["-u", "-g", "-p", "-C", "-D", "-h", "-r", "-t", "-U", "-T"].includes(flag)) rest.shift(); }
90
+ continue;
91
+ }
92
+ if (prog === "timeout") { rest.shift(); while (rest.length && rest[0].startsWith("-")) { const f = rest.shift(); if (["-k", "-s", "--kill-after", "--signal"].includes(f)) rest.shift(); } rest.shift(); continue; }
93
+ if (prog === "nice" || prog === "ionice" || prog === "stdbuf") { rest.shift(); while (rest.length && rest[0].startsWith("-")) { const f = rest.shift(); if (/^-[nc]$|^-[oei]$/.test(f)) rest.shift(); } continue; }
94
+ if (prog === "xargs") { rest.shift(); while (rest.length && rest[0].startsWith("-")) { const f = rest.shift(); if (/^-[nILPsdEa]$|^--max-args$|^--replace$|^--max-procs$|^--delimiter$|^--arg-file$/.test(f)) rest.shift(); } continue; }
95
+ if (["sh", "bash", "zsh", "dash", "ksh", "fish"].includes(prog)) {
96
+ // `-c` may be combined with other single-letter flags (`bash -lc '…'`).
97
+ for (let i = 1; i < rest.length; i++) {
98
+ const w = rest[i];
99
+ if (/^-[A-Za-z]*c[A-Za-z]*$/.test(w) || w === "--command") { if (rest[i + 1] !== undefined) return { nested: classify(rest[i + 1], depth + 1) }; break; }
100
+ if (w === "-o" || w === "+o") { i++; continue; }
101
+ if (!w.startsWith("-") && !w.startsWith("+")) break; // a script file: its contents are not visible here
102
+ }
103
+ return { words: rest };
104
+ }
105
+ if (prog === "eval") return { nested: classify(rest.slice(1).join(" "), depth + 1) };
106
+ return { words: [prog, ...rest.slice(1)] };
107
+ }
108
+ return { words: rest };
109
+ }
110
+
111
+ function classifyGit(args) {
112
+ let i = 0, configOverride = false;
113
+ while (i < args.length) {
114
+ const a = args[i];
115
+ if (GIT_GLOBAL_WITH_ARG.has(a)) { if (a === "-c" && /hooksPath|receive\.|push\./i.test(args[i + 1] || "")) configOverride = true; i += 2; continue; }
116
+ if (a.startsWith("--") && a.includes("=")) { if (/^--config-env=|^-c=/.test(a)) configOverride = true; i++; continue; }
117
+ if (a.startsWith("-")) { i++; continue; }
118
+ break;
119
+ }
120
+ const sub = args[i];
121
+ return { publishes: PUBLISH_GIT.has(sub) || configOverride, history: HISTORY_GIT.has(sub), sub };
122
+ }
123
+
124
+ function classifyGh(args) {
125
+ const [a, b] = args;
126
+ if (a === "pr" && PUBLISH_GH_PR.has(b)) return { publishes: true };
127
+ if (a === "release" && b === "create") return { publishes: true };
128
+ if (a === "repo" && (b === "sync" || b === "create")) return { publishes: true };
129
+ if (a === "api") {
130
+ const mutating = args.some((w, i) => (["-X", "--method"].includes(w) && /^(POST|PUT|PATCH|DELETE)$/i.test(args[i + 1] || "")) ||
131
+ /^--method=(POST|PUT|PATCH|DELETE)$/i.test(w) || ["-f", "-F", "--field", "--raw-field", "--input"].includes(w) || /^--(raw-)?field=/.test(w));
132
+ return { publishes: mutating };
133
+ }
134
+ return { publishes: false };
135
+ }
136
+
137
+ // classify(command) → { publishes, history, unparseable }
138
+ // publishes — pushes commits or creates/changes a PR (or cannot be
139
+ // ruled out because the syntax is not modelled and the text
140
+ // hints at it)
141
+ // history — commits, moves HEAD, or rewrites the tree
142
+ // unparseable — contains constructs this parser does not model
143
+ export function classify(command, depth = 0) {
144
+ const result = { publishes: false, history: false, unparseable: false };
145
+ if (depth > 4) return { ...result, publishes: true, unparseable: true };
146
+ const { tokens, unparseable } = tokenize(command);
147
+ if (unparseable) result.unparseable = true;
148
+ for (const words of splitCommands(tokens)) {
149
+ const u = unwrap(words, depth);
150
+ if (u.unparseable) { result.unparseable = true; continue; }
151
+ if (u.nested) {
152
+ result.publishes ||= u.nested.publishes; result.history ||= u.nested.history; result.unparseable ||= u.nested.unparseable;
153
+ continue;
154
+ }
155
+ const [prog, ...args] = u.words;
156
+ if (!prog) continue;
157
+ if (prog === "git") { const g = classifyGit(args); result.publishes ||= g.publishes; result.history ||= g.history; }
158
+ else if (prog === "gh") { result.publishes ||= classifyGh(args).publishes; }
159
+ }
160
+ if (result.unparseable && PUBLISH_HINT.test(String(command || ""))) result.publishes = true;
161
+ return result;
162
+ }
@@ -132,13 +132,15 @@ export async function runChecks({ checks = [], cwd, env = {}, gitEnv: extraGitEn
132
132
  }
133
133
 
134
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 }) {
135
+ // same tree the checks and reviewers saw — same revision, and (where a clean
136
+ // tree is required) still clean.
137
+ export function evaluateApproval({ verdict, evidence, headNow, cleanNow }) {
137
138
  if (!verdict || verdict.outcome !== "APPROVED") return { approved: false, reason: "review did not approve" };
138
139
  if (evidence && !evidence.passed) return { approved: false, reason: evidence.aborted ? "verification was cancelled" : "engine checks did not pass" };
139
140
  if (evidence?.revision && headNow && evidence.revision !== headNow) {
140
141
  return { approved: false, reason: `code changed after verification (${shortRev(evidence.revision)} → ${shortRev(headNow)})` };
141
142
  }
143
+ if (evidence?.requireClean && cleanNow === false) return { approved: false, reason: "uncommitted changes appeared during review" };
142
144
  return { approved: true, reason: null };
143
145
  }
144
146
 
@@ -8,49 +8,117 @@
8
8
  //
9
9
  // decidePreToolUse() is pure so the policy is unit-tested without the SDK.
10
10
 
11
+ import { classify } from "./commands.mjs";
12
+
11
13
  export const MUTATING_TOOLS = Object.freeze(new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]));
12
14
  export const CODE_TOOLS = Object.freeze(new Set([...MUTATING_TOOLS, "Bash"]));
13
15
 
14
- // Commands that publish work. Matched loosely on purpose: `gh pr create`,
15
- // `git push`, `git push --force-with-lease origin HEAD`, chained after `&&`.
16
- const PUBLISH_RE = /(^|[;&|]\s*)(gh\s+pr\s+create|git\s+push)\b/;
17
-
16
+ // Commands that publish work: `git push`, `gh pr create` and their relatives,
17
+ // however they are spelled — `git -C . push`, `env git push`, on a later
18
+ // line, inside `sh -c`, behind `sudo`. commands.mjs parses the shell text;
19
+ // anything it cannot model fails closed when the text hints at publishing.
18
20
  export function isPublishCommand(command) {
19
- return PUBLISH_RE.test(String(command || ""));
21
+ return classify(command).publishes;
20
22
  }
21
23
 
22
24
  // Commands that move HEAD or rewrite the tree. SUMMARY reports on an approved
23
25
  // 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
26
  export function isHistoryCommand(command) {
27
- return HISTORY_RE.test(String(command || ""));
27
+ return classify(command).history;
28
28
  }
29
29
 
30
30
  // The auditor whose sign-off gates publishing. "Sam's audit is a blocking
31
- // gate — not advisory" used to be prose; now it is the observable fact that a
32
- // Sam subagent finished in this EXECUTION phase (SubagentStop).
31
+ // gate — not advisory" used to be prose; now it is an explicit verdict the
32
+ // engine reads from his report and ties to the revision he audited.
33
33
  export const AUDITOR = "Sam";
34
34
 
35
+ // The last line of the auditor's report. Bold or plain, any case; an
36
+ // optional commit after the verdict is checked against the real HEAD.
37
+ const AUDIT_LINE_RE = /^[ \t]*(?:\*\*)?AUDIT:?(?:\*\*)?[ \t]*(?:\*\*)?(APPROVED|REJECTED)(?:\*\*)?\b(?:[ \t]*(?:at|@)?[ \t]*`?([0-9a-f]{7,40})`?)?/gim;
38
+
39
+ export const AUDIT_INSTRUCTION = `End your report with exactly one line, on its own: \`AUDIT: APPROVED\` when the committed revision has no violations left, or \`AUDIT: REJECTED — <one line why>\` when it does. The engine reads that line: without it, or after REJECTED, nothing can be published. Audit committed code — a commit made after your approval needs a new audit.`;
40
+
41
+ export function parseAuditVerdict(text) {
42
+ const s = String(text || "");
43
+ let m, last = null;
44
+ AUDIT_LINE_RE.lastIndex = 0;
45
+ while ((m = AUDIT_LINE_RE.exec(s))) last = m;
46
+ if (!last) return { verdict: "MISSING", statedRevision: null };
47
+ return { verdict: last[1].toUpperCase(), statedRevision: last[2] || null };
48
+ }
49
+
50
+ // The text of a tool result, whatever shape the harness hands it in.
51
+ export function responseText(response) {
52
+ if (response == null) return "";
53
+ if (typeof response === "string") return response;
54
+ if (Array.isArray(response)) return response.map(responseText).filter(Boolean).join("\n");
55
+ if (typeof response === "object") {
56
+ if (typeof response.text === "string") return response.text;
57
+ if (response.content !== undefined) return responseText(response.content);
58
+ if (typeof response.result === "string") return response.result;
59
+ if (typeof response.output === "string") return response.output;
60
+ }
61
+ return "";
62
+ }
63
+
35
64
  // Called at the start of every EXECUTION: nothing may be published until the
36
- // auditor has run in this phase. `openFindings` (from a rejected REVIEW) is
37
- // carried for the denial message so the team sees what is outstanding.
65
+ // auditor has approved in this phase. `openFindings` (from a rejected REVIEW)
66
+ // is carried for the denial message so the team sees what is outstanding.
38
67
  export function armPublishGate(state, openFindings = []) {
39
68
  state.awaitingAudit = true;
69
+ state.audit = null;
40
70
  state.openFindings = Array.isArray(openFindings) ? openFindings : [];
41
71
  }
42
72
 
43
- export function onSubagentStopped(state, { phase, agentType }) {
44
- if (phase === "EXECUTION" && agentType === AUDITOR) {
45
- state.awaitingAudit = false;
46
- state.openFindings = [];
73
+ // Records the auditor's verdict for the tree as it is right now.
74
+ // tree { revision, clean } observed by the engine when the report arrived
75
+ // Returns the audit record, or null when the report is not the auditor's in
76
+ // EXECUTION. The latest report wins: a re-audit after fixes replaces the old.
77
+ export function recordAudit(state, { phase, agentType, text, tree }) {
78
+ if (phase !== "EXECUTION" || agentType !== AUDITOR) return null;
79
+ const { verdict, statedRevision } = parseAuditVerdict(text);
80
+ const revision = tree?.revision ?? null;
81
+ const stale = !!(statedRevision && revision && !revision.startsWith(statedRevision));
82
+ const audit = { verdict: stale ? "STALE" : verdict, revision, clean: tree?.clean ?? null, statedRevision, at: new Date().toISOString() };
83
+ state.audit = audit;
84
+ state.awaitingAudit = audit.verdict !== "APPROVED";
85
+ if (audit.verdict === "APPROVED") state.openFindings = [];
86
+ return audit;
87
+ }
88
+
89
+ const short = rev => (rev ? String(rev).slice(0, 7) : "?");
90
+
91
+ function auditDenial(state, treeNow) {
92
+ const open = Array.isArray(state.openFindings) ? state.openFindings : [];
93
+ const audit = state.audit;
94
+ const fixThenAudit = `have ${AUDITOR} audit the committed revision, fix what he flags, commit, have him re-audit, then publish`;
95
+ if (state.awaitingAudit || open.length > 0) {
96
+ const list = open.slice(0, 5).map(f => `- ${f.title || f}`).join("\n");
97
+ const findings = open.length > 0 ? `${open.length} review finding(s) are unresolved:\n${list}\n` : "";
98
+ let why;
99
+ if (audit?.verdict === "REJECTED") why = `${AUDITOR}'s audit REJECTED the change`;
100
+ else if (audit?.verdict === "STALE") why = `${AUDITOR} approved ${short(audit.statedRevision)} but the code is at ${short(audit.revision)}`;
101
+ else if (audit?.verdict === "MISSING") why = `${AUDITOR}'s report did not end with an AUDIT line`;
102
+ else why = `${AUDITOR} has not audited in this phase`;
103
+ return `Cannot publish yet — ${findings}${why}; ${fixThenAudit}.`;
47
104
  }
105
+ if (audit?.verdict === "APPROVED") {
106
+ const tree = typeof treeNow === "function" ? treeNow() : null;
107
+ if (tree?.revision && audit.revision && tree.revision !== audit.revision) {
108
+ return `Cannot publish: ${AUDITOR} approved ${short(audit.revision)} but HEAD is now ${short(tree.revision)} — the approval covers only the revision he read. Have ${AUDITOR} audit the new commits, then publish.`;
109
+ }
110
+ if (state.requireClean && audit.clean === false) {
111
+ return `Cannot publish: ${AUDITOR} audited while the tree had uncommitted changes, so his approval is not tied to a commit. Commit, have ${AUDITOR} audit the commit, then publish.`;
112
+ }
113
+ }
114
+ return null;
48
115
  }
49
116
 
50
- // input — the SDK PreToolUseHookInput ({ tool_name, tool_input, agent_id?, agent_type? }).
51
- // `agent_id` is present only inside a subagent (BaseHookInput docs).
52
- // state — mutable session state; reads `awaitingAudit` and `openFindings`.
53
- export function decidePreToolUse({ phase, input, state = {} }) {
117
+ // input — the SDK PreToolUseHookInput ({ tool_name, tool_input, agent_id?, agent_type? }).
118
+ // `agent_id` is present only inside a subagent (BaseHookInput docs).
119
+ // state — mutable session state; reads `awaitingAudit`, `audit`, `openFindings`, `requireClean`.
120
+ // treeNow () => { revision, clean }; consulted only for a publish command.
121
+ export function decidePreToolUse({ phase, input, state = {}, treeNow = state.treeNow }) {
54
122
  const tool = input?.tool_name;
55
123
  const isMainThread = !input?.agent_id;
56
124
 
@@ -69,6 +137,9 @@ export function decidePreToolUse({ phase, input, state = {} }) {
69
137
  if (phase === "REVIEW" && MUTATING_TOOLS.has(tool)) {
70
138
  return { decision: "deny", reason: "REVIEW is read-only: report findings; fixes happen back in EXECUTION." };
71
139
  }
140
+ if (phase === "REVIEW" && tool === "Bash" && (isPublishCommand(input?.tool_input?.command) || isHistoryCommand(input?.tool_input?.command))) {
141
+ return { decision: "deny", reason: "REVIEW is read-only: it cannot commit, move HEAD, or publish — the verdict refers to the revision as it is." };
142
+ }
72
143
 
73
144
  // The approval refers to one revision. SUMMARY may read it and talk about
74
145
  // it; it may not edit, commit, move HEAD, or publish.
@@ -81,17 +152,8 @@ export function decidePreToolUse({ phase, input, state = {} }) {
81
152
  }
82
153
 
83
154
  if (phase === "EXECUTION" && tool === "Bash" && isPublishCommand(input?.tool_input?.command)) {
84
- const open = Array.isArray(state.openFindings) ? state.openFindings : [];
85
- if (state.awaitingAudit || open.length > 0) {
86
- const list = open.slice(0, 5).map(f => `- ${f.title || f}`).join("\n");
87
- const why = open.length > 0
88
- ? `${open.length} review finding(s) are unresolved:\n${list}\n`
89
- : "";
90
- return {
91
- decision: "deny",
92
- reason: `Cannot publish yet — ${why}${AUDITOR} must audit the changed files in this phase first (have the lead delegate the audit to ${AUDITOR}, fix what he flags, then publish).`,
93
- };
94
- }
155
+ const reason = auditDenial(state, treeNow);
156
+ if (reason) return { decision: "deny", reason };
95
157
  }
96
158
 
97
159
  return { decision: "allow" };
@@ -108,17 +170,28 @@ function denyOutput(reason) {
108
170
  }
109
171
 
110
172
  // Build the SDK `hooks` option for one phase.
111
- // state — shared session state (openFindings, ...)
173
+ // state — shared session state (openFindings, audit, verifyPublish, treeNow, ...)
112
174
  // onToolUse — ({ agentType, tool, input }) for the dashboard
113
- // onToolResult — ({ agentType, tool, response }) for the dashboard
114
- // onSubagentStop — ({ agentType }) when a subagent finishes
115
- export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagentStop } = {}) {
175
+ // onToolResult — ({ agentType, tool, input, actionId, response }) for the dashboard and the audit
176
+ // onSubagentStop — ({ agentType, text }) when a subagent finishes; `text` is its last message when the harness provides it
177
+ // verifyTimeoutSec hook timeout for PreToolUse; publishing may run the
178
+ // project's checks inside the hook (state.verifyPublish)
179
+ export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagentStop, verifyTimeoutSec } = {}) {
116
180
  return {
117
181
  PreToolUse: [{
182
+ ...(verifyTimeoutSec > 0 ? { timeout: verifyTimeoutSec } : {}),
118
183
  hooks: [async (input) => {
119
184
  onToolUse?.({ agentType: input.agent_type || null, tool: input.tool_name, input: input.tool_input });
120
185
  const d = decidePreToolUse({ phase, input, state });
121
- return d.decision === "deny" ? denyOutput(d.reason) : {};
186
+ if (d.decision === "deny") return denyOutput(d.reason);
187
+ // Publishing is the one action the policy cannot judge from state
188
+ // alone: the engine verifies the revision being published, on the
189
+ // spot. state.verifyPublish is set by the session loop for EXECUTION.
190
+ if (phase === "EXECUTION" && input.tool_name === "Bash" && isPublishCommand(input.tool_input?.command) && typeof state?.verifyPublish === "function") {
191
+ const verdict = await state.verifyPublish();
192
+ if (!verdict?.ok) return denyOutput(verdict?.reason || "Cannot publish: verification did not pass.");
193
+ }
194
+ return {};
122
195
  }],
123
196
  }],
124
197
  PostToolUse: [{
@@ -130,7 +203,7 @@ export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagen
130
203
  }],
131
204
  SubagentStop: [{
132
205
  hooks: [async (input) => {
133
- onSubagentStop?.({ agentType: input.agent_type || null });
206
+ onSubagentStop?.({ agentType: input.agent_type || null, text: typeof input.last_assistant_message === "string" ? input.last_assistant_message : null });
134
207
  return {};
135
208
  }],
136
209
  }],
@@ -14,7 +14,7 @@ The reviewers returned the following. Work from this list; do not re-derive it.
14
14
  ## Rules
15
15
 
16
16
  - Follow CLAUDE.md conventions (if present). Do not modify files unrelated to the task.
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.
17
+ - **Sam's audit is a blocking gate.** After Dennis implements and commits, Sam must read every changed file and run his full checklist, citing file:line for every finding — "looks clean" without evidence is invalid. Sam ends his report with `AUDIT: APPROVED` or `AUDIT: REJECTED — <why>`; the engine reads that line and ties an approval to the commit he audited. Publishing is refused without an approval for the current commit: after REJECTED, Dennis fixes and commits and Sam re-audits; any commit made after an approval needs a new audit too.
18
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
 
@@ -36,10 +36,10 @@ Screenshots are **disabled** for this project. Do not capture any unless the use
36
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
- 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.
39
+ 2. **Sam audits the commit** — every changed file, full checklist (feature envy, separation of concerns, clear interfaces, layering, god files), file:line for each finding, closing with his `AUDIT:` line. If there are violations, send Dennis back to fix and commit them, then have Sam re-audit.
40
40
  3. **Vera tests** — unit/regression tests for the changed code, run and verified, committed.
41
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
- 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.
42
+ 5. **Bart reviews and publishes** — reads all changed files, checks edge cases and error handling, runs linter and build, captures screenshots if applicable, pushes and creates the PR, posts the PR link on the tracker, posts screenshots as a separate comment. Sam's approval must cover the commit being published: if anyone committed after his audit (Vera's tests, a specialist's change), have Sam audit the new commits first — a short re-audit is enough.
43
43
  6. Ask Dennis, Sam and Bart to post their brief tracker comments (files changed & decisions; architecture findings or clean audit with evidence; PR link, test results, screenshots).
44
44
 
45
45
  The structured output required by the schema is captured automatically — what was implemented, files changed, the PR URL (empty string if none), QA results, issues fixed, and what the reviewers should look at. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
@@ -136,7 +136,7 @@ function createTaskSection({ tracker, config, description }) {
136
136
  export function renderPhasePrompt({
137
137
  phase, taskId, taskLink, description, createTask, tracker, config = {}, project = {},
138
138
  sessionUrl, cwd, sessionMemory = "", retryVerdict = null, evidence = null, openItems = [],
139
- roster = null, profile = null,
139
+ roster = null, profile = null, handoffRetry = false,
140
140
  }) {
141
141
  const vars = {
142
142
  TASK_ID: taskId,
@@ -180,6 +180,9 @@ export function renderPhasePrompt({
180
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
181
  }
182
182
  if (openItems.length) body += `\n\n${renderOpenItems(openItems)}`;
183
+ if (handoffRetry) {
184
+ body += "\n\n## PREVIOUS RUN ENDED WITHOUT ITS HANDOFF\n\nThe last run of this phase did not produce the structured output the schema requires, so nothing was handed forward. Keep this run focused and shorter, and finish while turns remain so the structured summary is captured.";
185
+ }
183
186
  if (sessionMemory) {
184
187
  body += `\n\n## SESSION MEMORY (previous phases)\n\n${sessionMemory}`;
185
188
  }
@@ -35,6 +35,7 @@ export function buildQueryOptions({
35
35
  phase, cwd, env, model, agents, allowedTools, lead, state, config = {},
36
36
  abortController, sandbox, onChild, onIsolation, hookCallbacks = {},
37
37
  extraWritePaths = [],
38
+ verifyTimeoutSec,
38
39
  claudePath = process.env.AGENTDESK_CLAUDE_PATH,
39
40
  }) {
40
41
  const options = {
@@ -50,7 +51,7 @@ export function buildQueryOptions({
50
51
  abortController,
51
52
  forwardSubagentText: true,
52
53
  outputFormat: { type: "json_schema", schema: PHASE_OUTPUT_SCHEMAS[phase] },
53
- hooks: hooksForPhase({ phase, state, ...hookCallbacks }),
54
+ hooks: hooksForPhase({ phase, state, verifyTimeoutSec, ...hookCallbacks }),
54
55
  spawnClaudeCodeProcess: createSandboxedSpawn({ sandbox, onChild, onIsolation, extraWritePaths }),
55
56
  };
56
57
  if (model) options.model = model;
@@ -17,7 +17,7 @@ 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
- MAX_REVIEW_RETRIES, MAX_PHASE_RUNS, phaseFailed, finalStatus, archiveStaleMemory,
20
+ MAX_REVIEW_RETRIES, MAX_HANDOFF_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";
@@ -36,7 +36,7 @@ import { spawnSandboxedCommand } from "./spawn.mjs";
36
36
  import { createLedger, openItemsFrom, noCommitItem } from "./handoff.mjs";
37
37
  import { teamProfileFor, phasesFor } from "./team-profile.mjs";
38
38
  import { buildQueryOptions, defaultRunQuery } from "./query.mjs";
39
- import { armPublishGate, onSubagentStopped } from "./hooks.mjs";
39
+ import { armPublishGate, recordAudit, responseText, AUDITOR } from "./hooks.mjs";
40
40
  import { prepareWorkspace } from "../worktrees.mjs";
41
41
  import { detectProject } from "../detect.mjs";
42
42
  import { preparePrivateGit } from "../worktree-git.mjs";
@@ -235,6 +235,7 @@ async function executeSession({
235
235
  let handoff = false, aborted = false, reviewResolved = !!solo; // solo has no review gate
236
236
  let reviewRetries = 0, phaseRuns = 0, lastVerdict = null, lastPhase = null;
237
237
  let isolationLogged = false;
238
+ const handoffRetries = new Map(); // phase → runs that ended without a structured handoff
238
239
 
239
240
  // --- evidence (engine-owned) ---------------------------------------------
240
241
  // The engine runs the project's checks itself before every REVIEW and pins
@@ -248,16 +249,60 @@ async function executeSession({
248
249
  extraWritePaths: workspaceStateDir ? [workspaceStateDir, workspaceRecord.tree || cwd] : [],
249
250
  }),
250
251
  });
251
- const headNow = () => gitState(cwd, workspaceGitEnv).revision;
252
+ const treeNow = () => gitState(cwd, workspaceGitEnv);
253
+ const headNow = () => treeNow().revision;
254
+ const describeTree = t => `${shortRev(t.revision)}${t.clean === false ? " + uncommitted changes" : ""}`;
255
+ state.treeNow = treeNow;
256
+ state.requireClean = requireClean;
252
257
  let approvedRevision = null;
258
+ const verify = () => runChecks({ checks, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }),
259
+ gitEnv: workspaceGitEnv, runCheck: runOneCheck, timeoutMs: checkTimeoutMs(config), signal: abortController.signal, requireClean });
260
+ // Generous: every configured check may run to its own timeout inside the hook.
261
+ const verifyTimeoutSec = Math.ceil((checkTimeoutMs(config) * Math.max(1, checks.length)) / 1000) + 60;
262
+
263
+ // Publishing (git push / gh pr create) is verified on the spot: the engine
264
+ // runs the checks at the revision being published and refuses with the
265
+ // failing output when they do not pass. A pass is evidence for the exact
266
+ // tree it ran on — same commit and a clean tree, then and now — and only
267
+ // that is reused (by a later publish attempt and by the REVIEW that
268
+ // follows). Uncommitted edits leave HEAD unchanged, so HEAD alone would let
269
+ // stale evidence outlive the code it checked. A failure is never cached.
270
+ let verified = null; // { revision, clean, evidence, verdict } from the latest verification
271
+ const reusableFor = tree => !!(verified && verified.evidence.passed && verified.clean === true
272
+ && tree.revision && tree.revision === verified.revision && tree.clean === true);
273
+ const verifyPublish = async () => {
274
+ if (reusableFor(treeNow())) return verified.verdict;
275
+ const evidence = await verify();
276
+ if (abortController.signal.aborted) return { ok: false, reason: "Session is being cancelled." };
277
+ // The tree is read again after the checks: evidence for a tree that no
278
+ // longer exists is not evidence.
279
+ const after = treeNow();
280
+ const moved = after.revision !== evidence.revision || after.clean !== evidence.clean;
281
+ let verdict;
282
+ if (moved) {
283
+ verdict = { ok: false, reason: `Cannot publish: the working tree changed while verification ran (${describeTree(evidence)} → ${describeTree(after)}). Commit, then publish again — the engine re-runs the checks.` };
284
+ } else if (!evidence.checked && evidence.clean !== false) verdict = { ok: true };
285
+ else {
286
+ reportEvidence(evidence, "EXECUTION");
287
+ appendMemory(renderEvidenceSection(evidence));
288
+ if (evidence.passed) verdict = { ok: true };
289
+ else {
290
+ const lines = evidenceFindings(evidence).map(f => `- ${f.title}\n ${String(f.detail || "").split("\n").slice(-8).join("\n ")}`);
291
+ verdict = { ok: false, reason: `Cannot publish: verification did not pass at ${shortRev(evidence.revision)}.\n${lines.join("\n")}\nFix it, commit, and publish again — the engine re-runs the checks for the new revision.` };
292
+ }
293
+ }
294
+ verified = moved ? null : { revision: evidence.revision, clean: evidence.clean, evidence, verdict };
295
+ return verdict;
296
+ };
297
+ state.verifyPublish = verifyPublish;
253
298
 
254
299
  // --- handoff ledger (engine-owned) ---------------------------------------
255
300
  // One entry per phase run; the open-items list travels into the next prompt.
256
301
  const ledger = createLedger(ledgerPath);
257
302
  let openItems = [];
258
303
 
259
- const reportEvidence = evidence => {
260
- emit({ type: "session:evidence", phase: "REVIEW", revision: evidence.revision, clean: evidence.clean,
304
+ const reportEvidence = (evidence, evidencePhase = "REVIEW") => {
305
+ emit({ type: "session:evidence", phase: evidencePhase, revision: evidence.revision, clean: evidence.clean,
261
306
  passed: evidence.passed, checked: evidence.checked, results: evidence.results });
262
307
  const at = evidence.revision ? ` at ${shortRev(evidence.revision)}` : "";
263
308
  if (!evidence.checked) {
@@ -300,13 +345,18 @@ async function executeSession({
300
345
  }
301
346
  }
302
347
 
303
- // An approval is void the moment the code moves past the reviewed revision.
304
- const invalidateApproval = now => {
348
+ // An approval is void the moment the code moves past the reviewed revision
349
+ // a new commit, or (in a session worktree) uncommitted changes on top.
350
+ const drifted = tree => tree.revision !== approvedRevision || (requireClean && tree.clean === false);
351
+ const invalidateApproval = tree => {
305
352
  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)}).` }];
353
+ const what = tree.revision !== approvedRevision
354
+ ? `Code changed after approval (${shortRev(approvedRevision)} → ${shortRev(tree.revision)})`
355
+ : `Uncommitted changes appeared after approval of ${shortRev(approvedRevision)}`;
356
+ emit({ type: "session:error", code: "REVIEW_STALE", message: `${what} — the approval no longer applies; ending for human review.` });
357
+ openItems = [...openItems, { source: "engine", title: "Approval invalidated", detail: `${what}.` }];
308
358
  if (lastVerdict) {
309
- lastVerdict = { ...lastVerdict, approved: false, approvalReason: "code changed after approval", headNow: now };
359
+ lastVerdict = { ...lastVerdict, approved: false, approvalReason: tree.revision !== approvedRevision ? "code changed after approval" : "uncommitted changes after approval", headNow: tree.revision };
310
360
  try { writeFileSync(findingsPath, JSON.stringify(lastVerdict, null, 2)); } catch {}
311
361
  }
312
362
  };
@@ -328,7 +378,8 @@ async function executeSession({
328
378
  const run = { index: phaseRuns, startedAt: Date.now(), revisionBefore: headNow() };
329
379
  const finishRun = ({ output = null, evidence: runEvidence = null, status }) => {
330
380
  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 };
381
+ revisionBefore: run.revisionBefore, revisionAfter: headNow(), output, evidence: runEvidence, openItems: [...openItems], status,
382
+ ...(run.audit ? { audit: run.audit } : {}) };
332
383
  ledger.record(entry);
333
384
  emit({ type: "session:handoff", ...entry });
334
385
  };
@@ -341,11 +392,17 @@ async function executeSession({
341
392
  // to EXECUTION as findings, through the same retry path.
342
393
  let evidence = null;
343
394
  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 });
395
+ const tree = treeNow();
396
+ const reusable = reusableFor(tree) && verified.evidence.checked;
397
+ evidence = reusable ? verified.evidence : await verify();
346
398
  if (abortController.signal.aborted) { aborted = true; break; }
347
- reportEvidence(evidence);
348
- appendMemory(renderEvidenceSection(evidence));
399
+ if (reusable) {
400
+ emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Verification at ${shortRev(tree.revision)} already passed when the work was published (same commit, clean tree) — reusing it for review.` });
401
+ emit({ type: "session:evidence", phase: "REVIEW", revision: evidence.revision, clean: evidence.clean, passed: evidence.passed, checked: evidence.checked, results: evidence.results });
402
+ } else {
403
+ reportEvidence(evidence);
404
+ appendMemory(renderEvidenceSection(evidence));
405
+ }
349
406
  if (!evidence.passed) {
350
407
  settleReview({ outcome: "NEEDS_MORE_WORK", findings: evidenceFindings(evidence), deferred: [], unverifiedClaims: [], reason: null,
351
408
  revision: evidence.revision, headNow: evidence.revision, evidence, approved: false, approvalReason: "engine checks did not pass", engineOnly: true });
@@ -354,8 +411,8 @@ async function executeSession({
354
411
  }
355
412
  }
356
413
  if (phase === "SUMMARY" && reviewResolved && approvedRevision) {
357
- const now = headNow();
358
- if (now !== approvedRevision) invalidateApproval(now);
414
+ const now = treeNow();
415
+ if (drifted(now)) invalidateApproval(now);
359
416
  }
360
417
 
361
418
  const { agents, allowedTools, lead } = solo
@@ -374,6 +431,7 @@ async function executeSession({
374
431
  openItems: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? [] : openItems,
375
432
  roster: Object.keys(agents).filter(name => name !== lead),
376
433
  profile,
434
+ handoffRetry: (handoffRetries.get(phase) || 0) > 0,
377
435
  });
378
436
  if (workspaceRecord) {
379
437
  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`;
@@ -383,16 +441,38 @@ async function executeSession({
383
441
  prompt += `For the final substantive tracker reply only, include the literal marker ${replyMarker(sessionId)} in the posted comment. Never mark startup/progress comments. Keep the successful provider response intact (no jq, redirects or pipelines). For GitHub use standalone gh issue comment ${taskId} --repo ${outcomeRepo || "OWNER/REPO"} --body with a literal body. For Jira use standalone curl with a literal JSON --data-raw payload and the task's comment endpoint. For Linear use standalone curl with a literal JSON --data-raw payload and a commentCreate mutation selecting success and comment { id url body issue { identifier } }. Do not post an extra reply just to record an outcome.\n`;
384
442
  }
385
443
 
386
- // Publishing is gated until Sam has audited in this EXECUTION phase.
444
+ // Publishing is gated until Sam has approved, in this EXECUTION phase,
445
+ // the revision being published. His verdict is read from his report —
446
+ // the harness hands it over when he stops and again as the lead's tool
447
+ // result — and tied to the tree as the engine sees it at that moment.
387
448
  if (phase === "EXECUTION") armPublishGate(state, lastVerdict?.outcome === "APPROVED" ? [] : (lastVerdict?.findings || []));
449
+ const noteAudit = text => {
450
+ if (phase !== "EXECUTION") return;
451
+ const previous = state.audit;
452
+ const audit = recordAudit(state, { phase, agentType: AUDITOR, text, tree: treeNow() });
453
+ if (!audit) return;
454
+ run.audit = audit;
455
+ const same = previous && ["verdict", "revision", "clean", "statedRevision"].every(k => previous[k] === audit[k]);
456
+ if (same) return; // the same report, seen twice
457
+ emit({ type: "session:audit", phase, ...audit });
458
+ const messages = {
459
+ APPROVED: `${AUDITOR}'s audit: APPROVED at ${shortRev(audit.revision)}${audit.clean === false ? " (tree has uncommitted changes)" : ""} — publishing is open for that revision.`,
460
+ REJECTED: `${AUDITOR}'s audit: REJECTED — publishing stays closed until the fixes are committed and ${AUDITOR} re-audits.`,
461
+ STALE: `${AUDITOR} approved ${shortRev(audit.statedRevision)} but the code is at ${shortRev(audit.revision)} — publishing stays closed until he audits the current revision.`,
462
+ MISSING: `${AUDITOR}'s report has no AUDIT line — publishing stays closed until he ends his audit with AUDIT: APPROVED or AUDIT: REJECTED.`,
463
+ };
464
+ emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: messages[audit.verdict] });
465
+ };
388
466
 
389
467
  const mapper = createEventMapper({ leadAgent: lead, onEvent: emit });
390
468
  const options = buildQueryOptions({
391
469
  phase, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }), model,
392
470
  agents, allowedTools, lead, state, config, abortController, sandbox, onChild,
393
471
  extraWritePaths: workspaceStateDir ? [workspaceStateDir, workspaceRecord.tree || cwd] : [],
472
+ verifyTimeoutSec,
394
473
  hookCallbacks: {
395
474
  onToolResult: result => {
475
+ if (result.tool === "Agent" && result.input?.subagent_type === AUDITOR) noteAudit(responseText(result.response));
396
476
  const outcome = captureOutcome({ ...result, phase, sessionId, taskId, repo: outcomeRepo,
397
477
  branch: workspaceRecord?.branch || (result.tool === "Bash" && /^gh\s+pr\s+create\b/.test(result.input?.command || "") ? currentBranch(cwd) : null), tracker, config });
398
478
  if (outcome && !outcomeIds.has(outcome.id)) {
@@ -400,8 +480,8 @@ async function executeSession({
400
480
  emit({ type: "session:outcome", project: project?.name || null, outcome });
401
481
  }
402
482
  },
403
- onSubagentStop: ({ agentType }) => {
404
- onSubagentStopped(state, { phase, agentType });
483
+ onSubagentStop: ({ agentType, text }) => {
484
+ if (agentType === AUDITOR && typeof text === "string") noteAudit(text);
405
485
  if (agentType) emit({ type: "agent:message", agent: agentType, tag: "SAY", message: `${agentType} finished and reported back.` });
406
486
  },
407
487
  },
@@ -441,15 +521,31 @@ async function executeSession({
441
521
 
442
522
  if (phase === "REVIEW") {
443
523
  const verdict = verdictFromResult({ is_error: summary.isError, subtype: summary.subtype, structured_output: summary.structuredOutput });
444
- const now = headNow();
445
- const approval = evaluateApproval({ verdict, evidence, headNow: now });
524
+ const now = treeNow();
525
+ const approval = evaluateApproval({ verdict, evidence, headNow: now.revision, cleanNow: now.clean });
446
526
  appendMemory(renderMemorySection("REVIEW", summary.structuredOutput));
447
- settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now, evidence, approved: approval.approved, approvalReason: approval.reason });
527
+ settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now.revision, evidence, approved: approval.approved, approvalReason: approval.reason });
448
528
  finishRun({ output: summary.structuredOutput ?? null, evidence, status: lastVerdict.approved ? "ok" : "not-approved" });
449
529
  continue;
450
530
  }
451
531
 
452
- // Other phases: the structured output is the handoff.
532
+ // Other phases: the structured output IS the handoff. Without it the
533
+ // next phase would start from nothing — run the phase again once, then
534
+ // fail closed. Solo mode keeps its single-run behaviour.
535
+ if (!summary.structuredOutput && phase !== "SOLO") {
536
+ const attempt = (handoffRetries.get(phase) || 0) + 1;
537
+ handoffRetries.set(phase, attempt);
538
+ finishRun({ status: "missing-output" });
539
+ if (attempt <= MAX_HANDOFF_RETRIES) {
540
+ emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} ended without its structured handoff — running it again (retry ${attempt}/${MAX_HANDOFF_RETRIES}).` });
541
+ queue.unshift(phase);
542
+ continue;
543
+ }
544
+ emit({ type: "session:error", code: "HANDOFF_INVALID", message: `${phase} ended without its structured handoff again — ending session for human review.` });
545
+ handoff = true;
546
+ writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
547
+ break;
548
+ }
453
549
  if (!summary.structuredOutput) {
454
550
  emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
455
551
  }
@@ -484,8 +580,8 @@ async function executeSession({
484
580
 
485
581
  // Belt to SUMMARY's braces: nothing may have moved the approved revision.
486
582
  if (!aborted && reviewResolved && approvedRevision) {
487
- const now = headNow();
488
- if (now !== approvedRevision) invalidateApproval(now);
583
+ const now = treeNow();
584
+ if (drifted(now)) invalidateApproval(now);
489
585
  }
490
586
 
491
587
  const duration = seconds(startedAt);
@@ -12,10 +12,17 @@ export const PHASES = ["INTAKE", "PLAN", "EXECUTION", "REVIEW", "SUMMARY"];
12
12
  // One execution redo after a failed review, then the session ends unresolved.
13
13
  export const MAX_REVIEW_RETRIES = 1;
14
14
 
15
- // Absolute ceiling on phase runs. The review loop re-enqueues phases, so a bug
16
- // in verdict parsing must not be able to spin forever burning tokens. With
17
- // MAX_REVIEW_RETRIES=1 a legitimate run tops out at 7 (5 + EXECUTION + REVIEW).
18
- export const MAX_PHASE_RUNS = 10;
15
+ // A phase that ends without its structured handoff is run again once; a
16
+ // second miss ends the session for human review rather than letting the next
17
+ // phase start from nothing.
18
+ export const MAX_HANDOFF_RETRIES = 1;
19
+
20
+ // Absolute ceiling on phase runs. The review and handoff loops re-enqueue
21
+ // phases, so a bug in verdict or output parsing must not be able to spin
22
+ // forever burning tokens. With MAX_REVIEW_RETRIES=1 and MAX_HANDOFF_RETRIES=1
23
+ // a legitimate run tops out at 12 (5 + EXECUTION + REVIEW + one handoff
24
+ // retry for each of the five non-review runs).
25
+ export const MAX_PHASE_RUNS = 14;
19
26
 
20
27
  // Classify the first line of `.agentdesk/review-verdict.md`.
21
28
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.31.1",
3
+ "version": "0.32.1",
4
4
  "description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "server": "node server/index.mjs",
23
23
  "build": "vite build",
24
24
  "preview": "vite preview",
25
- "test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs tests/projects-registry.test.mjs tests/phase-loop.test.mjs tests/proc.test.mjs tests/crypto.test.mjs tests/dotenv.test.mjs tests/update-check.test.mjs tests/setup-helpers.test.mjs tests/project-key.test.mjs tests/tracker-project.test.mjs tests/tracker-check.test.mjs tests/config.test.mjs tests/engine-env.test.mjs tests/engine-events.test.mjs tests/engine-verdict.test.mjs tests/engine-hooks.test.mjs tests/engine-agents.test.mjs tests/session-isolation.test.mjs tests/engine-session.test.mjs tests/engine-prompts.test.mjs tests/engine-schemas.test.mjs tests/engine-claude-auth.test.mjs tests/worktrees.test.mjs tests/workspaces-api.test.mjs tests/engine-outcome.test.mjs tests/session-outcomes.test.mjs tests/outcome-hydration.test.mjs tests/mobile-outcomes.test.mjs tests/session-queue.test.mjs tests/useFollowScroll.test.mjs tests/session-usage.test.mjs tests/task-lookup.test.mjs tests/engine-evidence.test.mjs tests/engine-handoff.test.mjs tests/engine-team-profile.test.mjs tests/project-settings.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-commands.test.mjs tests/engine-agents.test.mjs tests/session-isolation.test.mjs tests/engine-session.test.mjs tests/engine-prompts.test.mjs tests/engine-schemas.test.mjs tests/engine-claude-auth.test.mjs tests/worktrees.test.mjs tests/workspaces-api.test.mjs tests/engine-outcome.test.mjs tests/session-outcomes.test.mjs tests/outcome-hydration.test.mjs tests/mobile-outcomes.test.mjs tests/session-queue.test.mjs tests/useFollowScroll.test.mjs tests/session-usage.test.mjs tests/task-lookup.test.mjs tests/engine-evidence.test.mjs tests/engine-handoff.test.mjs tests/engine-team-profile.test.mjs tests/project-settings.test.mjs tests/delivery.test.mjs tests/feed-view.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",
@@ -0,0 +1,70 @@
1
+ // Delivery record shared by server and dashboard: the engine's verification
2
+ // evidence, the open items carried between phases, and the handoff ledger.
3
+ // Events arrive from the daemon (untrusted shape); everything is validated
4
+ // and bounded here, once, before it is stored or rendered.
5
+
6
+ const PHASES = new Set(["INTAKE", "PLAN", "EXECUTION", "REVIEW", "SUMMARY", "SOLO"]);
7
+ const STATUSES = new Set(["ok", "not-approved", "checks-failed", "failed", "missing-output"]);
8
+ const SOURCES = new Set(["review", "engine", "deferred"]);
9
+ const MAX_RESULTS = 10, MAX_OUTPUT = 1000, MAX_ITEMS = 50, MAX_TEXT = 300, MAX_DETAIL = 1000, MAX_HANDOFFS = 20;
10
+
11
+ const str = (v, max) => (typeof v === "string" ? v.slice(0, max) : "");
12
+ const revision = v => (typeof v === "string" && /^[a-f0-9]{7,64}$/.test(v) ? v : null);
13
+
14
+ export function emptyDelivery() {
15
+ return { evidence: null, openItems: [], handoffs: [] };
16
+ }
17
+
18
+ export function normalizeEvidence(event) {
19
+ if (!event || typeof event !== "object" || !Array.isArray(event.results)) return null;
20
+ const results = event.results.slice(0, MAX_RESULTS).flatMap(r => {
21
+ if (!r || typeof r !== "object" || typeof r.command !== "string") return [];
22
+ return [{
23
+ name: str(r.name, 40), command: str(r.command, MAX_TEXT),
24
+ exitCode: Number.isInteger(r.exitCode) ? r.exitCode : null,
25
+ durationMs: Number.isFinite(r.durationMs) && r.durationMs >= 0 ? Math.round(r.durationMs) : 0,
26
+ timedOut: r.timedOut === true, output: str(r.output, MAX_OUTPUT).slice(-MAX_OUTPUT),
27
+ }];
28
+ });
29
+ return {
30
+ revision: revision(event.revision), clean: typeof event.clean === "boolean" ? event.clean : null,
31
+ passed: event.passed === true, checked: event.checked === true, results,
32
+ };
33
+ }
34
+
35
+ export function normalizeOpenItems(items) {
36
+ if (!Array.isArray(items)) return [];
37
+ return items.slice(0, MAX_ITEMS).flatMap(i => {
38
+ if (!i || typeof i !== "object" || !SOURCES.has(i.source) || typeof i.title !== "string" || !i.title) return [];
39
+ return [{ source: i.source, title: str(i.title, MAX_TEXT), detail: str(i.detail, MAX_DETAIL) }];
40
+ });
41
+ }
42
+
43
+ export function normalizeHandoff(event) {
44
+ if (!event || typeof event !== "object" || !PHASES.has(event.phase) || !Number.isInteger(event.run) || event.run < 1) return null;
45
+ return {
46
+ phase: event.phase, run: event.run,
47
+ status: STATUSES.has(event.status) ? event.status : "ok",
48
+ durationMs: Number.isFinite(event.durationMs) && event.durationMs >= 0 ? Math.round(event.durationMs) : 0,
49
+ revisionBefore: revision(event.revisionBefore), revisionAfter: revision(event.revisionAfter),
50
+ openItems: normalizeOpenItems(event.openItems),
51
+ };
52
+ }
53
+
54
+ // Applies one session:evidence or session:handoff event to a delivery record.
55
+ // Returns the same object when the event is not one of ours or is malformed.
56
+ export function mergeDelivery(delivery, event) {
57
+ const base = delivery && typeof delivery === "object" ? delivery : emptyDelivery();
58
+ if (event?.type === "session:evidence") {
59
+ const evidence = normalizeEvidence(event);
60
+ return evidence ? { ...base, evidence } : base;
61
+ }
62
+ if (event?.type === "session:handoff") {
63
+ const handoff = normalizeHandoff(event);
64
+ if (!handoff) return base;
65
+ const handoffs = [...(base.handoffs || []).filter(h => h.run !== handoff.run), handoff]
66
+ .sort((a, b) => a.run - b.run).slice(-MAX_HANDOFFS);
67
+ return { ...base, openItems: handoff.openItems, handoffs };
68
+ }
69
+ return base;
70
+ }