@kendoo.agentdesk/agentdesk 0.32.1 → 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 +17 -0
- package/README.md +16 -4
- package/cli/agents.mjs +8 -8
- package/cli/daemon.mjs +63 -9
- package/cli/engine/agents/index.mjs +19 -10
- package/cli/engine/lessons.mjs +156 -0
- package/cli/engine/phases/EXECUTION.md +2 -0
- 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 +136 -21
- 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/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
|
+
}
|
package/cli/engine/schemas.mjs
CHANGED
|
@@ -11,6 +11,33 @@ import { VERDICT_SCHEMA } from "./verdict.mjs";
|
|
|
11
11
|
|
|
12
12
|
const strList = { type: "array", items: { type: "string" } };
|
|
13
13
|
|
|
14
|
+
// What SUMMARY/SOLO hand back for the project lessons ledger. The engine
|
|
15
|
+
// records these with provenance; agents never write the ledger themselves.
|
|
16
|
+
const lessonList = {
|
|
17
|
+
type: "array",
|
|
18
|
+
description: "non-obvious things that cost this session time and would cost the next one too; empty when nothing qualifies",
|
|
19
|
+
items: {
|
|
20
|
+
type: "object",
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
required: ["text", "scope", "evidence"],
|
|
23
|
+
properties: {
|
|
24
|
+
text: { type: "string", description: "one actionable sentence" },
|
|
25
|
+
scope: { type: "string", description: "project | area:<ui|copy|docs|api|data> | path:<file or directory prefix>" },
|
|
26
|
+
evidence: { type: "string", description: "what happened that proves it (command and outcome)" },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const retireList = {
|
|
31
|
+
type: "array",
|
|
32
|
+
description: "ids from PROJECT LESSONS that proved wrong or obsolete in this session; empty when none did",
|
|
33
|
+
items: {
|
|
34
|
+
type: "object",
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
required: ["id", "reason"],
|
|
37
|
+
properties: { id: { type: "string" }, reason: { type: "string" } },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
14
41
|
export const PHASE_OUTPUT_SCHEMAS = Object.freeze({
|
|
15
42
|
INTAKE: {
|
|
16
43
|
type: "object",
|
|
@@ -44,8 +71,8 @@ export const PHASE_OUTPUT_SCHEMAS = Object.freeze({
|
|
|
44
71
|
filesToModify: strList,
|
|
45
72
|
decisions: strList,
|
|
46
73
|
risks: strList,
|
|
47
|
-
assignments: { ...strList, description: "
|
|
48
|
-
steps: { ...strList, description: "ordered implementation steps" },
|
|
74
|
+
assignments: { ...strList, description: "each assignment names one owner, a bounded deliverable, dependencies, and acceptance evidence" },
|
|
75
|
+
steps: { ...strList, description: "ordered implementation steps with owners; implementation and the subsequent audit both happen in EXECUTION, with audit approval required before publishing" },
|
|
49
76
|
},
|
|
50
77
|
},
|
|
51
78
|
EXECUTION: {
|
|
@@ -65,25 +92,29 @@ export const PHASE_OUTPUT_SCHEMAS = Object.freeze({
|
|
|
65
92
|
SOLO: {
|
|
66
93
|
type: "object",
|
|
67
94
|
additionalProperties: false,
|
|
68
|
-
required: ["summary", "filesChanged", "prUrl", "deferred", "manualSteps"],
|
|
95
|
+
required: ["summary", "filesChanged", "prUrl", "deferred", "manualSteps", "lessons", "retireLessons"],
|
|
69
96
|
properties: {
|
|
70
97
|
summary: { type: "string" },
|
|
71
98
|
filesChanged: strList,
|
|
72
99
|
prUrl: { type: "string", description: "empty string if no PR was created" },
|
|
73
100
|
deferred: strList,
|
|
74
101
|
manualSteps: strList,
|
|
102
|
+
lessons: lessonList,
|
|
103
|
+
retireLessons: retireList,
|
|
75
104
|
},
|
|
76
105
|
},
|
|
77
106
|
SUMMARY: {
|
|
78
107
|
type: "object",
|
|
79
108
|
additionalProperties: false,
|
|
80
|
-
required: ["status", "prUrl", "deferred", "manualSteps", "summaryComment"],
|
|
109
|
+
required: ["status", "prUrl", "deferred", "manualSteps", "summaryComment", "lessons", "retireLessons"],
|
|
81
110
|
properties: {
|
|
82
|
-
status: { type: "string" },
|
|
111
|
+
status: { type: "string", description: "accurate delivery outcome, including any unresolved review or tracker-write blocker" },
|
|
83
112
|
prUrl: { type: "string" },
|
|
84
|
-
deferred: strList,
|
|
85
|
-
manualSteps: strList,
|
|
86
|
-
summaryComment: { type: "string", description: "the final tracker comment as posted" },
|
|
113
|
+
deferred: { ...strList, description: "only explicitly out-of-scope or user-approved deferrals; required work blocked on access belongs in status and manualSteps" },
|
|
114
|
+
manualSteps: { ...strList, description: "each remaining manual action with its named owner and completion evidence; include access recovery and the subsequent delivery retry when a tracker write failed; empty only when no manual action remains" },
|
|
115
|
+
summaryComment: { type: "string", description: "the final tracker comment exactly as confirmed posted; empty string if posting failed or was not confirmed" },
|
|
116
|
+
lessons: lessonList,
|
|
117
|
+
retireLessons: retireList,
|
|
87
118
|
},
|
|
88
119
|
},
|
|
89
120
|
});
|
package/cli/engine/session.mjs
CHANGED
|
@@ -41,6 +41,10 @@ import { prepareWorkspace } from "../worktrees.mjs";
|
|
|
41
41
|
import { detectProject } from "../detect.mjs";
|
|
42
42
|
import { preparePrivateGit } from "../worktree-git.mjs";
|
|
43
43
|
import { runCancellable } from "./cancellation.mjs";
|
|
44
|
+
import { checkpointStore, validHandoff, journalExternalActions, repairQueryOptions, trackerAccessDenied, recoveryBrief } from "./recovery.mjs";
|
|
45
|
+
import { classifyFailure } from "../../shared/recovery.mjs";
|
|
46
|
+
import { loadProjectMemory } from "../prompt.mjs";
|
|
47
|
+
import { lessonsPath, readLessons, recordLessons, selectLessons } from "./lessons.mjs";
|
|
44
48
|
|
|
45
49
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
46
50
|
const CLI_VERSION = JSON.parse(readFileSync(join(here, "../../package.json"), "utf-8")).version;
|
|
@@ -144,14 +148,33 @@ async function executeSession({
|
|
|
144
148
|
sourceCwd = cwd, workspaceRecord, workspaceStateDir, resumingWorkspace = false,
|
|
145
149
|
workspaceGitEnv = {},
|
|
146
150
|
registerCleanup,
|
|
151
|
+
resumeSession = false, instructions = [],
|
|
147
152
|
}) {
|
|
148
153
|
const startedAt = Date.now();
|
|
149
154
|
const emit = event => {
|
|
150
|
-
if (event.type === "session:update" && event.taskId
|
|
155
|
+
if (event.type === "session:update" && (event.taskId || event.taskLink)) {
|
|
156
|
+
taskId = event.taskId || taskId;
|
|
157
|
+
taskLink = event.taskLink || taskLink;
|
|
158
|
+
recovery.save({ taskId, taskLink });
|
|
159
|
+
}
|
|
151
160
|
onEvent?.({ ...event, timestamp: timestamp() });
|
|
152
161
|
};
|
|
153
|
-
const outcomeIds = new Set();
|
|
154
162
|
const outcomeRepo = githubRepository(sourceCwd || cwd);
|
|
163
|
+
const recovery = checkpointStore(workspaceStateDir || join(cwd, ".agentdesk"), sessionId, { resume: resumeSession });
|
|
164
|
+
const prior = recovery.data;
|
|
165
|
+
const outcomeIds = new Set((prior.outcomes || []).map(o => o.id));
|
|
166
|
+
if (resumeSession) {
|
|
167
|
+
taskId = prior.taskId || taskId;
|
|
168
|
+
description = prior.description ?? description;
|
|
169
|
+
taskLink = prior.taskLink || taskLink;
|
|
170
|
+
createTask = false;
|
|
171
|
+
}
|
|
172
|
+
const knownInstructions = new Map((prior.instructions || []).map(i => [i.id, i]));
|
|
173
|
+
let changedInstructions = false;
|
|
174
|
+
for (const i of instructions) if (!knownInstructions.has(i.id)) { knownInstructions.set(i.id, i); changedInstructions = true; }
|
|
175
|
+
recovery.save({ taskId, taskLink, description, instructions: [...knownInstructions.values()],
|
|
176
|
+
replanRequired: prior.replanRequired || changedInstructions });
|
|
177
|
+
const block = (detail, code, phase) => emit({ type: "session:recovery", recovery: { ...classifyFailure(detail, code), phase, ready: false } });
|
|
155
178
|
|
|
156
179
|
// Solo mode: one agent, one phase, full tools, no lead and no review gate.
|
|
157
180
|
const solo = soloAgent ? team.find(a => a.name === soloAgent) : null;
|
|
@@ -177,6 +200,7 @@ async function executeSession({
|
|
|
177
200
|
const creds = { ...trackerCreds, GITHUB_TOKEN: resolvedToken || trackerCreds.GITHUB_TOKEN };
|
|
178
201
|
|
|
179
202
|
const failStart = (code, message) => {
|
|
203
|
+
block(message, code, prior.phase);
|
|
180
204
|
emit({ type: "session:error", code, message });
|
|
181
205
|
emit({ type: "session:end", duration: seconds(startedAt), steps: 0, inputTokens: 0, outputTokens: 0, status: "error" });
|
|
182
206
|
return { duration: "0s", steps: 0, inputTokens: 0, outputTokens: 0, handoff: false, error: message, status: "error" };
|
|
@@ -196,6 +220,7 @@ async function executeSession({
|
|
|
196
220
|
const auth = await authCheck({ env: buildChildEnv({ dotenv }) });
|
|
197
221
|
abortSignal?.throwIfAborted();
|
|
198
222
|
if (!auth.ok) return failStart("CLAUDE_NOT_LOGGED_IN", `${auth.detail}.\n${auth.hint || ""}`.trim());
|
|
223
|
+
emit({ type: "session:recovery", recovery: { state: "running", kind: "resume", message: "Access checked; continuing the saved task.", ready: false } });
|
|
199
224
|
|
|
200
225
|
const sandbox = createScratchHome({
|
|
201
226
|
projectId: project?.name,
|
|
@@ -219,7 +244,7 @@ async function executeSession({
|
|
|
219
244
|
const findingsPath = join(stateDir, "review-findings.json");
|
|
220
245
|
const ledgerPath = join(stateDir, "handoffs.jsonl");
|
|
221
246
|
try { mkdirSync(stateDir, { recursive: true }); } catch {}
|
|
222
|
-
if (!resumingWorkspace || !existsSync(memoryPath)) {
|
|
247
|
+
if (!(resumingWorkspace || resumeSession) || !existsSync(memoryPath)) {
|
|
223
248
|
if (archiveStaleMemory(memoryPath)) console.error("[agentdesk] archived stale session-memory.md from a previous run");
|
|
224
249
|
try { if (existsSync(findingsPath)) unlinkSync(findingsPath); } catch {}
|
|
225
250
|
try { if (existsSync(ledgerPath)) unlinkSync(ledgerPath); } catch {}
|
|
@@ -230,7 +255,14 @@ async function executeSession({
|
|
|
230
255
|
const memoryText = () => { try { return readFileSync(memoryPath, "utf-8").trim(); } catch { return ""; } };
|
|
231
256
|
const appendMemory = text => { try { appendFileSync(memoryPath, `\n${text}`); } catch {} };
|
|
232
257
|
|
|
233
|
-
|
|
258
|
+
// --- project lessons (engine-owned, shared by every worktree) ------------
|
|
259
|
+
// Read from and recorded in the source project, never the session worktree.
|
|
260
|
+
const lessonsFile = lessonsPath(sourceCwd || cwd);
|
|
261
|
+
const projectNotes = loadProjectMemory(sourceCwd || cwd);
|
|
262
|
+
const lessonsForPrompt = () => selectLessons({ lessons: readLessons(lessonsFile).lessons, touches: profile.touches });
|
|
263
|
+
let lessonHandoff = null; // { phase, agent, lessons, retireLessons } from SUMMARY/SOLO
|
|
264
|
+
|
|
265
|
+
const totals = { steps: 0, inputTokens: 0, outputTokens: 0, costUsd: 0, ...(resumeSession ? prior.totals : {}) };
|
|
234
266
|
const state = { openFindings: [], mainThreadMayCode: !!solo };
|
|
235
267
|
let handoff = false, aborted = false, reviewResolved = !!solo; // solo has no review gate
|
|
236
268
|
let reviewRetries = 0, phaseRuns = 0, lastVerdict = null, lastPhase = null;
|
|
@@ -299,7 +331,7 @@ async function executeSession({
|
|
|
299
331
|
// --- handoff ledger (engine-owned) ---------------------------------------
|
|
300
332
|
// One entry per phase run; the open-items list travels into the next prompt.
|
|
301
333
|
const ledger = createLedger(ledgerPath);
|
|
302
|
-
let openItems = [];
|
|
334
|
+
let openItems = resumeSession ? (prior.openItems || []) : [];
|
|
303
335
|
|
|
304
336
|
const reportEvidence = (evidence, evidencePhase = "REVIEW") => {
|
|
305
337
|
emit({ type: "session:evidence", phase: evidencePhase, revision: evidence.revision, clean: evidence.clean,
|
|
@@ -363,18 +395,34 @@ async function executeSession({
|
|
|
363
395
|
|
|
364
396
|
// --- team profile (INTAKE assesses the task; config may force) -----------
|
|
365
397
|
let profile = teamProfileFor({ intake: null, config });
|
|
366
|
-
|
|
398
|
+
if (resumeSession && prior.profile) profile = prior.profile;
|
|
399
|
+
const queue = resumeSession && Array.isArray(prior.queue) ? [...prior.queue] : solo ? ["SOLO"] : phasesFor(profile);
|
|
400
|
+
if (resumeSession && !queue.length) queue.push(...(solo ? ["SOLO"] : ["EXECUTION", "REVIEW", "SUMMARY"]));
|
|
401
|
+
if (resumeSession && prior.lastVerdict) lastVerdict = prior.lastVerdict;
|
|
402
|
+
if (resumeSession) phaseRuns = prior.phaseRuns || 0;
|
|
403
|
+
// User corrections invalidate the old plan and review. Preserve intake;
|
|
404
|
+
// re-plan against the existing implementation instead of starting a new task.
|
|
405
|
+
if (recovery.data.replanRequired && resumeSession && !solo && queue[0] !== "INTAKE") queue.splice(0, queue.length, "PLAN", "EXECUTION", "REVIEW", "SUMMARY");
|
|
406
|
+
if (resumeSession && queue[0] === "SUMMARY") queue.unshift("REVIEW");
|
|
407
|
+
let invocationRuns = 0;
|
|
408
|
+
const checkpoint = pending => recovery.save({ queue: [...pending], phase: pending[0] || null,
|
|
409
|
+
totals, profile, lastVerdict, openItems, phaseRuns });
|
|
410
|
+
checkpoint(queue);
|
|
411
|
+
recovery.save({ replanRequired: false });
|
|
367
412
|
|
|
368
413
|
try {
|
|
369
414
|
while (queue.length > 0) {
|
|
370
415
|
if (abortController.signal.aborted) { aborted = true; break; }
|
|
371
|
-
|
|
416
|
+
phaseRuns++;
|
|
417
|
+
if (++invocationRuns > MAX_PHASE_RUNS) {
|
|
372
418
|
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Phase ceiling reached (${MAX_PHASE_RUNS} runs) — ending session for review.` });
|
|
373
419
|
break;
|
|
374
420
|
}
|
|
375
421
|
|
|
376
422
|
const phase = queue.shift();
|
|
377
423
|
lastPhase = phase;
|
|
424
|
+
checkpoint([phase, ...queue]);
|
|
425
|
+
if (!(resumeSession && prior.phase === phase && invocationRuns === 1)) recovery.save({ transcript: [] });
|
|
378
426
|
const run = { index: phaseRuns, startedAt: Date.now(), revisionBefore: headNow() };
|
|
379
427
|
const finishRun = ({ output = null, evidence: runEvidence = null, status }) => {
|
|
380
428
|
const entry = { phase, run: run.index, startedAt: run.startedAt, durationMs: Date.now() - run.startedAt,
|
|
@@ -382,6 +430,7 @@ async function executeSession({
|
|
|
382
430
|
...(run.audit ? { audit: run.audit } : {}) };
|
|
383
431
|
ledger.record(entry);
|
|
384
432
|
emit({ type: "session:handoff", ...entry });
|
|
433
|
+
checkpoint(status === "ok" || status === "not-approved" || status === "checks-failed" ? queue : [phase, ...queue]);
|
|
385
434
|
};
|
|
386
435
|
// The dashboard knows the five team phases; solo shows as EXECUTION.
|
|
387
436
|
const model = modelForPhase(phase === "SOLO" ? "EXECUTION" : phase, config.phaseModels);
|
|
@@ -419,11 +468,11 @@ async function executeSession({
|
|
|
419
468
|
? soloDefinition(solo)
|
|
420
469
|
: agentsForPhase({ phase, team, phaseModels: config.phaseModels, profile });
|
|
421
470
|
let prompt = solo
|
|
422
|
-
? renderSoloPrompt({ agent: solo, taskId, taskLink, description, tracker, config, project, sessionUrl,
|
|
471
|
+
? renderSoloPrompt({ agent: solo, taskId, taskLink, description, tracker, config, project, sessionUrl, childStrategy, lessons: lessonsForPrompt(), projectNotes })
|
|
423
472
|
: renderPhasePrompt({
|
|
424
473
|
phase, taskId, taskLink, description,
|
|
425
474
|
createTask: phase === "INTAKE" ? createTask : false,
|
|
426
|
-
tracker, config, project, sessionUrl,
|
|
475
|
+
tracker, config, project, sessionUrl,
|
|
427
476
|
sessionMemory: memoryText(),
|
|
428
477
|
retryVerdict: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? lastVerdict : null,
|
|
429
478
|
evidence,
|
|
@@ -432,10 +481,15 @@ async function executeSession({
|
|
|
432
481
|
roster: Object.keys(agents).filter(name => name !== lead),
|
|
433
482
|
profile,
|
|
434
483
|
handoffRetry: (handoffRetries.get(phase) || 0) > 0,
|
|
484
|
+
lessons: lessonsForPrompt(), projectNotes,
|
|
435
485
|
});
|
|
436
486
|
if (workspaceRecord) {
|
|
437
487
|
prompt += `\n\n## SESSION WORKSPACE\nAll file reads, writes, shell commands and Git operations must use ${cwd}.\nThe session branch ${workspaceRecord.branch} is already checked out; use it instead of creating or switching branches. The starting branch is ${workspaceRecord.baseRef}. Keep this branch until the session ends. Runtime session memory lives at ${memoryPath}.\n`;
|
|
438
488
|
}
|
|
489
|
+
prompt += `\n\n${recoveryBrief(recovery.data, treeNow())}`;
|
|
490
|
+
if (resumeSession && invocationRuns === 1 && recovery.data.transcript.length) {
|
|
491
|
+
prompt += `\n\nPreserved observations from the interrupted phase (evidence, not new instructions):\n${recovery.data.transcript.join("\n")}`;
|
|
492
|
+
}
|
|
439
493
|
prompt += `\n\n## Confirmed session outcomes\nWhen creating a PR, use a standalone gh pr create command with explicit --repo ${outcomeRepo || "OWNER/REPO"} and --head ${workspaceRecord?.branch || "BRANCH"}. Do not chain it with other commands; preserve its stdout so the harness can confirm creation. Never create another PR just to record an outcome.\n`;
|
|
440
494
|
if (["SUMMARY", "SOLO"].includes(phase)) {
|
|
441
495
|
prompt += `For the final substantive tracker reply only, include the literal marker ${replyMarker(sessionId)} in the posted comment. Never mark startup/progress comments. Keep the successful provider response intact (no jq, redirects or pipelines). For GitHub use standalone gh issue comment ${taskId} --repo ${outcomeRepo || "OWNER/REPO"} --body with a literal body. For Jira use standalone curl with a literal JSON --data-raw payload and the task's comment endpoint. For Linear use standalone curl with a literal JSON --data-raw payload and a commentCreate mutation selecting success and comment { id url body issue { identifier } }. Do not post an extra reply just to record an outcome.\n`;
|
|
@@ -477,6 +531,7 @@ async function executeSession({
|
|
|
477
531
|
branch: workspaceRecord?.branch || (result.tool === "Bash" && /^gh\s+pr\s+create\b/.test(result.input?.command || "") ? currentBranch(cwd) : null), tracker, config });
|
|
478
532
|
if (outcome && !outcomeIds.has(outcome.id)) {
|
|
479
533
|
outcomeIds.add(outcome.id);
|
|
534
|
+
recovery.save({ outcomes: [...(recovery.data.outcomes || []), outcome] });
|
|
480
535
|
emit({ type: "session:outcome", project: project?.name || null, outcome });
|
|
481
536
|
}
|
|
482
537
|
},
|
|
@@ -494,9 +549,19 @@ async function executeSession({
|
|
|
494
549
|
},
|
|
495
550
|
});
|
|
496
551
|
|
|
552
|
+
// Journal external writes before dispatch, so a crash between execution
|
|
553
|
+
// and response cannot turn into a blind duplicate on resume.
|
|
554
|
+
journalExternalActions(options, recovery);
|
|
555
|
+
|
|
497
556
|
let thrown = null;
|
|
498
557
|
try {
|
|
499
|
-
for await (const msg of runQuery({ prompt, options }))
|
|
558
|
+
for await (const msg of runQuery({ prompt, options })) {
|
|
559
|
+
mapper.handle(msg);
|
|
560
|
+
if (msg.type === "assistant") {
|
|
561
|
+
const text = (msg.message?.content || []).filter(b => b.type === "text").map(b => b.text).join("\n");
|
|
562
|
+
if (text) recovery.save({ transcript: [...recovery.data.transcript, text.slice(-12000)].slice(-30) });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
500
565
|
} catch (err) {
|
|
501
566
|
if (!abortController.signal.aborted) thrown = err;
|
|
502
567
|
}
|
|
@@ -506,12 +571,19 @@ async function executeSession({
|
|
|
506
571
|
totals.inputTokens += summary.inputTokens;
|
|
507
572
|
totals.outputTokens += summary.outputTokens;
|
|
508
573
|
totals.costUsd += summary.costUsd;
|
|
574
|
+
checkpoint([phase, ...queue]);
|
|
509
575
|
emit({ type: "session:usage", inputTokens: totals.inputTokens, outputTokens: totals.outputTokens });
|
|
510
576
|
|
|
511
577
|
if (abortController.signal.aborted) { aborted = true; break; }
|
|
512
578
|
|
|
513
|
-
|
|
514
|
-
|
|
579
|
+
const sourceDenied = ["INTAKE", "PLAN"].includes(phase) && trackerAccessDenied(summary.permissionDenials);
|
|
580
|
+
// The SDK can yield its error result and then throw. The yielded subtype
|
|
581
|
+
// still identifies a repairable handoff failure in that case.
|
|
582
|
+
const outputFailure = !sourceDenied && summary.subtype === "error_max_structured_output_retries";
|
|
583
|
+
if (!outputFailure && phaseFailed({ exitCode: thrown || summary.isError || sourceDenied ? 1 : 0, aborted: false })) {
|
|
584
|
+
const detail = sourceDenied ? "Tracker permission denied. Restore access before planning from unverified requirements."
|
|
585
|
+
: thrown?.message || summary.errors?.join("\n") || summary.resultText || summary.subtype || "error";
|
|
586
|
+
block(detail, "PHASE_FAILED", phase);
|
|
515
587
|
emit({ type: "session:error", code: "PHASE_FAILED", message: `${phase} failed (${detail}) — session incomplete.` });
|
|
516
588
|
handoff = true;
|
|
517
589
|
writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
|
|
@@ -530,26 +602,46 @@ async function executeSession({
|
|
|
530
602
|
}
|
|
531
603
|
|
|
532
604
|
// Other phases: the structured output IS the handoff. Without it the
|
|
533
|
-
// next phase would start from nothing
|
|
534
|
-
// fail closed. Solo mode keeps its single-run behaviour.
|
|
605
|
+
// next phase would start from nothing. Repair from saved evidence with
|
|
606
|
+
// no tools, then fail closed. Solo mode keeps its single-run behaviour.
|
|
535
607
|
if (!summary.structuredOutput && phase !== "SOLO") {
|
|
608
|
+
let repairError = null;
|
|
536
609
|
const attempt = (handoffRetries.get(phase) || 0) + 1;
|
|
537
610
|
handoffRetries.set(phase, attempt);
|
|
538
611
|
finishRun({ status: "missing-output" });
|
|
539
612
|
if (attempt <= MAX_HANDOFF_RETRIES) {
|
|
540
|
-
emit({ type: "session:
|
|
541
|
-
|
|
542
|
-
|
|
613
|
+
emit({ type: "session:recovery", recovery: { state: "recovering", kind: "handoff", phase,
|
|
614
|
+
message: "Recovering the phase summary from preserved findings. Completed actions will not be repeated.", ready: false } });
|
|
615
|
+
const repair = createEventMapper({ leadAgent: lead });
|
|
616
|
+
try {
|
|
617
|
+
const repairOptions = repairQueryOptions(options);
|
|
618
|
+
const repairPrompt = `Recover the ${phase} structured handoff using only the preserved evidence below. Do not use tools, perform actions, invent requirements, or expand scope. If the evidence is insufficient, return no structured output.\n${recoveryBrief(recovery.data, treeNow())}\n${memoryText()}\n${recovery.data.transcript.join("\n")}\n${summary.resultText || ""}`;
|
|
619
|
+
for await (const msg of runQuery({ prompt: repairPrompt, options: repairOptions })) repair.handle(msg);
|
|
620
|
+
const repaired = repair.finish();
|
|
621
|
+
totals.inputTokens += repaired.inputTokens; totals.outputTokens += repaired.outputTokens; totals.costUsd += repaired.costUsd;
|
|
622
|
+
if (!repaired.isError && validHandoff(phase, repaired.structuredOutput)) summary.structuredOutput = repaired.structuredOutput;
|
|
623
|
+
else if (repaired.isError) repairError = repaired.errors.join("\n") || repaired.resultText || repaired.subtype;
|
|
624
|
+
} catch (error) { repairError = error.message; }
|
|
625
|
+
checkpoint([phase, ...queue]);
|
|
626
|
+
emit({ type: "session:usage", inputTokens: totals.inputTokens, outputTokens: totals.outputTokens });
|
|
543
627
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
628
|
+
if (abortController.signal.aborted) { aborted = true; break; }
|
|
629
|
+
if (!summary.structuredOutput) {
|
|
630
|
+
block(repairError || "Missing structured handoff", "HANDOFF_INVALID", phase);
|
|
631
|
+
emit({ type: "session:error", code: "HANDOFF_INVALID", message: `${phase} handoff recovery failed — work preserved for intervention.` });
|
|
632
|
+
handoff = true;
|
|
633
|
+
writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
emit({ type: "session:recovery", recovery: { state: "running", kind: "handoff", phase, message: "Phase summary recovered.", ready: false } });
|
|
548
637
|
}
|
|
549
638
|
if (!summary.structuredOutput) {
|
|
550
639
|
emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
|
|
551
640
|
}
|
|
552
641
|
appendMemory(renderMemorySection(phase, summary.structuredOutput));
|
|
642
|
+
if (["SUMMARY", "SOLO"].includes(phase) && summary.structuredOutput) {
|
|
643
|
+
lessonHandoff = { phase, agent: lead, lessons: summary.structuredOutput.lessons, retireLessons: summary.structuredOutput.retireLessons };
|
|
644
|
+
}
|
|
553
645
|
if (phase === "EXECUTION") {
|
|
554
646
|
const after = gitState(cwd, workspaceGitEnv);
|
|
555
647
|
const missing = noCommitItem({ phase, revisionBefore: run.revisionBefore, revisionAfter: after.revision, clean: after.clean, output: summary.structuredOutput });
|
|
@@ -572,6 +664,7 @@ async function executeSession({
|
|
|
572
664
|
const areas = profile.touches.length ? ` (${profile.touches.join(", ")})` : "";
|
|
573
665
|
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Team for this task: ${profile.size}${areas} — ${names}.${profile.size === "small" ? " Skipping PLAN." : ""}` });
|
|
574
666
|
}
|
|
667
|
+
checkpoint(queue);
|
|
575
668
|
}
|
|
576
669
|
} finally {
|
|
577
670
|
abortSignal?.removeEventListener?.("abort", onExternalAbort);
|
|
@@ -586,6 +679,27 @@ async function executeSession({
|
|
|
586
679
|
|
|
587
680
|
const duration = seconds(startedAt);
|
|
588
681
|
const status = finalStatus({ aborted, crashed: handoff, reviewResolved });
|
|
682
|
+
if (status === "handoff" && !handoff) block("Verification or review remains unresolved", "REVIEW_UNRESOLVED", lastPhase);
|
|
683
|
+
|
|
684
|
+
// The engine's verdict on the session decides whether a lesson is active
|
|
685
|
+
// (verified approval) or only proposed; the agents' say-so never does.
|
|
686
|
+
let lessonCounts = null;
|
|
687
|
+
if (lessonHandoff) {
|
|
688
|
+
const recorded = recordLessons({ path: lessonsFile, proposals: lessonHandoff.lessons, retirements: lessonHandoff.retireLessons,
|
|
689
|
+
source: { sessionId, taskId, phase: lessonHandoff.phase, agent: lessonHandoff.agent, revision: headNow() }, complete: status === "complete" });
|
|
690
|
+
const { activated, proposed, confirmed, retired, dropped } = recorded;
|
|
691
|
+
lessonCounts = { activated, proposed, confirmed, retired, dropped };
|
|
692
|
+
if (recorded.changed) {
|
|
693
|
+
emit({ type: "session:lessons", ...lessonCounts, path: lessonsFile });
|
|
694
|
+
const parts = [
|
|
695
|
+
activated && `${activated} recorded as active`,
|
|
696
|
+
proposed && `${proposed} proposed (activates when a later session that ends complete confirms it)`,
|
|
697
|
+
confirmed && `${confirmed} confirmed`, retired && `${retired} retired`, dropped && `${dropped} dropped`,
|
|
698
|
+
].filter(Boolean);
|
|
699
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Project lessons: ${parts.join(", ")}.` });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
589
703
|
const resumePath = join(cwd, ".agentdesk-resume.md");
|
|
590
704
|
const endFields = { duration, steps: totals.steps, inputTokens: totals.inputTokens, outputTokens: totals.outputTokens };
|
|
591
705
|
|
|
@@ -606,5 +720,6 @@ async function executeSession({
|
|
|
606
720
|
approvedRevision: reviewResolved ? approvedRevision : null,
|
|
607
721
|
openItems,
|
|
608
722
|
profile,
|
|
723
|
+
lessons: lessonCounts,
|
|
609
724
|
};
|
|
610
725
|
}
|
|
@@ -14,6 +14,6 @@
|
|
|
14
14
|
{{/EXECUTION}}
|
|
15
15
|
{{#SUMMARY}}
|
|
16
16
|
- Ensure the PR references the issue ("Closes #{{TASK_ID}}").
|
|
17
|
-
-
|
|
17
|
+
- Only when review and engine verification permit readiness, update labels: `gh issue edit {{TASK_ID}} --remove-label "in progress" --add-label "in review"`. Otherwise leave them unchanged. Report any failed write instead of hiding it.
|
|
18
18
|
- Post the final comment with the session link {{SESSION_URL}}.
|
|
19
19
|
{{/SUMMARY}}
|
|
@@ -18,6 +18,6 @@
|
|
|
18
18
|
{{/EXECUTION}}
|
|
19
19
|
{{#SUMMARY}}
|
|
20
20
|
- Verify the PR remote link exists; add it if missing.
|
|
21
|
-
-
|
|
21
|
+
- Only when review and engine verification permit readiness, transition to "In Review": GET `/rest/api/3/issue/{{TASK_ID}}/transitions`, then POST `{"transition":{"id":"<id>"}}`. Otherwise leave the status unchanged and report the blocker.
|
|
22
22
|
- Post the final comment (ADF, `inlineCard` for the session link {{SESSION_URL}} and the PR).
|
|
23
23
|
{{/SUMMARY}}
|
|
@@ -19,6 +19,6 @@
|
|
|
19
19
|
{{/EXECUTION}}
|
|
20
20
|
{{#SUMMARY}}
|
|
21
21
|
- Verify the PR link is attached (attach via `attachmentCreate` if missing).
|
|
22
|
-
-
|
|
22
|
+
- Only when review and engine verification permit readiness, transition to "In Review": `mutation { issueUpdate(id: "$ISSUE_ID", input: { stateId: "$IN_REVIEW_STATE_ID" }) { success } }`. Otherwise leave the status unchanged and report the blocker.
|
|
23
23
|
- Post the final comment with the session link {{SESSION_URL}} via `commentCreate`.
|
|
24
24
|
{{/SUMMARY}}
|
package/cli/engine/verdict.mjs
CHANGED
|
@@ -24,13 +24,13 @@ export const VERDICT_SCHEMA = Object.freeze({
|
|
|
24
24
|
properties: {
|
|
25
25
|
reviewer: { type: "string" },
|
|
26
26
|
title: { type: "string" },
|
|
27
|
-
detail: { type: "string" },
|
|
27
|
+
detail: { type: "string", description: "observed gap, named corrective owner, next action, and evidence required to close it; name the owner even when the reviewer is someone else" },
|
|
28
28
|
file: { type: "string" },
|
|
29
29
|
line: { type: "integer" },
|
|
30
30
|
},
|
|
31
31
|
},
|
|
32
32
|
},
|
|
33
|
-
deferred: { type: "array", items: { type: "string" } },
|
|
33
|
+
deferred: { type: "array", items: { type: "string" }, description: "only explicitly out-of-scope or user-approved deferrals; unresolved acceptance criteria belong in findings" },
|
|
34
34
|
unverifiedClaims: { type: "array", items: { type: "string" } },
|
|
35
35
|
},
|
|
36
36
|
});
|