agent-dealer 1.2.7 → 1.2.8
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/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
- package/bundle/server/dist/adapters/agent-health.js +14 -3
- package/bundle/server/dist/adapters/github.js +4 -1
- package/bundle/server/dist/adapters/muse-capability.js +443 -24
- package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
- package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
- package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
- package/bundle/server/dist/capacity/muse-probe.js +56 -9
- package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
- package/bundle/server/dist/coordinator/admission.js +14 -1
- package/bundle/server/dist/coordinator/admission.test.js +197 -4
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
- package/bundle/server/dist/coordinator/auto-merge.js +39 -3
- package/bundle/server/dist/coordinator/commands.js +90 -5
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
- package/bundle/server/dist/coordinator/developer-effect.js +79 -1
- package/bundle/server/dist/coordinator/execution-report.js +6 -0
- package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
- package/bundle/server/dist/coordinator/failure-cause.js +64 -1
- package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
- package/bundle/server/dist/coordinator/failure-reason.js +7 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
- package/bundle/server/dist/coordinator/human-resolution.js +17 -1
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
- package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
- package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +690 -0
- package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
- package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
- package/bundle/server/dist/coordinator/prompts.js +78 -2
- package/bundle/server/dist/coordinator/prompts.test.js +83 -0
- package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
- package/bundle/server/dist/coordinator/reviewer-effect.js +10 -1
- package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
- package/bundle/server/dist/coordinator/routing.test.js +14 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
- package/bundle/server/dist/coordinator/worker-loop.js +18 -4
- package/bundle/server/dist/db/index.js +5 -0
- package/bundle/server/dist/db/schema.sql +3 -0
- package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
- package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
- package/bundle/server/dist/repository/human-actions.js +14 -0
- package/bundle/server/dist/repository/issues.js +29 -4
- package/bundle/server/dist/repository/worker-sessions.js +51 -2
- package/bundle/server/dist/routes/human-actions.js +10 -8
- package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
- package/bundle/server/dist/routes/issues.js +19 -2
- package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
- package/bundle/server/dist/runners/muse-config-core.js +18 -1
- package/bundle/server/dist/runners/muse-config.test.js +37 -0
- package/bundle/server/dist/runners/muse-serve-session.js +5 -0
- package/bundle/server/dist/runners/spawn-cli.js +116 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-yLyxRd-7.js → index-B6SVCzMR.js} +14 -14
- package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/execution-analysis.d.ts +22 -22
- package/bundle/shared/dist/execution-contract.d.ts +117 -0
- package/bundle/shared/dist/execution-contract.js +307 -0
- package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
- package/bundle/shared/dist/execution-contract.test.js +499 -0
- package/bundle/shared/dist/execution-report.d.ts +9 -9
- package/bundle/shared/dist/execution-report.js +2 -0
- package/bundle/shared/dist/failure-cause.d.ts +4 -4
- package/bundle/shared/dist/failure-cause.js +2 -0
- package/bundle/shared/dist/human-actions.d.ts +4 -4
- package/bundle/shared/dist/human-actions.js +9 -0
- package/bundle/shared/dist/index.d.ts +85 -84
- package/bundle/shared/dist/index.js +3 -0
- package/bundle/shared/dist/issues.d.ts +146 -16
- package/bundle/shared/dist/issues.js +8 -0
- package/bundle/shared/dist/issues.test.js +1 -0
- package/bundle/shared/dist/outbound-draft.d.ts +12 -12
- package/bundle/shared/dist/worker-sessions.d.ts +10 -0
- package/bundle/shared/dist/worker-sessions.js +8 -0
- package/bundle/shared/dist/worker-sessions.test.js +1 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +10 -0
- package/bundle/shared/dist/workflow.test.js +2 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// NOT-306: developer and reviewer prompts render the frozen execution contract
|
|
2
|
+
// as dedicated sections — mode, non-goals, exit predicate, one-PR boundary,
|
|
3
|
+
// per-criterion evidence — without telling the worker to rediscover them.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { buildDeveloperPrompt, buildReviewerPrompt } from "./prompts.js";
|
|
7
|
+
import { parseReviewerResult } from "./reviewer-result.js";
|
|
8
|
+
const CONTRACT = {
|
|
9
|
+
version: "v1",
|
|
10
|
+
executionMode: "bug fix",
|
|
11
|
+
nonGoals: ["Rewriting the Linear ticket from Dealer", "Changing product scope after start"],
|
|
12
|
+
exitPredicate: "The login retry succeeds observably on the second attempt.",
|
|
13
|
+
onePrStoppingPoint: "Stop after the retry fix lands with its regression test.",
|
|
14
|
+
acceptanceCriteria: [
|
|
15
|
+
{ text: "Retry succeeds on the second attempt", evidence: "auth tests | npm run test:unit | green" },
|
|
16
|
+
{ text: "No new warnings in the log", evidence: null },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
const LEGACY_SNAPSHOT = {
|
|
20
|
+
title: "Add widget",
|
|
21
|
+
description: "Build the widget.",
|
|
22
|
+
acceptanceCriteria: "Widget renders.",
|
|
23
|
+
repo: "acme/app",
|
|
24
|
+
baseBranch: "main",
|
|
25
|
+
};
|
|
26
|
+
const CONTRACT_SNAPSHOT = { ...LEGACY_SNAPSHOT, executionContract: CONTRACT };
|
|
27
|
+
test("developer prompt renders every contract section verbatim, never as pointers", () => {
|
|
28
|
+
const prompt = buildDeveloperPrompt({ taskSnapshot: CONTRACT_SNAPSHOT, round: 1 });
|
|
29
|
+
assert.match(prompt, /## Execution contract \(frozen v1/);
|
|
30
|
+
assert.match(prompt, /### Execution mode/);
|
|
31
|
+
assert.ok(prompt.includes("bug fix"), "mode value must appear verbatim");
|
|
32
|
+
assert.match(prompt, /### Non-goals/);
|
|
33
|
+
assert.ok(prompt.includes("Rewriting the Linear ticket from Dealer"));
|
|
34
|
+
assert.ok(prompt.includes("Changing product scope after start"));
|
|
35
|
+
assert.match(prompt, /### Exit predicate/);
|
|
36
|
+
assert.ok(prompt.includes("The login retry succeeds observably on the second attempt."));
|
|
37
|
+
assert.match(prompt, /### One-PR stopping point/);
|
|
38
|
+
assert.ok(prompt.includes("Stop after the retry fix lands with its regression test."));
|
|
39
|
+
assert.match(prompt, /### Per-criterion evidence/);
|
|
40
|
+
assert.ok(prompt.includes("Retry succeeds on the second attempt"));
|
|
41
|
+
assert.ok(prompt.includes("Evidence: auth tests | npm run test:unit | green"));
|
|
42
|
+
assert.ok(prompt.includes("No new warnings in the log"));
|
|
43
|
+
assert.match(prompt, /do not rediscover/i);
|
|
44
|
+
});
|
|
45
|
+
test("reviewer prompt renders the contract and demands exit-predicate plus per-criterion evidence verdicts", () => {
|
|
46
|
+
const prompt = buildReviewerPrompt({
|
|
47
|
+
taskSnapshot: CONTRACT_SNAPSHOT,
|
|
48
|
+
round: 1,
|
|
49
|
+
baseSha: "a".repeat(40),
|
|
50
|
+
headSha: "b".repeat(40),
|
|
51
|
+
diff: "diff --git a/x b/x\n+added line\n",
|
|
52
|
+
});
|
|
53
|
+
assert.match(prompt, /## Execution contract \(frozen v1/);
|
|
54
|
+
assert.ok(prompt.includes("The login retry succeeds observably on the second attempt."));
|
|
55
|
+
assert.ok(prompt.includes("Evidence: auth tests | npm run test:unit | green"));
|
|
56
|
+
// The verdict contract assesses the exit predicate and each evidence line —
|
|
57
|
+
// not just the prose acceptance criteria.
|
|
58
|
+
assert.match(prompt, /"exitPredicateAssessment"/);
|
|
59
|
+
assert.match(prompt, /EACH criterion's stated evidence/i);
|
|
60
|
+
assert.match(prompt, /never a blanket "evidence looks good"/);
|
|
61
|
+
assert.match(prompt, /"approved" additionally requires the exit predicate to hold/);
|
|
62
|
+
});
|
|
63
|
+
test("legacy snapshots without a contract omit the contract section but keep the shared verdict shape", () => {
|
|
64
|
+
const developer = buildDeveloperPrompt({ taskSnapshot: LEGACY_SNAPSHOT, round: 1 });
|
|
65
|
+
assert.doesNotMatch(developer, /## Execution contract/);
|
|
66
|
+
assert.match(developer, /## Acceptance criteria/);
|
|
67
|
+
const reviewer = buildReviewerPrompt({
|
|
68
|
+
taskSnapshot: LEGACY_SNAPSHOT,
|
|
69
|
+
round: 1,
|
|
70
|
+
baseSha: "a".repeat(40),
|
|
71
|
+
headSha: "b".repeat(40),
|
|
72
|
+
diff: "diff --git a/x b/x\n+added line\n",
|
|
73
|
+
});
|
|
74
|
+
assert.doesNotMatch(reviewer, /## Execution contract/);
|
|
75
|
+
// The exit-predicate/evidence verdict rules apply to every review, so the
|
|
76
|
+
// reviewer shape always carries the optional assessment field.
|
|
77
|
+
assert.match(reviewer, /"exitPredicateAssessment"/);
|
|
78
|
+
assert.match(reviewer, /Tasks without a contract omit "exitPredicateAssessment"/);
|
|
79
|
+
});
|
|
80
|
+
test("a null contract is identical to an absent one", () => {
|
|
81
|
+
const absent = buildDeveloperPrompt({ taskSnapshot: LEGACY_SNAPSHOT, round: 1 });
|
|
82
|
+
assert.equal(buildDeveloperPrompt({ taskSnapshot: { ...LEGACY_SNAPSHOT, executionContract: null }, round: 1 }), absent);
|
|
83
|
+
});
|
|
84
|
+
test("reviewer output parses an exit-predicate assessment; legacy outputs still parse", () => {
|
|
85
|
+
const withPredicate = {
|
|
86
|
+
verdict: "approved",
|
|
87
|
+
baseSha: "aaaa",
|
|
88
|
+
headSha: "bbbb",
|
|
89
|
+
acceptanceCriteriaAssessment: "All criteria met.",
|
|
90
|
+
evidenceAssessment: "Criterion 1 verified via auth tests (green); criterion 2 has no stated evidence.",
|
|
91
|
+
exitPredicateAssessment: "The retry succeeds observably on the second attempt — holds at this tip.",
|
|
92
|
+
findings: [],
|
|
93
|
+
risks: [],
|
|
94
|
+
};
|
|
95
|
+
const parsed = parseReviewerResult(`\`\`\`json\n${JSON.stringify(withPredicate)}\n\`\`\``);
|
|
96
|
+
assert.deepEqual(parsed, withPredicate);
|
|
97
|
+
const legacy = {
|
|
98
|
+
verdict: "approved",
|
|
99
|
+
baseSha: "aaaa",
|
|
100
|
+
headSha: "bbbb",
|
|
101
|
+
acceptanceCriteriaAssessment: "Meets criteria.",
|
|
102
|
+
evidenceAssessment: "Tests pass.",
|
|
103
|
+
findings: [],
|
|
104
|
+
risks: [],
|
|
105
|
+
};
|
|
106
|
+
assert.deepEqual(parseReviewerResult(JSON.stringify(legacy)), legacy);
|
|
107
|
+
});
|
|
@@ -1,4 +1,40 @@
|
|
|
1
|
+
import { checkMuseVisualQa, museVisualQaPromptSection, } from "../adapters/muse-visual-qa.js";
|
|
1
2
|
import { formatVerificationReceiptSection, } from "./verification-receipt.js";
|
|
3
|
+
/**
|
|
4
|
+
* NOT-306: render the frozen execution contract as dedicated sections. These
|
|
5
|
+
* are the ticket's own execution semantics — the worker follows them as
|
|
6
|
+
* written and never goes back to the ticket (or anywhere else) to rediscover
|
|
7
|
+
* them. Empty/absent renders nothing so legacy prompts stay byte-for-byte.
|
|
8
|
+
*/
|
|
9
|
+
function executionContractSection(contract) {
|
|
10
|
+
if (!contract)
|
|
11
|
+
return [];
|
|
12
|
+
const lines = [
|
|
13
|
+
`## Execution contract (frozen ${contract.version} — follow as written, do not rediscover)`,
|
|
14
|
+
`These execution semantics come from the Planner-authored ticket and are frozen for this workflow. Do not re-derive them, do not renegotiate scope, and do not ask the operator to restate them.`,
|
|
15
|
+
``,
|
|
16
|
+
`### Execution mode`,
|
|
17
|
+
contract.executionMode,
|
|
18
|
+
``,
|
|
19
|
+
`### Non-goals (out of scope — do not build these)`,
|
|
20
|
+
...contract.nonGoals.map((goal) => `- ${goal}`),
|
|
21
|
+
``,
|
|
22
|
+
`### Exit predicate (you are done only when this observably holds)`,
|
|
23
|
+
contract.exitPredicate,
|
|
24
|
+
``,
|
|
25
|
+
`### One-PR stopping point (stop here even if more work suggests itself)`,
|
|
26
|
+
contract.onePrStoppingPoint,
|
|
27
|
+
``,
|
|
28
|
+
`### Per-criterion evidence (verify each criterion exactly this way)`,
|
|
29
|
+
];
|
|
30
|
+
for (const criterion of contract.acceptanceCriteria) {
|
|
31
|
+
lines.push(`- ${criterion.text}`);
|
|
32
|
+
if (criterion.evidence)
|
|
33
|
+
lines.push(` Evidence: ${criterion.evidence}`);
|
|
34
|
+
}
|
|
35
|
+
lines.push(``);
|
|
36
|
+
return lines;
|
|
37
|
+
}
|
|
2
38
|
function guidanceSection(guidance) {
|
|
3
39
|
if (!guidance?.length)
|
|
4
40
|
return [];
|
|
@@ -9,6 +45,28 @@ function guidanceSection(guidance) {
|
|
|
9
45
|
...guidance.flatMap((g) => [g.trim(), ``]),
|
|
10
46
|
];
|
|
11
47
|
}
|
|
48
|
+
/** NOT-310: the merge-conflict repair directive. Empty/absent renders nothing so
|
|
49
|
+
* ordinary rounds produce byte-for-byte the prompt they always did. */
|
|
50
|
+
function conflictRepairSection(directive) {
|
|
51
|
+
const base = directive?.baseBranch?.trim();
|
|
52
|
+
const branch = directive?.branch?.trim();
|
|
53
|
+
if (!base || !branch)
|
|
54
|
+
return [];
|
|
55
|
+
const files = (directive?.files ?? []).map((f) => f.trim()).filter((f) => f.length > 0);
|
|
56
|
+
return [
|
|
57
|
+
`## Merge conflict with ${base} — resolve it first`,
|
|
58
|
+
`This PR's branch \`${branch}\` cannot merge into \`${base}\`: the base moved and merging it conflicts. Do this before any other work in this round:`,
|
|
59
|
+
``,
|
|
60
|
+
`1. In your worktree: \`git fetch origin ${base}\` and \`git merge origin/${base}\`.`,
|
|
61
|
+
`2. Resolve every conflict preserving both sides' intent — the base side carries other tickets' merged work, never discard it to keep your side.`,
|
|
62
|
+
files.length > 0
|
|
63
|
+
? ` Files seen conflicting at merge time: ${files.map((f) => `\`${f}\``).join(", ")}.`
|
|
64
|
+
: ` The conflicting files are unknown — your own merge output lists them; resolve all of them.`,
|
|
65
|
+
`3. Run the full verification suite on the resolved tree, then commit the resolution.`,
|
|
66
|
+
`4. Never rebase this published branch and never force-push — the coordinator publishes your tip with a plain push.`,
|
|
67
|
+
``,
|
|
68
|
+
];
|
|
69
|
+
}
|
|
12
70
|
/** NOT-272: a resolved product_scope_decision's human note, verbatim. Empty/absent
|
|
13
71
|
* renders nothing so noteless resolves produce byte-for-byte the prompt they always did. */
|
|
14
72
|
function scopeDecisionSection(note) {
|
|
@@ -78,9 +136,11 @@ export function buildDeveloperPrompt(input) {
|
|
|
78
136
|
parts.push(...formatVerificationReceiptSection(input.priorVerificationReceipt));
|
|
79
137
|
}
|
|
80
138
|
}
|
|
139
|
+
// NOT-310: the conflict repair leads — it is this round's purpose, read before the task.
|
|
140
|
+
parts.push(...conflictRepairSection(input.conflictRepair));
|
|
81
141
|
// NOT-272: the scope decision leads — the developer reads it before the task itself.
|
|
82
142
|
parts.push(...scopeDecisionSection(input.scopeDecisionNote));
|
|
83
|
-
parts.push(`## Task`, input.taskSnapshot.title, input.taskSnapshot.description, ``, `## Acceptance criteria`, input.taskSnapshot.acceptanceCriteria,
|
|
143
|
+
parts.push(`## Task`, input.taskSnapshot.title, input.taskSnapshot.description, ``, `## Acceptance criteria`, input.taskSnapshot.acceptanceCriteria, ``, ...executionContractSection(input.taskSnapshot.executionContract));
|
|
84
144
|
if (input.findings?.length) {
|
|
85
145
|
parts.push(`## Findings to address`);
|
|
86
146
|
for (const f of input.findings) {
|
|
@@ -93,6 +153,12 @@ export function buildDeveloperPrompt(input) {
|
|
|
93
153
|
parts.push(...agentDeckSection(input.worktreePath, input.deckId));
|
|
94
154
|
if (input.museDeveloper)
|
|
95
155
|
parts.push(...museCronProhibitionSection());
|
|
156
|
+
// NOT-303: the screenshot-path preflight verdict is embedded before the session
|
|
157
|
+
// starts, so the worker reads it instead of discovering the Chrome.app abort
|
|
158
|
+
// mid-session. Non-Muse developers are untouched.
|
|
159
|
+
if (input.museDeveloper) {
|
|
160
|
+
parts.push(...museVisualQaPromptSection(input.museVisualQa ?? checkMuseVisualQa()));
|
|
161
|
+
}
|
|
96
162
|
parts.push(`## Required`,
|
|
97
163
|
// NOT-115: encourage incremental commits so a mid-session death leaves less
|
|
98
164
|
// uncommitted work for dirty_worktree escalation — soft mitigation only.
|
|
@@ -102,7 +168,7 @@ export function buildDeveloperPrompt(input) {
|
|
|
102
168
|
`Run targeted tests for the change. A clean handoff is valid with commits plus those targeted tests — full suite / Lens checks are not required inside this timed developer spawn (they run on a separate review/verify budget). Make incremental commits with \`git\` at coherent slice boundaries (for example after the tests for that slice pass) — do not leave all work uncommitted until the very end. A final commit of any remaining changes and the **implementation conclusion** remain required before you exit.`, `Commit any remaining changes with \`git\` — do NOT push and do NOT open a pull request; the coordinator pushes your branch and opens/updates the draft PR after this session ends.`, `End your reply with a short **implementation conclusion**: what changed, why, any deviations from the acceptance criteria, and known follow-ups. This is distinct from the PR description and is required every round.`);
|
|
103
169
|
return parts.join("\n");
|
|
104
170
|
}
|
|
105
|
-
const REVIEWER_RESULT_SHAPE = '{"verdict":"approved"|"changes_requested"|"escalated","baseSha":"...","headSha":"...","acceptanceCriteriaAssessment":"...","evidenceAssessment":"...","findings":[{"fingerprint":"stable-slug","severity":"blocking"|"non_blocking","title":"...","rationale":"...","file":"...","line":0}],"risks":["..."],"productScopeQuestion":"..."}';
|
|
171
|
+
const REVIEWER_RESULT_SHAPE = '{"verdict":"approved"|"changes_requested"|"escalated","baseSha":"...","headSha":"...","acceptanceCriteriaAssessment":"...","evidenceAssessment":"...","exitPredicateAssessment":"...","findings":[{"fingerprint":"stable-slug","severity":"blocking"|"non_blocking","title":"...","rationale":"...","file":"...","line":0}],"risks":["..."],"productScopeQuestion":"..."}';
|
|
106
172
|
function reviewerContractSection(baseSha, headSha) {
|
|
107
173
|
// Verdict table matches docs/PRD_ISSUE_COORDINATION.md §6.4 / design NOT-150 — do not drift.
|
|
108
174
|
return [
|
|
@@ -113,9 +179,18 @@ function reviewerContractSection(baseSha, headSha) {
|
|
|
113
179
|
`- Set "baseSha" to exactly "${baseSha}" and "headSha" to exactly "${headSha}" — these are the coordinator-verified SHAs you were checked out at, not values you compute.`,
|
|
114
180
|
`- "fingerprint" must be a short, stable slug for the finding (e.g. "missing-null-check-args-ts") so the same issue re-found next round is recognized as recurring, not duplicated.`,
|
|
115
181
|
`- "findings" holds every blocking AND non-blocking observation; "risks" is uncertainties that are not findings tied to a location.`,
|
|
182
|
+
// NOT-306: the execution contract (when the task carries one) is judged,
|
|
183
|
+
// not just the prose criteria — the exit predicate and each criterion's
|
|
184
|
+
// stated evidence get their own assessments on top of the AC verdict.
|
|
185
|
+
`- "acceptanceCriteriaAssessment" judges the acceptance criteria; "evidenceAssessment" must judge EACH criterion's stated evidence (where to look, action/command, expected result) one by one — never a blanket "evidence looks good".`,
|
|
186
|
+
`- When the task carries an execution contract, set "exitPredicateAssessment" to whether the frozen exit predicate observably holds at this tip, and "approved" additionally requires the exit predicate to hold (AC met alone is not enough). Tasks without a contract omit "exitPredicateAssessment".`,
|
|
116
187
|
`- "approved": AC met for this tip and no finding is "blocking" (non_blocking nits allowed).`,
|
|
117
188
|
`- "changes_requested": any "blocking" finding a coding pass can address — including incomplete review because AC-critical files were omitted/truncated from the diff. List omitted paths in a blocking finding.`,
|
|
118
189
|
`- "escalated": only when acceptance criteria / product scope are ambiguous, contradictory, or need a human product call — not ordinary code defects, not "diff too large". You MUST set non-empty "productScopeQuestion"; omit the field otherwise.`,
|
|
190
|
+
// NOT-303: a missing screenshot is never a pass. Some runtimes (Muse Code: the
|
|
191
|
+
// Chrome.app headless abort) cannot do visual QA in-session and say so in the
|
|
192
|
+
// conclusion — echo that as "visual QA not run" in "evidenceAssessment".
|
|
193
|
+
`- A missing screenshot or DOM capture is never verification: if the change has UI-visible effects and no visual evidence is in the diff, the conclusion, or the checks, state "visual QA not run" in "evidenceAssessment" — do not read the absence as a pass.`,
|
|
119
194
|
`- You cannot edit files, push, or publish anything — you only return this JSON. The coordinator publishes it to GitHub on your behalf.`,
|
|
120
195
|
];
|
|
121
196
|
}
|
|
@@ -176,6 +251,7 @@ export function buildReviewerPrompt(input) {
|
|
|
176
251
|
`## Acceptance criteria`,
|
|
177
252
|
input.taskSnapshot.acceptanceCriteria,
|
|
178
253
|
``,
|
|
254
|
+
...executionContractSection(input.taskSnapshot.executionContract),
|
|
179
255
|
`## Diff (base ${input.baseSha.slice(0, 8)} → head ${input.headSha.slice(0, 8)})`,
|
|
180
256
|
"```diff",
|
|
181
257
|
formatDiffForPrompt(input.diff).text,
|
|
@@ -248,7 +248,90 @@ test("NOT-278: a non-Muse developer prompt has the deck bootstrap but no cron pr
|
|
|
248
248
|
assert.match(prompt, /bind_workspace/);
|
|
249
249
|
assert.doesNotMatch(prompt, /cron_create/);
|
|
250
250
|
});
|
|
251
|
+
// NOT-303: a Muse developer prompt carries the screenshot-path preflight verdict.
|
|
252
|
+
// Default (no headless shell installed): unusable, naming the Chrome.app abort and
|
|
253
|
+
// requiring an explicit `Visual QA: not run` conclusion line — never sandbox
|
|
254
|
+
// widening. Env is scrubbed so the test is hermetic.
|
|
255
|
+
test("NOT-303: museDeveloper prompt carries the visual-QA preflight (unusable by default)", () => {
|
|
256
|
+
const saved = process.env.MUSE_HEADLESS_SHELL_BIN;
|
|
257
|
+
delete process.env.MUSE_HEADLESS_SHELL_BIN;
|
|
258
|
+
try {
|
|
259
|
+
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: "deck-1", worktreePath: "/wt", museDeveloper: true });
|
|
260
|
+
assert.match(prompt, /## Visual QA/);
|
|
261
|
+
assert.match(prompt, /Google Chrome\.app/);
|
|
262
|
+
assert.match(prompt, /RegisterApplication/);
|
|
263
|
+
assert.match(prompt, /do not spend steps probing/i);
|
|
264
|
+
assert.match(prompt, /Visual QA: not run/);
|
|
265
|
+
assert.doesNotMatch(prompt, /--disable-sandbox/);
|
|
266
|
+
assert.doesNotMatch(prompt, /--yolo/);
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
if (saved !== undefined)
|
|
270
|
+
process.env.MUSE_HEADLESS_SHELL_BIN = saved;
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
test("NOT-303: museDeveloper prompt names the pre-installed headless shell when injected", () => {
|
|
274
|
+
const prompt = buildDeveloperPrompt({
|
|
275
|
+
taskSnapshot,
|
|
276
|
+
round: 1,
|
|
277
|
+
deckId: "deck-1",
|
|
278
|
+
worktreePath: "/wt",
|
|
279
|
+
museDeveloper: true,
|
|
280
|
+
museVisualQa: {
|
|
281
|
+
usable: true,
|
|
282
|
+
binary: "/opt/headless/chrome-headless-shell",
|
|
283
|
+
args: ["--screenshot=<png>", "--window-size=1280,800"],
|
|
284
|
+
reason: "pre-installed headless shell",
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
assert.match(prompt, /\/opt\/headless\/chrome-headless-shell/);
|
|
288
|
+
assert.match(prompt, /--screenshot=<png>/);
|
|
289
|
+
assert.match(prompt, /Google Chrome\.app/);
|
|
290
|
+
});
|
|
291
|
+
test("NOT-303: a non-Muse developer prompt has no visual-QA section", () => {
|
|
292
|
+
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: "deck-1", worktreePath: "/wt" });
|
|
293
|
+
assert.doesNotMatch(prompt, /## Visual QA/);
|
|
294
|
+
});
|
|
295
|
+
test("NOT-303: reviewer prompt states a missing screenshot is never a pass", () => {
|
|
296
|
+
const prompt = buildReviewerPrompt(reviewerBase);
|
|
297
|
+
assert.match(prompt, /missing screenshot/i);
|
|
298
|
+
assert.match(prompt, /visual QA not run/);
|
|
299
|
+
assert.match(prompt, /do not read the absence as a pass/);
|
|
300
|
+
});
|
|
251
301
|
test("a deckless developer prompt still fails closed", () => {
|
|
252
302
|
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: null });
|
|
253
303
|
assert.match(prompt, /misconfigured: Agent Deck is required but missing/);
|
|
254
304
|
});
|
|
305
|
+
// NOT-310: the conflict-repair directive renders the base, the branch, and the
|
|
306
|
+
// conflicting files first — the developer merges the base before any other work.
|
|
307
|
+
test("NOT-310: conflict repair directive renders base, branch, and files before the task", () => {
|
|
308
|
+
const prompt = buildDeveloperPrompt({
|
|
309
|
+
taskSnapshot,
|
|
310
|
+
round: 3,
|
|
311
|
+
conflictRepair: { baseBranch: "main", branch: "issue-9", files: ["a.ts", "b.ts"] },
|
|
312
|
+
});
|
|
313
|
+
assert.match(prompt, /## Merge conflict with main/);
|
|
314
|
+
assert.ok(prompt.includes("`issue-9`"), "branch must be named");
|
|
315
|
+
assert.ok(prompt.includes("`a.ts`") && prompt.includes("`b.ts`"), "files must be named");
|
|
316
|
+
assert.ok(prompt.includes("git fetch origin main"), "must fetch the base");
|
|
317
|
+
assert.ok(prompt.includes("git merge origin/main"), "must merge the base");
|
|
318
|
+
assert.match(prompt, /never force-push/i);
|
|
319
|
+
assert.match(prompt, /never rebase/i);
|
|
320
|
+
assert.ok(prompt.indexOf("## Merge conflict") < prompt.indexOf("## Task"), "conflict section must precede the task");
|
|
321
|
+
});
|
|
322
|
+
test("NOT-310: unknown conflicting files render the discover-from-merge instruction", () => {
|
|
323
|
+
const prompt = buildDeveloperPrompt({
|
|
324
|
+
taskSnapshot,
|
|
325
|
+
round: 2,
|
|
326
|
+
conflictRepair: { baseBranch: "main", branch: "issue-9", files: [] },
|
|
327
|
+
});
|
|
328
|
+
assert.match(prompt, /## Merge conflict with main/);
|
|
329
|
+
assert.match(prompt, /conflicting files are unknown/);
|
|
330
|
+
});
|
|
331
|
+
test("NOT-310: no conflict directive produces byte-for-byte the same prompt as before", () => {
|
|
332
|
+
const base = { taskSnapshot, round: 2 };
|
|
333
|
+
const without = buildDeveloperPrompt(base);
|
|
334
|
+
assert.doesNotMatch(without, /## Merge conflict/);
|
|
335
|
+
assert.equal(buildDeveloperPrompt({ ...base, conflictRepair: undefined }), without);
|
|
336
|
+
assert.equal(buildDeveloperPrompt({ ...base, conflictRepair: { baseBranch: "", branch: "", files: [] } }), without);
|
|
337
|
+
});
|
|
@@ -1,198 +1,62 @@
|
|
|
1
|
-
import { parseProfileSnapshot, parseStringList } from "@agent-dealer/shared";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
1
|
import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
|
|
4
2
|
import { callDeckTool } from "../adapters/reflect-authority.js";
|
|
5
3
|
import { getIssue } from "../repository/issues.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { listWorkflowEventsForIssue } from "../repository/workflow-events.js";
|
|
9
|
-
import { listWorkerSessionsForIssue } from "../repository/worker-sessions.js";
|
|
10
|
-
import { createIssueArtifact, latestIssueArtifact } from "../repository/artifacts.js";
|
|
11
|
-
import { listArtifactsForIssueByKind } from "../repository/artifacts-for-issue.js";
|
|
4
|
+
import { createIssueArtifact } from "../repository/artifacts.js";
|
|
5
|
+
import { collectPlaybookUseReceiptsForIssue, latestReceiptErrors, reportDeckFailureSignals, } from "./playbook-feedback.js";
|
|
12
6
|
import { getHumanAction, resolveHumanAction, } from "../repository/human-actions.js";
|
|
13
|
-
const REFLECT_TOOL_TIMEOUT_MS = 15_000;
|
|
14
|
-
/**
|
|
15
|
-
* Reflect targets the deck the developer actually ran with (frozen snapshot). Playbook
|
|
16
|
-
* ids are no longer on the snapshot (NOT-149) — only dead legacy agent columns still
|
|
17
|
-
* carry them for in-flight profiles. New agents with an empty legacy list skip reflect
|
|
18
|
-
* until a follow-up discovers playbooks dynamically from the Deck.
|
|
19
|
-
*/
|
|
20
|
-
function resolveReflectTargets(issue) {
|
|
21
|
-
const developerSessions = listWorkerSessionsForIssue(issue.id).filter((s) => s.role === "developer");
|
|
22
|
-
const finalSession = developerSessions[developerSessions.length - 1] ?? null;
|
|
23
|
-
const snapshot = finalSession ? parseProfileSnapshot(finalSession.profileSnapshotJson) : null;
|
|
24
|
-
const developerAgent = issue.developerAgentId ? getAgent(issue.developerAgentId) : null;
|
|
25
|
-
const deckId = snapshot?.deckId ?? developerAgent?.deckId ?? null;
|
|
26
|
-
if (!developerAgent)
|
|
27
|
-
return { deckId, playbookIds: [] };
|
|
28
|
-
const fromList = parseStringList(developerAgent.playbookIdsJson);
|
|
29
|
-
if (fromList.length)
|
|
30
|
-
return { deckId, playbookIds: fromList };
|
|
31
|
-
return { deckId, playbookIds: developerAgent.playbookId ? [developerAgent.playbookId] : [] };
|
|
32
|
-
}
|
|
33
7
|
const defaultDeps = {
|
|
34
8
|
checkHealth: checkAgentDeckHealth,
|
|
35
9
|
callTool: callDeckTool,
|
|
36
10
|
};
|
|
37
|
-
function readConclusionExcerpt(issueId) {
|
|
38
|
-
const artifact = latestIssueArtifact(issueId, "implementation_conclusion");
|
|
39
|
-
if (!artifact?.contentJson)
|
|
40
|
-
return null;
|
|
41
|
-
try {
|
|
42
|
-
const text = JSON.parse(artifact.contentJson).text?.trim();
|
|
43
|
-
if (!text)
|
|
44
|
-
return null;
|
|
45
|
-
return text.length > 800 ? `${text.slice(0, 800)}…` : text;
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
function reviewRoundCount(issueId) {
|
|
52
|
-
const verdicts = listWorkflowEventsForIssue(issueId)
|
|
53
|
-
.filter((e) => e.type === "review.submitted")
|
|
54
|
-
.map((e) => {
|
|
55
|
-
try {
|
|
56
|
-
return JSON.parse(e.payloadJson ?? "{}").verdict ?? "unknown";
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
return "unknown";
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
return { rounds: verdicts.length, verdicts };
|
|
63
|
-
}
|
|
64
|
-
function buildRationale(issueId) {
|
|
65
|
-
const issue = getIssue(issueId);
|
|
66
|
-
const findings = listFindingsForIssue(issueId);
|
|
67
|
-
const resolved = findings.filter((f) => f.status === "resolved").length;
|
|
68
|
-
const recurring = findings.filter((f) => f.status === "recurring").length;
|
|
69
|
-
const { rounds, verdicts } = reviewRoundCount(issueId);
|
|
70
|
-
const conclusion = readConclusionExcerpt(issueId);
|
|
71
|
-
const parts = [
|
|
72
|
-
`Issue "${issue.title}" completed after ${rounds} review round(s)${verdicts.length ? ` (${verdicts.join(" → ")})` : ""}.`,
|
|
73
|
-
`${findings.length} finding(s) tracked across rounds (${resolved} resolved, ${recurring} recurring).`,
|
|
74
|
-
];
|
|
75
|
-
if (conclusion)
|
|
76
|
-
parts.push(`Final implementation conclusion: ${conclusion}`);
|
|
77
|
-
return parts.join(" ");
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Playbook ids this issue already has a `playbook_patch` artifact for, from any earlier
|
|
81
|
-
* reflect attempt (i.e. a prior attempt that parked partway through, before a `retry`).
|
|
82
|
-
* The retry loop skips these instead of re-proposing — otherwise a park on playbook N
|
|
83
|
-
* after playbooks `1..N-1` already succeeded would duplicate their Notes items on retry
|
|
84
|
-
* (PR #21 review finding #1). Reflect fires at most once per issue outside of retries, so
|
|
85
|
-
* every artifact this finds genuinely belongs to this same reflection, never a later
|
|
86
|
-
* unrelated one.
|
|
87
|
-
*
|
|
88
|
-
* NOT-96: queries `playbook_patch` rows by kind directly rather than scanning the
|
|
89
|
-
* newest-first, all-kinds `listArtifactsForIssue` window — on an artifact-heavy issue an
|
|
90
|
-
* older `playbook_patch` could otherwise fall outside that window and get re-proposed.
|
|
91
|
-
*/
|
|
92
|
-
function alreadyProposedPlaybookIds(issueId) {
|
|
93
|
-
const ids = new Set();
|
|
94
|
-
for (const artifact of listArtifactsForIssueByKind(issueId, "playbook_patch")) {
|
|
95
|
-
try {
|
|
96
|
-
const playbookId = JSON.parse(artifact.contentJson ?? "{}").playbookId;
|
|
97
|
-
if (playbookId)
|
|
98
|
-
ids.add(playbookId);
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
// malformed content — nothing to skip on its account
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return ids;
|
|
105
|
-
}
|
|
106
|
-
function recordStatus(issueId, content) {
|
|
107
|
-
createIssueArtifact({ issueId, kind: "reflect_status", author: "system", content });
|
|
108
|
-
}
|
|
109
11
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* human-action resolution.
|
|
12
|
+
* Records completion evidence for a terminal issue: actual-use receipts for every
|
|
13
|
+
* terminal worker session, then one idempotent `signal_only` Deck report per
|
|
14
|
+
* failure/correction trigger. Never throws — every failure mode is recorded as a
|
|
15
|
+
* `reflect_status`/`playbook_use_receipt` artifact, never as an exception that would
|
|
16
|
+
* undo the already-committed human-action resolution.
|
|
115
17
|
*/
|
|
116
18
|
export async function triggerIssueReflect(issueId, deps = defaultDeps) {
|
|
117
19
|
const issue = getIssue(issueId);
|
|
118
20
|
if (!issue)
|
|
119
21
|
return "skipped";
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
let anySucceeded = false;
|
|
132
|
-
let anyFailed = false;
|
|
133
|
-
for (const playbookId of playbookIds) {
|
|
134
|
-
if (alreadyProposed.has(playbookId)) {
|
|
135
|
-
// A prior attempt for this issue already proposed this playbook's patch — a retry
|
|
136
|
-
// must not duplicate it (PR #21 review finding #1).
|
|
137
|
-
anySucceeded = true;
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
const playbook = await deps.callTool({
|
|
141
|
-
deckId,
|
|
142
|
-
toolName: "get_playbook",
|
|
143
|
-
arguments: { playbook_id: playbookId },
|
|
144
|
-
timeoutMs: REFLECT_TOOL_TIMEOUT_MS,
|
|
145
|
-
});
|
|
146
|
-
if (!playbook.ok) {
|
|
147
|
-
recordStatus(issueId, { status: "failed", attemptId, error: playbook.reason });
|
|
148
|
-
anyFailed = true;
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
const proposed = await deps.callTool({
|
|
152
|
-
deckId,
|
|
153
|
-
toolName: "propose_playbook_patch",
|
|
154
|
-
arguments: {
|
|
155
|
-
kind: "update",
|
|
156
|
-
playbook_id: playbookId,
|
|
157
|
-
ops: [{ op: "add_item", section: "Notes", text: rationale }],
|
|
158
|
-
rationale,
|
|
159
|
-
},
|
|
160
|
-
timeoutMs: REFLECT_TOOL_TIMEOUT_MS,
|
|
161
|
-
});
|
|
162
|
-
if (!proposed.ok) {
|
|
163
|
-
recordStatus(issueId, { status: "failed", attemptId, error: proposed.reason });
|
|
164
|
-
anyFailed = true;
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
22
|
+
const receipts = await collectPlaybookUseReceiptsForIssue(issueId, deps);
|
|
23
|
+
const signals = await reportDeckFailureSignals(issueId, deps);
|
|
24
|
+
// Incomplete receipt collection is a visible issue-level status even when no
|
|
25
|
+
// signal candidate exists — otherwise missing evidence leaves no trace. The reason
|
|
26
|
+
// names the actual receipt errors (Deck offline vs. malformed correlation
|
|
27
|
+
// response), never a blanket "offline". Collection resumes the next time issue
|
|
28
|
+
// reflection runs — a later instance-completing trigger re-attempts every session
|
|
29
|
+
// still missing a receipt — or via an explicit reflection retry. Never changes
|
|
30
|
+
// the issue outcome.
|
|
31
|
+
if (receipts.errors > 0) {
|
|
32
|
+
const details = latestReceiptErrors(issueId);
|
|
167
33
|
createIssueArtifact({
|
|
168
34
|
issueId,
|
|
169
|
-
kind: "
|
|
35
|
+
kind: "reflect_status",
|
|
170
36
|
author: "system",
|
|
171
37
|
content: {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
38
|
+
status: "failed",
|
|
39
|
+
reason: details.length > 0
|
|
40
|
+
? `playbook-use receipt collection incomplete — retried on the next reflection trigger: ${details.join("; ")}`
|
|
41
|
+
: "playbook-use receipt collection incomplete — retried on the next reflection trigger",
|
|
42
|
+
pendingReceipts: receipts.pending,
|
|
177
43
|
},
|
|
178
44
|
});
|
|
179
|
-
anySucceeded = true;
|
|
180
45
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return anySucceeded ? "triggered" : "failed";
|
|
46
|
+
if (receipts.collected > 0 || signals.sent.length > 0)
|
|
47
|
+
return "triggered";
|
|
48
|
+
if (receipts.errors > 0 || signals.error)
|
|
49
|
+
return "failed";
|
|
50
|
+
return "skipped";
|
|
187
51
|
}
|
|
188
52
|
/**
|
|
189
53
|
* Resolves an open `reflection_interaction_required` action. Deliberately bypasses
|
|
190
54
|
* `resolveHumanActionAndAdvance`'s workflow state machine entirely (commands.ts) — that
|
|
191
55
|
* machine requires an active workflow instance to advance, but this action is raised
|
|
192
|
-
* against an issue that is already
|
|
193
|
-
* never reopen the issue or enqueue developer/reviewer work (NOT-94). `retry`
|
|
194
|
-
* brand new correlated
|
|
195
|
-
* further Deck call.
|
|
56
|
+
* against an issue that is already terminal with no active instance, and resolving it
|
|
57
|
+
* must never reopen the issue or enqueue developer/reviewer work (NOT-94). `retry`
|
|
58
|
+
* starts a brand new correlated evidence-collection attempt with its own new authority;
|
|
59
|
+
* `dismiss` makes no further Deck call.
|
|
196
60
|
*/
|
|
197
61
|
export function resolveReflectionInteractionAction(actionId, resolvedBy, choice, deps = defaultDeps) {
|
|
198
62
|
const action = getHumanAction(actionId);
|