@kendoo.agentdesk/agentdesk 0.31.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,14 @@ 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.0] — 2026-09-21
12
+
13
+ ### Changed
14
+ - `[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.
15
+ - `[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.
16
+ - `[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.
17
+ - `[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.
18
+
11
19
  ## [0.31.1] — 2026-09-21
12
20
 
13
21
  ### Fixed
package/README.md CHANGED
@@ -265,7 +265,7 @@ Valid values: `"default"`, `"opus"`, `"sonnet"`, `"haiku"`. `"default"` resolves
265
265
 
266
266
  The `REVIEW` phase is a read-only completeness check (no code changes) — it verifies the implementation meets requirements, flags missed documentation updates or silently-deferred scope. If gaps are found, the orchestrator loops back to `EXECUTION` once before moving on. The `SUMMARY` phase writes the final tracker comments and session protocol.
267
267
 
268
- Before the reviewers are asked, the engine runs the project's own checks itself — `commands.test`, `commands.build` and `commands.lint` from project settings, or the `test`/`build`/`lint` scripts in `package.json` when those are unset — inside the session sandbox, at the current commit. A failing check goes straight back to `EXECUTION` with the real output as findings; the reviewers are only asked once the checks pass. An approval is pinned to that commit: if the code changes afterwards, the approval no longer counts and the session ends for human review instead of reporting success. In a session worktree, uncommitted changes block review too — an approval refers to a committed revision. `SUMMARY` cannot commit, move `HEAD`, or push; it writes messages only.
268
+ Before the reviewers are asked, the engine runs the project's own checks itself — `commands.test`, `commands.build` and `commands.lint` from project settings, or the `test`/`build`/`lint` scripts in `package.json` when those are unset — inside the session sandbox, at the current commit. A failing check goes straight back to `EXECUTION` with the real output as findings; the reviewers are only asked once the checks pass. An approval is pinned to that commit: if the code changes afterwards, the approval no longer counts and the session ends for human review instead of reporting success. In a session worktree, uncommitted changes block review too — an approval refers to a committed revision. Publishing is verified the same way, on the spot: when the team runs `git push` or `gh pr create`, the engine runs the checks at the revision being published and refuses with the failing output until a new commit passes (a passing run is reused by the review that follows). `REVIEW` and `SUMMARY` cannot commit, move `HEAD`, or push; they read and write messages only.
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
 
@@ -69,6 +69,9 @@ export function decidePreToolUse({ phase, input, state = {} }) {
69
69
  if (phase === "REVIEW" && MUTATING_TOOLS.has(tool)) {
70
70
  return { decision: "deny", reason: "REVIEW is read-only: report findings; fixes happen back in EXECUTION." };
71
71
  }
72
+ if (phase === "REVIEW" && tool === "Bash" && (isPublishCommand(input?.tool_input?.command) || isHistoryCommand(input?.tool_input?.command))) {
73
+ return { decision: "deny", reason: "REVIEW is read-only: it cannot commit, move HEAD, or publish — the verdict refers to the revision as it is." };
74
+ }
72
75
 
73
76
  // The approval refers to one revision. SUMMARY may read it and talk about
74
77
  // it; it may not edit, commit, move HEAD, or publish.
@@ -108,17 +111,28 @@ function denyOutput(reason) {
108
111
  }
109
112
 
110
113
  // Build the SDK `hooks` option for one phase.
111
- // state — shared session state (openFindings, ...)
114
+ // state — shared session state (openFindings, verifyPublish, ...)
112
115
  // onToolUse — ({ agentType, tool, input }) for the dashboard
113
116
  // onToolResult — ({ agentType, tool, response }) for the dashboard
114
117
  // onSubagentStop — ({ agentType }) when a subagent finishes
115
- export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagentStop } = {}) {
118
+ // verifyTimeoutSec hook timeout for PreToolUse; publishing may run the
119
+ // project's checks inside the hook (state.verifyPublish)
120
+ export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagentStop, verifyTimeoutSec } = {}) {
116
121
  return {
117
122
  PreToolUse: [{
123
+ ...(verifyTimeoutSec > 0 ? { timeout: verifyTimeoutSec } : {}),
118
124
  hooks: [async (input) => {
119
125
  onToolUse?.({ agentType: input.agent_type || null, tool: input.tool_name, input: input.tool_input });
120
126
  const d = decidePreToolUse({ phase, input, state });
121
- return d.decision === "deny" ? denyOutput(d.reason) : {};
127
+ if (d.decision === "deny") return denyOutput(d.reason);
128
+ // Publishing is the one action the policy cannot judge from state
129
+ // alone: the engine verifies the revision being published, on the
130
+ // spot. state.verifyPublish is set by the session loop for EXECUTION.
131
+ if (phase === "EXECUTION" && input.tool_name === "Bash" && isPublishCommand(input.tool_input?.command) && typeof state?.verifyPublish === "function") {
132
+ const verdict = await state.verifyPublish();
133
+ if (!verdict?.ok) return denyOutput(verdict?.reason || "Cannot publish: verification did not pass.");
134
+ }
135
+ return {};
122
136
  }],
123
137
  }],
124
138
  PostToolUse: [{
@@ -136,7 +136,7 @@ function createTaskSection({ tracker, config, description }) {
136
136
  export function renderPhasePrompt({
137
137
  phase, taskId, taskLink, description, createTask, tracker, config = {}, project = {},
138
138
  sessionUrl, cwd, sessionMemory = "", retryVerdict = null, evidence = null, openItems = [],
139
- roster = null, profile = null,
139
+ roster = null, profile = null, handoffRetry = false,
140
140
  }) {
141
141
  const vars = {
142
142
  TASK_ID: taskId,
@@ -180,6 +180,9 @@ export function renderPhasePrompt({
180
180
  body += `\n\n## ENGINE VERIFICATION\n\nThe engine ran the project's own checks before this phase. Build on these observations; do not re-run the whole suite blind.\n\n${formatEvidenceForPrompt(evidence)}`;
181
181
  }
182
182
  if (openItems.length) body += `\n\n${renderOpenItems(openItems)}`;
183
+ if (handoffRetry) {
184
+ body += "\n\n## PREVIOUS RUN ENDED WITHOUT ITS HANDOFF\n\nThe last run of this phase did not produce the structured output the schema requires, so nothing was handed forward. Keep this run focused and shorter, and finish while turns remain so the structured summary is captured.";
185
+ }
183
186
  if (sessionMemory) {
184
187
  body += `\n\n## SESSION MEMORY (previous phases)\n\n${sessionMemory}`;
185
188
  }
@@ -35,6 +35,7 @@ export function buildQueryOptions({
35
35
  phase, cwd, env, model, agents, allowedTools, lead, state, config = {},
36
36
  abortController, sandbox, onChild, onIsolation, hookCallbacks = {},
37
37
  extraWritePaths = [],
38
+ verifyTimeoutSec,
38
39
  claudePath = process.env.AGENTDESK_CLAUDE_PATH,
39
40
  }) {
40
41
  const options = {
@@ -50,7 +51,7 @@ export function buildQueryOptions({
50
51
  abortController,
51
52
  forwardSubagentText: true,
52
53
  outputFormat: { type: "json_schema", schema: PHASE_OUTPUT_SCHEMAS[phase] },
53
- hooks: hooksForPhase({ phase, state, ...hookCallbacks }),
54
+ hooks: hooksForPhase({ phase, state, verifyTimeoutSec, ...hookCallbacks }),
54
55
  spawnClaudeCodeProcess: createSandboxedSpawn({ sandbox, onChild, onIsolation, extraWritePaths }),
55
56
  };
56
57
  if (model) options.model = model;
@@ -17,7 +17,7 @@ import { fileURLToPath } from "url";
17
17
  import { createScratchHome } from "../session-sandbox.mjs";
18
18
  import { resolveGitHubCreds, assertPushable, PreflightError } from "../session-preflight.mjs";
19
19
  import {
20
- MAX_REVIEW_RETRIES, MAX_PHASE_RUNS, phaseFailed, finalStatus, archiveStaleMemory,
20
+ MAX_REVIEW_RETRIES, MAX_HANDOFF_RETRIES, MAX_PHASE_RUNS, phaseFailed, finalStatus, archiveStaleMemory,
21
21
  } from "../phase-loop.mjs";
22
22
  import { buildChildEnv } from "./env.mjs";
23
23
  import { loadDotEnv } from "../dotenv.mjs";
@@ -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
@@ -250,14 +251,45 @@ async function executeSession({
250
251
  });
251
252
  const headNow = () => gitState(cwd, workspaceGitEnv).revision;
252
253
  let approvedRevision = null;
254
+ const verify = () => runChecks({ checks, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }),
255
+ gitEnv: workspaceGitEnv, runCheck: runOneCheck, timeoutMs: checkTimeoutMs(config), signal: abortController.signal, requireClean });
256
+ // Generous: every configured check may run to its own timeout inside the hook.
257
+ const verifyTimeoutSec = Math.ceil((checkTimeoutMs(config) * Math.max(1, checks.length)) / 1000) + 60;
258
+
259
+ // Publishing (git push / gh pr create) is verified on the spot: the engine
260
+ // runs the checks at the revision being published and refuses with the
261
+ // failing output when they do not pass. A pass is cached per revision, so
262
+ // REVIEW reuses it at the same HEAD instead of running everything twice; a
263
+ // failure is never cached — the next attempt gets a fresh run.
264
+ let verified = null; // { revision, evidence, verdict } from the latest publish verification
265
+ const verifyPublish = async () => {
266
+ const head = headNow();
267
+ if (verified && head && verified.revision === head && verified.evidence.passed) return verified.verdict;
268
+ const evidence = await verify();
269
+ if (abortController.signal.aborted) return { ok: false, reason: "Session is being cancelled." };
270
+ let verdict;
271
+ if (!evidence.checked && evidence.clean !== false) verdict = { ok: true };
272
+ else {
273
+ reportEvidence(evidence, "EXECUTION");
274
+ appendMemory(renderEvidenceSection(evidence));
275
+ if (evidence.passed) verdict = { ok: true };
276
+ else {
277
+ const lines = evidenceFindings(evidence).map(f => `- ${f.title}\n ${String(f.detail || "").split("\n").slice(-8).join("\n ")}`);
278
+ verdict = { ok: false, reason: `Cannot publish: verification did not pass at ${shortRev(head)}.\n${lines.join("\n")}\nFix it, commit, and publish again — the engine re-runs the checks for the new revision.` };
279
+ }
280
+ }
281
+ verified = { revision: evidence.revision, evidence, verdict };
282
+ return verdict;
283
+ };
284
+ state.verifyPublish = verifyPublish;
253
285
 
254
286
  // --- handoff ledger (engine-owned) ---------------------------------------
255
287
  // One entry per phase run; the open-items list travels into the next prompt.
256
288
  const ledger = createLedger(ledgerPath);
257
289
  let openItems = [];
258
290
 
259
- const reportEvidence = evidence => {
260
- emit({ type: "session:evidence", phase: "REVIEW", revision: evidence.revision, clean: evidence.clean,
291
+ const reportEvidence = (evidence, evidencePhase = "REVIEW") => {
292
+ emit({ type: "session:evidence", phase: evidencePhase, revision: evidence.revision, clean: evidence.clean,
261
293
  passed: evidence.passed, checked: evidence.checked, results: evidence.results });
262
294
  const at = evidence.revision ? ` at ${shortRev(evidence.revision)}` : "";
263
295
  if (!evidence.checked) {
@@ -341,11 +373,17 @@ async function executeSession({
341
373
  // to EXECUTION as findings, through the same retry path.
342
374
  let evidence = null;
343
375
  if (phase === "REVIEW") {
344
- evidence = await runChecks({ checks, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }),
345
- gitEnv: workspaceGitEnv, runCheck: runOneCheck, timeoutMs: checkTimeoutMs(config), signal: abortController.signal, requireClean });
376
+ const head = headNow();
377
+ const reusable = verified && head && verified.revision === head && verified.evidence.passed && verified.evidence.checked;
378
+ evidence = reusable ? verified.evidence : await verify();
346
379
  if (abortController.signal.aborted) { aborted = true; break; }
347
- reportEvidence(evidence);
348
- appendMemory(renderEvidenceSection(evidence));
380
+ if (reusable) {
381
+ emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Verification at ${shortRev(head)} already passed when the work was published — reusing it for review.` });
382
+ emit({ type: "session:evidence", phase: "REVIEW", revision: evidence.revision, clean: evidence.clean, passed: evidence.passed, checked: evidence.checked, results: evidence.results });
383
+ } else {
384
+ reportEvidence(evidence);
385
+ appendMemory(renderEvidenceSection(evidence));
386
+ }
349
387
  if (!evidence.passed) {
350
388
  settleReview({ outcome: "NEEDS_MORE_WORK", findings: evidenceFindings(evidence), deferred: [], unverifiedClaims: [], reason: null,
351
389
  revision: evidence.revision, headNow: evidence.revision, evidence, approved: false, approvalReason: "engine checks did not pass", engineOnly: true });
@@ -374,6 +412,7 @@ async function executeSession({
374
412
  openItems: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? [] : openItems,
375
413
  roster: Object.keys(agents).filter(name => name !== lead),
376
414
  profile,
415
+ handoffRetry: (handoffRetries.get(phase) || 0) > 0,
377
416
  });
378
417
  if (workspaceRecord) {
379
418
  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`;
@@ -391,6 +430,7 @@ async function executeSession({
391
430
  phase, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }), model,
392
431
  agents, allowedTools, lead, state, config, abortController, sandbox, onChild,
393
432
  extraWritePaths: workspaceStateDir ? [workspaceStateDir, workspaceRecord.tree || cwd] : [],
433
+ verifyTimeoutSec,
394
434
  hookCallbacks: {
395
435
  onToolResult: result => {
396
436
  const outcome = captureOutcome({ ...result, phase, sessionId, taskId, repo: outcomeRepo,
@@ -449,7 +489,23 @@ async function executeSession({
449
489
  continue;
450
490
  }
451
491
 
452
- // Other phases: the structured output is the handoff.
492
+ // Other phases: the structured output IS the handoff. Without it the
493
+ // next phase would start from nothing — run the phase again once, then
494
+ // fail closed. Solo mode keeps its single-run behaviour.
495
+ if (!summary.structuredOutput && phase !== "SOLO") {
496
+ const attempt = (handoffRetries.get(phase) || 0) + 1;
497
+ handoffRetries.set(phase, attempt);
498
+ finishRun({ status: "missing-output" });
499
+ if (attempt <= MAX_HANDOFF_RETRIES) {
500
+ emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} ended without its structured handoff — running it again (retry ${attempt}/${MAX_HANDOFF_RETRIES}).` });
501
+ queue.unshift(phase);
502
+ continue;
503
+ }
504
+ emit({ type: "session:error", code: "HANDOFF_INVALID", message: `${phase} ended without its structured handoff again — ending session for human review.` });
505
+ handoff = true;
506
+ writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
507
+ break;
508
+ }
453
509
  if (!summary.structuredOutput) {
454
510
  emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
455
511
  }
@@ -12,10 +12,17 @@ export const PHASES = ["INTAKE", "PLAN", "EXECUTION", "REVIEW", "SUMMARY"];
12
12
  // One execution redo after a failed review, then the session ends unresolved.
13
13
  export const MAX_REVIEW_RETRIES = 1;
14
14
 
15
- // Absolute ceiling on phase runs. The review loop re-enqueues phases, so a bug
16
- // in verdict parsing must not be able to spin forever burning tokens. With
17
- // MAX_REVIEW_RETRIES=1 a legitimate run tops out at 7 (5 + EXECUTION + REVIEW).
18
- export const MAX_PHASE_RUNS = 10;
15
+ // A phase that ends without its structured handoff is run again once; a
16
+ // second miss ends the session for human review rather than letting the next
17
+ // phase start from nothing.
18
+ export const MAX_HANDOFF_RETRIES = 1;
19
+
20
+ // Absolute ceiling on phase runs. The review and handoff loops re-enqueue
21
+ // phases, so a bug in verdict or output parsing must not be able to spin
22
+ // forever burning tokens. With MAX_REVIEW_RETRIES=1 and MAX_HANDOFF_RETRIES=1
23
+ // a legitimate run tops out at 12 (5 + EXECUTION + REVIEW + one handoff
24
+ // retry for each of the five non-review runs).
25
+ export const MAX_PHASE_RUNS = 14;
19
26
 
20
27
  // Classify the first line of `.agentdesk/review-verdict.md`.
21
28
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
4
4
  "description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "server": "node server/index.mjs",
23
23
  "build": "vite build",
24
24
  "preview": "vite preview",
25
- "test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs tests/projects-registry.test.mjs tests/phase-loop.test.mjs tests/proc.test.mjs tests/crypto.test.mjs tests/dotenv.test.mjs tests/update-check.test.mjs tests/setup-helpers.test.mjs tests/project-key.test.mjs tests/tracker-project.test.mjs tests/tracker-check.test.mjs tests/config.test.mjs tests/engine-env.test.mjs tests/engine-events.test.mjs tests/engine-verdict.test.mjs tests/engine-hooks.test.mjs tests/engine-agents.test.mjs tests/session-isolation.test.mjs tests/engine-session.test.mjs tests/engine-prompts.test.mjs tests/engine-schemas.test.mjs tests/engine-claude-auth.test.mjs tests/worktrees.test.mjs tests/workspaces-api.test.mjs tests/engine-outcome.test.mjs tests/session-outcomes.test.mjs tests/outcome-hydration.test.mjs tests/mobile-outcomes.test.mjs tests/session-queue.test.mjs tests/useFollowScroll.test.mjs tests/session-usage.test.mjs tests/task-lookup.test.mjs 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-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
+ }