@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 +7 -0
- package/README.md +1 -1
- package/cli/engine/agents/index.mjs +4 -0
- package/cli/engine/commands.mjs +162 -0
- package/cli/engine/evidence.mjs +4 -2
- package/cli/engine/hooks.mjs +94 -35
- package/cli/engine/phases/EXECUTION.md +3 -3
- package/cli/engine/session.mjs +70 -30
- package/package.json +2 -2
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
|
|
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
|
+
}
|
package/cli/engine/evidence.mjs
CHANGED
|
@@ -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
|
|
136
|
-
|
|
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
|
|
package/cli/engine/hooks.mjs
CHANGED
|
@@ -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
|
|
15
|
-
// `git push`, `git push
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
|
32
|
-
//
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
51
|
-
//
|
|
52
|
-
// state
|
|
53
|
-
|
|
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
|
|
88
|
-
if (
|
|
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.
|
|
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.
|
package/cli/engine/session.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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:
|
|
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
|
|
377
|
-
const reusable =
|
|
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(
|
|
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 =
|
|
396
|
-
if (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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
544
|
-
if (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.
|
|
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",
|