@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/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
|
@@ -36,11 +36,15 @@ import { spawnSandboxedCommand } from "./spawn.mjs";
|
|
|
36
36
|
import { createLedger, openItemsFrom, noCommitItem } from "./handoff.mjs";
|
|
37
37
|
import { teamProfileFor, phasesFor } from "./team-profile.mjs";
|
|
38
38
|
import { buildQueryOptions, defaultRunQuery } from "./query.mjs";
|
|
39
|
-
import { armPublishGate,
|
|
39
|
+
import { armPublishGate, recordAudit, responseText, AUDITOR } from "./hooks.mjs";
|
|
40
40
|
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;
|
|
@@ -249,7 +281,11 @@ async function executeSession({
|
|
|
249
281
|
extraWritePaths: workspaceStateDir ? [workspaceStateDir, workspaceRecord.tree || cwd] : [],
|
|
250
282
|
}),
|
|
251
283
|
});
|
|
252
|
-
const
|
|
284
|
+
const treeNow = () => gitState(cwd, workspaceGitEnv);
|
|
285
|
+
const headNow = () => treeNow().revision;
|
|
286
|
+
const describeTree = t => `${shortRev(t.revision)}${t.clean === false ? " + uncommitted changes" : ""}`;
|
|
287
|
+
state.treeNow = treeNow;
|
|
288
|
+
state.requireClean = requireClean;
|
|
253
289
|
let approvedRevision = null;
|
|
254
290
|
const verify = () => runChecks({ checks, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }),
|
|
255
291
|
gitEnv: workspaceGitEnv, runCheck: runOneCheck, timeoutMs: checkTimeoutMs(config), signal: abortController.signal, requireClean });
|
|
@@ -258,27 +294,36 @@ async function executeSession({
|
|
|
258
294
|
|
|
259
295
|
// Publishing (git push / gh pr create) is verified on the spot: the engine
|
|
260
296
|
// runs the checks at the revision being published and refuses with the
|
|
261
|
-
// failing output when they do not pass. A pass is
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
297
|
+
// failing output when they do not pass. A pass is evidence for the exact
|
|
298
|
+
// tree it ran on — same commit and a clean tree, then and now — and only
|
|
299
|
+
// that is reused (by a later publish attempt and by the REVIEW that
|
|
300
|
+
// follows). Uncommitted edits leave HEAD unchanged, so HEAD alone would let
|
|
301
|
+
// stale evidence outlive the code it checked. A failure is never cached.
|
|
302
|
+
let verified = null; // { revision, clean, evidence, verdict } from the latest verification
|
|
303
|
+
const reusableFor = tree => !!(verified && verified.evidence.passed && verified.clean === true
|
|
304
|
+
&& tree.revision && tree.revision === verified.revision && tree.clean === true);
|
|
265
305
|
const verifyPublish = async () => {
|
|
266
|
-
|
|
267
|
-
if (verified && head && verified.revision === head && verified.evidence.passed) return verified.verdict;
|
|
306
|
+
if (reusableFor(treeNow())) return verified.verdict;
|
|
268
307
|
const evidence = await verify();
|
|
269
308
|
if (abortController.signal.aborted) return { ok: false, reason: "Session is being cancelled." };
|
|
309
|
+
// The tree is read again after the checks: evidence for a tree that no
|
|
310
|
+
// longer exists is not evidence.
|
|
311
|
+
const after = treeNow();
|
|
312
|
+
const moved = after.revision !== evidence.revision || after.clean !== evidence.clean;
|
|
270
313
|
let verdict;
|
|
271
|
-
if (
|
|
314
|
+
if (moved) {
|
|
315
|
+
verdict = { ok: false, reason: `Cannot publish: the working tree changed while verification ran (${describeTree(evidence)} → ${describeTree(after)}). Commit, then publish again — the engine re-runs the checks.` };
|
|
316
|
+
} else if (!evidence.checked && evidence.clean !== false) verdict = { ok: true };
|
|
272
317
|
else {
|
|
273
318
|
reportEvidence(evidence, "EXECUTION");
|
|
274
319
|
appendMemory(renderEvidenceSection(evidence));
|
|
275
320
|
if (evidence.passed) verdict = { ok: true };
|
|
276
321
|
else {
|
|
277
322
|
const lines = evidenceFindings(evidence).map(f => `- ${f.title}\n ${String(f.detail || "").split("\n").slice(-8).join("\n ")}`);
|
|
278
|
-
verdict = { ok: false, reason: `Cannot publish: verification did not pass at ${shortRev(
|
|
323
|
+
verdict = { ok: false, reason: `Cannot publish: verification did not pass at ${shortRev(evidence.revision)}.\n${lines.join("\n")}\nFix it, commit, and publish again — the engine re-runs the checks for the new revision.` };
|
|
279
324
|
}
|
|
280
325
|
}
|
|
281
|
-
verified = { revision: evidence.revision, evidence, verdict };
|
|
326
|
+
verified = moved ? null : { revision: evidence.revision, clean: evidence.clean, evidence, verdict };
|
|
282
327
|
return verdict;
|
|
283
328
|
};
|
|
284
329
|
state.verifyPublish = verifyPublish;
|
|
@@ -286,7 +331,7 @@ async function executeSession({
|
|
|
286
331
|
// --- handoff ledger (engine-owned) ---------------------------------------
|
|
287
332
|
// One entry per phase run; the open-items list travels into the next prompt.
|
|
288
333
|
const ledger = createLedger(ledgerPath);
|
|
289
|
-
let openItems = [];
|
|
334
|
+
let openItems = resumeSession ? (prior.openItems || []) : [];
|
|
290
335
|
|
|
291
336
|
const reportEvidence = (evidence, evidencePhase = "REVIEW") => {
|
|
292
337
|
emit({ type: "session:evidence", phase: evidencePhase, revision: evidence.revision, clean: evidence.clean,
|
|
@@ -332,37 +377,60 @@ async function executeSession({
|
|
|
332
377
|
}
|
|
333
378
|
}
|
|
334
379
|
|
|
335
|
-
// An approval is void the moment the code moves past the reviewed revision
|
|
336
|
-
|
|
380
|
+
// An approval is void the moment the code moves past the reviewed revision
|
|
381
|
+
// — a new commit, or (in a session worktree) uncommitted changes on top.
|
|
382
|
+
const drifted = tree => tree.revision !== approvedRevision || (requireClean && tree.clean === false);
|
|
383
|
+
const invalidateApproval = tree => {
|
|
337
384
|
reviewResolved = false;
|
|
338
|
-
|
|
339
|
-
|
|
385
|
+
const what = tree.revision !== approvedRevision
|
|
386
|
+
? `Code changed after approval (${shortRev(approvedRevision)} → ${shortRev(tree.revision)})`
|
|
387
|
+
: `Uncommitted changes appeared after approval of ${shortRev(approvedRevision)}`;
|
|
388
|
+
emit({ type: "session:error", code: "REVIEW_STALE", message: `${what} — the approval no longer applies; ending for human review.` });
|
|
389
|
+
openItems = [...openItems, { source: "engine", title: "Approval invalidated", detail: `${what}.` }];
|
|
340
390
|
if (lastVerdict) {
|
|
341
|
-
lastVerdict = { ...lastVerdict, approved: false, approvalReason: "code changed after approval", headNow:
|
|
391
|
+
lastVerdict = { ...lastVerdict, approved: false, approvalReason: tree.revision !== approvedRevision ? "code changed after approval" : "uncommitted changes after approval", headNow: tree.revision };
|
|
342
392
|
try { writeFileSync(findingsPath, JSON.stringify(lastVerdict, null, 2)); } catch {}
|
|
343
393
|
}
|
|
344
394
|
};
|
|
345
395
|
|
|
346
396
|
// --- team profile (INTAKE assesses the task; config may force) -----------
|
|
347
397
|
let profile = teamProfileFor({ intake: null, config });
|
|
348
|
-
|
|
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 });
|
|
349
412
|
|
|
350
413
|
try {
|
|
351
414
|
while (queue.length > 0) {
|
|
352
415
|
if (abortController.signal.aborted) { aborted = true; break; }
|
|
353
|
-
|
|
416
|
+
phaseRuns++;
|
|
417
|
+
if (++invocationRuns > MAX_PHASE_RUNS) {
|
|
354
418
|
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Phase ceiling reached (${MAX_PHASE_RUNS} runs) — ending session for review.` });
|
|
355
419
|
break;
|
|
356
420
|
}
|
|
357
421
|
|
|
358
422
|
const phase = queue.shift();
|
|
359
423
|
lastPhase = phase;
|
|
424
|
+
checkpoint([phase, ...queue]);
|
|
425
|
+
if (!(resumeSession && prior.phase === phase && invocationRuns === 1)) recovery.save({ transcript: [] });
|
|
360
426
|
const run = { index: phaseRuns, startedAt: Date.now(), revisionBefore: headNow() };
|
|
361
427
|
const finishRun = ({ output = null, evidence: runEvidence = null, status }) => {
|
|
362
428
|
const entry = { phase, run: run.index, startedAt: run.startedAt, durationMs: Date.now() - run.startedAt,
|
|
363
|
-
revisionBefore: run.revisionBefore, revisionAfter: headNow(), output, evidence: runEvidence, openItems: [...openItems], status
|
|
429
|
+
revisionBefore: run.revisionBefore, revisionAfter: headNow(), output, evidence: runEvidence, openItems: [...openItems], status,
|
|
430
|
+
...(run.audit ? { audit: run.audit } : {}) };
|
|
364
431
|
ledger.record(entry);
|
|
365
432
|
emit({ type: "session:handoff", ...entry });
|
|
433
|
+
checkpoint(status === "ok" || status === "not-approved" || status === "checks-failed" ? queue : [phase, ...queue]);
|
|
366
434
|
};
|
|
367
435
|
// The dashboard knows the five team phases; solo shows as EXECUTION.
|
|
368
436
|
const model = modelForPhase(phase === "SOLO" ? "EXECUTION" : phase, config.phaseModels);
|
|
@@ -373,12 +441,12 @@ async function executeSession({
|
|
|
373
441
|
// to EXECUTION as findings, through the same retry path.
|
|
374
442
|
let evidence = null;
|
|
375
443
|
if (phase === "REVIEW") {
|
|
376
|
-
const
|
|
377
|
-
const reusable =
|
|
444
|
+
const tree = treeNow();
|
|
445
|
+
const reusable = reusableFor(tree) && verified.evidence.checked;
|
|
378
446
|
evidence = reusable ? verified.evidence : await verify();
|
|
379
447
|
if (abortController.signal.aborted) { aborted = true; break; }
|
|
380
448
|
if (reusable) {
|
|
381
|
-
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Verification at ${shortRev(
|
|
449
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Verification at ${shortRev(tree.revision)} already passed when the work was published (same commit, clean tree) — reusing it for review.` });
|
|
382
450
|
emit({ type: "session:evidence", phase: "REVIEW", revision: evidence.revision, clean: evidence.clean, passed: evidence.passed, checked: evidence.checked, results: evidence.results });
|
|
383
451
|
} else {
|
|
384
452
|
reportEvidence(evidence);
|
|
@@ -392,19 +460,19 @@ async function executeSession({
|
|
|
392
460
|
}
|
|
393
461
|
}
|
|
394
462
|
if (phase === "SUMMARY" && reviewResolved && approvedRevision) {
|
|
395
|
-
const now =
|
|
396
|
-
if (now
|
|
463
|
+
const now = treeNow();
|
|
464
|
+
if (drifted(now)) invalidateApproval(now);
|
|
397
465
|
}
|
|
398
466
|
|
|
399
467
|
const { agents, allowedTools, lead } = solo
|
|
400
468
|
? soloDefinition(solo)
|
|
401
469
|
: agentsForPhase({ phase, team, phaseModels: config.phaseModels, profile });
|
|
402
470
|
let prompt = solo
|
|
403
|
-
? 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 })
|
|
404
472
|
: renderPhasePrompt({
|
|
405
473
|
phase, taskId, taskLink, description,
|
|
406
474
|
createTask: phase === "INTAKE" ? createTask : false,
|
|
407
|
-
tracker, config, project, sessionUrl,
|
|
475
|
+
tracker, config, project, sessionUrl,
|
|
408
476
|
sessionMemory: memoryText(),
|
|
409
477
|
retryVerdict: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? lastVerdict : null,
|
|
410
478
|
evidence,
|
|
@@ -413,17 +481,42 @@ async function executeSession({
|
|
|
413
481
|
roster: Object.keys(agents).filter(name => name !== lead),
|
|
414
482
|
profile,
|
|
415
483
|
handoffRetry: (handoffRetries.get(phase) || 0) > 0,
|
|
484
|
+
lessons: lessonsForPrompt(), projectNotes,
|
|
416
485
|
});
|
|
417
486
|
if (workspaceRecord) {
|
|
418
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`;
|
|
419
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
|
+
}
|
|
420
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`;
|
|
421
494
|
if (["SUMMARY", "SOLO"].includes(phase)) {
|
|
422
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`;
|
|
423
496
|
}
|
|
424
497
|
|
|
425
|
-
// Publishing is gated until Sam has
|
|
498
|
+
// Publishing is gated until Sam has approved, in this EXECUTION phase,
|
|
499
|
+
// the revision being published. His verdict is read from his report —
|
|
500
|
+
// the harness hands it over when he stops and again as the lead's tool
|
|
501
|
+
// result — and tied to the tree as the engine sees it at that moment.
|
|
426
502
|
if (phase === "EXECUTION") armPublishGate(state, lastVerdict?.outcome === "APPROVED" ? [] : (lastVerdict?.findings || []));
|
|
503
|
+
const noteAudit = text => {
|
|
504
|
+
if (phase !== "EXECUTION") return;
|
|
505
|
+
const previous = state.audit;
|
|
506
|
+
const audit = recordAudit(state, { phase, agentType: AUDITOR, text, tree: treeNow() });
|
|
507
|
+
if (!audit) return;
|
|
508
|
+
run.audit = audit;
|
|
509
|
+
const same = previous && ["verdict", "revision", "clean", "statedRevision"].every(k => previous[k] === audit[k]);
|
|
510
|
+
if (same) return; // the same report, seen twice
|
|
511
|
+
emit({ type: "session:audit", phase, ...audit });
|
|
512
|
+
const messages = {
|
|
513
|
+
APPROVED: `${AUDITOR}'s audit: APPROVED at ${shortRev(audit.revision)}${audit.clean === false ? " (tree has uncommitted changes)" : ""} — publishing is open for that revision.`,
|
|
514
|
+
REJECTED: `${AUDITOR}'s audit: REJECTED — publishing stays closed until the fixes are committed and ${AUDITOR} re-audits.`,
|
|
515
|
+
STALE: `${AUDITOR} approved ${shortRev(audit.statedRevision)} but the code is at ${shortRev(audit.revision)} — publishing stays closed until he audits the current revision.`,
|
|
516
|
+
MISSING: `${AUDITOR}'s report has no AUDIT line — publishing stays closed until he ends his audit with AUDIT: APPROVED or AUDIT: REJECTED.`,
|
|
517
|
+
};
|
|
518
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: messages[audit.verdict] });
|
|
519
|
+
};
|
|
427
520
|
|
|
428
521
|
const mapper = createEventMapper({ leadAgent: lead, onEvent: emit });
|
|
429
522
|
const options = buildQueryOptions({
|
|
@@ -433,15 +526,17 @@ async function executeSession({
|
|
|
433
526
|
verifyTimeoutSec,
|
|
434
527
|
hookCallbacks: {
|
|
435
528
|
onToolResult: result => {
|
|
529
|
+
if (result.tool === "Agent" && result.input?.subagent_type === AUDITOR) noteAudit(responseText(result.response));
|
|
436
530
|
const outcome = captureOutcome({ ...result, phase, sessionId, taskId, repo: outcomeRepo,
|
|
437
531
|
branch: workspaceRecord?.branch || (result.tool === "Bash" && /^gh\s+pr\s+create\b/.test(result.input?.command || "") ? currentBranch(cwd) : null), tracker, config });
|
|
438
532
|
if (outcome && !outcomeIds.has(outcome.id)) {
|
|
439
533
|
outcomeIds.add(outcome.id);
|
|
534
|
+
recovery.save({ outcomes: [...(recovery.data.outcomes || []), outcome] });
|
|
440
535
|
emit({ type: "session:outcome", project: project?.name || null, outcome });
|
|
441
536
|
}
|
|
442
537
|
},
|
|
443
|
-
onSubagentStop: ({ agentType }) => {
|
|
444
|
-
|
|
538
|
+
onSubagentStop: ({ agentType, text }) => {
|
|
539
|
+
if (agentType === AUDITOR && typeof text === "string") noteAudit(text);
|
|
445
540
|
if (agentType) emit({ type: "agent:message", agent: agentType, tag: "SAY", message: `${agentType} finished and reported back.` });
|
|
446
541
|
},
|
|
447
542
|
},
|
|
@@ -454,9 +549,19 @@ async function executeSession({
|
|
|
454
549
|
},
|
|
455
550
|
});
|
|
456
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
|
+
|
|
457
556
|
let thrown = null;
|
|
458
557
|
try {
|
|
459
|
-
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
|
+
}
|
|
460
565
|
} catch (err) {
|
|
461
566
|
if (!abortController.signal.aborted) thrown = err;
|
|
462
567
|
}
|
|
@@ -466,12 +571,19 @@ async function executeSession({
|
|
|
466
571
|
totals.inputTokens += summary.inputTokens;
|
|
467
572
|
totals.outputTokens += summary.outputTokens;
|
|
468
573
|
totals.costUsd += summary.costUsd;
|
|
574
|
+
checkpoint([phase, ...queue]);
|
|
469
575
|
emit({ type: "session:usage", inputTokens: totals.inputTokens, outputTokens: totals.outputTokens });
|
|
470
576
|
|
|
471
577
|
if (abortController.signal.aborted) { aborted = true; break; }
|
|
472
578
|
|
|
473
|
-
|
|
474
|
-
|
|
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);
|
|
475
587
|
emit({ type: "session:error", code: "PHASE_FAILED", message: `${phase} failed (${detail}) — session incomplete.` });
|
|
476
588
|
handoff = true;
|
|
477
589
|
writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
|
|
@@ -481,35 +593,55 @@ async function executeSession({
|
|
|
481
593
|
|
|
482
594
|
if (phase === "REVIEW") {
|
|
483
595
|
const verdict = verdictFromResult({ is_error: summary.isError, subtype: summary.subtype, structured_output: summary.structuredOutput });
|
|
484
|
-
const now =
|
|
485
|
-
const approval = evaluateApproval({ verdict, evidence, headNow: now });
|
|
596
|
+
const now = treeNow();
|
|
597
|
+
const approval = evaluateApproval({ verdict, evidence, headNow: now.revision, cleanNow: now.clean });
|
|
486
598
|
appendMemory(renderMemorySection("REVIEW", summary.structuredOutput));
|
|
487
|
-
settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now, evidence, approved: approval.approved, approvalReason: approval.reason });
|
|
599
|
+
settleReview({ ...verdict, revision: evidence?.revision ?? null, headNow: now.revision, evidence, approved: approval.approved, approvalReason: approval.reason });
|
|
488
600
|
finishRun({ output: summary.structuredOutput ?? null, evidence, status: lastVerdict.approved ? "ok" : "not-approved" });
|
|
489
601
|
continue;
|
|
490
602
|
}
|
|
491
603
|
|
|
492
604
|
// Other phases: the structured output IS the handoff. Without it the
|
|
493
|
-
// next phase would start from nothing
|
|
494
|
-
// 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.
|
|
495
607
|
if (!summary.structuredOutput && phase !== "SOLO") {
|
|
608
|
+
let repairError = null;
|
|
496
609
|
const attempt = (handoffRetries.get(phase) || 0) + 1;
|
|
497
610
|
handoffRetries.set(phase, attempt);
|
|
498
611
|
finishRun({ status: "missing-output" });
|
|
499
612
|
if (attempt <= MAX_HANDOFF_RETRIES) {
|
|
500
|
-
emit({ type: "session:
|
|
501
|
-
|
|
502
|
-
|
|
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 });
|
|
503
627
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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 } });
|
|
508
637
|
}
|
|
509
638
|
if (!summary.structuredOutput) {
|
|
510
639
|
emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
|
|
511
640
|
}
|
|
512
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
|
+
}
|
|
513
645
|
if (phase === "EXECUTION") {
|
|
514
646
|
const after = gitState(cwd, workspaceGitEnv);
|
|
515
647
|
const missing = noCommitItem({ phase, revisionBefore: run.revisionBefore, revisionAfter: after.revision, clean: after.clean, output: summary.structuredOutput });
|
|
@@ -532,6 +664,7 @@ async function executeSession({
|
|
|
532
664
|
const areas = profile.touches.length ? ` (${profile.touches.join(", ")})` : "";
|
|
533
665
|
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Team for this task: ${profile.size}${areas} — ${names}.${profile.size === "small" ? " Skipping PLAN." : ""}` });
|
|
534
666
|
}
|
|
667
|
+
checkpoint(queue);
|
|
535
668
|
}
|
|
536
669
|
} finally {
|
|
537
670
|
abortSignal?.removeEventListener?.("abort", onExternalAbort);
|
|
@@ -540,12 +673,33 @@ async function executeSession({
|
|
|
540
673
|
|
|
541
674
|
// Belt to SUMMARY's braces: nothing may have moved the approved revision.
|
|
542
675
|
if (!aborted && reviewResolved && approvedRevision) {
|
|
543
|
-
const now =
|
|
544
|
-
if (now
|
|
676
|
+
const now = treeNow();
|
|
677
|
+
if (drifted(now)) invalidateApproval(now);
|
|
545
678
|
}
|
|
546
679
|
|
|
547
680
|
const duration = seconds(startedAt);
|
|
548
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
|
+
|
|
549
703
|
const resumePath = join(cwd, ".agentdesk-resume.md");
|
|
550
704
|
const endFields = { duration, steps: totals.steps, inputTokens: totals.inputTokens, outputTokens: totals.outputTokens };
|
|
551
705
|
|
|
@@ -566,5 +720,6 @@ async function executeSession({
|
|
|
566
720
|
approvedRevision: reviewResolved ? approvedRevision : null,
|
|
567
721
|
openItems,
|
|
568
722
|
profile,
|
|
723
|
+
lessons: lessonCounts,
|
|
569
724
|
};
|
|
570
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
|
});
|