@kendoo.agentdesk/agentdesk 0.26.0 → 0.28.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 +32 -1
- package/bin/agentdesk.mjs +35 -45
- package/cli/agents.mjs +4 -256
- package/cli/bootstrap.mjs +40 -59
- package/cli/config.mjs +29 -4
- package/cli/daemon.mjs +148 -66
- package/cli/dotenv.mjs +96 -13
- package/cli/engine/agents/index.mjs +151 -0
- package/cli/engine/claude-auth.mjs +72 -0
- package/cli/engine/env.mjs +56 -0
- package/cli/engine/events.mjs +214 -0
- package/cli/engine/hooks.mjs +112 -0
- package/cli/engine/phases/EXECUTION.md +45 -0
- package/cli/engine/phases/INTAKE.md +34 -0
- package/cli/engine/phases/PLAN.md +26 -0
- package/cli/engine/phases/REVIEW.md +21 -0
- package/cli/engine/phases/SOLO.md +115 -0
- package/cli/engine/phases/SUMMARY.md +23 -0
- package/cli/engine/prompts.mjs +181 -0
- package/cli/engine/query.mjs +63 -0
- package/cli/engine/schemas.mjs +180 -0
- package/cli/engine/session.mjs +285 -0
- package/cli/engine/spawn.mjs +83 -0
- package/cli/engine/tracker/github.md +19 -0
- package/cli/engine/tracker/jira.md +23 -0
- package/cli/engine/tracker/linear.md +24 -0
- package/cli/engine/verdict.mjs +83 -0
- package/cli/init.mjs +295 -149
- package/cli/login.mjs +52 -6
- package/cli/phase-loop.mjs +78 -0
- package/cli/proc.mjs +131 -0
- package/cli/project-key.mjs +56 -0
- package/cli/projects.mjs +41 -6
- package/cli/prompt.mjs +9 -503
- package/cli/prompts.mjs +20 -1
- package/cli/security-check.mjs +1 -1
- package/cli/session-isolation.mjs +65 -9
- package/cli/session-sandbox.mjs +13 -1
- package/cli/setup-helpers.mjs +83 -36
- package/cli/team.mjs +41 -34
- package/cli/tracker-check.mjs +12 -2
- package/cli/tracker-project.mjs +93 -0
- package/cli/update-check.mjs +62 -0
- package/package.json +12 -3
- package/cli/orchestrator.mjs +0 -461
- package/cli/stream-parser.mjs +0 -216
- package/prompts/phased.md +0 -549
- package/prompts/team.md +0 -505
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Persona registry → Agent SDK definitions.
|
|
2
|
+
//
|
|
3
|
+
// The team (cli/agents.mjs BUILT_IN_AGENTS + project customisations via
|
|
4
|
+
// resolveTeam) becomes, per phase:
|
|
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.
|
|
8
|
+
// - one AgentDefinition per engineering role, with the tool list that role
|
|
9
|
+
// needs in that phase and nothing more.
|
|
10
|
+
//
|
|
11
|
+
// Tool lists are the enforcement; prompts describe intent. hooks.mjs is the
|
|
12
|
+
// belt to these braces.
|
|
13
|
+
|
|
14
|
+
import { BUILT_IN_AGENTS } from "../../agents.mjs";
|
|
15
|
+
|
|
16
|
+
export const LEAD = "Jane";
|
|
17
|
+
export const READ_ONLY = Object.freeze(["Read", "Grep", "Glob"]);
|
|
18
|
+
const RO_BASH = Object.freeze([...READ_ONLY, "Bash"]);
|
|
19
|
+
const FULL = Object.freeze(["Read", "Edit", "Write", "Bash", "Grep", "Glob"]);
|
|
20
|
+
|
|
21
|
+
// Which built-in agents take part in each phase, and with which tools.
|
|
22
|
+
export const PHASE_ROSTER = Object.freeze({
|
|
23
|
+
INTAKE: { Dennis: RO_BASH },
|
|
24
|
+
PLAN: { Dennis: READ_ONLY, Sam: READ_ONLY, Vera: READ_ONLY, Luna: READ_ONLY, Mark: READ_ONLY, Nora: READ_ONLY },
|
|
25
|
+
EXECUTION: { Dennis: FULL, Sam: READ_ONLY, Vera: FULL, Bart: RO_BASH, Luna: RO_BASH, Mark: READ_ONLY, Nora: FULL },
|
|
26
|
+
// Reviewers read the diff cold and may run read-only commands (tests, git
|
|
27
|
+
// log). None of them can edit — fixes happen back in EXECUTION.
|
|
28
|
+
REVIEW: { Sam: READ_ONLY, Bart: RO_BASH, Vera: RO_BASH },
|
|
29
|
+
SUMMARY: { Dennis: RO_BASH },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Phases where project-defined custom agents (config.projectAgents) join.
|
|
33
|
+
const CUSTOM_AGENT_PHASES = new Set(["PLAN", "EXECUTION"]);
|
|
34
|
+
|
|
35
|
+
// Today's defaults: REVIEW and SUMMARY on haiku, the rest on the CLI default.
|
|
36
|
+
// `phaseModels` values are "opus" | "sonnet" | "haiku" | "default" | undefined.
|
|
37
|
+
export function modelForPhase(phase, phaseModels = {}) {
|
|
38
|
+
const choice = phaseModels?.[phase];
|
|
39
|
+
if (choice && choice !== "default") return choice;
|
|
40
|
+
if (phase === "REVIEW" || phase === "SUMMARY") return "haiku";
|
|
41
|
+
return undefined; // let the SDK/CLI pick its default
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PHASE_GUIDANCE = {
|
|
45
|
+
INTAKE: "Phase INTAKE: gather the task, its attachments and the repo context. Report facts. Do not plan the implementation and do not write code.",
|
|
46
|
+
PLAN: "Phase PLAN: assess the approach from your role's angle and report a concrete plan contribution. Do not modify any files.",
|
|
47
|
+
EXECUTION: "Phase EXECUTION: do your role's part of the plan. Verify every claim with an observation (command output, test result, rendered page) before reporting it.",
|
|
48
|
+
REVIEW: "Phase REVIEW: you are reviewing a diff you did not write. Report concrete findings with file:line. Be strict but not pedantic — only real gaps against the task and the plan. Do not modify anything.",
|
|
49
|
+
SUMMARY: "Phase SUMMARY: execute exactly the tracker writes the lead dictates and confirm each with the command's output.",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function executionTasks(a) {
|
|
53
|
+
const tasks = a.execution?.tasks;
|
|
54
|
+
if (!Array.isArray(tasks) || tasks.length === 0) return "";
|
|
55
|
+
return `\nYour execution checklist:\n${tasks.map((t, i) => `${i + 1}. ${t}`).join("\n")}\n`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function agentSystemPrompt(a, phase) {
|
|
59
|
+
return [
|
|
60
|
+
`You are ${a.name}, ${a.role} on an AgentDesk software team led by ${LEAD}.`,
|
|
61
|
+
`Role: ${a.description}.`,
|
|
62
|
+
a.groundRules ? `Ground rules: ${a.groundRules}` : "",
|
|
63
|
+
a.codePrinciple ? `Code principle: ${a.codePrinciple}` : "",
|
|
64
|
+
"",
|
|
65
|
+
PHASE_GUIDANCE[phase] || "",
|
|
66
|
+
phase === "EXECUTION" ? executionTasks(a) : "",
|
|
67
|
+
"",
|
|
68
|
+
"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.",
|
|
69
|
+
`Report back to ${LEAD} concisely. You may prefix a message with [THINK], [ACT], [ARGUE] or [AGREE] to make your stance clear.`,
|
|
70
|
+
].filter(l => l !== null && l !== undefined).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rosterLine(name, def) {
|
|
74
|
+
return `- ${name} (${def.role}) — ${def.description}. Tools: ${def.tools.join(", ")}.`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function leadSystemPrompt(roster, phase) {
|
|
78
|
+
const jane = BUILT_IN_AGENTS.Jane;
|
|
79
|
+
const lines = Object.entries(roster).map(([n, d]) => rosterLine(n, d));
|
|
80
|
+
return [
|
|
81
|
+
`You are ${LEAD}, ${jane.role}. ${jane.description}.`,
|
|
82
|
+
"",
|
|
83
|
+
"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.",
|
|
84
|
+
"Your language is product-only: user stories, acceptance criteria, scope, priorities, stakeholder impact.",
|
|
85
|
+
"",
|
|
86
|
+
`Team available in phase ${phase}:`,
|
|
87
|
+
...lines,
|
|
88
|
+
"",
|
|
89
|
+
"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.",
|
|
90
|
+
"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.",
|
|
91
|
+
"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>`.",
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Solo mode: the named agent IS the main thread, with the full tool set and
|
|
96
|
+
// no subagents. Its system prompt is the phase template (renderSoloPrompt), so
|
|
97
|
+
// the AgentDefinition prompt stays a short identity line.
|
|
98
|
+
export function soloDefinition(agent) {
|
|
99
|
+
const def = {
|
|
100
|
+
description: `${agent.role}: ${agent.description}`,
|
|
101
|
+
prompt: `You are ${agent.name}, ${agent.role}. You work alone on the task given to you, end to end.`,
|
|
102
|
+
tools: [...FULL],
|
|
103
|
+
};
|
|
104
|
+
return { agents: { [agent.name]: def }, allowedTools: [...FULL], lead: agent.name };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// team — resolveTeam(config) output (array of { name, role, description, ... })
|
|
108
|
+
// phase — one of PHASES
|
|
109
|
+
// phaseModels — config.phaseModels
|
|
110
|
+
// Returns { agents: Record<name, AgentDefinition>, allowedTools: string[], lead: "Jane" }
|
|
111
|
+
export function agentsForPhase({ phase, team, phaseModels = {} }) {
|
|
112
|
+
const roster = PHASE_ROSTER[phase] || {};
|
|
113
|
+
const subagentModel = (phase === "REVIEW" || phase === "SUMMARY") ? modelForPhase(phase, phaseModels) : "inherit";
|
|
114
|
+
|
|
115
|
+
const agents = {};
|
|
116
|
+
for (const a of team) {
|
|
117
|
+
if (a.name === LEAD) continue;
|
|
118
|
+
const isBuiltIn = !!BUILT_IN_AGENTS[a.name];
|
|
119
|
+
let tools;
|
|
120
|
+
if (isBuiltIn) {
|
|
121
|
+
tools = roster[a.name];
|
|
122
|
+
if (!tools) continue; // this role has no part in this phase
|
|
123
|
+
} else {
|
|
124
|
+
if (!CUSTOM_AGENT_PHASES.has(phase)) continue;
|
|
125
|
+
tools = Array.isArray(a.tools) && a.tools.length ? a.tools : READ_ONLY;
|
|
126
|
+
}
|
|
127
|
+
agents[a.name] = {
|
|
128
|
+
description: `${a.role}: ${a.description}`,
|
|
129
|
+
prompt: agentSystemPrompt(a, phase),
|
|
130
|
+
tools: [...tools],
|
|
131
|
+
model: subagentModel,
|
|
132
|
+
role: a.role,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Strip our bookkeeping field before handing definitions to the SDK.
|
|
137
|
+
const sdkAgents = {};
|
|
138
|
+
for (const [name, def] of Object.entries(agents)) {
|
|
139
|
+
sdkAgents[name] = { description: def.description, prompt: def.prompt, tools: def.tools, model: def.model };
|
|
140
|
+
}
|
|
141
|
+
sdkAgents[LEAD] = {
|
|
142
|
+
description: `${BUILT_IN_AGENTS.Jane.role}: ${BUILT_IN_AGENTS.Jane.description}`,
|
|
143
|
+
prompt: leadSystemPrompt(agents, phase),
|
|
144
|
+
tools: ["Agent"],
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const allowed = new Set(["Agent"]);
|
|
148
|
+
for (const def of Object.values(sdkAgents)) for (const t of def.tools) allowed.add(t);
|
|
149
|
+
|
|
150
|
+
return { agents: sdkAgents, allowedTools: [...allowed], lead: LEAD };
|
|
151
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Can a *standalone* Claude process authenticate on this machine?
|
|
2
|
+
//
|
|
3
|
+
// Setup used to check GitHub and the tracker and never Claude itself, so a
|
|
4
|
+
// new user reached their first session and got "Not logged in" from deep
|
|
5
|
+
// inside the engine. An interactive `claude` working in the user's terminal
|
|
6
|
+
// is not proof: it may be inheriting auth from a parent session that our
|
|
7
|
+
// child processes cannot reach.
|
|
8
|
+
//
|
|
9
|
+
// The check runs the same binary the engine will spawn, with the same
|
|
10
|
+
// scrubbed environment, and reads `claude auth status`.
|
|
11
|
+
|
|
12
|
+
import { execFile } from "child_process";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { dirname, join } from "path";
|
|
15
|
+
import { stripParentSessionVars } from "./env.mjs";
|
|
16
|
+
|
|
17
|
+
export function bundledClaudePath() {
|
|
18
|
+
const platformPkg = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`;
|
|
19
|
+
try {
|
|
20
|
+
const pkgDir = dirname(createRequire(import.meta.url).resolve(`${platformPkg}/package.json`));
|
|
21
|
+
return join(pkgDir, process.platform === "win32" ? "claude.exe" : "claude");
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function claudeBinary(env = process.env) {
|
|
28
|
+
return env.AGENTDESK_CLAUDE_PATH || bundledClaudePath() || "claude";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const CLAUDE_LOGIN_HINT = [
|
|
32
|
+
"Claude Code has no login that a standalone session can use.",
|
|
33
|
+
"Fix one of:",
|
|
34
|
+
" • run `claude auth login` in this terminal (if you use account profiles,",
|
|
35
|
+
" the same CLAUDE_CONFIG_DIR you will run agentdesk with), then re-run;",
|
|
36
|
+
" • or add ANTHROPIC_API_KEY=<key> to the project's .env.",
|
|
37
|
+
].join("\n");
|
|
38
|
+
|
|
39
|
+
function run(exec, bin, args, env, timeoutMs) {
|
|
40
|
+
return new Promise(resolve => {
|
|
41
|
+
exec(bin, args, { env, timeout: timeoutMs, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
|
|
42
|
+
resolve({ err, stdout: String(stdout || ""), stderr: String(stderr || "") });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Returns { ok, method, detail, hint? }.
|
|
48
|
+
// env — the environment the session child will get (dotenv + sandbox applied)
|
|
49
|
+
// exec — injectable for tests (child_process.execFile signature)
|
|
50
|
+
export async function checkClaudeAuth({ env = process.env, exec = execFile, timeoutMs = 10000 } = {}) {
|
|
51
|
+
if (env.ANTHROPIC_API_KEY) return { ok: true, method: "api-key", detail: "ANTHROPIC_API_KEY" };
|
|
52
|
+
|
|
53
|
+
const bin = claudeBinary(env);
|
|
54
|
+
const { err, stdout } = await run(exec, bin, ["auth", "status"], stripParentSessionVars(env), timeoutMs);
|
|
55
|
+
|
|
56
|
+
if (err && !stdout) {
|
|
57
|
+
const detail = err.code === "ENOENT"
|
|
58
|
+
? `Claude binary not found (${bin})`
|
|
59
|
+
: `could not run \`claude auth status\` (${err.message || err.code})`;
|
|
60
|
+
return { ok: false, method: "unknown", detail, hint: CLAUDE_LOGIN_HINT };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let status;
|
|
64
|
+
try { status = JSON.parse(stdout); } catch {
|
|
65
|
+
return { ok: false, method: "unknown", detail: "unexpected output from `claude auth status`", hint: CLAUDE_LOGIN_HINT };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (status.loggedIn === true) {
|
|
69
|
+
return { ok: true, method: status.authMethod || "oauth", detail: status.email || status.authMethod || "logged in" };
|
|
70
|
+
}
|
|
71
|
+
return { ok: false, method: "none", detail: "not logged in", hint: CLAUDE_LOGIN_HINT };
|
|
72
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Environment for the Claude child process.
|
|
2
|
+
//
|
|
3
|
+
// The SDK *replaces* the subprocess environment with `Options.env`, so PATH
|
|
4
|
+
// and friends must be spread in from the parent. But the parent may itself be
|
|
5
|
+
// an interactive Claude Code session (a developer running `agentdesk team`
|
|
6
|
+
// from inside Claude Code, or the daemon launched from one). That session
|
|
7
|
+
// stamps its own identity into the environment — session id, messaging
|
|
8
|
+
// socket/token, parent pid, entrypoint — and a child that inherits those
|
|
9
|
+
// believes it is a nested child of that session: it looks for the parent's
|
|
10
|
+
// credentials via the parent's socket, finds none, and reports "Not logged in"
|
|
11
|
+
// even though the machine is logged in. The old orchestrator spread
|
|
12
|
+
// process.env verbatim and had this exposure.
|
|
13
|
+
//
|
|
14
|
+
// CLAUDE_CONFIG_DIR is deliberately kept: it is how a user selects an account
|
|
15
|
+
// profile, and the sandbox allowlists it (session-isolation.mjs).
|
|
16
|
+
|
|
17
|
+
// Variables that describe a *parent* interactive session, never a child.
|
|
18
|
+
export const PARENT_SESSION_VARS = Object.freeze([
|
|
19
|
+
"CLAUDECODE",
|
|
20
|
+
"CLAUDE_CODE_CHILD_SESSION",
|
|
21
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
22
|
+
"CLAUDE_CODE_EXECPATH",
|
|
23
|
+
"CLAUDE_CODE_MESSAGING_SOCKET",
|
|
24
|
+
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
25
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
26
|
+
"CLAUDE_PID",
|
|
27
|
+
"CLAUDE_EFFORT",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Hard caps on what a session may spawn. Depth 1: our subagents may not spawn
|
|
31
|
+
// their own. Concurrency 4: REVIEW fans out three reviewers, plus headroom.
|
|
32
|
+
export const SUBAGENT_CAPS = Object.freeze({
|
|
33
|
+
CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH: "1",
|
|
34
|
+
CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: "4",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export function stripParentSessionVars(env) {
|
|
38
|
+
const out = { ...env };
|
|
39
|
+
for (const k of PARENT_SESSION_VARS) delete out[k];
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// base — usually process.env
|
|
44
|
+
// dotenv — the project's .env (loadDotEnv(cwd)); lets a project supply
|
|
45
|
+
// ANTHROPIC_API_KEY and its own variables to the child
|
|
46
|
+
// sandboxEnv — createScratchHome().env (git/gh identity, tokens) — wins over .env
|
|
47
|
+
// extra — per-session additions
|
|
48
|
+
export function buildChildEnv({ base = process.env, dotenv = {}, sandboxEnv = {}, extra = {} } = {}) {
|
|
49
|
+
return {
|
|
50
|
+
...stripParentSessionVars(base),
|
|
51
|
+
...dotenv,
|
|
52
|
+
...sandboxEnv,
|
|
53
|
+
...SUBAGENT_CAPS,
|
|
54
|
+
...extra,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// SDKMessage → AgentDesk session event protocol.
|
|
2
|
+
//
|
|
3
|
+
// This replaces cli/stream-parser.mjs. The old parser recovered "who is
|
|
4
|
+
// speaking" by regex-matching persona names at the start of lines of a single
|
|
5
|
+
// model's prose. Here attribution is structural: every message that comes from
|
|
6
|
+
// inside a subagent carries `parent_tool_use_id`, which points at the `Agent`
|
|
7
|
+
// tool call that spawned it, whose input names the agent (`subagent_type`).
|
|
8
|
+
// Text with no parent is the phase's lead agent.
|
|
9
|
+
//
|
|
10
|
+
// The emitted events and their fields are the contract the server
|
|
11
|
+
// (server/index.mjs processSessionEvent) and dashboard already consume:
|
|
12
|
+
// session:model {model}
|
|
13
|
+
// agent:message {agent, tag, message}
|
|
14
|
+
// tool:use {agent, tool, description}
|
|
15
|
+
// tool:result {success, summary}
|
|
16
|
+
// session:update {taskId?, title?}
|
|
17
|
+
// `phase:change` and `session:start|end|error` are emitted by the session
|
|
18
|
+
// loop, not here — phase boundaries are engine facts, not prose signals.
|
|
19
|
+
|
|
20
|
+
const TAG_RE = /\[(SAY|ACT|THINK|AGREE|ARGUE)\]\s*/gi;
|
|
21
|
+
const AGENT_TOOL_NAMES = new Set(["Agent", "Task"]);
|
|
22
|
+
|
|
23
|
+
export function timestamp() {
|
|
24
|
+
const d = new Date();
|
|
25
|
+
return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, "0")).join(":");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function detectTag(text) {
|
|
29
|
+
if (/\[ARGUE\]/i.test(text)) return "ARGUE";
|
|
30
|
+
if (/\[AGREE\]/i.test(text)) return "AGREE";
|
|
31
|
+
if (/\[THINK\]/i.test(text)) return "THINK";
|
|
32
|
+
if (/\[ACT\]/i.test(text)) return "ACT";
|
|
33
|
+
return "SAY";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function stripTags(text) {
|
|
37
|
+
return String(text).replace(TAG_RE, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Human-readable one-liner for the dashboard's tool feed. The "Reading|Editing|
|
|
41
|
+
// Writing <path>" forms are load-bearing: cli/daemon.mjs derives
|
|
42
|
+
// filePathsTouched from them.
|
|
43
|
+
function describeBash(cmd) {
|
|
44
|
+
if (cmd.includes("curl") && /linear/i.test(cmd)) return "Calling Linear API...";
|
|
45
|
+
if (cmd.includes("curl") && /atlassian|jira/i.test(cmd)) return "Calling Jira API...";
|
|
46
|
+
if (cmd.includes("curl")) return "Making API request...";
|
|
47
|
+
return `$ ${cmd.length > 80 ? cmd.slice(0, 80) + "..." : cmd}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function describeTool(name, input = {}) {
|
|
51
|
+
const shortPath = p => String(p || "").split("/").filter(Boolean).slice(-3).join("/");
|
|
52
|
+
switch (name) {
|
|
53
|
+
case "Bash": return describeBash(String(input.command || ""));
|
|
54
|
+
case "Read": return `Reading ${shortPath(input.file_path)}`;
|
|
55
|
+
case "Edit": return `Editing ${shortPath(input.file_path)}`;
|
|
56
|
+
case "Write": return `Writing ${shortPath(input.file_path)}`;
|
|
57
|
+
case "NotebookEdit": return `Editing ${shortPath(input.notebook_path)}`;
|
|
58
|
+
case "Glob":
|
|
59
|
+
case "Grep": return `Searching ${input.pattern || ""}`;
|
|
60
|
+
case "Agent":
|
|
61
|
+
case "Task": return `→ ${input.subagent_type || "agent"}: ${input.description || firstLine(input.prompt)}`;
|
|
62
|
+
default: return `${name}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function firstLine(s) {
|
|
67
|
+
return String(s || "").split("\n").find(l => l.trim())?.trim().slice(0, 100) || "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function blockText(content) {
|
|
71
|
+
if (typeof content === "string") return content;
|
|
72
|
+
if (Array.isArray(content)) return content.filter(b => b?.type === "text").map(b => b.text).join("\n");
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function summarizeToolResult(block) {
|
|
77
|
+
const text = blockText(block.content).trim();
|
|
78
|
+
const looksFailed = block.is_error === true || /\b(error|failed)\b/i.test(text.slice(0, 200));
|
|
79
|
+
const summary = text.length > 300 ? `Done (${text.length} chars)` : (text || "Done");
|
|
80
|
+
return { success: !looksFailed, summary };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Token totals for the dashboard. `modelUsage` is the only per-query figure
|
|
84
|
+
// that includes subagent calls; `usage` covers the main loop only.
|
|
85
|
+
export function totalsFromModelUsage(modelUsage = {}) {
|
|
86
|
+
let inputTokens = 0, outputTokens = 0, costUsd = 0;
|
|
87
|
+
for (const u of Object.values(modelUsage)) {
|
|
88
|
+
inputTokens += (u.inputTokens ?? u.input_tokens ?? 0)
|
|
89
|
+
+ (u.cacheReadInputTokens ?? u.cache_read_input_tokens ?? 0)
|
|
90
|
+
+ (u.cacheCreationInputTokens ?? u.cache_creation_input_tokens ?? 0);
|
|
91
|
+
outputTokens += u.outputTokens ?? u.output_tokens ?? 0;
|
|
92
|
+
costUsd += u.costUSD ?? u.cost_usd ?? 0;
|
|
93
|
+
}
|
|
94
|
+
return { inputTokens, outputTokens, costUsd };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// leadAgent — name attributed to main-thread text (the phase lead, e.g. "Jane").
|
|
98
|
+
// onEvent — receives protocol events (without sessionId; the caller adds it).
|
|
99
|
+
export function createEventMapper({ leadAgent = "Jane", onEvent } = {}) {
|
|
100
|
+
const agents = new Map(); // Agent tool_use id → subagent name
|
|
101
|
+
const sawText = new Set(); // Agent tool_use ids whose subagent text we forwarded
|
|
102
|
+
let steps = 0;
|
|
103
|
+
let reportedModel = null;
|
|
104
|
+
let result = null;
|
|
105
|
+
|
|
106
|
+
function emit(type, fields) {
|
|
107
|
+
onEvent?.({ type, ...fields, timestamp: timestamp() });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function reportModel(model) {
|
|
111
|
+
// "<synthetic>" is the SDK's placeholder on harness-generated messages
|
|
112
|
+
// (errors, notices) — not a model the session is running on.
|
|
113
|
+
if (!model || model === reportedModel || model === "<synthetic>") return;
|
|
114
|
+
reportedModel = model;
|
|
115
|
+
emit("session:model", { model });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function agentFor(msg) {
|
|
119
|
+
const pid = msg.parent_tool_use_id;
|
|
120
|
+
if (!pid) return leadAgent;
|
|
121
|
+
return agents.get(pid) || "Agent";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function handleText(agent, raw, { isLead }) {
|
|
125
|
+
const text = String(raw).trim();
|
|
126
|
+
if (!text) return;
|
|
127
|
+
|
|
128
|
+
if (isLead) {
|
|
129
|
+
const taskId = text.match(/TASK_ID:\s*(\S+)/)?.[1];
|
|
130
|
+
const title = text.match(/SESSION_TITLE:\s*(.+)/)?.[1]?.trim().slice(0, 60);
|
|
131
|
+
if (taskId || title) emit("session:update", { ...(taskId && { taskId }), ...(title && { title }) });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
emit("agent:message", { agent, tag: detectTag(text), message: stripTags(text).replace(/\*+/g, "").trim() });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function handleAssistant(msg) {
|
|
138
|
+
reportModel(msg.message?.model);
|
|
139
|
+
const agent = agentFor(msg);
|
|
140
|
+
const isLead = !msg.parent_tool_use_id;
|
|
141
|
+
if (msg.parent_tool_use_id) sawText.add(msg.parent_tool_use_id);
|
|
142
|
+
|
|
143
|
+
for (const block of msg.message?.content || []) {
|
|
144
|
+
if (block.type === "text") {
|
|
145
|
+
handleText(agent, block.text, { isLead });
|
|
146
|
+
} else if (block.type === "tool_use") {
|
|
147
|
+
steps++;
|
|
148
|
+
if (AGENT_TOOL_NAMES.has(block.name)) {
|
|
149
|
+
const name = block.input?.subagent_type || "general-purpose";
|
|
150
|
+
agents.set(block.id, name);
|
|
151
|
+
emit("agent:message", { agent, tag: "ACT", message: describeTool(block.name, block.input) });
|
|
152
|
+
}
|
|
153
|
+
emit("tool:use", { agent, tool: block.name, description: describeTool(block.name, block.input) });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function handleUser(msg) {
|
|
159
|
+
const content = msg.message?.content;
|
|
160
|
+
if (!Array.isArray(content)) return;
|
|
161
|
+
for (const block of content) {
|
|
162
|
+
if (block.type !== "tool_result") continue;
|
|
163
|
+
const { success, summary } = summarizeToolResult(block);
|
|
164
|
+
emit("tool:result", { success, summary });
|
|
165
|
+
|
|
166
|
+
// A subagent reporting back. Its running text was forwarded live (with
|
|
167
|
+
// parent_tool_use_id); if none arrived, surface its final report so the
|
|
168
|
+
// dashboard still shows the agent said something.
|
|
169
|
+
const name = agents.get(block.tool_use_id);
|
|
170
|
+
if (name && !sawText.has(block.tool_use_id)) {
|
|
171
|
+
const text = blockText(block.content).trim();
|
|
172
|
+
if (text) emit("agent:message", { agent: name, tag: "SAY", message: stripTags(text).slice(0, 1200) });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function handle(msg) {
|
|
178
|
+
switch (msg?.type) {
|
|
179
|
+
case "system":
|
|
180
|
+
if (msg.subtype === "init") reportModel(msg.model);
|
|
181
|
+
break;
|
|
182
|
+
case "assistant":
|
|
183
|
+
handleAssistant(msg);
|
|
184
|
+
break;
|
|
185
|
+
case "user":
|
|
186
|
+
handleUser(msg);
|
|
187
|
+
break;
|
|
188
|
+
case "result":
|
|
189
|
+
result = msg;
|
|
190
|
+
break;
|
|
191
|
+
default:
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function finish() {
|
|
197
|
+
const totals = totalsFromModelUsage(result?.modelUsage);
|
|
198
|
+
return {
|
|
199
|
+
steps,
|
|
200
|
+
numTurns: result?.num_turns ?? 0,
|
|
201
|
+
...totals,
|
|
202
|
+
costUsd: result?.total_cost_usd ?? totals.costUsd,
|
|
203
|
+
isError: !!result?.is_error,
|
|
204
|
+
subtype: result?.subtype || null,
|
|
205
|
+
errors: result?.errors || [],
|
|
206
|
+
permissionDenials: result?.permission_denials || [],
|
|
207
|
+
structuredOutput: result?.structured_output,
|
|
208
|
+
resultText: result?.result ?? null,
|
|
209
|
+
agents: [...new Set(agents.values())],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { handle, finish, agentFor };
|
|
214
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Enforcement hooks. Every "MUST NEVER" that used to live in prompt prose
|
|
2
|
+
// becomes a decision here, applied by the harness before a tool runs.
|
|
3
|
+
//
|
|
4
|
+
// Layering: the AgentDefinition tool lists already make most of this
|
|
5
|
+
// impossible (Jane has only the Agent tool; reviewers have no Edit/Write).
|
|
6
|
+
// These hooks are the belt to those braces, and they carry the one rule tool
|
|
7
|
+
// lists cannot express — "no PR while review findings are open".
|
|
8
|
+
//
|
|
9
|
+
// decidePreToolUse() is pure so the policy is unit-tested without the SDK.
|
|
10
|
+
|
|
11
|
+
export const MUTATING_TOOLS = Object.freeze(new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]));
|
|
12
|
+
export const CODE_TOOLS = Object.freeze(new Set([...MUTATING_TOOLS, "Bash"]));
|
|
13
|
+
|
|
14
|
+
// Commands that publish work. Matched loosely on purpose: `gh pr create`,
|
|
15
|
+
// `git push`, `git push --force-with-lease origin HEAD`, chained after `&&`.
|
|
16
|
+
const PUBLISH_RE = /(^|[;&|]\s*)(gh\s+pr\s+create|git\s+push)\b/;
|
|
17
|
+
|
|
18
|
+
export function isPublishCommand(command) {
|
|
19
|
+
return PUBLISH_RE.test(String(command || ""));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The auditor whose sign-off gates publishing. "Sam's audit is a blocking
|
|
23
|
+
// gate — not advisory" used to be prose; now it is the observable fact that a
|
|
24
|
+
// Sam subagent finished in this EXECUTION phase (SubagentStop).
|
|
25
|
+
export const AUDITOR = "Sam";
|
|
26
|
+
|
|
27
|
+
// Called at the start of every EXECUTION: nothing may be published until the
|
|
28
|
+
// auditor has run in this phase. `openFindings` (from a rejected REVIEW) is
|
|
29
|
+
// carried for the denial message so the team sees what is outstanding.
|
|
30
|
+
export function armPublishGate(state, openFindings = []) {
|
|
31
|
+
state.awaitingAudit = true;
|
|
32
|
+
state.openFindings = Array.isArray(openFindings) ? openFindings : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function onSubagentStopped(state, { phase, agentType }) {
|
|
36
|
+
if (phase === "EXECUTION" && agentType === AUDITOR) {
|
|
37
|
+
state.awaitingAudit = false;
|
|
38
|
+
state.openFindings = [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// input — the SDK PreToolUseHookInput ({ tool_name, tool_input, agent_id?, agent_type? }).
|
|
43
|
+
// `agent_id` is present only inside a subagent (BaseHookInput docs).
|
|
44
|
+
// state — mutable session state; reads `awaitingAudit` and `openFindings`.
|
|
45
|
+
export function decidePreToolUse({ phase, input, state = {} }) {
|
|
46
|
+
const tool = input?.tool_name;
|
|
47
|
+
const isMainThread = !input?.agent_id;
|
|
48
|
+
|
|
49
|
+
// Solo mode: the main thread is the worker, not the lead.
|
|
50
|
+
if (isMainThread && CODE_TOOLS.has(tool) && !state.mainThreadMayCode) {
|
|
51
|
+
return { decision: "deny", reason: `The lead does not touch code or run commands — delegate ${tool} to a team agent.` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (phase === "REVIEW" && MUTATING_TOOLS.has(tool)) {
|
|
55
|
+
return { decision: "deny", reason: "REVIEW is read-only: report findings; fixes happen back in EXECUTION." };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (phase === "EXECUTION" && tool === "Bash" && isPublishCommand(input?.tool_input?.command)) {
|
|
59
|
+
const open = Array.isArray(state.openFindings) ? state.openFindings : [];
|
|
60
|
+
if (state.awaitingAudit || open.length > 0) {
|
|
61
|
+
const list = open.slice(0, 5).map(f => `- ${f.title || f}`).join("\n");
|
|
62
|
+
const why = open.length > 0
|
|
63
|
+
? `${open.length} review finding(s) are unresolved:\n${list}\n`
|
|
64
|
+
: "";
|
|
65
|
+
return {
|
|
66
|
+
decision: "deny",
|
|
67
|
+
reason: `Cannot publish yet — ${why}${AUDITOR} must audit the changed files in this phase first (have the lead delegate the audit to ${AUDITOR}, fix what he flags, then publish).`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { decision: "allow" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function denyOutput(reason) {
|
|
76
|
+
return {
|
|
77
|
+
hookSpecificOutput: {
|
|
78
|
+
hookEventName: "PreToolUse",
|
|
79
|
+
permissionDecision: "deny",
|
|
80
|
+
permissionDecisionReason: reason,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build the SDK `hooks` option for one phase.
|
|
86
|
+
// state — shared session state (openFindings, ...)
|
|
87
|
+
// onToolUse — ({ agentType, tool, input }) for the dashboard
|
|
88
|
+
// onToolResult — ({ agentType, tool, response }) for the dashboard
|
|
89
|
+
// onSubagentStop — ({ agentType }) when a subagent finishes
|
|
90
|
+
export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagentStop } = {}) {
|
|
91
|
+
return {
|
|
92
|
+
PreToolUse: [{
|
|
93
|
+
hooks: [async (input) => {
|
|
94
|
+
onToolUse?.({ agentType: input.agent_type || null, tool: input.tool_name, input: input.tool_input });
|
|
95
|
+
const d = decidePreToolUse({ phase, input, state });
|
|
96
|
+
return d.decision === "deny" ? denyOutput(d.reason) : {};
|
|
97
|
+
}],
|
|
98
|
+
}],
|
|
99
|
+
PostToolUse: [{
|
|
100
|
+
hooks: [async (input) => {
|
|
101
|
+
onToolResult?.({ agentType: input.agent_type || null, tool: input.tool_name, response: input.tool_response });
|
|
102
|
+
return {};
|
|
103
|
+
}],
|
|
104
|
+
}],
|
|
105
|
+
SubagentStop: [{
|
|
106
|
+
hooks: [async (input) => {
|
|
107
|
+
onSubagentStop?.({ agentType: input.agent_type || null });
|
|
108
|
+
return {};
|
|
109
|
+
}],
|
|
110
|
+
}],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
You are running **Phase 3: EXECUTION + QA**.
|
|
2
|
+
|
|
3
|
+
Task: {{TASK_ID}}
|
|
4
|
+
{{TASK_LINK}}
|
|
5
|
+
|
|
6
|
+
{{#RETRY}}
|
|
7
|
+
## THIS IS A RETRY — REVIEW DID NOT APPROVE
|
|
8
|
+
|
|
9
|
+
The reviewers returned the following. Work from this list; do not re-derive it.
|
|
10
|
+
|
|
11
|
+
{{RETRY_FINDINGS}}
|
|
12
|
+
{{/RETRY}}
|
|
13
|
+
|
|
14
|
+
## Rules
|
|
15
|
+
|
|
16
|
+
- Follow CLAUDE.md conventions (if present). Do not modify files unrelated to the task.
|
|
17
|
+
- **Sam's audit is a blocking gate.** After Dennis implements, Sam must read every changed file and run his full checklist, citing file:line for every finding — "looks clean" without evidence is invalid. Dennis fixes every violation before Bart creates the PR. Publishing is refused while findings are open.
|
|
18
|
+
- Nora's sign-off is a gate too: Bart cannot create the PR until Nora reports either "No doc impact — skipped" or "Docs updated: [files]".
|
|
19
|
+
- Do NOT post the final tracker summary or transition the task here — SUMMARY owns all final tracker writes.
|
|
20
|
+
|
|
21
|
+
{{#SCREENSHOTS_ENABLED}}
|
|
22
|
+
## Screenshots
|
|
23
|
+
|
|
24
|
+
Screenshots are **enabled** for this project. For UI changes, Bart captures screenshots after the code is final and before the PR, following Luna's plan, and posts them to the tracker as a separate comment.
|
|
25
|
+
{{/SCREENSHOTS_ENABLED}}
|
|
26
|
+
{{#SCREENSHOTS_DISABLED}}
|
|
27
|
+
## Screenshots
|
|
28
|
+
|
|
29
|
+
Screenshots are **disabled** for this project. Do not capture any unless the user explicitly asks.
|
|
30
|
+
{{/SCREENSHOTS_DISABLED}}
|
|
31
|
+
|
|
32
|
+
{{TRACKER_SECTION}}
|
|
33
|
+
|
|
34
|
+
## Your mission
|
|
35
|
+
|
|
36
|
+
Drive the plan from session memory step by step. Delegate each step with the Agent tool, give the agent the exact step and the relevant decisions, and require an observation for every claim ("tests pass" means the test output, "endpoint works" means the response). In order:
|
|
37
|
+
|
|
38
|
+
1. **Dennis implements** — create the branch, implement per the plan, run linter and build, commit. Report files changed and technical decisions.
|
|
39
|
+
2. **Sam audits** — every changed file, full checklist (feature envy, separation of concerns, clear interfaces, layering, god files), file:line for each finding. If there are violations, send Dennis back to fix them, then have Sam re-audit.
|
|
40
|
+
3. **Vera tests** — unit/regression tests for the changed code, run and verified, committed.
|
|
41
|
+
4. **Luna / Mark / Nora** — only where applicable (UI, user-facing copy, user-facing behaviour). Each proposes exact changes; Dennis applies them.
|
|
42
|
+
5. **Bart reviews and publishes** — reads all changed files, checks edge cases and error handling, runs linter and build, captures screenshots if applicable, pushes and creates the PR, posts the PR link on the tracker, posts screenshots as a separate comment.
|
|
43
|
+
6. Ask Dennis, Sam and Bart to post their brief tracker comments (files changed & decisions; architecture findings or clean audit with evidence; PR link, test results, screenshots).
|
|
44
|
+
|
|
45
|
+
Finish by answering with the JSON object required by the output schema — what was implemented, files changed, the PR URL (empty string if none), QA results, issues fixed, and what the reviewers should look at. Nothing else after it.
|