@kendoo.agentdesk/agentdesk 0.32.0 → 0.33.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,30 @@ 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
+ ## [Unreleased]
12
+
13
+ ### Changed
14
+ - `[Both]` Jane now leads delivery explicitly: she sets priorities, assigns an owner and required evidence for each step, resolves disagreements, follows up on incomplete work, and carries decisions into the next phase. Her reports distinguish verified outcomes from blockers and unconfirmed tracker writes; unmet requirements cannot be silently deferred or described as ready.
15
+
16
+ ### Fixed
17
+ - `[CLI]` Team and solo agents can now submit their structured handoffs. The required submission tool was missing from their allowed tool lists, so intake could finish its research and Jira startup comment but stop before implementation or a final tracker report. Handoff repair also has access to the submission tool without execution tools.
18
+
19
+ ## [0.33.0] — 2026-09-21
20
+
21
+ ### Added
22
+ - `[Both]` New dashboard sessions support pausing, saving task corrections, and resuming the same conversation and workspace after workers stop. Saved instructions and recovery status survive server restarts; the daemon retains the task checkpoint and checks access again before continuing.
23
+ - `[UI]` A recovery panel explains access failures, shows when intervention is needed, and offers instructions for reconnecting the model provider.
24
+
25
+ ### Changed
26
+ - `[CLI]` Missing phase handoffs get one repair attempt from saved findings with execution tools disabled. Failed recovery preserves the work for intervention. Recognized external writes are recorded before dispatch, and exact repeats are blocked during continuation.
27
+
28
+ ## [0.32.1] — 2026-09-21
29
+
30
+ ### Fixed
31
+ - `[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.
32
+ - `[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.
33
+ - `[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.
34
+
11
35
  ## [0.32.0] — 2026-09-21
12
36
 
13
37
  ### Changed
package/README.md CHANGED
@@ -46,7 +46,7 @@ AgentDesk spawns a team of AI agents inside Claude Code that collaborate on your
46
46
 
47
47
  | Agent | Role |
48
48
  |-------|------|
49
- | Jane | Product Lead — facilitates, clarifies requirements, coordinates the team |
49
+ | Jane | Team Lead — owns priorities and delivery, assigns work, challenges evidence, and resolves disagreements |
50
50
  | Dennis | Senior Developer — implements the solution |
51
51
  | Sam | Architecture Auditor — code structure, separation of concerns |
52
52
  | Bart | QA Engineer — edge cases, risks, acceptance criteria, screenshots |
@@ -265,7 +265,7 @@ Valid values: `"default"`, `"opus"`, `"sonnet"`, `"haiku"`. `"default"` resolves
265
265
 
266
266
  The `REVIEW` phase is a read-only completeness check (no code changes) — it verifies the implementation meets requirements, flags missed documentation updates or silently-deferred scope. If gaps are found, the orchestrator loops back to `EXECUTION` once before moving on. The `SUMMARY` phase writes the final tracker comments and session protocol.
267
267
 
268
- Before the reviewers are asked, the engine runs the project's own checks itself — `commands.test`, `commands.build` and `commands.lint` from project settings, or the `test`/`build`/`lint` scripts in `package.json` when those are unset — inside the session sandbox, at the current commit. A failing check goes straight back to `EXECUTION` with the real output as findings; the reviewers are only asked once the checks pass. An approval is pinned to that commit: if the code changes afterwards, the approval no longer counts and the session ends for human review instead of reporting success. In a session worktree, uncommitted changes block review too — an approval refers to a committed revision. Publishing is verified the same way, on the spot: when the team runs `git push` or `gh pr create`, the engine runs the checks at the revision being published and refuses with the failing output until a new commit passes (a passing run is reused by the review that follows). `REVIEW` and `SUMMARY` cannot commit, move `HEAD`, or push; they read and write messages only.
268
+ Before the reviewers are asked, the engine runs the project's own checks itself — `commands.test`, `commands.build` and `commands.lint` from project settings, or the `test`/`build`/`lint` scripts in `package.json` when those are unset — inside the session sandbox, at the current commit. A failing check goes straight back to `EXECUTION` with the real output as findings; the reviewers are only asked once the checks pass. An approval is pinned to that commit: if the code changes afterwards, the approval no longer counts and the session ends for human review instead of reporting success. In a session worktree, uncommitted changes block review too — an approval refers to a committed revision. Publishing is verified the same way, on the spot: when the team runs `git push` or `gh pr create` — however the command is spelled (`git -C . push`, `env git push`, on a later line, inside `sh -c`) — the engine runs the checks at the revision being published and refuses with the failing output until a new commit passes. A passing run is evidence for one tree only: the same commit with a clean working tree, then and now. It is reused by the review that follows only while that still holds; uncommitted changes force a fresh run. Publishing also needs Sam's explicit verdict: he ends his audit with `AUDIT: APPROVED` or `AUDIT: REJECTED`, the engine reads that line and ties an approval to the commit he read — a rejection, a report without the line, or a commit made after his approval keeps publishing closed until he audits again. `REVIEW` and `SUMMARY` cannot commit, move `HEAD`, or push; they read and write messages only.
269
269
 
270
270
  The team follows the task. `INTAKE` assesses the scope — `small` or `standard`, and which areas the change touches (`ui`, `copy`, `docs`, `api`, `data`). A small task skips `PLAN` (Dennis states the approach at the start of `EXECUTION`) and is reviewed by Bart and Vera; Sam's architecture audit still gates the PR inside `EXECUTION`. Luna, Mark and Nora join only when the task touches UI, user-facing copy or docs respectively. Set `"teamProfile": "small"` or `"standard"` in `.agentdesk.json` to force it (`"auto"`, the default, lets `INTAKE` decide).
271
271
 
@@ -288,12 +288,24 @@ When working on Jira or Linear tasks, agents automatically download and review a
288
288
 
289
289
  ### Handoff & Resume
290
290
 
291
- If a session hits Claude's rate or context limit, AgentDesk saves a resume snapshot (`.agentdesk-resume.md`) and marks the session as **Handoff** in the dashboard. When you run the same task again, agents pick up where the previous session left off skipping completed work and continuing from the last phase.
291
+ The phase lead submits each handoff through the SDK's `StructuredOutput` tool. It must be included in both the selected agent's tool list and the unattended tool permissions; chat text alone cannot advance the phase. To check this against the real SDK after changes to tool configuration, run `npm run test:sdk-handoff`. This opt-in diagnostic requires a Claude login and uses model tokens. It checks every phase, solo mode, and handoff repair with fixed fixtures and execution tools blocked.
292
+
293
+ Run `npm run test:sdk-leadership` to check Jane's decisions against supplied reports: a superseded scope deferral, a completion claim missing visual evidence, and a failed Jira comment. This opt-in behavioral check also requires a Claude login and uses model tokens; delegation and external actions are blocked. It checks decisions and named follow-up owners using the production prompts.
294
+
295
+ Sessions started from the dashboard with an updated daemon can be paused and resumed in the same conversation and workspace. Use **Pause / check workers**, or enter a correction and choose **Pause & save**. Instructions are saved immediately; **Verify & resume** becomes available after the previous workers have stopped and released the workspace. A quarantined workspace remains blocked until its workers exit.
296
+
297
+ Resume restores the original task, discovered tracker task ID, pending phase, saved findings, user corrections, token totals, and recorded external actions. Corrections trigger replanning against the existing work. Access is checked again before continuing; a failed login check preserves both the checkpoint and the pending correction. For a revoked model login, reconnect Claude on the daemon computer using the same account profile, then choose **Verify & resume**. Session controls require the session owner and a connected daemon that supports recovery; older sessions need a separate continuation.
298
+
299
+ A missing phase handoff gets one repair attempt using the preserved findings with execution tools disabled. If repair fails, or required tracker access is denied during intake or planning, work pauses for intervention. Recognized external write commands are fingerprinted before dispatch and exact repeats are blocked. An uncertain result must be reconciled with the provider before continuing. This is a conservative replay guard, not general deduplication of equivalent commands or arbitrary scripts.
300
+
301
+ The recovery checkpoint lives on the daemon computer. The dashboard retains instructions and recovery status across server restarts, but cannot resume without the local checkpoint and workspace. It never silently creates a new task when a required checkpoint is missing.
302
+
303
+ Terminal sessions also save a resume snapshot (`.agentdesk-resume.md`) when interrupted. To start a continuation from the terminal, use the command printed in that snapshot, including `--resume-worktree` when present:
292
304
 
293
305
  ```bash
294
306
  # Session hits limit → "HANDOFF" shown in terminal
295
- # Resume when ready:
296
- agentdesk team KEN-517
307
+ # Continue in the retained workspace:
308
+ agentdesk team KEN-517 --resume-worktree SESSION-ID
297
309
  ```
298
310
 
299
311
  ### Session protocol
package/cli/agents.mjs CHANGED
@@ -3,16 +3,16 @@
3
3
  export const BUILT_IN_AGENTS = {
4
4
  Jane: {
5
5
  badge: "●● JANE ●●",
6
- role: "Product Analyst / Team Lead",
7
- description: "leads the session, clarifies requirements, coordinates the team, dictates tracker comments (Dennis executes), decomposes large tasks into subtasks",
8
- groundRules: "Jane is strictly non-technical and NEVER calls tools. She MUST NEVER: call Read, Write, Edit, Bash, Grep, Glob, or any other tool; read or reference code; mention file names, paths, function names, variable names, class names, imports, code snippets, terminal commands, error messages, or stack traces; write or modify code; execute tracker API calls herself. Her language is product-only: user stories, acceptance criteria, scope, priorities, stakeholder impact. During INTAKE she evaluates task complexity and decomposes large features into subtasks (basic vs deferred). When she needs technical information she asks Dennis or Sam (\"Dennis, can you check...\"). When tracker comments or status transitions are needed, Jane DICTATES the exact text and Dennis runs the command. If you are about to call a tool while the current speaker is Jane, STOP and switch to Dennis instead.",
9
- planning: "Requirements: what we're building, acceptance criteria, scope. Flags UI tasks for Luna. (No code, no files, no tool calls.)",
6
+ role: "Team Lead",
7
+ description: "owns delivery against the user's requirements, sets priorities, makes decisions, assigns work, challenges unsupported reports, resolves disagreements, and decomposes large tasks into accountable steps",
8
+ groundRules: "Jane owns the outcome: she sets acceptance criteria, decomposes work, assigns an owner and expected evidence, and follows up until each requirement is verified or explicitly blocked. The latest user correction takes precedence over earlier plans and agent-proposed deferrals. She makes routine decisions within the authorized scope and asks the user only for a consequential choice or missing information the team cannot resolve. In team mode she delegates implementation and tracker operations; she does not edit code or run commands herself. She can discuss technical evidence, file references and tradeoffs when needed to judge a report. She never overrides a failed check or required review gate, invents evidence, or treats an agent's unsupported claim as completion. She keeps unrelated improvements out of scope and reports unresolved blockers honestly.",
9
+ planning: "Set the delivery target and acceptance criteria; choose the approach from the team's evidence; resolve tradeoffs; assign owners, dependencies and verification for each step.",
10
10
  execution: {
11
- step: "Jane wraps up (dictates, Dennis executes)",
11
+ step: "Jane leads delivery and closes the loop",
12
12
  tasks: [
13
- "Jane dictates the final summary text (product outcome, what was delivered, any scope decisions).",
14
- "Dennis transitions the task to 'In Review' and posts the final summary comment on the tracker.",
15
- "Jane verifies the PR description reflects the product outcome (reads Dennis's report, does not open files).",
13
+ "Track each acceptance criterion, its owner, and the evidence still owed; send incomplete work back with a specific next action.",
14
+ "Resolve disagreements using requirements, project conventions and observations; preserve all required audit and verification gates.",
15
+ "Dictate an accurate final tracker summary and verify the posting receipt; distinguish delivered, unverified, blocked and user-approved deferred work.",
16
16
  ],
17
17
  order: 99,
18
18
  },
package/cli/daemon.mjs CHANGED
@@ -17,6 +17,7 @@ import { fileURLToPath } from "url";
17
17
  import { dirname } from "path";
18
18
  import { repositoryInfo, listWorkspaces, findWorkspacesByTask, inspectWorkspace, cleanupWorkspace, recoverWorkspaceLocks } from "./worktrees.mjs";
19
19
  import { createSessionQueue, sessionLimit, confirmWithAbort } from "./session-queue.mjs";
20
+ import { classifyFailure } from "../shared/recovery.mjs";
20
21
 
21
22
  const __dirname_daemon = dirname(fileURLToPath(import.meta.url));
22
23
  const DAEMON_VERSION = JSON.parse(readFileSync(join(__dirname_daemon, "../package.json"), "utf-8")).version;
@@ -280,17 +281,20 @@ export async function runDaemon() {
280
281
  let confirmationQueue = Promise.resolve();
281
282
  const maxSessions = sessionLimit();
282
283
  const sessionQueue = createSessionQueue({ limit: maxSessions, run: handleStartSession,
283
- onQueued: job => sendBuffered(job.sessionId, { type: "session:update", status: "queued" }),
284
- onError: job => send({ type: "daemon:error", sessionId: job.sessionId, error: "Unable to start queued session" }),
284
+ onQueued: job => sendBuffered(job.sessionId, { type: "session:update", status: "queued", runId: job.runId }),
285
+ onSettled: job => {
286
+ if (job.recoveryCompletion) sendBuffered(job.sessionId, { type: "session:recovery", runId: job.runId, recovery: job.recoveryCompletion });
287
+ },
288
+ onError: job => send({ type: "daemon:error", sessionId: job.sessionId, runId: job.runId, error: "Unable to start queued session" }),
285
289
  });
286
290
  function enqueueSession(job) {
287
291
  const project = projects.find(p => p.id === job.projectId);
288
- if (!project) { send({ type: "daemon:error", sessionId: job.sessionId, error: "Unknown project" }); return; }
292
+ if (!project) { send({ type: "daemon:error", sessionId: job.sessionId, runId: job.runId, error: "Unknown project" }); return; }
289
293
  try {
290
294
  let key = job.workspace?.resumeId ? `workspace:${job.workspace.resumeId}` : null;
291
295
  if (job.workspace?.enabled === false) key = `repo:${repositoryInfo(project.path).repo}`;
292
296
  sessionQueue.enqueue({ ...job, key });
293
- } catch (error) { send({ type: "daemon:error", sessionId: job.sessionId, error: error.message }); }
297
+ } catch (error) { send({ type: "daemon:error", sessionId: job.sessionId, runId: job.runId, error: error.message }); }
294
298
  }
295
299
 
296
300
  function send(data) {
@@ -332,6 +336,7 @@ export async function runDaemon() {
332
336
  type: "daemon:connect",
333
337
  version: DAEMON_VERSION,
334
338
  worktrees: true,
339
+ recovery: true,
335
340
  maxSessions,
336
341
  projects: projects.map(p => ({ id: p.id, name: p.name })),
337
342
  });
@@ -366,6 +371,13 @@ export async function runDaemon() {
366
371
  if (msg.type === "daemon:cancel-session") {
367
372
  handleCancelSession(msg);
368
373
  }
374
+ if (msg.type === "daemon:pause-session") {
375
+ try { handlePauseSession(msg); }
376
+ catch {
377
+ sendBuffered(msg.sessionId, { type: "session:recovery", runId: msg.runId,
378
+ recovery: { state: "paused", kind: "restart", ready: false, message: "Could not verify workspace ownership. Check the daemon and try checking workers again." } });
379
+ }
380
+ }
369
381
  if (msg.type === "daemon:workspace-request") handleWorkspaceRequest(msg);
370
382
  });
371
383
 
@@ -453,12 +465,13 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
453
465
  }
454
466
  }
455
467
 
456
- async function handleStartSession({ sessionId, projectId, taskId: remoteTaskId, prompt, screenshots: screenshotsOverride, workspace }) {
468
+ async function handleStartSession(job) {
469
+ const { sessionId, projectId, taskId: remoteTaskId, prompt, screenshots: screenshotsOverride, workspace, runId, resumeSession, instructions = [] } = job;
457
470
  // Validate project against local allowlist
458
471
  const project = projects.find(p => p.id === projectId);
459
472
  if (!project) {
460
473
  console.log(` ${red}Rejected:${reset} unknown project ${dim}${projectId}${reset}`);
461
- send({ type: "daemon:error", sessionId, error: `Project "${projectId}" not registered with this daemon` });
474
+ send({ type: "daemon:error", sessionId, runId, error: `Project "${projectId}" not registered with this daemon` });
462
475
  return;
463
476
  }
464
477
 
@@ -471,6 +484,7 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
471
484
  if (activeSessions.has(sessionId)) return;
472
485
  const activeSession = {
473
486
  sessionId, projectId,
487
+ runId,
474
488
  child: null,
475
489
  // Cancelling must stop the whole phase pipeline, not just the child that
476
490
  // happens to be running: killing one phase's child would otherwise let
@@ -531,6 +545,8 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
531
545
  sessionId,
532
546
  cwd: project.path,
533
547
  workspace,
548
+ resumeSession, instructions,
549
+ onWorkspace: record => { activeSession.workspaceId = record?.id; },
534
550
  apiKey,
535
551
  serverUrl: agentdeskServer,
536
552
  abortSignal: sessionAbort.signal,
@@ -564,6 +580,10 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
564
580
  }
565
581
 
566
582
  return (event) => {
583
+ event = { ...event, runId };
584
+ if (event.type === "session:start") activeSession.startedExecution = true;
585
+ if (event.type === "session:recovery") activeSession.recovery = event.recovery;
586
+ if (event.type === "session:workspace" && event.quarantined) activeSession.quarantined = true;
567
587
  // Confirmed accomplishments may arrive during cancellation teardown.
568
588
  // They do not revive the session or emit a second terminal event.
569
589
  if (["session:outcome", "session:workspace", "session:usage"].includes(event.type)) { sendBuffered(sessionId, event); return; }
@@ -594,6 +614,7 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
594
614
  };
595
615
  })(),
596
616
  });
617
+ activeSession.quarantined ||= result.quarantined;
597
618
 
598
619
  const outcome = result.status || (result.handoff ? "handoff" : "complete");
599
620
  const clean = outcome === "complete";
@@ -616,10 +637,42 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
616
637
  } catch (err) {
617
638
  console.log(` ${red}Failed to start session:${reset} ${err.message}`);
618
639
  if (activeSession.cancelled) return;
640
+ activeSession.recovery = classifyFailure(err.message);
619
641
  // Send generic error to server — don't leak internal details (paths, config, etc.)
620
- send({ type: "daemon:error", sessionId, error: "Failed to start session on daemon" });
642
+ send({ type: "daemon:error", sessionId, runId, error: "Failed to start session on daemon" });
621
643
  } finally {
622
644
  if (activeSessions.get(sessionId) === activeSession) activeSessions.delete(sessionId);
645
+ if (activeSession.paused || activeSession.recovery?.state !== "running" && activeSession.recovery) {
646
+ job.recoveryCompletion = activeSession.paused
647
+ ? { state: "paused", kind: activeSession.startedExecution || resumeSession ? "user" : "queued", message: "Work preserved. Your saved instructions will be applied when you resume." }
648
+ : activeSession.recovery;
649
+ job.recoveryCompletion = { ...job.recoveryCompletion, ready: !activeSession.quarantined };
650
+ if (activeSession.quarantined) job.recoveryCompletion.message = "Waiting for previous workers to exit. The workspace is quarantined; it cannot be resumed yet.";
651
+ }
652
+ }
653
+ }
654
+
655
+ function handlePauseSession({ sessionId, runId, workspaceId }) {
656
+ const job = sessionQueue.get(sessionId);
657
+ if (job && job.runId !== runId) return;
658
+ const session = activeSessions.get(sessionId);
659
+ if (session && session.runId !== runId) return;
660
+ if (session) {
661
+ session.paused = true;
662
+ session.cancelled = true;
663
+ stopSessionWork(session);
664
+ } else if (sessionQueue.cancel(sessionId)) {
665
+ sendBuffered(sessionId, { type: "session:recovery", runId, recovery: { state: "paused", kind: job?.resumeSession ? "user" : "queued", ready: true,
666
+ message: "Paused before starting. Resume to begin with your saved instructions." } });
667
+ } else {
668
+ // After a daemon restart, no local worker owns the session. Workspace
669
+ // acquisition on resume still checks surviving child PIDs and leases.
670
+ recoverWorkspaceLocks();
671
+ const record = workspaceId ? listWorkspaces({ projectPaths: projects.map(p => p.path) }).find(r => r.id === workspaceId) : null;
672
+ const busy = sessionQueue.activeIds.includes(sessionId) || (record ? inspectWorkspace(record).active : !!workspaceId);
673
+ if (record && !busy) sendBuffered(sessionId, { type: "session:workspace", runId, quarantined: false });
674
+ sendBuffered(sessionId, { type: "session:recovery", runId, recovery: { state: "paused", kind: "restart", ready: !busy,
675
+ message: busy ? "The workspace is still busy or unavailable. Check workers again after they exit." : "No worker is running on this daemon. Resume will check the saved workspace lease and checkpoint." } });
623
676
  }
624
677
  }
625
678
 
@@ -633,8 +686,9 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
633
686
  }
634
687
 
635
688
  function handleCancelSession({ sessionId }) {
689
+ const job = sessionQueue.get(sessionId);
636
690
  if (sessionQueue.cancel(sessionId)) {
637
- sendBuffered(sessionId, { type: "session:end", status: "stopped", duration: "0s" });
691
+ sendBuffered(sessionId, { type: "session:end", runId: job?.runId, status: "stopped", duration: "0s" });
638
692
  return;
639
693
  }
640
694
  const activeSession = activeSessions.get(sessionId);
@@ -649,7 +703,7 @@ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
649
703
 
650
704
  // The `cancelled` flag also suppresses the orchestrator's own
651
705
  // session:end, so this stays the single terminal event for the session.
652
- sendBuffered(sessionId, { type: "session:end", duration, steps: 0, inputTokens: 0, outputTokens: 0, status: "stopped" });
706
+ sendBuffered(sessionId, { type: "session:end", runId: activeSession.runId, duration, steps: 0, inputTokens: 0, outputTokens: 0, status: "stopped" });
653
707
  }
654
708
  }
655
709
 
@@ -3,8 +3,8 @@
3
3
  // The team (cli/agents.mjs BUILT_IN_AGENTS + project customisations via
4
4
  // resolveTeam) becomes, per phase:
5
5
  // - one lead definition — Jane — that the main thread runs *as* (Options.agent),
6
- // with the Agent tool and nothing else. She cannot touch code because the
7
- // tools do not exist in her session.
6
+ // with Agent for delegation and StructuredOutput for the phase handoff.
7
+ // She cannot touch code because those tools do not exist in her session.
8
8
  // - one AgentDefinition per engineering role, with the tool list that role
9
9
  // needs in that phase and nothing more.
10
10
  //
@@ -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.`,
@@ -100,16 +104,23 @@ export function leadSystemPrompt(roster, phase) {
100
104
  const lines = Object.entries(roster).map(([n, d]) => rosterLine(n, d));
101
105
  return [
102
106
  `You are ${LEAD}, ${jane.role}. ${jane.description}.`,
107
+ `Leadership responsibilities: ${jane.groundRules}`,
103
108
  "",
104
- "You coordinate; you do not build. You have exactly one tool Agent and you use it to delegate to the team. You never read files, run commands, or edit anything yourself; when you need technical information, ask an agent for it.",
109
+ "You coordinate; you do not build. Use Agent to delegate to the team and StructuredOutput to submit the phase handoff when the work is done. You never read files, run commands, or edit anything yourself; when you need technical information, ask an agent for it.",
110
+ "Finish by calling StructuredOutput with the required schema. A closing chat message alone does not submit the handoff. Do not repeat the JSON in chat.",
105
111
  "Always delegate with run_in_background: false. A backgrounded agent's own tool calls cannot be approved, so it will be unable to do anything.",
106
- "Your language is product-only: user stories, acceptance criteria, scope, priorities, stakeholder impact.",
112
+ "Explain decisions in plain language tied to the user's outcome. Use technical details when they help assign work, challenge a claim, or explain a tradeoff.",
107
113
  "",
108
114
  `Team available in phase ${phase}:`,
109
115
  ...lines,
110
116
  "",
111
- "When you delegate, give the agent everything it needs in the prompt: the task, the relevant decisions so far, and exactly what to report back. Agents start with no memory of this conversation.",
112
- "When an agent reports, relay the substance in one to three lines and move on. Never claim a result you did not receive from an agent.",
117
+ "Start each phase by stating its objective, the outstanding acceptance criteria, and who owns the next action. On a resumed task, work from the existing implementation and latest user feedback; reopen only what that feedback or current evidence requires.",
118
+ "When you delegate, name one owner and provide the objective, relevant context and user corrections, scope boundaries, dependencies, and the exact evidence needed to accept the result. Agents start with no memory of this conversation. Use only agents available in this phase.",
119
+ "When an agent reports, assess it against the assignment. Accept with evidence, return a specific gap to its owner, or identify a real blocker. Follow each assessment with your decision and the next action; do not merely relay the report. Never claim a result you did not receive or treat a proposed action as an executed one.",
120
+ "Resolve disagreements by comparing observations with the user's requirements and project conventions. Request a focused check when facts conflict, then make the call and explain why. An unsupported rejection needs evidence; a valid blocking finding must be fixed and rechecked, never voted away. Do not re-run broad discovery when a targeted check will settle the issue.",
121
+ "Keep a concise account of requirements, owners, accepted evidence and unresolved blockers in the phase's existing handoff fields. Carry your decisions and next actions forward. A prior agent's deferral does not authorize skipping a current user requirement; do not silently expand scope either.",
122
+ "If blocked, assign a bounded diagnostic or recovery step to an available agent. If it requires user access or a consequential scope decision, state what is blocked, what was tried, and the smallest input needed; never weaken acceptance criteria to call the task done. Continue independent authorized work only where the phase's gates permit it.",
123
+ "Report meaningful decisions, resolved blockers, and the next owner/action concisely. Avoid repeating long agent reports, narrating every delegation, or requesting permission for routine work already authorized.",
113
124
  "Announce a new task id, if you create one, on its own line as `TASK_ID: <id>`, and a short session title as `SESSION_TITLE: <title>`.",
114
125
  ].join("\n");
115
126
  }
@@ -120,10 +131,10 @@ export function leadSystemPrompt(roster, phase) {
120
131
  export function soloDefinition(agent) {
121
132
  const def = {
122
133
  description: `${agent.role}: ${agent.description}`,
123
- prompt: `You are ${agent.name}, ${agent.role}. You work alone on the task given to you, end to end.`,
124
- tools: [...FULL],
134
+ prompt: `You are ${agent.name}, ${agent.role}. You work alone on the task given to you, end to end. Finish by calling StructuredOutput with the required schema; a chat message alone does not submit the handoff.`,
135
+ tools: [...FULL, "StructuredOutput"],
125
136
  };
126
- return { agents: { [agent.name]: def }, allowedTools: [...FULL], lead: agent.name };
137
+ return { agents: { [agent.name]: def }, allowedTools: [...def.tools], lead: agent.name };
127
138
  }
128
139
 
129
140
  // team — resolveTeam(config) output (array of { name, role, description, ... })
@@ -164,7 +175,9 @@ export function agentsForPhase({ phase, team, phaseModels = {}, profile = null }
164
175
  sdkAgents[LEAD] = {
165
176
  description: `${BUILT_IN_AGENTS.Jane.role}: ${BUILT_IN_AGENTS.Jane.description}`,
166
177
  prompt: leadSystemPrompt(agents, phase),
167
- tools: ["Agent"],
178
+ // The SDK filters even its schema tool against the selected agent's tools.
179
+ // Without this, outputFormat yields success with no structured_output.
180
+ tools: ["Agent", "StructuredOutput"],
168
181
  };
169
182
 
170
183
  const allowed = new Set(["Agent"]);
@@ -0,0 +1,162 @@
1
+ // Shell command classification for the publish and history gates.
2
+ //
3
+ // A regex over the raw text is not enforcement: `git -C . push`, `env git
4
+ // push`, a newline before `git push`, `sh -c 'git push'`, `/usr/bin/git
5
+ // push` all publish. This tokenizes the command the way a POSIX shell
6
+ // would, splits it into simple commands, unwraps environment prefixes and
7
+ // wrapper programs, and classifies each simple command by its effective
8
+ // program and subcommand. Constructs it does not model (command
9
+ // substitution, backticks, heredocs, process substitution) are marked
10
+ // unparseable and fail closed whenever the text hints at publishing.
11
+
12
+ const OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")", "{", "}"];
13
+ const WRAPPERS_NO_ARGS = new Set(["command", "exec", "nohup", "time", "builtin", "caffeinate", "chronic", "nocorrect", "noglob"]);
14
+ const PUBLISH_GIT = new Set(["push", "send-pack"]);
15
+ const HISTORY_GIT = new Set(["commit", "merge", "rebase", "reset", "checkout", "switch", "cherry-pick", "revert", "am", "apply", "stash", "restore", "clean"]);
16
+ const PUBLISH_GH_PR = new Set(["create", "merge", "ready", "reopen", "edit"]);
17
+ const GIT_GLOBAL_WITH_ARG = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--super-prefix", "--config-env", "--list-cmds", "--attr-source"]);
18
+ const PUBLISH_HINT = /\b(push|send-pack|pr\s+(create|merge|ready|reopen|edit)|release\s+create|repo\s+sync|gh\s+api)\b/i;
19
+
20
+ // POSIX-ish tokenizer: words with quote/escape handling, operators, and a
21
+ // flag for constructs we do not model.
22
+ export function tokenize(command) {
23
+ const text = String(command || "");
24
+ const tokens = [];
25
+ let word = "", inWord = false, quote = null, unparseable = false;
26
+ const push = () => { if (inWord) { tokens.push({ word }); word = ""; inWord = false; } };
27
+ for (let i = 0; i < text.length; i++) {
28
+ const ch = text[i];
29
+ if (quote === "'") { if (ch === "'") quote = null; else word += ch; continue; }
30
+ if (quote === '"') {
31
+ if (ch === '"') { quote = null; continue; }
32
+ if (ch === "\\" && i + 1 < text.length && /["\\$`\n]/.test(text[i + 1])) { word += text[++i]; continue; }
33
+ if (ch === "`" || (ch === "$" && text[i + 1] === "(")) unparseable = true;
34
+ word += ch; continue;
35
+ }
36
+ if (ch === "\\") { if (i + 1 < text.length) { word += text[++i]; inWord = true; } continue; }
37
+ if (ch === "'" || ch === '"') { quote = ch; inWord = true; continue; }
38
+ if (ch === "`" || (ch === "$" && text[i + 1] === "(") || (ch === "<" && text[i + 1] === "(") || (ch === ">" && text[i + 1] === "(")) { unparseable = true; word += ch; inWord = true; continue; }
39
+ if (ch === "<" && text[i + 1] === "<") { unparseable = true; word += ch; inWord = true; continue; }
40
+ if (/\s/.test(ch) && ch !== "\n") { push(); continue; }
41
+ const op = OPERATORS.find(o => text.startsWith(o, i));
42
+ if (op) { push(); tokens.push({ op }); i += op.length - 1; continue; }
43
+ word += ch; inWord = true;
44
+ }
45
+ if (quote) unparseable = true;
46
+ push();
47
+ return { tokens, unparseable };
48
+ }
49
+
50
+ // Splits tokens into simple commands (lists of words); operators and
51
+ // grouping punctuation only separate.
52
+ export function splitCommands(tokens) {
53
+ const commands = [];
54
+ let current = [];
55
+ for (const t of tokens) {
56
+ if (t.op !== undefined) { if (current.length) commands.push(current); current = []; }
57
+ else current.push(t.word);
58
+ }
59
+ if (current.length) commands.push(current);
60
+ return commands;
61
+ }
62
+
63
+ const basename = word => String(word).split("/").pop();
64
+ const isAssignment = word => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
65
+
66
+ // Returns the effective simple command with prefixes and wrappers removed,
67
+ // or a list of nested commands when a wrapper carries a shell string.
68
+ export function unwrap(words, depth = 0) {
69
+ const rest = [...words];
70
+ for (let guard = 0; guard < 16 && rest.length; guard++) {
71
+ while (rest.length && isAssignment(rest[0])) rest.shift();
72
+ if (!rest.length) return { words: [] };
73
+ const prog = basename(rest[0]);
74
+ if (WRAPPERS_NO_ARGS.has(prog)) { rest.shift(); continue; }
75
+ if (prog === "env") {
76
+ rest.shift();
77
+ while (rest.length) {
78
+ if (rest[0] === "-S" || rest[0] === "--split-string") return { unparseable: true, words: rest };
79
+ if (rest[0] === "-u" || rest[0] === "-C" || rest[0] === "--unset" || rest[0] === "--chdir") { rest.splice(0, 2); continue; }
80
+ if (rest[0].startsWith("-") && rest[0] !== "--") { rest.shift(); continue; }
81
+ if (rest[0] === "--") { rest.shift(); break; }
82
+ if (isAssignment(rest[0])) { rest.shift(); continue; }
83
+ break;
84
+ }
85
+ continue;
86
+ }
87
+ if (prog === "sudo" || prog === "doas") {
88
+ rest.shift();
89
+ while (rest.length && rest[0].startsWith("-")) { const flag = rest.shift(); if (["-u", "-g", "-p", "-C", "-D", "-h", "-r", "-t", "-U", "-T"].includes(flag)) rest.shift(); }
90
+ continue;
91
+ }
92
+ if (prog === "timeout") { rest.shift(); while (rest.length && rest[0].startsWith("-")) { const f = rest.shift(); if (["-k", "-s", "--kill-after", "--signal"].includes(f)) rest.shift(); } rest.shift(); continue; }
93
+ if (prog === "nice" || prog === "ionice" || prog === "stdbuf") { rest.shift(); while (rest.length && rest[0].startsWith("-")) { const f = rest.shift(); if (/^-[nc]$|^-[oei]$/.test(f)) rest.shift(); } continue; }
94
+ if (prog === "xargs") { rest.shift(); while (rest.length && rest[0].startsWith("-")) { const f = rest.shift(); if (/^-[nILPsdEa]$|^--max-args$|^--replace$|^--max-procs$|^--delimiter$|^--arg-file$/.test(f)) rest.shift(); } continue; }
95
+ if (["sh", "bash", "zsh", "dash", "ksh", "fish"].includes(prog)) {
96
+ // `-c` may be combined with other single-letter flags (`bash -lc '…'`).
97
+ for (let i = 1; i < rest.length; i++) {
98
+ const w = rest[i];
99
+ if (/^-[A-Za-z]*c[A-Za-z]*$/.test(w) || w === "--command") { if (rest[i + 1] !== undefined) return { nested: classify(rest[i + 1], depth + 1) }; break; }
100
+ if (w === "-o" || w === "+o") { i++; continue; }
101
+ if (!w.startsWith("-") && !w.startsWith("+")) break; // a script file: its contents are not visible here
102
+ }
103
+ return { words: rest };
104
+ }
105
+ if (prog === "eval") return { nested: classify(rest.slice(1).join(" "), depth + 1) };
106
+ return { words: [prog, ...rest.slice(1)] };
107
+ }
108
+ return { words: rest };
109
+ }
110
+
111
+ function classifyGit(args) {
112
+ let i = 0, configOverride = false;
113
+ while (i < args.length) {
114
+ const a = args[i];
115
+ if (GIT_GLOBAL_WITH_ARG.has(a)) { if (a === "-c" && /hooksPath|receive\.|push\./i.test(args[i + 1] || "")) configOverride = true; i += 2; continue; }
116
+ if (a.startsWith("--") && a.includes("=")) { if (/^--config-env=|^-c=/.test(a)) configOverride = true; i++; continue; }
117
+ if (a.startsWith("-")) { i++; continue; }
118
+ break;
119
+ }
120
+ const sub = args[i];
121
+ return { publishes: PUBLISH_GIT.has(sub) || configOverride, history: HISTORY_GIT.has(sub), sub };
122
+ }
123
+
124
+ function classifyGh(args) {
125
+ const [a, b] = args;
126
+ if (a === "pr" && PUBLISH_GH_PR.has(b)) return { publishes: true };
127
+ if (a === "release" && b === "create") return { publishes: true };
128
+ if (a === "repo" && (b === "sync" || b === "create")) return { publishes: true };
129
+ if (a === "api") {
130
+ const mutating = args.some((w, i) => (["-X", "--method"].includes(w) && /^(POST|PUT|PATCH|DELETE)$/i.test(args[i + 1] || "")) ||
131
+ /^--method=(POST|PUT|PATCH|DELETE)$/i.test(w) || ["-f", "-F", "--field", "--raw-field", "--input"].includes(w) || /^--(raw-)?field=/.test(w));
132
+ return { publishes: mutating };
133
+ }
134
+ return { publishes: false };
135
+ }
136
+
137
+ // classify(command) → { publishes, history, unparseable }
138
+ // publishes — pushes commits or creates/changes a PR (or cannot be
139
+ // ruled out because the syntax is not modelled and the text
140
+ // hints at it)
141
+ // history — commits, moves HEAD, or rewrites the tree
142
+ // unparseable — contains constructs this parser does not model
143
+ export function classify(command, depth = 0) {
144
+ const result = { publishes: false, history: false, unparseable: false };
145
+ if (depth > 4) return { ...result, publishes: true, unparseable: true };
146
+ const { tokens, unparseable } = tokenize(command);
147
+ if (unparseable) result.unparseable = true;
148
+ for (const words of splitCommands(tokens)) {
149
+ const u = unwrap(words, depth);
150
+ if (u.unparseable) { result.unparseable = true; continue; }
151
+ if (u.nested) {
152
+ result.publishes ||= u.nested.publishes; result.history ||= u.nested.history; result.unparseable ||= u.nested.unparseable;
153
+ continue;
154
+ }
155
+ const [prog, ...args] = u.words;
156
+ if (!prog) continue;
157
+ if (prog === "git") { const g = classifyGit(args); result.publishes ||= g.publishes; result.history ||= g.history; }
158
+ else if (prog === "gh") { result.publishes ||= classifyGh(args).publishes; }
159
+ }
160
+ if (result.unparseable && PUBLISH_HINT.test(String(command || ""))) result.publishes = true;
161
+ return result;
162
+ }
@@ -132,13 +132,15 @@ export async function runChecks({ checks = [], cwd, env = {}, gitEnv: extraGitEn
132
132
  }
133
133
 
134
134
  // An approval stands only on: an explicit APPROVED, passing checks, and the
135
- // same revision the checks and reviewers saw.
136
- export function evaluateApproval({ verdict, evidence, headNow }) {
135
+ // same tree the checks and reviewers saw — same revision, and (where a clean
136
+ // tree is required) still clean.
137
+ export function evaluateApproval({ verdict, evidence, headNow, cleanNow }) {
137
138
  if (!verdict || verdict.outcome !== "APPROVED") return { approved: false, reason: "review did not approve" };
138
139
  if (evidence && !evidence.passed) return { approved: false, reason: evidence.aborted ? "verification was cancelled" : "engine checks did not pass" };
139
140
  if (evidence?.revision && headNow && evidence.revision !== headNow) {
140
141
  return { approved: false, reason: `code changed after verification (${shortRev(evidence.revision)} → ${shortRev(headNow)})` };
141
142
  }
143
+ if (evidence?.requireClean && cleanNow === false) return { approved: false, reason: "uncommitted changes appeared during review" };
142
144
  return { approved: true, reason: null };
143
145
  }
144
146