@kendoo.agentdesk/agentdesk 0.32.0 → 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,13 @@ 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
+
11
18
  ## [0.32.0] — 2026-09-21
12
19
 
13
20
  ### Changed
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. Publishing is verified the same way, on the spot: when the team runs `git push` or `gh pr create`, 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 reused by the review that follows). `REVIEW` and `SUMMARY` cannot commit, move `HEAD`, or push; they read and write 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
 
@@ -84,17 +152,8 @@ export function decidePreToolUse({ phase, input, state = {} }) {
84
152
  }
85
153
 
86
154
  if (phase === "EXECUTION" && tool === "Bash" && isPublishCommand(input?.tool_input?.command)) {
87
- const open = Array.isArray(state.openFindings) ? state.openFindings : [];
88
- if (state.awaitingAudit || open.length > 0) {
89
- const list = open.slice(0, 5).map(f => `- ${f.title || f}`).join("\n");
90
- const why = open.length > 0
91
- ? `${open.length} review finding(s) are unresolved:\n${list}\n`
92
- : "";
93
- return {
94
- decision: "deny",
95
- 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).`,
96
- };
97
- }
155
+ const reason = auditDenial(state, treeNow);
156
+ if (reason) return { decision: "deny", reason };
98
157
  }
99
158
 
100
159
  return { decision: "allow" };
@@ -111,10 +170,10 @@ function denyOutput(reason) {
111
170
  }
112
171
 
113
172
  // Build the SDK `hooks` option for one phase.
114
- // state — shared session state (openFindings, verifyPublish, ...)
173
+ // state — shared session state (openFindings, audit, verifyPublish, treeNow, ...)
115
174
  // onToolUse — ({ agentType, tool, input }) for the dashboard
116
- // onToolResult — ({ agentType, tool, response }) for the dashboard
117
- // onSubagentStop — ({ agentType }) when a subagent finishes
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
118
177
  // verifyTimeoutSec — hook timeout for PreToolUse; publishing may run the
119
178
  // project's checks inside the hook (state.verifyPublish)
120
179
  export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagentStop, verifyTimeoutSec } = {}) {
@@ -144,7 +203,7 @@ export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagen
144
203
  }],
145
204
  SubagentStop: [{
146
205
  hooks: [async (input) => {
147
- 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 });
148
207
  return {};
149
208
  }],
150
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.
@@ -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";
@@ -249,7 +249,11 @@ async function executeSession({
249
249
  extraWritePaths: workspaceStateDir ? [workspaceStateDir, workspaceRecord.tree || cwd] : [],
250
250
  }),
251
251
  });
252
- 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;
253
257
  let approvedRevision = null;
254
258
  const verify = () => runChecks({ checks, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }),
255
259
  gitEnv: workspaceGitEnv, runCheck: runOneCheck, timeoutMs: checkTimeoutMs(config), signal: abortController.signal, requireClean });
@@ -258,27 +262,36 @@ async function executeSession({
258
262
 
259
263
  // Publishing (git push / gh pr create) is verified on the spot: the engine
260
264
  // runs the checks at the revision being published and refuses with the
261
- // failing output when they do not pass. A pass is cached per revision, so
262
- // REVIEW reuses it at the same HEAD instead of running everything twice; a
263
- // failure is never cached the next attempt gets a fresh run.
264
- let verified = null; // { revision, evidence, verdict } from the latest publish verification
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);
265
273
  const verifyPublish = async () => {
266
- const head = headNow();
267
- if (verified && head && verified.revision === head && verified.evidence.passed) return verified.verdict;
274
+ if (reusableFor(treeNow())) return verified.verdict;
268
275
  const evidence = await verify();
269
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;
270
281
  let verdict;
271
- if (!evidence.checked && evidence.clean !== false) verdict = { ok: true };
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 };
272
285
  else {
273
286
  reportEvidence(evidence, "EXECUTION");
274
287
  appendMemory(renderEvidenceSection(evidence));
275
288
  if (evidence.passed) verdict = { ok: true };
276
289
  else {
277
290
  const lines = evidenceFindings(evidence).map(f => `- ${f.title}\n ${String(f.detail || "").split("\n").slice(-8).join("\n ")}`);
278
- verdict = { ok: false, reason: `Cannot publish: verification did not pass at ${shortRev(head)}.\n${lines.join("\n")}\nFix it, commit, and publish again — the engine re-runs the checks for the new revision.` };
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.` };
279
292
  }
280
293
  }
281
- verified = { revision: evidence.revision, evidence, verdict };
294
+ verified = moved ? null : { revision: evidence.revision, clean: evidence.clean, evidence, verdict };
282
295
  return verdict;
283
296
  };
284
297
  state.verifyPublish = verifyPublish;
@@ -332,13 +345,18 @@ async function executeSession({
332
345
  }
333
346
  }
334
347
 
335
- // An approval is void the moment the code moves past the reviewed revision.
336
- 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 => {
337
352
  reviewResolved = false;
338
- 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.` });
339
- 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}.` }];
340
358
  if (lastVerdict) {
341
- 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 };
342
360
  try { writeFileSync(findingsPath, JSON.stringify(lastVerdict, null, 2)); } catch {}
343
361
  }
344
362
  };
@@ -360,7 +378,8 @@ async function executeSession({
360
378
  const run = { index: phaseRuns, startedAt: Date.now(), revisionBefore: headNow() };
361
379
  const finishRun = ({ output = null, evidence: runEvidence = null, status }) => {
362
380
  const entry = { phase, run: run.index, startedAt: run.startedAt, durationMs: Date.now() - run.startedAt,
363
- 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 } : {}) };
364
383
  ledger.record(entry);
365
384
  emit({ type: "session:handoff", ...entry });
366
385
  };
@@ -373,12 +392,12 @@ async function executeSession({
373
392
  // to EXECUTION as findings, through the same retry path.
374
393
  let evidence = null;
375
394
  if (phase === "REVIEW") {
376
- const head = headNow();
377
- const reusable = verified && head && verified.revision === head && verified.evidence.passed && verified.evidence.checked;
395
+ const tree = treeNow();
396
+ const reusable = reusableFor(tree) && verified.evidence.checked;
378
397
  evidence = reusable ? verified.evidence : await verify();
379
398
  if (abortController.signal.aborted) { aborted = true; break; }
380
399
  if (reusable) {
381
- emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Verification at ${shortRev(head)} already passed when the work was published — reusing it for review.` });
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.` });
382
401
  emit({ type: "session:evidence", phase: "REVIEW", revision: evidence.revision, clean: evidence.clean, passed: evidence.passed, checked: evidence.checked, results: evidence.results });
383
402
  } else {
384
403
  reportEvidence(evidence);
@@ -392,8 +411,8 @@ async function executeSession({
392
411
  }
393
412
  }
394
413
  if (phase === "SUMMARY" && reviewResolved && approvedRevision) {
395
- const now = headNow();
396
- if (now !== approvedRevision) invalidateApproval(now);
414
+ const now = treeNow();
415
+ if (drifted(now)) invalidateApproval(now);
397
416
  }
398
417
 
399
418
  const { agents, allowedTools, lead } = solo
@@ -422,8 +441,28 @@ async function executeSession({
422
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`;
423
442
  }
424
443
 
425
- // 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.
426
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
+ };
427
466
 
428
467
  const mapper = createEventMapper({ leadAgent: lead, onEvent: emit });
429
468
  const options = buildQueryOptions({
@@ -433,6 +472,7 @@ async function executeSession({
433
472
  verifyTimeoutSec,
434
473
  hookCallbacks: {
435
474
  onToolResult: result => {
475
+ if (result.tool === "Agent" && result.input?.subagent_type === AUDITOR) noteAudit(responseText(result.response));
436
476
  const outcome = captureOutcome({ ...result, phase, sessionId, taskId, repo: outcomeRepo,
437
477
  branch: workspaceRecord?.branch || (result.tool === "Bash" && /^gh\s+pr\s+create\b/.test(result.input?.command || "") ? currentBranch(cwd) : null), tracker, config });
438
478
  if (outcome && !outcomeIds.has(outcome.id)) {
@@ -440,8 +480,8 @@ async function executeSession({
440
480
  emit({ type: "session:outcome", project: project?.name || null, outcome });
441
481
  }
442
482
  },
443
- onSubagentStop: ({ agentType }) => {
444
- onSubagentStopped(state, { phase, agentType });
483
+ onSubagentStop: ({ agentType, text }) => {
484
+ if (agentType === AUDITOR && typeof text === "string") noteAudit(text);
445
485
  if (agentType) emit({ type: "agent:message", agent: agentType, tag: "SAY", message: `${agentType} finished and reported back.` });
446
486
  },
447
487
  },
@@ -481,10 +521,10 @@ async function executeSession({
481
521
 
482
522
  if (phase === "REVIEW") {
483
523
  const verdict = verdictFromResult({ is_error: summary.isError, subtype: summary.subtype, structured_output: summary.structuredOutput });
484
- const now = headNow();
485
- const approval = evaluateApproval({ verdict, evidence, headNow: now });
524
+ const now = treeNow();
525
+ const approval = evaluateApproval({ verdict, evidence, headNow: now.revision, cleanNow: now.clean });
486
526
  appendMemory(renderMemorySection("REVIEW", summary.structuredOutput));
487
- 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 });
488
528
  finishRun({ output: summary.structuredOutput ?? null, evidence, status: lastVerdict.approved ? "ok" : "not-approved" });
489
529
  continue;
490
530
  }
@@ -540,8 +580,8 @@ async function executeSession({
540
580
 
541
581
  // Belt to SUMMARY's braces: nothing may have moved the approved revision.
542
582
  if (!aborted && reviewResolved && approvedRevision) {
543
- const now = headNow();
544
- if (now !== approvedRevision) invalidateApproval(now);
583
+ const now = treeNow();
584
+ if (drifted(now)) invalidateApproval(now);
545
585
  }
546
586
 
547
587
  const duration = seconds(startedAt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.32.0",
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 tests/delivery.test.mjs tests/feed-view.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",