@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 +8 -0
- package/README.md +1 -1
- package/cli/engine/hooks.mjs +17 -3
- package/cli/engine/prompts.mjs +4 -1
- package/cli/engine/query.mjs +2 -1
- package/cli/engine/session.mjs +64 -8
- 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,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;
|
|
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
|
|
package/cli/engine/hooks.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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: [{
|
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";
|
|
@@ -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:
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
|
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
|
}
|
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.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
|
+
}
|