@kendoo.agentdesk/agentdesk 0.31.1 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -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 +110 -37
- package/cli/engine/phases/EXECUTION.md +3 -3
- package/cli/engine/prompts.mjs +4 -1
- package/cli/engine/query.mjs +2 -1
- package/cli/engine/session.mjs +122 -26
- package/cli/phase-loop.mjs +11 -4
- package/package.json +2 -2
- package/shared/delivery.mjs +70 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,21 @@ All user-facing changes to AgentDesk. Each entry is tagged:
|
|
|
8
8
|
|
|
9
9
|
Internal refactors, infrastructure changes, and architectural notes are not listed here.
|
|
10
10
|
|
|
11
|
+
## [0.32.1] — 2026-09-21
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- `[CLI]` The publish and history gates now understand the command they are looking at. `git -C . push`, `env git push`, a `git push` on a second line, `sh -c 'git push'`, `/usr/bin/git push` and similar spellings used to slip past both Sam's audit gate and the verification; the engine now parses the shell command and classifies what it actually runs, and refuses anything it cannot model when the text hints at publishing.
|
|
15
|
+
- `[CLI]` A cached verification pass is only reused for the exact tree it ran on — same commit *and* a clean working tree, then and now. Uncommitted edits left `HEAD` unchanged, so later publish attempts and the review could reuse stale evidence; they now run the checks again. An approval is also invalidated by uncommitted changes appearing after it in a session worktree.
|
|
16
|
+
- `[CLI]` Sam finishing his audit no longer counts as approving it. Sam ends his report with `AUDIT: APPROVED` or `AUDIT: REJECTED — <why>`; the engine reads that line, ties an approval to the commit he audited, and refuses to publish after a rejection, without the line, or once a newer commit exists — until he audits again. The verdict shows in the feed and in the handoff ledger.
|
|
17
|
+
|
|
18
|
+
## [0.32.0] — 2026-09-21
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- `[CLI]` Publishing is verified on the spot. When the team runs `git push` or `gh pr create`, the engine runs the project's `test`/`build`/`lint` at the commit being published and refuses with the failing output until a new commit passes; the review that follows reuses a passing run instead of running everything twice. Reviewers can no longer push or change history either.
|
|
22
|
+
- `[CLI]` A phase that ends without its structured handoff is run again once; if it misses again the session ends for human review instead of letting the next phase start from nothing.
|
|
23
|
+
- `[UI]` A Delivery strip above the conversation shows the engine's verification results (each command, pass/fail, failing output), the open items carried between phases, and the phase-by-phase handoff timeline with commit changes — live, and after a restart.
|
|
24
|
+
- `[UI]` The conversation defaults to a product view: the lead's narrative stays inline, the team's reports fold into one expandable row, and tool calls and status notes are hidden. Switch to Full in the session row to see everything; the choice is remembered.
|
|
25
|
+
|
|
11
26
|
## [0.31.1] — 2026-09-21
|
|
12
27
|
|
|
13
28
|
### Fixed
|
package/README.md
CHANGED
|
@@ -265,7 +265,7 @@ Valid values: `"default"`, `"opus"`, `"sonnet"`, `"haiku"`. `"default"` resolves
|
|
|
265
265
|
|
|
266
266
|
The `REVIEW` phase is a read-only completeness check (no code changes) — it verifies the implementation meets requirements, flags missed documentation updates or silently-deferred scope. If gaps are found, the orchestrator loops back to `EXECUTION` once before moving on. The `SUMMARY` phase writes the final tracker comments and session protocol.
|
|
267
267
|
|
|
268
|
-
Before the reviewers are asked, the engine runs the project's own checks itself — `commands.test`, `commands.build` and `commands.lint` from project settings, or the `test`/`build`/`lint` scripts in `package.json` when those are unset — inside the session sandbox, at the current commit. A failing check goes straight back to `EXECUTION` with the real output as findings; the reviewers are only asked once the checks pass. An approval is pinned to that commit: if the code changes afterwards, the approval no longer counts and the session ends for human review instead of reporting success. In a session worktree, uncommitted changes block review too — an approval refers to a committed revision. `SUMMARY` cannot commit, move `HEAD`, or push;
|
|
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
|
|
|
@@ -69,6 +137,9 @@ export function decidePreToolUse({ phase, input, state = {} }) {
|
|
|
69
137
|
if (phase === "REVIEW" && MUTATING_TOOLS.has(tool)) {
|
|
70
138
|
return { decision: "deny", reason: "REVIEW is read-only: report findings; fixes happen back in EXECUTION." };
|
|
71
139
|
}
|
|
140
|
+
if (phase === "REVIEW" && tool === "Bash" && (isPublishCommand(input?.tool_input?.command) || isHistoryCommand(input?.tool_input?.command))) {
|
|
141
|
+
return { decision: "deny", reason: "REVIEW is read-only: it cannot commit, move HEAD, or publish — the verdict refers to the revision as it is." };
|
|
142
|
+
}
|
|
72
143
|
|
|
73
144
|
// The approval refers to one revision. SUMMARY may read it and talk about
|
|
74
145
|
// it; it may not edit, commit, move HEAD, or publish.
|
|
@@ -81,17 +152,8 @@ export function decidePreToolUse({ phase, input, state = {} }) {
|
|
|
81
152
|
}
|
|
82
153
|
|
|
83
154
|
if (phase === "EXECUTION" && tool === "Bash" && isPublishCommand(input?.tool_input?.command)) {
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
-
const list = open.slice(0, 5).map(f => `- ${f.title || f}`).join("\n");
|
|
87
|
-
const why = open.length > 0
|
|
88
|
-
? `${open.length} review finding(s) are unresolved:\n${list}\n`
|
|
89
|
-
: "";
|
|
90
|
-
return {
|
|
91
|
-
decision: "deny",
|
|
92
|
-
reason: `Cannot publish yet — ${why}${AUDITOR} must audit the changed files in this phase first (have the lead delegate the audit to ${AUDITOR}, fix what he flags, then publish).`,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
155
|
+
const reason = auditDenial(state, treeNow);
|
|
156
|
+
if (reason) return { decision: "deny", reason };
|
|
95
157
|
}
|
|
96
158
|
|
|
97
159
|
return { decision: "allow" };
|
|
@@ -108,17 +170,28 @@ function denyOutput(reason) {
|
|
|
108
170
|
}
|
|
109
171
|
|
|
110
172
|
// Build the SDK `hooks` option for one phase.
|
|
111
|
-
// state — shared session state (openFindings, ...)
|
|
173
|
+
// state — shared session state (openFindings, audit, verifyPublish, treeNow, ...)
|
|
112
174
|
// onToolUse — ({ agentType, tool, input }) for the dashboard
|
|
113
|
-
// onToolResult — ({ agentType, tool, response }) for the dashboard
|
|
114
|
-
// onSubagentStop — ({ agentType }) when a subagent finishes
|
|
115
|
-
|
|
175
|
+
// onToolResult — ({ agentType, tool, input, actionId, response }) for the dashboard and the audit
|
|
176
|
+
// onSubagentStop — ({ agentType, text }) when a subagent finishes; `text` is its last message when the harness provides it
|
|
177
|
+
// verifyTimeoutSec — hook timeout for PreToolUse; publishing may run the
|
|
178
|
+
// project's checks inside the hook (state.verifyPublish)
|
|
179
|
+
export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagentStop, verifyTimeoutSec } = {}) {
|
|
116
180
|
return {
|
|
117
181
|
PreToolUse: [{
|
|
182
|
+
...(verifyTimeoutSec > 0 ? { timeout: verifyTimeoutSec } : {}),
|
|
118
183
|
hooks: [async (input) => {
|
|
119
184
|
onToolUse?.({ agentType: input.agent_type || null, tool: input.tool_name, input: input.tool_input });
|
|
120
185
|
const d = decidePreToolUse({ phase, input, state });
|
|
121
|
-
|
|
186
|
+
if (d.decision === "deny") return denyOutput(d.reason);
|
|
187
|
+
// Publishing is the one action the policy cannot judge from state
|
|
188
|
+
// alone: the engine verifies the revision being published, on the
|
|
189
|
+
// spot. state.verifyPublish is set by the session loop for EXECUTION.
|
|
190
|
+
if (phase === "EXECUTION" && input.tool_name === "Bash" && isPublishCommand(input.tool_input?.command) && typeof state?.verifyPublish === "function") {
|
|
191
|
+
const verdict = await state.verifyPublish();
|
|
192
|
+
if (!verdict?.ok) return denyOutput(verdict?.reason || "Cannot publish: verification did not pass.");
|
|
193
|
+
}
|
|
194
|
+
return {};
|
|
122
195
|
}],
|
|
123
196
|
}],
|
|
124
197
|
PostToolUse: [{
|
|
@@ -130,7 +203,7 @@ export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagen
|
|
|
130
203
|
}],
|
|
131
204
|
SubagentStop: [{
|
|
132
205
|
hooks: [async (input) => {
|
|
133
|
-
onSubagentStop?.({ agentType: input.agent_type || null });
|
|
206
|
+
onSubagentStop?.({ agentType: input.agent_type || null, text: typeof input.last_assistant_message === "string" ? input.last_assistant_message : null });
|
|
134
207
|
return {};
|
|
135
208
|
}],
|
|
136
209
|
}],
|
|
@@ -14,7 +14,7 @@ The reviewers returned the following. Work from this list; do not re-derive it.
|
|
|
14
14
|
## Rules
|
|
15
15
|
|
|
16
16
|
- Follow CLAUDE.md conventions (if present). Do not modify files unrelated to the task.
|
|
17
|
-
- **Sam's audit is a blocking gate.** After Dennis implements, Sam must read every changed file and run his full checklist, citing file:line for every finding — "looks clean" without evidence is invalid.
|
|
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/prompts.mjs
CHANGED
|
@@ -136,7 +136,7 @@ function createTaskSection({ tracker, config, description }) {
|
|
|
136
136
|
export function renderPhasePrompt({
|
|
137
137
|
phase, taskId, taskLink, description, createTask, tracker, config = {}, project = {},
|
|
138
138
|
sessionUrl, cwd, sessionMemory = "", retryVerdict = null, evidence = null, openItems = [],
|
|
139
|
-
roster = null, profile = null,
|
|
139
|
+
roster = null, profile = null, handoffRetry = false,
|
|
140
140
|
}) {
|
|
141
141
|
const vars = {
|
|
142
142
|
TASK_ID: taskId,
|
|
@@ -180,6 +180,9 @@ export function renderPhasePrompt({
|
|
|
180
180
|
body += `\n\n## ENGINE VERIFICATION\n\nThe engine ran the project's own checks before this phase. Build on these observations; do not re-run the whole suite blind.\n\n${formatEvidenceForPrompt(evidence)}`;
|
|
181
181
|
}
|
|
182
182
|
if (openItems.length) body += `\n\n${renderOpenItems(openItems)}`;
|
|
183
|
+
if (handoffRetry) {
|
|
184
|
+
body += "\n\n## PREVIOUS RUN ENDED WITHOUT ITS HANDOFF\n\nThe last run of this phase did not produce the structured output the schema requires, so nothing was handed forward. Keep this run focused and shorter, and finish while turns remain so the structured summary is captured.";
|
|
185
|
+
}
|
|
183
186
|
if (sessionMemory) {
|
|
184
187
|
body += `\n\n## SESSION MEMORY (previous phases)\n\n${sessionMemory}`;
|
|
185
188
|
}
|
package/cli/engine/query.mjs
CHANGED
|
@@ -35,6 +35,7 @@ export function buildQueryOptions({
|
|
|
35
35
|
phase, cwd, env, model, agents, allowedTools, lead, state, config = {},
|
|
36
36
|
abortController, sandbox, onChild, onIsolation, hookCallbacks = {},
|
|
37
37
|
extraWritePaths = [],
|
|
38
|
+
verifyTimeoutSec,
|
|
38
39
|
claudePath = process.env.AGENTDESK_CLAUDE_PATH,
|
|
39
40
|
}) {
|
|
40
41
|
const options = {
|
|
@@ -50,7 +51,7 @@ export function buildQueryOptions({
|
|
|
50
51
|
abortController,
|
|
51
52
|
forwardSubagentText: true,
|
|
52
53
|
outputFormat: { type: "json_schema", schema: PHASE_OUTPUT_SCHEMAS[phase] },
|
|
53
|
-
hooks: hooksForPhase({ phase, state, ...hookCallbacks }),
|
|
54
|
+
hooks: hooksForPhase({ phase, state, verifyTimeoutSec, ...hookCallbacks }),
|
|
54
55
|
spawnClaudeCodeProcess: createSandboxedSpawn({ sandbox, onChild, onIsolation, extraWritePaths }),
|
|
55
56
|
};
|
|
56
57
|
if (model) options.model = model;
|
package/cli/engine/session.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { fileURLToPath } from "url";
|
|
|
17
17
|
import { createScratchHome } from "../session-sandbox.mjs";
|
|
18
18
|
import { resolveGitHubCreds, assertPushable, PreflightError } from "../session-preflight.mjs";
|
|
19
19
|
import {
|
|
20
|
-
MAX_REVIEW_RETRIES, MAX_PHASE_RUNS, phaseFailed, finalStatus, archiveStaleMemory,
|
|
20
|
+
MAX_REVIEW_RETRIES, MAX_HANDOFF_RETRIES, MAX_PHASE_RUNS, phaseFailed, finalStatus, archiveStaleMemory,
|
|
21
21
|
} from "../phase-loop.mjs";
|
|
22
22
|
import { buildChildEnv } from "./env.mjs";
|
|
23
23
|
import { loadDotEnv } from "../dotenv.mjs";
|
|
@@ -36,7 +36,7 @@ import { spawnSandboxedCommand } from "./spawn.mjs";
|
|
|
36
36
|
import { createLedger, openItemsFrom, noCommitItem } from "./handoff.mjs";
|
|
37
37
|
import { teamProfileFor, phasesFor } from "./team-profile.mjs";
|
|
38
38
|
import { buildQueryOptions, defaultRunQuery } from "./query.mjs";
|
|
39
|
-
import { armPublishGate,
|
|
39
|
+
import { armPublishGate, recordAudit, responseText, AUDITOR } from "./hooks.mjs";
|
|
40
40
|
import { prepareWorkspace } from "../worktrees.mjs";
|
|
41
41
|
import { detectProject } from "../detect.mjs";
|
|
42
42
|
import { preparePrivateGit } from "../worktree-git.mjs";
|
|
@@ -235,6 +235,7 @@ async function executeSession({
|
|
|
235
235
|
let handoff = false, aborted = false, reviewResolved = !!solo; // solo has no review gate
|
|
236
236
|
let reviewRetries = 0, phaseRuns = 0, lastVerdict = null, lastPhase = null;
|
|
237
237
|
let isolationLogged = false;
|
|
238
|
+
const handoffRetries = new Map(); // phase → runs that ended without a structured handoff
|
|
238
239
|
|
|
239
240
|
// --- evidence (engine-owned) ---------------------------------------------
|
|
240
241
|
// The engine runs the project's checks itself before every REVIEW and pins
|
|
@@ -248,16 +249,60 @@ async function executeSession({
|
|
|
248
249
|
extraWritePaths: workspaceStateDir ? [workspaceStateDir, workspaceRecord.tree || cwd] : [],
|
|
249
250
|
}),
|
|
250
251
|
});
|
|
251
|
-
const
|
|
252
|
+
const treeNow = () => gitState(cwd, workspaceGitEnv);
|
|
253
|
+
const headNow = () => treeNow().revision;
|
|
254
|
+
const describeTree = t => `${shortRev(t.revision)}${t.clean === false ? " + uncommitted changes" : ""}`;
|
|
255
|
+
state.treeNow = treeNow;
|
|
256
|
+
state.requireClean = requireClean;
|
|
252
257
|
let approvedRevision = null;
|
|
258
|
+
const verify = () => runChecks({ checks, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }),
|
|
259
|
+
gitEnv: workspaceGitEnv, runCheck: runOneCheck, timeoutMs: checkTimeoutMs(config), signal: abortController.signal, requireClean });
|
|
260
|
+
// Generous: every configured check may run to its own timeout inside the hook.
|
|
261
|
+
const verifyTimeoutSec = Math.ceil((checkTimeoutMs(config) * Math.max(1, checks.length)) / 1000) + 60;
|
|
262
|
+
|
|
263
|
+
// Publishing (git push / gh pr create) is verified on the spot: the engine
|
|
264
|
+
// runs the checks at the revision being published and refuses with the
|
|
265
|
+
// failing output when they do not pass. A pass is evidence for the exact
|
|
266
|
+
// tree it ran on — same commit and a clean tree, then and now — and only
|
|
267
|
+
// that is reused (by a later publish attempt and by the REVIEW that
|
|
268
|
+
// follows). Uncommitted edits leave HEAD unchanged, so HEAD alone would let
|
|
269
|
+
// stale evidence outlive the code it checked. A failure is never cached.
|
|
270
|
+
let verified = null; // { revision, clean, evidence, verdict } from the latest verification
|
|
271
|
+
const reusableFor = tree => !!(verified && verified.evidence.passed && verified.clean === true
|
|
272
|
+
&& tree.revision && tree.revision === verified.revision && tree.clean === true);
|
|
273
|
+
const verifyPublish = async () => {
|
|
274
|
+
if (reusableFor(treeNow())) return verified.verdict;
|
|
275
|
+
const evidence = await verify();
|
|
276
|
+
if (abortController.signal.aborted) return { ok: false, reason: "Session is being cancelled." };
|
|
277
|
+
// The tree is read again after the checks: evidence for a tree that no
|
|
278
|
+
// longer exists is not evidence.
|
|
279
|
+
const after = treeNow();
|
|
280
|
+
const moved = after.revision !== evidence.revision || after.clean !== evidence.clean;
|
|
281
|
+
let verdict;
|
|
282
|
+
if (moved) {
|
|
283
|
+
verdict = { ok: false, reason: `Cannot publish: the working tree changed while verification ran (${describeTree(evidence)} → ${describeTree(after)}). Commit, then publish again — the engine re-runs the checks.` };
|
|
284
|
+
} else if (!evidence.checked && evidence.clean !== false) verdict = { ok: true };
|
|
285
|
+
else {
|
|
286
|
+
reportEvidence(evidence, "EXECUTION");
|
|
287
|
+
appendMemory(renderEvidenceSection(evidence));
|
|
288
|
+
if (evidence.passed) verdict = { ok: true };
|
|
289
|
+
else {
|
|
290
|
+
const lines = evidenceFindings(evidence).map(f => `- ${f.title}\n ${String(f.detail || "").split("\n").slice(-8).join("\n ")}`);
|
|
291
|
+
verdict = { ok: false, reason: `Cannot publish: verification did not pass at ${shortRev(evidence.revision)}.\n${lines.join("\n")}\nFix it, commit, and publish again — the engine re-runs the checks for the new revision.` };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
verified = moved ? null : { revision: evidence.revision, clean: evidence.clean, evidence, verdict };
|
|
295
|
+
return verdict;
|
|
296
|
+
};
|
|
297
|
+
state.verifyPublish = verifyPublish;
|
|
253
298
|
|
|
254
299
|
// --- handoff ledger (engine-owned) ---------------------------------------
|
|
255
300
|
// One entry per phase run; the open-items list travels into the next prompt.
|
|
256
301
|
const ledger = createLedger(ledgerPath);
|
|
257
302
|
let openItems = [];
|
|
258
303
|
|
|
259
|
-
const reportEvidence = evidence => {
|
|
260
|
-
emit({ type: "session:evidence", phase:
|
|
304
|
+
const reportEvidence = (evidence, evidencePhase = "REVIEW") => {
|
|
305
|
+
emit({ type: "session:evidence", phase: evidencePhase, revision: evidence.revision, clean: evidence.clean,
|
|
261
306
|
passed: evidence.passed, checked: evidence.checked, results: evidence.results });
|
|
262
307
|
const at = evidence.revision ? ` at ${shortRev(evidence.revision)}` : "";
|
|
263
308
|
if (!evidence.checked) {
|
|
@@ -300,13 +345,18 @@ async function executeSession({
|
|
|
300
345
|
}
|
|
301
346
|
}
|
|
302
347
|
|
|
303
|
-
// An approval is void the moment the code moves past the reviewed revision
|
|
304
|
-
|
|
348
|
+
// An approval is void the moment the code moves past the reviewed revision
|
|
349
|
+
// — a new commit, or (in a session worktree) uncommitted changes on top.
|
|
350
|
+
const drifted = tree => tree.revision !== approvedRevision || (requireClean && tree.clean === false);
|
|
351
|
+
const invalidateApproval = tree => {
|
|
305
352
|
reviewResolved = false;
|
|
306
|
-
|
|
307
|
-
|
|
353
|
+
const what = tree.revision !== approvedRevision
|
|
354
|
+
? `Code changed after approval (${shortRev(approvedRevision)} → ${shortRev(tree.revision)})`
|
|
355
|
+
: `Uncommitted changes appeared after approval of ${shortRev(approvedRevision)}`;
|
|
356
|
+
emit({ type: "session:error", code: "REVIEW_STALE", message: `${what} — the approval no longer applies; ending for human review.` });
|
|
357
|
+
openItems = [...openItems, { source: "engine", title: "Approval invalidated", detail: `${what}.` }];
|
|
308
358
|
if (lastVerdict) {
|
|
309
|
-
lastVerdict = { ...lastVerdict, approved: false, approvalReason: "code changed after approval", headNow:
|
|
359
|
+
lastVerdict = { ...lastVerdict, approved: false, approvalReason: tree.revision !== approvedRevision ? "code changed after approval" : "uncommitted changes after approval", headNow: tree.revision };
|
|
310
360
|
try { writeFileSync(findingsPath, JSON.stringify(lastVerdict, null, 2)); } catch {}
|
|
311
361
|
}
|
|
312
362
|
};
|
|
@@ -328,7 +378,8 @@ async function executeSession({
|
|
|
328
378
|
const run = { index: phaseRuns, startedAt: Date.now(), revisionBefore: headNow() };
|
|
329
379
|
const finishRun = ({ output = null, evidence: runEvidence = null, status }) => {
|
|
330
380
|
const entry = { phase, run: run.index, startedAt: run.startedAt, durationMs: Date.now() - run.startedAt,
|
|
331
|
-
revisionBefore: run.revisionBefore, revisionAfter: headNow(), output, evidence: runEvidence, openItems: [...openItems], status
|
|
381
|
+
revisionBefore: run.revisionBefore, revisionAfter: headNow(), output, evidence: runEvidence, openItems: [...openItems], status,
|
|
382
|
+
...(run.audit ? { audit: run.audit } : {}) };
|
|
332
383
|
ledger.record(entry);
|
|
333
384
|
emit({ type: "session:handoff", ...entry });
|
|
334
385
|
};
|
|
@@ -341,11 +392,17 @@ async function executeSession({
|
|
|
341
392
|
// to EXECUTION as findings, through the same retry path.
|
|
342
393
|
let evidence = null;
|
|
343
394
|
if (phase === "REVIEW") {
|
|
344
|
-
|
|
345
|
-
|
|
395
|
+
const tree = treeNow();
|
|
396
|
+
const reusable = reusableFor(tree) && verified.evidence.checked;
|
|
397
|
+
evidence = reusable ? verified.evidence : await verify();
|
|
346
398
|
if (abortController.signal.aborted) { aborted = true; break; }
|
|
347
|
-
|
|
348
|
-
|
|
399
|
+
if (reusable) {
|
|
400
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Verification at ${shortRev(tree.revision)} already passed when the work was published (same commit, clean tree) — reusing it for review.` });
|
|
401
|
+
emit({ type: "session:evidence", phase: "REVIEW", revision: evidence.revision, clean: evidence.clean, passed: evidence.passed, checked: evidence.checked, results: evidence.results });
|
|
402
|
+
} else {
|
|
403
|
+
reportEvidence(evidence);
|
|
404
|
+
appendMemory(renderEvidenceSection(evidence));
|
|
405
|
+
}
|
|
349
406
|
if (!evidence.passed) {
|
|
350
407
|
settleReview({ outcome: "NEEDS_MORE_WORK", findings: evidenceFindings(evidence), deferred: [], unverifiedClaims: [], reason: null,
|
|
351
408
|
revision: evidence.revision, headNow: evidence.revision, evidence, approved: false, approvalReason: "engine checks did not pass", engineOnly: true });
|
|
@@ -354,8 +411,8 @@ async function executeSession({
|
|
|
354
411
|
}
|
|
355
412
|
}
|
|
356
413
|
if (phase === "SUMMARY" && reviewResolved && approvedRevision) {
|
|
357
|
-
const now =
|
|
358
|
-
if (now
|
|
414
|
+
const now = treeNow();
|
|
415
|
+
if (drifted(now)) invalidateApproval(now);
|
|
359
416
|
}
|
|
360
417
|
|
|
361
418
|
const { agents, allowedTools, lead } = solo
|
|
@@ -374,6 +431,7 @@ async function executeSession({
|
|
|
374
431
|
openItems: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? [] : openItems,
|
|
375
432
|
roster: Object.keys(agents).filter(name => name !== lead),
|
|
376
433
|
profile,
|
|
434
|
+
handoffRetry: (handoffRetries.get(phase) || 0) > 0,
|
|
377
435
|
});
|
|
378
436
|
if (workspaceRecord) {
|
|
379
437
|
prompt += `\n\n## SESSION WORKSPACE\nAll file reads, writes, shell commands and Git operations must use ${cwd}.\nThe session branch ${workspaceRecord.branch} is already checked out; use it instead of creating or switching branches. The starting branch is ${workspaceRecord.baseRef}. Keep this branch until the session ends. Runtime session memory lives at ${memoryPath}.\n`;
|
|
@@ -383,16 +441,38 @@ async function executeSession({
|
|
|
383
441
|
prompt += `For the final substantive tracker reply only, include the literal marker ${replyMarker(sessionId)} in the posted comment. Never mark startup/progress comments. Keep the successful provider response intact (no jq, redirects or pipelines). For GitHub use standalone gh issue comment ${taskId} --repo ${outcomeRepo || "OWNER/REPO"} --body with a literal body. For Jira use standalone curl with a literal JSON --data-raw payload and the task's comment endpoint. For Linear use standalone curl with a literal JSON --data-raw payload and a commentCreate mutation selecting success and comment { id url body issue { identifier } }. Do not post an extra reply just to record an outcome.\n`;
|
|
384
442
|
}
|
|
385
443
|
|
|
386
|
-
// Publishing is gated until Sam has
|
|
444
|
+
// Publishing is gated until Sam has approved, in this EXECUTION phase,
|
|
445
|
+
// the revision being published. His verdict is read from his report —
|
|
446
|
+
// the harness hands it over when he stops and again as the lead's tool
|
|
447
|
+
// result — and tied to the tree as the engine sees it at that moment.
|
|
387
448
|
if (phase === "EXECUTION") armPublishGate(state, lastVerdict?.outcome === "APPROVED" ? [] : (lastVerdict?.findings || []));
|
|
449
|
+
const noteAudit = text => {
|
|
450
|
+
if (phase !== "EXECUTION") return;
|
|
451
|
+
const previous = state.audit;
|
|
452
|
+
const audit = recordAudit(state, { phase, agentType: AUDITOR, text, tree: treeNow() });
|
|
453
|
+
if (!audit) return;
|
|
454
|
+
run.audit = audit;
|
|
455
|
+
const same = previous && ["verdict", "revision", "clean", "statedRevision"].every(k => previous[k] === audit[k]);
|
|
456
|
+
if (same) return; // the same report, seen twice
|
|
457
|
+
emit({ type: "session:audit", phase, ...audit });
|
|
458
|
+
const messages = {
|
|
459
|
+
APPROVED: `${AUDITOR}'s audit: APPROVED at ${shortRev(audit.revision)}${audit.clean === false ? " (tree has uncommitted changes)" : ""} — publishing is open for that revision.`,
|
|
460
|
+
REJECTED: `${AUDITOR}'s audit: REJECTED — publishing stays closed until the fixes are committed and ${AUDITOR} re-audits.`,
|
|
461
|
+
STALE: `${AUDITOR} approved ${shortRev(audit.statedRevision)} but the code is at ${shortRev(audit.revision)} — publishing stays closed until he audits the current revision.`,
|
|
462
|
+
MISSING: `${AUDITOR}'s report has no AUDIT line — publishing stays closed until he ends his audit with AUDIT: APPROVED or AUDIT: REJECTED.`,
|
|
463
|
+
};
|
|
464
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: messages[audit.verdict] });
|
|
465
|
+
};
|
|
388
466
|
|
|
389
467
|
const mapper = createEventMapper({ leadAgent: lead, onEvent: emit });
|
|
390
468
|
const options = buildQueryOptions({
|
|
391
469
|
phase, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }), model,
|
|
392
470
|
agents, allowedTools, lead, state, config, abortController, sandbox, onChild,
|
|
393
471
|
extraWritePaths: workspaceStateDir ? [workspaceStateDir, workspaceRecord.tree || cwd] : [],
|
|
472
|
+
verifyTimeoutSec,
|
|
394
473
|
hookCallbacks: {
|
|
395
474
|
onToolResult: result => {
|
|
475
|
+
if (result.tool === "Agent" && result.input?.subagent_type === AUDITOR) noteAudit(responseText(result.response));
|
|
396
476
|
const outcome = captureOutcome({ ...result, phase, sessionId, taskId, repo: outcomeRepo,
|
|
397
477
|
branch: workspaceRecord?.branch || (result.tool === "Bash" && /^gh\s+pr\s+create\b/.test(result.input?.command || "") ? currentBranch(cwd) : null), tracker, config });
|
|
398
478
|
if (outcome && !outcomeIds.has(outcome.id)) {
|
|
@@ -400,8 +480,8 @@ async function executeSession({
|
|
|
400
480
|
emit({ type: "session:outcome", project: project?.name || null, outcome });
|
|
401
481
|
}
|
|
402
482
|
},
|
|
403
|
-
onSubagentStop: ({ agentType }) => {
|
|
404
|
-
|
|
483
|
+
onSubagentStop: ({ agentType, text }) => {
|
|
484
|
+
if (agentType === AUDITOR && typeof text === "string") noteAudit(text);
|
|
405
485
|
if (agentType) emit({ type: "agent:message", agent: agentType, tag: "SAY", message: `${agentType} finished and reported back.` });
|
|
406
486
|
},
|
|
407
487
|
},
|
|
@@ -441,15 +521,31 @@ async function executeSession({
|
|
|
441
521
|
|
|
442
522
|
if (phase === "REVIEW") {
|
|
443
523
|
const verdict = verdictFromResult({ is_error: summary.isError, subtype: summary.subtype, structured_output: summary.structuredOutput });
|
|
444
|
-
const now =
|
|
445
|
-
const approval = evaluateApproval({ verdict, evidence, headNow: now });
|
|
524
|
+
const now = treeNow();
|
|
525
|
+
const approval = evaluateApproval({ verdict, evidence, headNow: now.revision, cleanNow: now.clean });
|
|
446
526
|
appendMemory(renderMemorySection("REVIEW", summary.structuredOutput));
|
|
447
|
-
settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now, evidence, approved: approval.approved, approvalReason: approval.reason });
|
|
527
|
+
settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now.revision, evidence, approved: approval.approved, approvalReason: approval.reason });
|
|
448
528
|
finishRun({ output: summary.structuredOutput ?? null, evidence, status: lastVerdict.approved ? "ok" : "not-approved" });
|
|
449
529
|
continue;
|
|
450
530
|
}
|
|
451
531
|
|
|
452
|
-
// Other phases: the structured output
|
|
532
|
+
// Other phases: the structured output IS the handoff. Without it the
|
|
533
|
+
// next phase would start from nothing — run the phase again once, then
|
|
534
|
+
// fail closed. Solo mode keeps its single-run behaviour.
|
|
535
|
+
if (!summary.structuredOutput && phase !== "SOLO") {
|
|
536
|
+
const attempt = (handoffRetries.get(phase) || 0) + 1;
|
|
537
|
+
handoffRetries.set(phase, attempt);
|
|
538
|
+
finishRun({ status: "missing-output" });
|
|
539
|
+
if (attempt <= MAX_HANDOFF_RETRIES) {
|
|
540
|
+
emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} ended without its structured handoff — running it again (retry ${attempt}/${MAX_HANDOFF_RETRIES}).` });
|
|
541
|
+
queue.unshift(phase);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
emit({ type: "session:error", code: "HANDOFF_INVALID", message: `${phase} ended without its structured handoff again — ending session for human review.` });
|
|
545
|
+
handoff = true;
|
|
546
|
+
writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
453
549
|
if (!summary.structuredOutput) {
|
|
454
550
|
emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
|
|
455
551
|
}
|
|
@@ -484,8 +580,8 @@ async function executeSession({
|
|
|
484
580
|
|
|
485
581
|
// Belt to SUMMARY's braces: nothing may have moved the approved revision.
|
|
486
582
|
if (!aborted && reviewResolved && approvedRevision) {
|
|
487
|
-
const now =
|
|
488
|
-
if (now
|
|
583
|
+
const now = treeNow();
|
|
584
|
+
if (drifted(now)) invalidateApproval(now);
|
|
489
585
|
}
|
|
490
586
|
|
|
491
587
|
const duration = seconds(startedAt);
|
package/cli/phase-loop.mjs
CHANGED
|
@@ -12,10 +12,17 @@ export const PHASES = ["INTAKE", "PLAN", "EXECUTION", "REVIEW", "SUMMARY"];
|
|
|
12
12
|
// One execution redo after a failed review, then the session ends unresolved.
|
|
13
13
|
export const MAX_REVIEW_RETRIES = 1;
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
export const
|
|
15
|
+
// A phase that ends without its structured handoff is run again once; a
|
|
16
|
+
// second miss ends the session for human review rather than letting the next
|
|
17
|
+
// phase start from nothing.
|
|
18
|
+
export const MAX_HANDOFF_RETRIES = 1;
|
|
19
|
+
|
|
20
|
+
// Absolute ceiling on phase runs. The review and handoff loops re-enqueue
|
|
21
|
+
// phases, so a bug in verdict or output parsing must not be able to spin
|
|
22
|
+
// forever burning tokens. With MAX_REVIEW_RETRIES=1 and MAX_HANDOFF_RETRIES=1
|
|
23
|
+
// a legitimate run tops out at 12 (5 + EXECUTION + REVIEW + one handoff
|
|
24
|
+
// retry for each of the five non-review runs).
|
|
25
|
+
export const MAX_PHASE_RUNS = 14;
|
|
19
26
|
|
|
20
27
|
// Classify the first line of `.agentdesk/review-verdict.md`.
|
|
21
28
|
//
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kendoo.agentdesk/agentdesk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.1",
|
|
4
4
|
"description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"server": "node server/index.mjs",
|
|
23
23
|
"build": "vite build",
|
|
24
24
|
"preview": "vite preview",
|
|
25
|
-
"test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs tests/projects-registry.test.mjs tests/phase-loop.test.mjs tests/proc.test.mjs tests/crypto.test.mjs tests/dotenv.test.mjs tests/update-check.test.mjs tests/setup-helpers.test.mjs tests/project-key.test.mjs tests/tracker-project.test.mjs tests/tracker-check.test.mjs tests/config.test.mjs tests/engine-env.test.mjs tests/engine-events.test.mjs tests/engine-verdict.test.mjs tests/engine-hooks.test.mjs tests/engine-agents.test.mjs tests/session-isolation.test.mjs tests/engine-session.test.mjs tests/engine-prompts.test.mjs tests/engine-schemas.test.mjs tests/engine-claude-auth.test.mjs tests/worktrees.test.mjs tests/workspaces-api.test.mjs tests/engine-outcome.test.mjs tests/session-outcomes.test.mjs tests/outcome-hydration.test.mjs tests/mobile-outcomes.test.mjs tests/session-queue.test.mjs tests/useFollowScroll.test.mjs tests/session-usage.test.mjs tests/task-lookup.test.mjs tests/engine-evidence.test.mjs tests/engine-handoff.test.mjs tests/engine-team-profile.test.mjs tests/project-settings.test.mjs",
|
|
25
|
+
"test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs tests/projects-registry.test.mjs tests/phase-loop.test.mjs tests/proc.test.mjs tests/crypto.test.mjs tests/dotenv.test.mjs tests/update-check.test.mjs tests/setup-helpers.test.mjs tests/project-key.test.mjs tests/tracker-project.test.mjs tests/tracker-check.test.mjs tests/config.test.mjs tests/engine-env.test.mjs tests/engine-events.test.mjs tests/engine-verdict.test.mjs tests/engine-hooks.test.mjs tests/engine-commands.test.mjs tests/engine-agents.test.mjs tests/session-isolation.test.mjs tests/engine-session.test.mjs tests/engine-prompts.test.mjs tests/engine-schemas.test.mjs tests/engine-claude-auth.test.mjs tests/worktrees.test.mjs tests/workspaces-api.test.mjs tests/engine-outcome.test.mjs tests/session-outcomes.test.mjs tests/outcome-hydration.test.mjs tests/mobile-outcomes.test.mjs tests/session-queue.test.mjs tests/useFollowScroll.test.mjs tests/session-usage.test.mjs tests/task-lookup.test.mjs tests/engine-evidence.test.mjs tests/engine-handoff.test.mjs tests/engine-team-profile.test.mjs tests/project-settings.test.mjs tests/delivery.test.mjs tests/feed-view.test.mjs",
|
|
26
26
|
"test:coverage": "node --test --experimental-test-coverage --test-coverage-include='cli/**' --test-coverage-include='server/**' --test-coverage-lines=60 --test-coverage-branches=62 tests/*.test.mjs",
|
|
27
27
|
"lint": "eslint .",
|
|
28
28
|
"lint:fix": "eslint . --fix",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Delivery record shared by server and dashboard: the engine's verification
|
|
2
|
+
// evidence, the open items carried between phases, and the handoff ledger.
|
|
3
|
+
// Events arrive from the daemon (untrusted shape); everything is validated
|
|
4
|
+
// and bounded here, once, before it is stored or rendered.
|
|
5
|
+
|
|
6
|
+
const PHASES = new Set(["INTAKE", "PLAN", "EXECUTION", "REVIEW", "SUMMARY", "SOLO"]);
|
|
7
|
+
const STATUSES = new Set(["ok", "not-approved", "checks-failed", "failed", "missing-output"]);
|
|
8
|
+
const SOURCES = new Set(["review", "engine", "deferred"]);
|
|
9
|
+
const MAX_RESULTS = 10, MAX_OUTPUT = 1000, MAX_ITEMS = 50, MAX_TEXT = 300, MAX_DETAIL = 1000, MAX_HANDOFFS = 20;
|
|
10
|
+
|
|
11
|
+
const str = (v, max) => (typeof v === "string" ? v.slice(0, max) : "");
|
|
12
|
+
const revision = v => (typeof v === "string" && /^[a-f0-9]{7,64}$/.test(v) ? v : null);
|
|
13
|
+
|
|
14
|
+
export function emptyDelivery() {
|
|
15
|
+
return { evidence: null, openItems: [], handoffs: [] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeEvidence(event) {
|
|
19
|
+
if (!event || typeof event !== "object" || !Array.isArray(event.results)) return null;
|
|
20
|
+
const results = event.results.slice(0, MAX_RESULTS).flatMap(r => {
|
|
21
|
+
if (!r || typeof r !== "object" || typeof r.command !== "string") return [];
|
|
22
|
+
return [{
|
|
23
|
+
name: str(r.name, 40), command: str(r.command, MAX_TEXT),
|
|
24
|
+
exitCode: Number.isInteger(r.exitCode) ? r.exitCode : null,
|
|
25
|
+
durationMs: Number.isFinite(r.durationMs) && r.durationMs >= 0 ? Math.round(r.durationMs) : 0,
|
|
26
|
+
timedOut: r.timedOut === true, output: str(r.output, MAX_OUTPUT).slice(-MAX_OUTPUT),
|
|
27
|
+
}];
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
revision: revision(event.revision), clean: typeof event.clean === "boolean" ? event.clean : null,
|
|
31
|
+
passed: event.passed === true, checked: event.checked === true, results,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeOpenItems(items) {
|
|
36
|
+
if (!Array.isArray(items)) return [];
|
|
37
|
+
return items.slice(0, MAX_ITEMS).flatMap(i => {
|
|
38
|
+
if (!i || typeof i !== "object" || !SOURCES.has(i.source) || typeof i.title !== "string" || !i.title) return [];
|
|
39
|
+
return [{ source: i.source, title: str(i.title, MAX_TEXT), detail: str(i.detail, MAX_DETAIL) }];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function normalizeHandoff(event) {
|
|
44
|
+
if (!event || typeof event !== "object" || !PHASES.has(event.phase) || !Number.isInteger(event.run) || event.run < 1) return null;
|
|
45
|
+
return {
|
|
46
|
+
phase: event.phase, run: event.run,
|
|
47
|
+
status: STATUSES.has(event.status) ? event.status : "ok",
|
|
48
|
+
durationMs: Number.isFinite(event.durationMs) && event.durationMs >= 0 ? Math.round(event.durationMs) : 0,
|
|
49
|
+
revisionBefore: revision(event.revisionBefore), revisionAfter: revision(event.revisionAfter),
|
|
50
|
+
openItems: normalizeOpenItems(event.openItems),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Applies one session:evidence or session:handoff event to a delivery record.
|
|
55
|
+
// Returns the same object when the event is not one of ours or is malformed.
|
|
56
|
+
export function mergeDelivery(delivery, event) {
|
|
57
|
+
const base = delivery && typeof delivery === "object" ? delivery : emptyDelivery();
|
|
58
|
+
if (event?.type === "session:evidence") {
|
|
59
|
+
const evidence = normalizeEvidence(event);
|
|
60
|
+
return evidence ? { ...base, evidence } : base;
|
|
61
|
+
}
|
|
62
|
+
if (event?.type === "session:handoff") {
|
|
63
|
+
const handoff = normalizeHandoff(event);
|
|
64
|
+
if (!handoff) return base;
|
|
65
|
+
const handoffs = [...(base.handoffs || []).filter(h => h.run !== handoff.run), handoff]
|
|
66
|
+
.sort((a, b) => a.run - b.run).slice(-MAX_HANDOFFS);
|
|
67
|
+
return { ...base, openItems: handoff.openItems, handoffs };
|
|
68
|
+
}
|
|
69
|
+
return base;
|
|
70
|
+
}
|