@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.
@@ -8,49 +8,117 @@
8
8
  //
9
9
  // decidePreToolUse() is pure so the policy is unit-tested without the SDK.
10
10
 
11
+ import { classify } from "./commands.mjs";
12
+
11
13
  export const MUTATING_TOOLS = Object.freeze(new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]));
12
14
  export const CODE_TOOLS = Object.freeze(new Set([...MUTATING_TOOLS, "Bash"]));
13
15
 
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
-
16
+ // Commands that publish work: `git push`, `gh pr create` and their relatives,
17
+ // however they are spelled — `git -C . push`, `env git push`, on a later
18
+ // line, inside `sh -c`, behind `sudo`. commands.mjs parses the shell text;
19
+ // anything it cannot model fails closed when the text hints at publishing.
18
20
  export function isPublishCommand(command) {
19
- return PUBLISH_RE.test(String(command || ""));
21
+ return classify(command).publishes;
20
22
  }
21
23
 
22
24
  // Commands that move HEAD or rewrite the tree. SUMMARY reports on an approved
23
25
  // revision; it must not be able to change which revision that is.
24
- const HISTORY_RE = /(^|[;&|]\s*)git\s+(commit|merge|rebase|reset|checkout|switch|cherry-pick|revert|am|apply|stash|restore|clean)\b/;
25
-
26
26
  export function isHistoryCommand(command) {
27
- return HISTORY_RE.test(String(command || ""));
27
+ return classify(command).history;
28
28
  }
29
29
 
30
30
  // The auditor whose sign-off gates publishing. "Sam's audit is a blocking
31
- // gate — not advisory" used to be prose; now it is the observable fact that a
32
- // Sam subagent finished in this EXECUTION phase (SubagentStop).
31
+ // gate — not advisory" used to be prose; now it is an explicit verdict the
32
+ // engine reads from his report and ties to the revision he audited.
33
33
  export const AUDITOR = "Sam";
34
34
 
35
+ // The last line of the auditor's report. Bold or plain, any case; an
36
+ // optional commit after the verdict is checked against the real HEAD.
37
+ const AUDIT_LINE_RE = /^[ \t]*(?:\*\*)?AUDIT:?(?:\*\*)?[ \t]*(?:\*\*)?(APPROVED|REJECTED)(?:\*\*)?\b(?:[ \t]*(?:at|@)?[ \t]*`?([0-9a-f]{7,40})`?)?/gim;
38
+
39
+ export const AUDIT_INSTRUCTION = `End your report with exactly one line, on its own: \`AUDIT: APPROVED\` when the committed revision has no violations left, or \`AUDIT: REJECTED — <one line why>\` when it does. The engine reads that line: without it, or after REJECTED, nothing can be published. Audit committed code — a commit made after your approval needs a new audit.`;
40
+
41
+ export function parseAuditVerdict(text) {
42
+ const s = String(text || "");
43
+ let m, last = null;
44
+ AUDIT_LINE_RE.lastIndex = 0;
45
+ while ((m = AUDIT_LINE_RE.exec(s))) last = m;
46
+ if (!last) return { verdict: "MISSING", statedRevision: null };
47
+ return { verdict: last[1].toUpperCase(), statedRevision: last[2] || null };
48
+ }
49
+
50
+ // The text of a tool result, whatever shape the harness hands it in.
51
+ export function responseText(response) {
52
+ if (response == null) return "";
53
+ if (typeof response === "string") return response;
54
+ if (Array.isArray(response)) return response.map(responseText).filter(Boolean).join("\n");
55
+ if (typeof response === "object") {
56
+ if (typeof response.text === "string") return response.text;
57
+ if (response.content !== undefined) return responseText(response.content);
58
+ if (typeof response.result === "string") return response.result;
59
+ if (typeof response.output === "string") return response.output;
60
+ }
61
+ return "";
62
+ }
63
+
35
64
  // Called at the start of every EXECUTION: nothing may be published until the
36
- // auditor has run in this phase. `openFindings` (from a rejected REVIEW) is
37
- // carried for the denial message so the team sees what is outstanding.
65
+ // auditor has approved in this phase. `openFindings` (from a rejected REVIEW)
66
+ // is carried for the denial message so the team sees what is outstanding.
38
67
  export function armPublishGate(state, openFindings = []) {
39
68
  state.awaitingAudit = true;
69
+ state.audit = null;
40
70
  state.openFindings = Array.isArray(openFindings) ? openFindings : [];
41
71
  }
42
72
 
43
- export function onSubagentStopped(state, { phase, agentType }) {
44
- if (phase === "EXECUTION" && agentType === AUDITOR) {
45
- state.awaitingAudit = false;
46
- state.openFindings = [];
73
+ // Records the auditor's verdict for the tree as it is right now.
74
+ // tree { revision, clean } observed by the engine when the report arrived
75
+ // Returns the audit record, or null when the report is not the auditor's in
76
+ // EXECUTION. The latest report wins: a re-audit after fixes replaces the old.
77
+ export function recordAudit(state, { phase, agentType, text, tree }) {
78
+ if (phase !== "EXECUTION" || agentType !== AUDITOR) return null;
79
+ const { verdict, statedRevision } = parseAuditVerdict(text);
80
+ const revision = tree?.revision ?? null;
81
+ const stale = !!(statedRevision && revision && !revision.startsWith(statedRevision));
82
+ const audit = { verdict: stale ? "STALE" : verdict, revision, clean: tree?.clean ?? null, statedRevision, at: new Date().toISOString() };
83
+ state.audit = audit;
84
+ state.awaitingAudit = audit.verdict !== "APPROVED";
85
+ if (audit.verdict === "APPROVED") state.openFindings = [];
86
+ return audit;
87
+ }
88
+
89
+ const short = rev => (rev ? String(rev).slice(0, 7) : "?");
90
+
91
+ function auditDenial(state, treeNow) {
92
+ const open = Array.isArray(state.openFindings) ? state.openFindings : [];
93
+ const audit = state.audit;
94
+ const fixThenAudit = `have ${AUDITOR} audit the committed revision, fix what he flags, commit, have him re-audit, then publish`;
95
+ if (state.awaitingAudit || open.length > 0) {
96
+ const list = open.slice(0, 5).map(f => `- ${f.title || f}`).join("\n");
97
+ const findings = open.length > 0 ? `${open.length} review finding(s) are unresolved:\n${list}\n` : "";
98
+ let why;
99
+ if (audit?.verdict === "REJECTED") why = `${AUDITOR}'s audit REJECTED the change`;
100
+ else if (audit?.verdict === "STALE") why = `${AUDITOR} approved ${short(audit.statedRevision)} but the code is at ${short(audit.revision)}`;
101
+ else if (audit?.verdict === "MISSING") why = `${AUDITOR}'s report did not end with an AUDIT line`;
102
+ else why = `${AUDITOR} has not audited in this phase`;
103
+ return `Cannot publish yet — ${findings}${why}; ${fixThenAudit}.`;
47
104
  }
105
+ if (audit?.verdict === "APPROVED") {
106
+ const tree = typeof treeNow === "function" ? treeNow() : null;
107
+ if (tree?.revision && audit.revision && tree.revision !== audit.revision) {
108
+ return `Cannot publish: ${AUDITOR} approved ${short(audit.revision)} but HEAD is now ${short(tree.revision)} — the approval covers only the revision he read. Have ${AUDITOR} audit the new commits, then publish.`;
109
+ }
110
+ if (state.requireClean && audit.clean === false) {
111
+ return `Cannot publish: ${AUDITOR} audited while the tree had uncommitted changes, so his approval is not tied to a commit. Commit, have ${AUDITOR} audit the commit, then publish.`;
112
+ }
113
+ }
114
+ return null;
48
115
  }
49
116
 
50
- // input — the SDK PreToolUseHookInput ({ tool_name, tool_input, agent_id?, agent_type? }).
51
- // `agent_id` is present only inside a subagent (BaseHookInput docs).
52
- // state — mutable session state; reads `awaitingAudit` and `openFindings`.
53
- export function decidePreToolUse({ phase, input, state = {} }) {
117
+ // input — the SDK PreToolUseHookInput ({ tool_name, tool_input, agent_id?, agent_type? }).
118
+ // `agent_id` is present only inside a subagent (BaseHookInput docs).
119
+ // state — mutable session state; reads `awaitingAudit`, `audit`, `openFindings`, `requireClean`.
120
+ // treeNow () => { revision, clean }; consulted only for a publish command.
121
+ export function decidePreToolUse({ phase, input, state = {}, treeNow = state.treeNow }) {
54
122
  const tool = input?.tool_name;
55
123
  const isMainThread = !input?.agent_id;
56
124
 
@@ -84,17 +152,8 @@ export function decidePreToolUse({ phase, input, state = {} }) {
84
152
  }
85
153
 
86
154
  if (phase === "EXECUTION" && tool === "Bash" && isPublishCommand(input?.tool_input?.command)) {
87
- const open = Array.isArray(state.openFindings) ? state.openFindings : [];
88
- if (state.awaitingAudit || open.length > 0) {
89
- const list = open.slice(0, 5).map(f => `- ${f.title || f}`).join("\n");
90
- const why = open.length > 0
91
- ? `${open.length} review finding(s) are unresolved:\n${list}\n`
92
- : "";
93
- return {
94
- decision: "deny",
95
- 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).`,
96
- };
97
- }
155
+ const reason = auditDenial(state, treeNow);
156
+ if (reason) return { decision: "deny", reason };
98
157
  }
99
158
 
100
159
  return { decision: "allow" };
@@ -111,10 +170,10 @@ function denyOutput(reason) {
111
170
  }
112
171
 
113
172
  // Build the SDK `hooks` option for one phase.
114
- // state — shared session state (openFindings, verifyPublish, ...)
173
+ // state — shared session state (openFindings, audit, verifyPublish, treeNow, ...)
115
174
  // onToolUse — ({ agentType, tool, input }) for the dashboard
116
- // onToolResult — ({ agentType, tool, response }) for the dashboard
117
- // onSubagentStop — ({ agentType }) when a subagent finishes
175
+ // onToolResult — ({ agentType, tool, input, actionId, response }) for the dashboard and the audit
176
+ // onSubagentStop — ({ agentType, text }) when a subagent finishes; `text` is its last message when the harness provides it
118
177
  // verifyTimeoutSec — hook timeout for PreToolUse; publishing may run the
119
178
  // project's checks inside the hook (state.verifyPublish)
120
179
  export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagentStop, verifyTimeoutSec } = {}) {
@@ -144,7 +203,7 @@ export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagen
144
203
  }],
145
204
  SubagentStop: [{
146
205
  hooks: [async (input) => {
147
- onSubagentStop?.({ agentType: input.agent_type || null });
206
+ onSubagentStop?.({ agentType: input.agent_type || null, text: typeof input.last_assistant_message === "string" ? input.last_assistant_message : null });
148
207
  return {};
149
208
  }],
150
209
  }],
@@ -0,0 +1,156 @@
1
+ // Project lessons with provenance — an engine-owned ledger.
2
+ //
3
+ // `.agentdesk/memory.md` was free text the agents edited themselves: no
4
+ // source, no scope, no way to correct or retire an entry, and in a worktree
5
+ // session it was read from the worktree, where it never existed. This ledger
6
+ // lives in the *source* project directory (shared by every worktree; local,
7
+ // gitignored). Agents never write it: SUMMARY and SOLO propose lessons and
8
+ // retirements in their structured handoff, the engine records them with
9
+ // where they came from, and the engine's own verdict on the session decides
10
+ // whether a lesson is `active` (session ended `complete`) or only `proposed`.
11
+
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { randomBytes } from "node:crypto";
15
+ import { TOUCH_AREAS } from "./team-profile.mjs";
16
+ import { shortRev } from "./evidence.mjs";
17
+
18
+ export const LESSON_STATUSES = Object.freeze(["proposed", "active", "retired"]);
19
+ export const MAX_LESSONS = 500; // ledger size; oldest retired, then oldest proposed, go first
20
+ export const MAX_LESSON_TEXT = 1000; // stored text
21
+ export const MAX_LESSON_NOTE = 500; // stored evidence / retirement reason
22
+ export const INJECT_LIMIT = 30; // entries per prompt
23
+ export const INJECT_TEXT_LIMIT = 300; // characters per entry in a prompt
24
+
25
+ export function lessonsPath(sourceCwd) {
26
+ return join(sourceCwd, ".agentdesk", "lessons.json");
27
+ }
28
+
29
+ const EMPTY = () => ({ version: 1, lessons: [] });
30
+
31
+ export function readLessons(path) {
32
+ try {
33
+ if (!existsSync(path)) return EMPTY();
34
+ const data = JSON.parse(readFileSync(path, "utf8"));
35
+ if (!data || data.version !== 1 || !Array.isArray(data.lessons)) return EMPTY();
36
+ return { version: 1, lessons: data.lessons.filter(l => l && typeof l.id === "string" && typeof l.text === "string") };
37
+ } catch { return EMPTY(); }
38
+ }
39
+
40
+ export function writeLessons(path, data) {
41
+ mkdirSync(dirname(path), { recursive: true });
42
+ writeFileSync(`${path}.tmp`, JSON.stringify(data, null, 2), { mode: 0o600 });
43
+ renameSync(`${path}.tmp`, path);
44
+ }
45
+
46
+ // "project" | "area:<ui|copy|docs|api|data>" | "path:<relative prefix>".
47
+ // Anything else widens to "project" — the safe direction for a hint.
48
+ export function normalizeScope(scope) {
49
+ const s = String(scope ?? "").trim();
50
+ if (s === "project") return s;
51
+ const area = s.match(/^area:([a-z]+)$/);
52
+ if (area) return TOUCH_AREAS.includes(area[1]) ? s : "project";
53
+ const path = s.match(/^path:(.+)$/);
54
+ if (path) {
55
+ const prefix = path[1].trim().replace(/^\.\//, "");
56
+ if (prefix && prefix.length <= 200 && !prefix.startsWith("/") && !prefix.split("/").includes("..")) return `path:${prefix}`;
57
+ }
58
+ return "project";
59
+ }
60
+
61
+ const clean = (text, max) => String(text ?? "").replace(/\s+/g, " ").trim().slice(0, max);
62
+ const textKey = text => clean(text, MAX_LESSON_TEXT).toLowerCase();
63
+ const newId = () => randomBytes(4).toString("hex");
64
+ const byAge = (a, b) => String(a.createdAt).localeCompare(String(b.createdAt));
65
+
66
+ // When the ledger outgrows MAX_LESSONS, drop the oldest retired entries, then
67
+ // the oldest proposed ones. Active lessons are never dropped here.
68
+ function bounded(list) {
69
+ const keep = [...list];
70
+ for (const status of ["retired", "proposed"]) {
71
+ while (keep.length > MAX_LESSONS) {
72
+ const victim = keep.filter(l => l.status === status).sort(byAge)[0];
73
+ if (!victim) break;
74
+ keep.splice(keep.indexOf(victim), 1);
75
+ }
76
+ }
77
+ return keep;
78
+ }
79
+
80
+ // Apply a single proposal to the lessons list, mutating list and counts.
81
+ function applyProposal({ list, proposal, source, complete, at, counts }) {
82
+ const text = clean(proposal?.text, MAX_LESSON_TEXT);
83
+ if (!text) return;
84
+ const key = textKey(text);
85
+ const existing = list.find(l => l.status !== "retired" && textKey(l.text) === key);
86
+ if (existing) {
87
+ if (!complete) return; // an unverified session cannot confirm anything
88
+ if (!existing.confirmedBy.includes(source.sessionId)) existing.confirmedBy.push(source.sessionId);
89
+ if (existing.status === "proposed") { existing.status = "active"; counts.activated++; } else counts.confirmed++;
90
+ return;
91
+ }
92
+ list.push({
93
+ id: newId(), text, scope: normalizeScope(proposal?.scope), evidence: clean(proposal?.evidence, MAX_LESSON_NOTE),
94
+ source: { sessionId: source.sessionId, taskId: source.taskId || null, phase: source.phase, agent: source.agent, revision: source.revision || null },
95
+ createdAt: at, status: complete ? "active" : "proposed", confirmedBy: complete ? [source.sessionId] : [],
96
+ });
97
+ if (complete) counts.activated++; else counts.proposed++;
98
+ }
99
+
100
+ // Apply a single retirement to the lessons list, mutating list and counts.
101
+ function applyRetirement({ list, retirement, source, at, counts }) {
102
+ const index = list.findIndex(l => l.id === retirement?.id);
103
+ if (index < 0 || list[index].status === "retired") return;
104
+ if (list[index].source?.sessionId === source.sessionId) { list.splice(index, 1); counts.dropped++; return; }
105
+ list[index] = { ...list[index], status: "retired", retired: { sessionId: source.sessionId, reason: clean(retirement?.reason, MAX_LESSON_NOTE), at } };
106
+ counts.retired++;
107
+ }
108
+
109
+ // Records what a session's SUMMARY/SOLO handed back. `complete` is the
110
+ // engine's verdict on the whole session (verified approval), not the agents'.
111
+ export function recordLessons({ path, proposals = [], retirements = [], source, complete = false, now = () => new Date().toISOString() }) {
112
+ const data = readLessons(path);
113
+ const list = data.lessons;
114
+ const at = now();
115
+ const counts = { activated: 0, proposed: 0, confirmed: 0, retired: 0, dropped: 0 };
116
+
117
+ for (const p of Array.isArray(proposals) ? proposals : []) {
118
+ applyProposal({ list, proposal: p, source, complete, at, counts });
119
+ }
120
+
121
+ for (const r of Array.isArray(retirements) ? retirements : []) {
122
+ applyRetirement({ list, retirement: r, source, at, counts });
123
+ }
124
+
125
+ const changed = Object.values(counts).some(n => n > 0);
126
+ data.lessons = bounded(list);
127
+ if (changed) writeLessons(path, data);
128
+ return { ...counts, changed, lessons: data.lessons };
129
+ }
130
+
131
+ // Active lessons in scope, newest first, bounded — what a prompt gets.
132
+ export function selectLessons({ lessons = [], touches = [...TOUCH_AREAS], limit = INJECT_LIMIT, textLimit = INJECT_TEXT_LIMIT } = {}) {
133
+ const inScope = l => l.scope === "project" || String(l.scope).startsWith("path:")
134
+ || (String(l.scope).startsWith("area:") && touches.includes(String(l.scope).slice(5)));
135
+ return lessons
136
+ .filter(l => l.status === "active" && inScope(l))
137
+ .sort((a, b) => byAge(b, a))
138
+ .slice(0, limit)
139
+ .map(l => ({ ...l, text: l.text.length > textLimit ? `${l.text.slice(0, textLimit - 1)}…` : l.text }));
140
+ }
141
+
142
+ export function renderLessonsSection(lessons = []) {
143
+ if (!lessons.length) return "";
144
+ const lines = [
145
+ "## PROJECT LESSONS",
146
+ "",
147
+ "Recorded by the engine from earlier sessions on this project, newest first, each with its id and where it came from. Apply them. If one proved wrong or obsolete in this session, retire it in the SUMMARY handoff (`retireLessons`, with the id and why) — never edit files to change them.",
148
+ "",
149
+ ];
150
+ for (const l of lessons) {
151
+ const from = [l.source?.taskId, l.source?.phase, l.source?.revision ? shortRev(l.source.revision) : null, String(l.createdAt || "").slice(0, 10)]
152
+ .filter(Boolean).join(" · ");
153
+ lines.push(`- [${l.id}] (${l.scope}) ${l.text}${l.evidence ? ` — evidence: ${l.evidence}` : ""}${from ? ` — from ${from}` : ""}`);
154
+ }
155
+ return lines.join("\n");
156
+ }
@@ -14,7 +14,7 @@ The reviewers returned the following. Work from this list; do not re-derive it.
14
14
  ## Rules
15
15
 
16
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.
17
+ - **Sam's audit is a blocking gate.** After Dennis implements and commits, Sam must read every changed file and run his full checklist, citing file:line for every finding — "looks clean" without evidence is invalid. Sam ends his report with `AUDIT: APPROVED` or `AUDIT: REJECTED — <why>`; the engine reads that line and ties an approval to the commit he audited. Publishing is refused without an approval for the current commit: after REJECTED, Dennis fixes and commits and Sam re-audits; any commit made after an approval needs a new audit too.
18
18
  {{#HAS_NORA}}- 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]".{{/HAS_NORA}}
19
19
  - Do NOT post the final tracker summary or transition the task here — SUMMARY owns all final tracker writes.
20
20
 
@@ -36,10 +36,12 @@ Screenshots are **disabled** for this project. Do not capture any unless the use
36
36
  {{#NO_PLAN}}This task was assessed as small, so there was no PLAN phase. First have Dennis state the approach in two or three lines — files to change, the risk, how it will be verified — and confirm it; that is the plan. Then drive it step by step.{{/NO_PLAN}}{{#HAS_PLAN}}Drive the plan from session memory step by step.{{/HAS_PLAN}} 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
37
 
38
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.
39
+ 2. **Sam audits the commit** — every changed file, full checklist (feature envy, separation of concerns, clear interfaces, layering, god files), file:line for each finding, closing with his `AUDIT:` line. If there are violations, send Dennis back to fix and commit them, then have Sam re-audit.
40
40
  3. **Vera tests** — unit/regression tests for the changed code, run and verified, committed.
41
41
  {{#HAS_SPECIALISTS}}4. **{{SPECIALISTS}}** — only where applicable (UI, user-facing copy, user-facing behaviour). Each proposes exact changes; Dennis applies them.{{/HAS_SPECIALISTS}}
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.
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. Sam's approval must cover the commit being published: if anyone committed after his audit (Vera's tests, a specialist's change), have Sam audit the new commits first — a short re-audit is enough.
43
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
44
 
45
+ After each report, decide whether its assignment is accepted, needs a specific correction, or is blocked. Name the next owner and action. Keep unmet acceptance criteria visible and send incomplete work back to its owner; do not simply pass the report to the next agent. If a required check cannot run, assign a bounded attempt to restore it and retain the requirement as unverified if that fails. For example, a CSS class assertion alone does not establish rendered button clearance. Preserve required audit gates when resolving disagreements, and keep unrelated cleanup out of this task. Carry accepted evidence in `qaResults` and unresolved criteria, blockers and their next owners/actions in `reviewerAttention`.
46
+
45
47
  The structured output required by the schema is captured automatically — what was implemented, files changed, the PR URL (empty string if none), QA results, issues fixed, and what the reviewers should look at. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
@@ -28,7 +28,8 @@ Task description:
28
28
  - explore the code relevant to the task and report the patterns he finds.
29
29
  Ask him to report everything back plainly — you decide what matters.
30
30
  2. **Tracker session start.** Dictate the exact comment text ("Team session started. Session: {{SESSION_URL}}") and ask Dennis to post it and move the task to "In Progress", confirming with the command output.
31
- 3. **Assess scope.** Restate the task as user outcomes and acceptance criteria. If it is too large for one session, decompose it into subtasks (basic vs deferred) in product terms and have Dennis create them in the tracker.
31
+ 3. **Set the delivery target.** Reconcile the original task, latest user corrections, and existing work. State what remains to deliver and what evidence will close each acceptance criterion. A previous agent's deferral is not permission to omit something the user now explicitly requires. Make routine scope interpretations yourself; ask only when missing information or a consequential choice cannot be resolved by the team. Decompose large work into owned steps without silently deferring requested scope; obtain a user decision if completing the request requires changing that scope.
32
32
  4. Announce `SESSION_TITLE: <4-8 word title>` on its own line.
33
+ 5. **Hand over a direction.** Put the current acceptance criteria in `requirements`, accepted facts and scope decisions in `assessment`, and the next owner, action and required evidence in `nextPhaseFocus`. Distinguish confirmed work from claims still needing verification.
33
34
 
34
35
  The structured output required by the schema is captured automatically — title, task summary, requirements, assessment (branches, PRs, patterns, resume context), subtasks, and what PLAN should focus on. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
@@ -7,13 +7,13 @@ Task: {{TASK_ID}}
7
7
 
8
8
  - Follow CLAUDE.md conventions (if present). Do not modify files unrelated to the task.
9
9
  - No code and no file changes in this phase. Plan only.
10
- - Sam's audit is a blocking gate in EXECUTION: the plan must leave room for it. The PR cannot be created until Sam signs off with file:line evidence.
10
+ - Order the work correctly: PLAN chooses the approach; EXECUTION starts with implementation and tests; Sam then audits the resulting changes inside EXECUTION; approval permits publishing. Sam's code audit gates publishing, not entry into EXECUTION. Do not create a circular dependency by requiring an audit of unwritten changes before implementation can start.
11
11
 
12
12
  {{TRACKER_SECTION}}
13
13
 
14
14
  ## Your mission
15
15
 
16
- 1. Restate the task in product terms — what the user gets, acceptance criteria, scope boundaries. No file names, no jargon.
16
+ 1. Set the delivery target — what the user gets, acceptance criteria, scope boundaries, and the highest-priority unresolved requirement. Explain technical constraints when they affect the decision.
17
17
  2. **Delegate in parallel** to the team, each with the task summary and requirements from session memory. Ask each to verify assumptions with tools (Glob, Grep, Read) — no guessing — and to report:
18
18
  - Dennis: implementation plan — files to modify, approach, complexity (S/M/L).
19
19
  - Sam: architecture review — existing patterns, module boundaries, whether the approach keeps concerns separated.
@@ -21,6 +21,7 @@ Task: {{TASK_ID}}
21
21
  {{#HAS_LUNA}} - Luna (only if the task touches UI): visual impact, accessibility, and a screenshot plan (pages, viewports).{{/HAS_LUNA}}
22
22
  {{#HAS_MARK}} - Mark (only if user-facing text changes): copy audit.{{/HAS_MARK}}
23
23
  {{#HAS_NORA}} - Nora (only if user-facing behaviour changes): which docs/README/help surfaces must change.{{/HAS_NORA}}
24
- 3. Relay the substance of each report in a few lines. Ask for objections once. Resolve them and declare the plan final do not brainstorm beyond two rounds.
24
+ 3. **Choose the plan.** Compare the proposals against acceptance criteria, existing project conventions and evidence. Ask for objections once; resolve factual disagreements with a focused check and record the decision and reason. Make routine implementation choices within the authorized scope without sending them back to the user. Do not brainstorm beyond two rounds.
25
+ 4. **Assign accountable work.** Each step needs one owner, dependencies, a bounded deliverable and the evidence that will demonstrate acceptance. Put these in `assignments` and `steps`; put tradeoffs and rationale in `decisions`. Include every required acceptance criterion and identify real blockers in `risks`.
25
26
 
26
27
  The structured output required by the schema is captured automatically — approach, files to modify, decisions, risks, agent assignments, and the ordered implementation steps. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
@@ -16,7 +16,7 @@ Task: {{TASK_ID}}
16
16
  - **Bart** — does the implementation meet the acceptance criteria from INTAKE? Is any requirement missed or silently deferred? Is the PR description accurate, does it reference the task, are screenshots attached where expected?
17
17
  {{#NO_SAM}} - **Bart** also runs the verification audit in Sam's place: for every claim EXECUTION made (deployed, tests pass, endpoint works, migration ran), confirm it is backed by an observation he can reproduce; anything resting on inference is a finding.{{/NO_SAM}}
18
18
  - **Vera** — run the test suite and report the real output; is the changed code covered; do the new tests exercise the behaviour that changed?
19
- 2. Weigh the reports. Be strict but not pedantic: only actual gaps against the task requirements and the plan — not stylistic preferences or speculative refactors.
20
- 3. Decide: `APPROVED` or `NEEDS_MORE_WORK`.
19
+ 2. **Own the acceptance decision.** Reconcile conflicting reports against the current user requirements and direct observations. Request a focused check when evidence conflicts. Be strict but not pedantic: only actual gaps against the task requirements and the plan — not stylistic preferences or speculative refactors. An agent saying "done" or a majority vote cannot override a failed check, a missing observation, or a required acceptance criterion.
20
+ 3. Decide: `APPROVED` only when required outcomes have evidence and no blocking finding remains; otherwise `NEEDS_MORE_WORK`. For each finding, write `Owner: <agent name>; Action: <specific corrective work>; Accept when: <required observation>` in its `detail` so EXECUTION can act immediately. Assign implementation fixes to Dennis, test gaps to Vera, and missing user-flow or rendered acceptance checks to Bart. The reviewer field identifies who found the gap and does not replace the corrective owner. Reserve `deferred` for work explicitly outside the authorized scope or deferred by the user; a previous agent's convenience deferral does not remove a requirement.
21
21
 
22
22
  The structured output required by the schema is captured automatically — the verdict, the findings (reviewer, title, detail, file, line), items explicitly out of scope, and any claims that were not backed by an observation. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
@@ -112,4 +112,8 @@ This keeps everything in one branch — no conflicts, one PR to review.
112
112
  If the task has no child items, just work on it normally as a single task.
113
113
  {{/CHILD_TASKS}}
114
114
 
115
- The structured output required by the schema is captured automatically — a summary of what was done, files changed, the PR URL (empty string if none), deferred items, and manual steps. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
115
+ ## Lessons for future sessions
116
+
117
+ The handoff has `lessons` and `retireLessons`. Propose a lesson only for something non-obvious that cost you time and would cost the next session too — a setup step, seed data, an environment quirk, a deployment step. Each needs the evidence (the command and its outcome) and a scope: `project`, `area:<ui|copy|docs|api|data>`, or `path:<file or directory prefix>`. Never store secrets — name the env var instead. Do not restate what README, CLAUDE.md or the code already says. If an entry under PROJECT LESSONS proved wrong or obsolete, retire it by its id with the reason. Empty arrays are the normal case.
118
+
119
+ The structured output required by the schema is captured automatically — a summary of what was done, files changed, the PR URL (empty string if none), deferred items, manual steps, lessons, and retirements. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
@@ -12,12 +12,13 @@ Task: {{TASK_ID}}
12
12
 
13
13
  ## Your mission
14
14
 
15
- 1. Dictate the final summary in product terms. It must include:
15
+ 1. **Own the delivery report.** Reconcile the latest review and engine verification before dictating the summary. Distinguish delivered, unverified, blocked and user-approved deferred items. If review remains unresolved, report the work as incomplete; do not describe it as ready or substitute a successful test for an unmet acceptance criterion. It must include:
16
16
  - **What was done** — outcome-focused, non-technical.
17
17
  - **What was omitted / deferred** — everything from REVIEW's out-of-scope list plus anything the team explicitly skipped.
18
- - **Manual steps** — actions the developer must perform (migrations, config, deploys).
18
+ - **Manual steps** — every remaining action outside this session, including access recovery, migrations, config and deploys. Name the responsible person or role and how completion will be confirmed. Required work blocked on access is not an out-of-scope deferral.
19
19
  - **PR link**.
20
20
  - **Session link**: {{SESSION_URL}}
21
- 2. Delegate the tracker writes to Dennis and require the command output for each: verify the PR link is attached (attach it if missing), transition the task to "In Review", post the final comment.
21
+ 2. Delegate the tracker writes to Dennis and require the command output for each: verify the existing PR link is attached (attach it if missing), transition the task to "In Review" only when the review and engine verification permit it, and post the accurate final comment. If a write fails, identify the blocker in `status` and put the recovery owner/action in `manualSteps`, including who retries delivery after access is restored and the receipt needed to confirm it. Do not leave `manualSteps` empty when an access fix is required. Put only the confirmed posted text in `summaryComment`, leaving it empty if posting was not confirmed. The final user-facing update must state the outcome and any remaining action with its owner.
22
+ 3. **Lessons for future sessions.** The handoff has `lessons` and `retireLessons`. Propose a lesson only for something non-obvious that cost this session time and would cost the next one too — a setup step, seed data, an environment quirk, a deployment step, a review finding that keeps recurring. Each needs the evidence (what happened: the command and its outcome) and a scope: `project`, `area:<ui|copy|docs|api|data>`, or `path:<file or directory prefix>`. Never store secrets — name the env var instead. Do not restate what README, CLAUDE.md or the code already says. If an entry under PROJECT LESSONS proved wrong or obsolete in this session, retire it by its id with the reason. Empty arrays are the normal case.
22
23
 
23
- The structured output required by the schema is captured automatically — status, PR URL, deferred items, manual steps, and the summary comment exactly as posted. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
24
+ The structured output required by the schema is captured automatically — status, PR URL, deferred items, manual steps, the summary comment exactly as posted, lessons, and retirements. Do not repeat the JSON object in chat; keep any closing chat message brief and human-readable.
@@ -10,11 +10,12 @@
10
10
  import { readFileSync } from "fs";
11
11
  import { dirname, join } from "path";
12
12
  import { fileURLToPath } from "url";
13
- import { wrapUntrusted, PROMPT_SECURITY_HEADER, MEMORY_INSTRUCTIONS, loadProjectMemory } from "../prompt.mjs";
13
+ import { wrapUntrusted, PROMPT_SECURITY_HEADER, LEGACY_NOTES_HEADER } from "../prompt.mjs";
14
14
  import { generateContext } from "../detect.mjs";
15
15
  import { formatFindingsForRetry } from "./verdict.mjs";
16
16
  import { formatEvidenceForPrompt } from "./evidence.mjs";
17
17
  import { renderOpenItems } from "./handoff.mjs";
18
+ import { renderLessonsSection } from "./lessons.mjs";
18
19
 
19
20
  const here = dirname(fileURLToPath(import.meta.url));
20
21
 
@@ -40,6 +41,16 @@ export function renderTemplate(text, { flags = new Set(), vars = {} } = {}) {
40
41
  return out;
41
42
  }
42
43
 
44
+ // Engine-provided project knowledge: active lessons in scope, then the
45
+ // hand-written notes from before the ledger existed (read-only).
46
+ function projectKnowledge({ lessons = [], projectNotes = "" }) {
47
+ let out = "";
48
+ const block = renderLessonsSection(lessons);
49
+ if (block) out += `\n\n${block}`;
50
+ if (projectNotes) out += `\n\n${LEGACY_NOTES_HEADER}\n\n${projectNotes}`;
51
+ return out;
52
+ }
53
+
43
54
  function trackerSection(tracker, phases, vars) {
44
55
  if (!tracker) return "";
45
56
  let text;
@@ -67,7 +78,7 @@ function soloSearchAndCreate({ tracker, config }) {
67
78
  // tools, no lead and no review gate. Ported from the legacy buildSoloPrompt.
68
79
  export function renderSoloPrompt({
69
80
  agent, taskId, taskLink, description, tracker, config = {}, project = {},
70
- sessionUrl, cwd, childStrategy,
81
+ sessionUrl, childStrategy, lessons = [], projectNotes = "",
71
82
  }) {
72
83
  const hasRealTaskId = !!taskId && !String(taskId).startsWith("new-") && !String(taskId).startsWith("task-");
73
84
  const vars = {
@@ -104,9 +115,7 @@ export function renderSoloPrompt({
104
115
  }
105
116
  if (config.instructions) body += `\n\n## ADDITIONAL INSTRUCTIONS\n\n${config.instructions}`;
106
117
 
107
- body += `\n\n${MEMORY_INSTRUCTIONS}`;
108
- const memory = loadProjectMemory(cwd);
109
- if (memory) body += `\n\n### Current memory\n\n${memory}`;
118
+ body += projectKnowledge({ lessons, projectNotes });
110
119
 
111
120
  const context = generateContext(project);
112
121
  const now = new Date();
@@ -135,8 +144,8 @@ function createTaskSection({ tracker, config, description }) {
135
144
  // Returns the full user prompt for one phase's query().
136
145
  export function renderPhasePrompt({
137
146
  phase, taskId, taskLink, description, createTask, tracker, config = {}, project = {},
138
- sessionUrl, cwd, sessionMemory = "", retryVerdict = null, evidence = null, openItems = [],
139
- roster = null, profile = null, handoffRetry = false,
147
+ sessionUrl, sessionMemory = "", retryVerdict = null, evidence = null, openItems = [],
148
+ roster = null, profile = null, handoffRetry = false, lessons = [], projectNotes = "",
140
149
  }) {
141
150
  const vars = {
142
151
  TASK_ID: taskId,
@@ -187,9 +196,7 @@ export function renderPhasePrompt({
187
196
  body += `\n\n## SESSION MEMORY (previous phases)\n\n${sessionMemory}`;
188
197
  }
189
198
 
190
- body += `\n\n${MEMORY_INSTRUCTIONS}`;
191
- const memory = loadProjectMemory(cwd);
192
- if (memory) body += `\n\n### Current memory\n\n${memory}`;
199
+ body += projectKnowledge({ lessons, projectNotes });
193
200
 
194
201
  if (config.projectAgents?.length) {
195
202
  project.configAgents = config.projectAgents.map(a => ({ ...a, type: a.type || "declared", source: ".agentdesk.json" }));
@@ -0,0 +1,101 @@
1
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { PHASE_OUTPUT_SCHEMAS } from "./schemas.mjs";
5
+
6
+ export function checkpointStore(directory, sessionId, { resume = false } = {}) {
7
+ mkdirSync(directory, { recursive: true });
8
+ const path = join(directory, `recovery-${createHash("sha256").update(sessionId).digest("hex").slice(0, 24)}.json`);
9
+ let data = { version: 1, sessionId, queue: null, receipts: {}, outcomes: [], instructions: [], transcript: [], totals: null };
10
+ if (resume) {
11
+ if (!existsSync(path)) throw new Error("No recovery checkpoint exists for this session. Start a separately reviewed continuation instead.");
12
+ data = JSON.parse(readFileSync(path, "utf8"));
13
+ if (data.version !== 1 || data.sessionId !== sessionId) throw new Error("Recovery checkpoint does not match this session.");
14
+ }
15
+ const save = patch => {
16
+ data = { ...data, ...patch };
17
+ writeFileSync(`${path}.tmp`, JSON.stringify(data), { mode: 0o600 });
18
+ renameSync(`${path}.tmp`, path);
19
+ };
20
+ return { get data() { return data; }, save, path };
21
+ }
22
+
23
+ export function validHandoff(phase, value) {
24
+ const matches = (schema, v) => {
25
+ if (schema.type === "object") return !!v && typeof v === "object" && !Array.isArray(v)
26
+ && (schema.required || []).every(k => k in v)
27
+ && Object.entries(v).every(([k, item]) => schema.properties?.[k] ? matches(schema.properties[k], item) : schema.additionalProperties !== false);
28
+ if (schema.type === "array") return Array.isArray(v) && v.every(item => matches(schema.items, item));
29
+ if (schema.type === "string") return typeof v === "string" && (!schema.enum || schema.enum.includes(v));
30
+ if (schema.type === "integer") return Number.isInteger(v);
31
+ return true;
32
+ };
33
+ return !!PHASE_OUTPUT_SCHEMAS[phase] && matches(PHASE_OUTPUT_SCHEMAS[phase], value);
34
+ }
35
+
36
+ // Conservative journal for externally visible commands. Exact repeats are
37
+ // refused; uncertain responses require reconciliation, never automatic replay.
38
+ export function externalActionKey(tool, input = {}) {
39
+ const command = String(input.command || "");
40
+ const external = tool.startsWith("mcp__") && /create|update|delete|transition|comment|post|send/i.test(tool) || (tool === "Bash" && (
41
+ /\bgh\s+(?:pr|issue)\s+(?:create|comment|edit|merge|close|reopen)\b/.test(command)
42
+ || /\bcurl\b/.test(command) && /(?:--data|-d\b|-X\s*(?:POST|PUT|PATCH|DELETE)|--request\s+(?:POST|PUT|PATCH|DELETE))/.test(command)
43
+ ));
44
+ if (!external) return null;
45
+ const canonical = value => Array.isArray(value) ? value.map(canonical)
46
+ : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])])) : value;
47
+ // Bash descriptions/timeouts are presentation, not the external action.
48
+ return createHash("sha256").update(JSON.stringify([tool, tool === "Bash" ? command.trim() : canonical(input)])).digest("hex");
49
+ }
50
+
51
+ export function journalExternalActions(options, recovery) {
52
+ const previousPre = options.hooks.PreToolUse[0].hooks[0];
53
+ options.hooks.PreToolUse[0].hooks[0] = async input => {
54
+ const decision = await previousPre(input);
55
+ if (decision.hookSpecificOutput?.permissionDecision === "deny") return decision;
56
+ const key = externalActionKey(input.tool_name, input.tool_input);
57
+ if (!key) return decision;
58
+ const receipt = recovery.data.receipts[key];
59
+ if (receipt) return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny",
60
+ permissionDecisionReason: `This external action is recorded as ${receipt.state}. Do not repeat or reword it to bypass this guard. Inspect the provider to reconcile its result; preserve the existing PR/comment.` } };
61
+ recovery.save({ receipts: { ...recovery.data.receipts, [key]: { state: "pending", tool: input.tool_name, at: Date.now() } } });
62
+ return decision;
63
+ };
64
+ const previousPost = options.hooks.PostToolUse[0].hooks[0];
65
+ options.hooks.PostToolUse[0].hooks[0] = async input => {
66
+ const result = await previousPost(input);
67
+ const key = externalActionKey(input.tool_name, input.tool_input);
68
+ if (key && recovery.data.receipts[key]) {
69
+ // A tool returning is not proof of an external write succeeding. Only
70
+ // the provider-specific outcome parser can produce a confirmed receipt.
71
+ recovery.save({ receipts: { ...recovery.data.receipts, [key]: { ...recovery.data.receipts[key], state: "response-received" } } });
72
+ }
73
+ return result;
74
+ };
75
+ }
76
+
77
+ export function repairQueryOptions(options) {
78
+ return { ...options, agents: {}, agent: undefined, allowedTools: ["StructuredOutput"], tools: ["StructuredOutput"], mcpServers: {},
79
+ hooks: { PreToolUse: [{ hooks: [async input => input.tool_name === "StructuredOutput" ? {} : {
80
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Handoff repair cannot execute tools." },
81
+ }] }] }, maxTurns: 2 };
82
+ }
83
+
84
+ export function trackerAccessDenied(denials) {
85
+ return denials.some(d => /mcp__.*(?:jira|atlassian|linear|github)/i.test(d.tool_name || "")
86
+ || d.tool_name === "Bash" && /\bcurl\b|\bgh\s+(?:issue|pr)\s+(?:view|list)\b/.test(d.tool_input?.command || ""));
87
+ }
88
+
89
+ export function recoveryBrief(data, tree) {
90
+ return [
91
+ "## Authoritative task and user instructions",
92
+ `Original task: ${data.taskId || ""}\n${data.description || ""}`,
93
+ "Stay within this objective. Propose unrelated discoveries separately; do not expand scope to repair lost context.",
94
+ ...data.instructions.map((i, n) => `${n + 1}. ${i.text}`),
95
+ "Latest user corrections override earlier plans. Acknowledge their effect before working.",
96
+ `Current revision: ${tree.revision || "unknown"}; working tree clean: ${tree.clean}. Preserve unfinished edits. Inspect the diff and existing PR before continuing. Prior verification must be re-established.`,
97
+ `Recorded external actions: ${JSON.stringify(data.receipts)}`,
98
+ `Confirmed provider outcomes: ${JSON.stringify(data.outcomes || [])}`,
99
+ "Do not repeat confirmed actions. A pending or response-received entry is uncertain: reconcile it against the provider first. Never reword commands to bypass the replay guard. Never claim an action failed merely because this phase cannot access the provider.",
100
+ ].join("\n\n");
101
+ }