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.
Files changed (87) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
  3. package/bundle/server/dist/adapters/agent-health.js +14 -3
  4. package/bundle/server/dist/adapters/github.js +4 -1
  5. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  6. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  7. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  8. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  9. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  10. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  11. package/bundle/server/dist/coordinator/admission.js +14 -1
  12. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  13. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  14. package/bundle/server/dist/coordinator/auto-merge.js +39 -3
  15. package/bundle/server/dist/coordinator/commands.js +90 -5
  16. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  17. package/bundle/server/dist/coordinator/developer-effect.js +79 -1
  18. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  19. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  20. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  21. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  22. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  23. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  24. package/bundle/server/dist/coordinator/human-resolution.js +17 -1
  25. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  26. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  27. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  28. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  29. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  30. package/bundle/server/dist/coordinator/playbook-feedback.js +690 -0
  31. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  32. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  33. package/bundle/server/dist/coordinator/prompts.js +78 -2
  34. package/bundle/server/dist/coordinator/prompts.test.js +83 -0
  35. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  36. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  37. package/bundle/server/dist/coordinator/reviewer-effect.js +10 -1
  38. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  39. package/bundle/server/dist/coordinator/routing.test.js +14 -0
  40. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  41. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  42. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  43. package/bundle/server/dist/db/index.js +5 -0
  44. package/bundle/server/dist/db/schema.sql +3 -0
  45. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  46. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  47. package/bundle/server/dist/repository/human-actions.js +14 -0
  48. package/bundle/server/dist/repository/issues.js +29 -4
  49. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  50. package/bundle/server/dist/routes/human-actions.js +10 -8
  51. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  52. package/bundle/server/dist/routes/issues.js +19 -2
  53. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  54. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  55. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  56. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  57. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  58. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  59. package/bundle/server/package.json +2 -2
  60. package/bundle/server/static-ui/assets/{index-yLyxRd-7.js → index-B6SVCzMR.js} +14 -14
  61. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  62. package/bundle/server/static-ui/index.html +2 -2
  63. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  64. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  65. package/bundle/shared/dist/execution-contract.js +307 -0
  66. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  67. package/bundle/shared/dist/execution-contract.test.js +499 -0
  68. package/bundle/shared/dist/execution-report.d.ts +9 -9
  69. package/bundle/shared/dist/execution-report.js +2 -0
  70. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  71. package/bundle/shared/dist/failure-cause.js +2 -0
  72. package/bundle/shared/dist/human-actions.d.ts +4 -4
  73. package/bundle/shared/dist/human-actions.js +9 -0
  74. package/bundle/shared/dist/index.d.ts +85 -84
  75. package/bundle/shared/dist/index.js +3 -0
  76. package/bundle/shared/dist/issues.d.ts +146 -16
  77. package/bundle/shared/dist/issues.js +8 -0
  78. package/bundle/shared/dist/issues.test.js +1 -0
  79. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  80. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  81. package/bundle/shared/dist/worker-sessions.js +8 -0
  82. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  83. package/bundle/shared/dist/workflow.d.ts +4 -4
  84. package/bundle/shared/dist/workflow.js +10 -0
  85. package/bundle/shared/dist/workflow.test.js +2 -0
  86. package/bundle/shared/package.json +1 -1
  87. 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 { getAgent } from "../repository/agents.js";
7
- import { listFindingsForIssue } from "../repository/findings.js";
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
- * Fires once per completed workflow instance, only on final_review:complete. Never throws —
111
- * every failure mode (no deck/playbooks configured, Agent Deck offline, a failed patch
112
- * proposal) is recorded as a `reflect_status`/`playbook_patch` artifact and reported back
113
- * as a result the caller can log, not an exception that would undo the already-committed
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 { deckId, playbookIds } = resolveReflectTargets(issue);
121
- if (!deckId || playbookIds.length === 0)
122
- return "skipped";
123
- const healthy = await deps.checkHealth().catch(() => false);
124
- if (!healthy) {
125
- recordStatus(issueId, { status: "skipped", reason: "Agent Deck offline" });
126
- return "skipped";
127
- }
128
- const attemptId = randomUUID();
129
- const rationale = buildRationale(issueId);
130
- const alreadyProposed = alreadyProposedPlaybookIds(issueId);
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: "playbook_patch",
35
+ kind: "reflect_status",
170
36
  author: "system",
171
37
  content: {
172
- patchId: proposed.data.id,
173
- playbookId,
174
- playbookTitle: playbook.data.title,
175
- rationale,
176
- status: "proposed",
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
- recordStatus(issueId, {
182
- status: anySucceeded ? (anyFailed ? "partial" : "completed") : "failed",
183
- attemptId,
184
- playbookCount: playbookIds.length,
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 `done` with no active instance, and resolving it must
193
- * never reopen the issue or enqueue developer/reviewer work (NOT-94). `retry` starts a
194
- * brand new correlated reflection attempt with its own new authority; `dismiss` makes no
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);