@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/cli/engine/hooks.mjs
CHANGED
|
@@ -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
|
|
15
|
-
// `git push`, `git push
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
|
32
|
-
//
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
51
|
-
//
|
|
52
|
-
// state
|
|
53
|
-
|
|
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
|
|
88
|
-
if (
|
|
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.
|
|
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. **
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
20
|
-
3. Decide: `APPROVED`
|
|
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
|
-
|
|
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.
|
|
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** —
|
|
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,
|
|
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.
|
package/cli/engine/prompts.mjs
CHANGED
|
@@ -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,
|
|
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,
|
|
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 +=
|
|
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,
|
|
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 +=
|
|
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
|
+
}
|