@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 +24 -0
- package/README.md +17 -5
- package/cli/agents.mjs +8 -8
- package/cli/daemon.mjs +63 -9
- package/cli/engine/agents/index.mjs +23 -10
- package/cli/engine/commands.mjs +162 -0
- package/cli/engine/evidence.mjs +4 -2
- package/cli/engine/hooks.mjs +94 -35
- package/cli/engine/lessons.mjs +156 -0
- package/cli/engine/phases/EXECUTION.md +5 -3
- package/cli/engine/phases/INTAKE.md +2 -1
- package/cli/engine/phases/PLAN.md +4 -3
- package/cli/engine/phases/REVIEW.md +2 -2
- package/cli/engine/phases/SOLO.md +5 -1
- package/cli/engine/phases/SUMMARY.md +5 -4
- package/cli/engine/prompts.mjs +17 -10
- package/cli/engine/recovery.mjs +101 -0
- package/cli/engine/schemas.mjs +39 -8
- package/cli/engine/session.mjs +206 -51
- package/cli/engine/tracker/github.md +1 -1
- package/cli/engine/tracker/jira.md +1 -1
- package/cli/engine/tracker/linear.md +1 -1
- package/cli/engine/verdict.mjs +2 -2
- package/cli/prompt.mjs +5 -12
- package/cli/session-queue.mjs +3 -1
- package/package.json +4 -2
- package/shared/recovery.mjs +28 -0
- package/shared/session-status.mjs +1 -1
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 |
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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: "
|
|
7
|
-
description: "
|
|
8
|
-
groundRules: "Jane
|
|
9
|
-
planning: "
|
|
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
|
|
11
|
+
step: "Jane leads delivery and closes the loop",
|
|
12
12
|
tasks: [
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
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
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
112
|
-
"When
|
|
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: [...
|
|
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
|
|
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
|
+
}
|
package/cli/engine/evidence.mjs
CHANGED
|
@@ -132,13 +132,15 @@ export async function runChecks({ checks = [], cwd, env = {}, gitEnv: extraGitEn
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
// An approval stands only on: an explicit APPROVED, passing checks, and the
|
|
135
|
-
// same
|
|
136
|
-
|
|
135
|
+
// same tree the checks and reviewers saw — same revision, and (where a clean
|
|
136
|
+
// tree is required) still clean.
|
|
137
|
+
export function evaluateApproval({ verdict, evidence, headNow, cleanNow }) {
|
|
137
138
|
if (!verdict || verdict.outcome !== "APPROVED") return { approved: false, reason: "review did not approve" };
|
|
138
139
|
if (evidence && !evidence.passed) return { approved: false, reason: evidence.aborted ? "verification was cancelled" : "engine checks did not pass" };
|
|
139
140
|
if (evidence?.revision && headNow && evidence.revision !== headNow) {
|
|
140
141
|
return { approved: false, reason: `code changed after verification (${shortRev(evidence.revision)} → ${shortRev(headNow)})` };
|
|
141
142
|
}
|
|
143
|
+
if (evidence?.requireClean && cleanNow === false) return { approved: false, reason: "uncommitted changes appeared during review" };
|
|
142
144
|
return { approved: true, reason: null };
|
|
143
145
|
}
|
|
144
146
|
|