@piagent/platform 1.45.0 → 1.46.1

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 (97) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +5 -3
  3. package/README.vi.md +5 -3
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +6 -6
  6. package/docs/en/architecture.md +1 -1
  7. package/docs/harness-conformance.md +22 -5
  8. package/docs/languages.json +4 -4
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +2 -0
  11. package/docs/release-install-policy.md +2 -2
  12. package/docs/team-onboarding.md +2 -2
  13. package/docs/vi/architecture.md +1 -1
  14. package/docs-site/architecture.html +1 -1
  15. package/docs-site/assets/search-en.json +1 -1
  16. package/docs-site/assets/search-vi.json +1 -1
  17. package/docs-site/benchmark.html +1 -1
  18. package/docs-site/commands.html +1 -1
  19. package/docs-site/context-engine.html +1 -1
  20. package/docs-site/ecosystem.html +1 -1
  21. package/docs-site/en/architecture.html +1 -1
  22. package/docs-site/en/benchmark.html +1 -1
  23. package/docs-site/en/commands.html +1 -1
  24. package/docs-site/en/context-engine.html +1 -1
  25. package/docs-site/en/ecosystem.html +1 -1
  26. package/docs-site/en/index.html +15 -15
  27. package/docs-site/en/mcp-approval.html +1 -1
  28. package/docs-site/en/mcp-auth.html +1 -1
  29. package/docs-site/en/mcp-servers.html +1 -1
  30. package/docs-site/en/mcp.html +1 -1
  31. package/docs-site/en/permissions.html +1 -1
  32. package/docs-site/en/profiles.html +1 -1
  33. package/docs-site/en/quickstart.html +1 -1
  34. package/docs-site/en/runtime.html +1 -1
  35. package/docs-site/en/scope.html +1 -1
  36. package/docs-site/en/security.html +1 -1
  37. package/docs-site/en/team.html +1 -1
  38. package/docs-site/en/update.html +3 -3
  39. package/docs-site/en/whats-new.html +55 -29
  40. package/docs-site/en/windows.html +1 -1
  41. package/docs-site/en/workflows.html +1 -1
  42. package/docs-site/index.html +15 -15
  43. package/docs-site/mcp-approval.html +1 -1
  44. package/docs-site/mcp-auth.html +1 -1
  45. package/docs-site/mcp-servers.html +1 -1
  46. package/docs-site/mcp.html +1 -1
  47. package/docs-site/permissions.html +1 -1
  48. package/docs-site/profiles.html +1 -1
  49. package/docs-site/quickstart.html +1 -1
  50. package/docs-site/releases.json +83 -0
  51. package/docs-site/runtime.html +1 -1
  52. package/docs-site/scope.html +1 -1
  53. package/docs-site/security.html +1 -1
  54. package/docs-site/team.html +1 -1
  55. package/docs-site/update.html +3 -3
  56. package/docs-site/whats-new.html +55 -29
  57. package/docs-site/windows.html +1 -1
  58. package/docs-site/workflows.html +1 -1
  59. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  60. package/package.json +1 -1
  61. package/packages/piagent-core/benchmark/benchmark-bootstrap.js +3 -1
  62. package/packages/piagent-core/extensions/agent-home.d.ts +4 -0
  63. package/packages/piagent-core/extensions/agent-home.js +52 -0
  64. package/packages/piagent-core/extensions/piagent-guard.ts +5 -1
  65. package/packages/piagent-core/managed/copies.mjs +61 -0
  66. package/packages/piagent-core/managed/grant-share.mjs +9 -1
  67. package/packages/piagent-core/managed/helper-roles.mjs +5 -3
  68. package/packages/piagent-core/managed/helper-run.mjs +120 -52
  69. package/packages/piagent-core/managed/helper-settlement.mjs +5 -1
  70. package/packages/piagent-core/managed/ledger.mjs +187 -0
  71. package/packages/piagent-core/managed/objections.mjs +49 -27
  72. package/packages/piagent-core/managed/patch-snapshot.mjs +9 -0
  73. package/packages/piagent-core/managed/session.mjs +14 -14
  74. package/packages/piagent-core/managed/toolchain.mjs +7 -3
  75. package/packages/piagent-core/managed/workflow.mjs +32 -26
  76. package/packages/piagent-core/mcp/mcp-config-layers.js +4 -1
  77. package/packages/piagent-core/package.json +1 -1
  78. package/packages/piagent-core/runtime/managed-failure.mjs +8 -0
  79. package/packages/piagent-webui/extension/piagent-webui.ts +2 -0
  80. package/packages/piagent-webui/gateway/gateway-service.ts +4 -1
  81. package/packages/piagent-webui/gateway/pi-host.ts +2 -2
  82. package/packages/piagent-webui/ownership/profile-state.ts +10 -1
  83. package/packages/piagent-webui/server/benchmark-release-monitor.ts +2 -1
  84. package/scripts/benchmark-runner-support.mjs +2 -1
  85. package/scripts/company-terminal.mjs +19 -1
  86. package/scripts/dashboard-update-job.mjs +4 -2
  87. package/scripts/install-global.sh +113 -5
  88. package/scripts/model-scope-prune.mjs +2 -1
  89. package/scripts/personal-pi-config.mjs +2 -1
  90. package/scripts/personal-pi-restore.mjs +184 -0
  91. package/scripts/pi-usage-history.mjs +2 -1
  92. package/scripts/piagent-cli.mjs +49 -9
  93. package/scripts/piagent-dashboard.mjs +5 -3
  94. package/scripts/setup.sh +2 -6
  95. package/scripts/team-doctor.sh +11 -5
  96. package/scripts/uninstall-global.sh +17 -2
  97. package/scripts/update-global.mjs +5 -2
@@ -7,12 +7,14 @@ import { fenced, HELPER_UNTRUSTED_RULE } from './untrusted.mjs';
7
7
  import { jobTitle } from './call-purpose.mjs';
8
8
  import { ManagedToolBoundary } from './tool-boundary.mjs';
9
9
  import { managedResourceLoader } from './resource-loader.mjs';
10
- import { shareGrant, releaseGrant } from './grant-share.mjs';
10
+ import { shareGrant, releaseGrant, slotArgs, baseRole } from './grant-share.mjs';
11
11
  import { describeFailure, failureCode, failureText } from '../runtime/managed-failure.mjs';
12
- import { reviewParts, mergeReviews, REVIEW_PARALLEL, emptySnapshot, readBranchBase, readPatchDigest, readPatchSnapshot, resolveReviewBase } from './patch-snapshot.mjs';
13
- import { reviewFindings, briefIssues } from './workflow.mjs';
12
+ import { reviewParts, focusParts, mergeReviews, REVIEW_PARALLEL, emptySnapshot, readBranchBase, readPatchDigest, readPatchSnapshot, resolveReviewBase } from './patch-snapshot.mjs';
13
+ import { reviewFindings, briefIssues, verifyVerdicts } from './workflow.mjs';
14
14
  import { currentWorkflow, helperWorkflowPrompt } from './workflow-context.mjs';
15
15
  import { anchorText, relayObjection, recordVerdicts } from './objections.mjs';
16
+ import { recordFindings } from './ledger.mjs';
17
+ import { copyNote, mergeCopies } from './copies.mjs';
16
18
  import { HELPER_CALLS, HELPER_SETUP, READ_TOOLS, countedCheck } from './helper-roles.mjs';
17
19
  import { trackToolFailures } from './tool-failures.mjs';
18
20
  import { helperMemoryTools, renewer } from './memory.mjs';
@@ -43,14 +45,16 @@ async function reviewSnapshot(managed, harness, since) {
43
45
  return branch ? Object.assign(await readPatchSnapshot(managed.boundary, null, branch.base), { from: { kind: 'branch', ref: branch.ref, base: branch.base } }) : snapshot;
44
46
  }
45
47
 
46
- export async function runHelper(managed, role, task, signal, harness, { verifyGrant, patiently, title, since }) {
47
- const snapshot = role === 'review' ? await reviewSnapshot(managed, harness, since) : null, parts = snapshot ? reviewParts(snapshot) : null;
48
+ export async function runHelper(managed, role, task, signal, harness, { verifyGrant, patiently, title, since, fan = null }) {
49
+ const snapshot = role === 'review' ? await reviewSnapshot(managed, harness, since) : null;
50
+ // The harness's re-review after a fix reads only the parts holding a file changed since (reviewFocus).
51
+ const parts = snapshot ? focusParts(reviewParts(snapshot), harness && !since ? managed.reviewFocus : null) : null;
48
52
  // A helper whose company model account is out of usage, or resting after
49
53
  // a refusal, starts once more: Studio then gives the new helper the next
50
54
  // model of its role that serves (an auto role's list). Helpers read and
51
55
  // run checks only, so starting over repeats no change.
52
56
  for (let attempt = 1; ; attempt += 1) {
53
- try { return await helperAttempt(managed, role, task, signal, harness, snapshot, parts, { verifyGrant, patiently, title }); }
57
+ try { return await helperAttempt(managed, role, task, signal, harness, snapshot, parts, { verifyGrant, patiently, title, fan }); }
54
58
  catch (error) {
55
59
  if (attempt >= HELPER_ATTEMPTS || signal?.aborted || !HELPER_SWITCH.has(failureCode(error?.message)) || (managed.helperCalls.get(role) ?? 0) >= HELPER_CALLS) throw error;
56
60
  }
@@ -59,13 +63,62 @@ export async function runHelper(managed, role, task, signal, harness, { verifyGr
59
63
  // A helper's role closes through the same wait as the run's close: a passing
60
64
  // failure of Agent Watch's way to Studio is asked again. A close that still
61
65
  // fails leaves the helper's answer standing: the role closes with the run,
62
- // and the next helper of the role closes it first.
63
- export async function closeRole(managed, role) {
64
- try { await viaLink(managed.studioLink, () => managed.broker.request('close', { role })); managed.unclosed?.delete(role); }
65
- catch { (managed.unclosed ??= new Set()).add(role); }
66
+ // and the next helper of the role closes it first. `key`: the role, or
67
+ // role#slot for a helper the member asked for beside it (grant-share.mjs).
68
+ export async function closeRole(managed, key) {
69
+ try { await viaLink(managed.studioLink, () => managed.broker.request('close', slotArgs(key))); managed.unclosed?.delete(key); }
70
+ catch { (managed.unclosed ??= new Set()).add(key); }
66
71
  }
67
- async function helperAttempt(managed, role, task, signal, harness, snapshot, parts, { verifyGrant, patiently, title }) {
68
- if (managed.unclosed?.has(role)) await closeRole(managed, role);
72
+ // Helpers the member asked for run on grants of their own (slots) when
73
+ // Studio and Agent Watch hold several of a role at once; otherwise side by
74
+ // side on the role's one grant, where Studio takes their requests in turn.
75
+ const slotsReady = managed => (managed.manifest?.broker_features ?? []).includes('helper-slots');
76
+
77
+ async function helperAttempt(managed, role, task, signal, harness, snapshot, parts, { verifyGrant, patiently, title, fan }) {
78
+ const setup = HELPER_SETUP[role], opts = { verifyGrant, patiently, title };
79
+ let result;
80
+ if (!fan) result = await answerUnder(managed, role, task, signal, harness, snapshot, parts, opts, ['']);
81
+ else if (!slotsReady(managed)) result = await answerUnder(managed, role, task, signal, harness, snapshot, parts, opts, Array.from({ length: fan.n }, (_, i) => copyNote(role, i, fan)));
82
+ else {
83
+ // One grant per helper, all at once; the first that fails stops the others.
84
+ const stop = new AbortController(), both = AbortSignal.any([stop.signal, ...(signal ? [signal] : [])]);
85
+ let results;
86
+ try { results = await Promise.all(Array.from({ length: fan.n }, (_, i) => answerUnder(managed, i ? `${role}#${i + 1}` : role, task, both, harness, snapshot, parts, opts, [copyNote(role, i, fan)]).catch(error => { stop.abort(); throw error; }))); }
87
+ catch (error) { throw signal?.aborted ? Error('managed-helper-cancelled') : error; }
88
+ const merged = mergeCopies(role, setup.label, results, { findingsOf: reviewFindings, issuesOf: briefIssues, verdictsOf: verifyVerdicts });
89
+ result = { ...results[0], reply: merged.reply, usage: merged.usage, review: merged.findings ? merged : null, stale: results.some(r => r.stale), report: null };
90
+ }
91
+ const { reply, usage, review, stale, from, grant } = result;
92
+ const details = { role, runID: grant.run_id, tokens: usage, patchDigest: snapshot?.digest, stale, ...(fan ? { helpers: fan.n, ...(slotsReady(managed) ? { slots: true } : {}) } : {}), ...(from ? { since: { kind: from.kind, ref: from.ref, base: from.base } } : {}) };
93
+ if (snapshot) {
94
+ // Severity-graded findings decide whether the harness sends the agent back.
95
+ const findings = review ? review.findings : reviewFindings(reply);
96
+ Object.assign(details, { parsed: review ? review.parsed : !!findings, findings: (findings ?? []).slice(0, 40), blocking: findings?.filter(f => f.severity === 'blocking').length ?? 0 });
97
+ if (review) Object.assign(details, { parts: parts.parts.length, unread: parts.omitted.map(o => o.path).slice(0, 50) });
98
+ if (managed.run) { managed.run.reviews += 1; managed.run.blocking += details.blocking; }
99
+ // A review of earlier commits is not the review of this turn's change;
100
+ // what each file held then tells a later re-review what changed since.
101
+ if (!from) { managed.review = details; if (!stale) { recordFindings(managed, findings); managed.reviewFiles = await managed.changedFiles?.(); } }
102
+ managed.session.sessionManager.appendCustomEntry('agent-watch-review', details);
103
+ }
104
+ const verdicts = role === 'verify' ? recordVerdicts(managed, reply, harness, details) : '';
105
+ const report = result.report ?? answeredSettlement(role, reply, details, managed.helperSpecs?.get(role) ?? {}); details.outcome = report.outcome;
106
+ await managed.session.sendCustomMessage({ customType: 'agent-watch-helper-receipt', display: true,
107
+ content: `${setup.label}: ${usage.toLocaleString('en-US')} tokens${verdicts}${fan ? ` · ${fan.n} helpers` : review ? ` · ${parts.parts.length} parts` : ''}${snapshot ? (stale ? ' · review is stale' : ' · patch ' + snapshot.digest.slice(0, 12)) : ''} · ${OUTCOME_WORDS[report.outcome]}.`, details }, { triggerTurn: false });
108
+ // An objection to the brief comes with the answer; a stale review has none to act on.
109
+ const objection = stale ? '' : await relayObjection(managed, role, reply, harness);
110
+ const ending = `Outcome: ${report.outcome}${report.outcome === 'unverifiable' ? ` (${report.summary})` : ''}.\n\n`;
111
+ // The helper read untrusted content: its answer is data for the main agent.
112
+ return { content: [{ type: 'text', text: ending + (stale ? 'Review is stale: the patch changed during review. Ask for another review only after fixing blocking findings; under a required review the harness reviews the final patch.' : fenced(`${setup.label} helper's answer`, reply) + objection) }], details };
113
+ }
114
+
115
+ // One grant of the role (`key`: the role, or role#slot) and the helper
116
+ // sessions that answer under it, one per note (a copy's place among the
117
+ // helpers the member asked for). Its settlement goes to Studio and the grant
118
+ // closes before the answer is used.
119
+ async function answerUnder(managed, key, task, signal, harness, snapshot, parts, { verifyGrant, patiently, title }, notes) {
120
+ const role = baseRole(key);
121
+ if (managed.unclosed?.has(key)) await closeRole(managed, key);
69
122
  // A long local tool step may outlive the main lease. Refresh authority
70
123
  // before asking for a child; the broker serializes concurrent renewals,
71
124
  // and main's own searches running beside it share managed renew.
@@ -75,18 +128,18 @@ async function helperAttempt(managed, role, task, signal, harness, snapshot, par
75
128
  try {
76
129
  const parent = await share.grant;
77
130
  verifyGrant(parent, managed.manifest, 'main'); managed.grant = parent;
78
- grant = await patiently(() => managed.broker.request('child', { role }), 2);
131
+ grant = await patiently(() => managed.broker.request('child', slotArgs(key)), 2);
79
132
  } finally { releaseGrant(managed, 'main', share); }
80
- managed.helperCalls.set(role, (managed.helperCalls.get(role) ?? 0) + 1);
81
- try { verifyGrant(grant, managed.manifest, role); }
82
- catch (error) { try { await managed.broker.request('close', { role }); } catch { /* closes with the run */ } throw error; }
133
+ managed.helperCalls.set(role, (managed.helperCalls.get(role) ?? 0) + notes.length); // each helper the member asked for counts
134
+ try { verifyGrant(grant, managed.manifest, key); }
135
+ catch (error) { try { await managed.broker.request('close', slotArgs(key)); } catch { /* closes with the run */ } throw error; }
83
136
  } catch (error) { throw Error(`managed-helper-failed: ${describeFailure(role, failureText(error))}`); }
84
137
  let boundary, report = null;
85
- const sessions = new Set(), spec = managed.helperSpecs?.get(role) ?? {};
86
- const renew = renewer(managed, role, g => verifyGrant(g, managed.manifest, role));
138
+ const sessions = new Set(), watched = new Set(), spec = managed.helperSpecs?.get(role) ?? {};
139
+ const renew = renewer(managed, key, g => verifyGrant(g, managed.manifest, key));
87
140
  try {
88
141
  const runtime = await managed.api.ModelRuntime.create({ credentials: new managed.ai.InMemoryCredentialStore(), modelsPath: null, refreshOnCreate: false, allowModelNetwork: false });
89
- const model = managed.installModel(runtime, grant); managed.wrapStreams(runtime, role);
142
+ const model = managed.installModel(runtime, grant); managed.wrapStreams(runtime, key);
90
143
  // Studio's logs name the job by its title (the brief's first line without one).
91
144
  (managed.jobTitles ??= new WeakMap()).set(runtime, jobTitle(task, title));
92
145
  const setup = HELPER_SETUP[role], memory = helperMemoryTools(managed, () => grant, renew);
@@ -95,15 +148,15 @@ async function helperAttempt(managed, role, task, signal, harness, snapshot, par
95
148
  // One helper session answers the brief (with one part of the patch for a
96
149
  // review in parts); its reply and tokens. A helper runs at its role's
97
150
  // level from Studio (fixed by the Harness, or its main agent's).
98
- const ask = async extra => {
151
+ const ask = async (extra, note = '', who = key) => {
99
152
  const { session } = await managed.api.createAgentSession({ cwd: managed.cwd, model, modelRuntime: runtime, thinkingLevel: grant.effort || 'off',
100
153
  settingsManager: managed.api.SettingsManager.inMemory({ retry: { enabled: false, provider: { maxRetries: 0 } }, cacheWarming: 'off' }),
101
154
  sessionManager: managed.api.SessionManager.inMemory(managed.cwd), noTools: 'builtin', tools: [...READ_TOOLS, ...(setup.commands ? ['bash'] : []), ...(setup.web ? ['web_search', 'web_fetch'] : []), ...memory.map(tool => tool.name)],
102
155
  customTools: [...boundary.tools(managed.api).filter(tool => READ_TOOLS.includes(tool.name) || setup.commands && tool.name === 'bash').map(tool => countedCheck(managed, tool)),
103
- ...(setup.web ? managed.webTools(runtime, role) : []), ...memory], resourceLoader: managedResourceLoader(managed.api, { systemPrompt: setup.prompt + HELPER_UNTRUSTED_RULE }) });
104
- sessions.add(session); trackToolFailures(managed, session, role);
156
+ ...(setup.web ? managed.webTools(runtime, key) : []), ...memory], resourceLoader: managedResourceLoader(managed.api, { systemPrompt: setup.prompt + HELPER_UNTRUSTED_RULE }) });
157
+ sessions.add(session); trackToolFailures(managed, session, role); watched.add(who); watchHelper(managed, who, session, task, title);
105
158
  const abort = () => void session.abort(); signal?.addEventListener('abort', abort, { once: true });
106
- try { if (signal?.aborted) throw Error('managed-helper-cancelled'); await session.prompt(anchorText(managed.session.sessionManager, harness) + task + helperWorkflowPrompt(currentWorkflow(managed.session.sessionManager)) + checks + extra, { expandPromptTemplates: false }); }
159
+ try { if (signal?.aborted) throw Error('managed-helper-cancelled'); await session.prompt(anchorText(managed.session.sessionManager, harness) + task + note + helperWorkflowPrompt(currentWorkflow(managed.session.sessionManager)) + checks + extra, { expandPromptTemplates: false }); }
107
160
  finally { signal?.removeEventListener('abort', abort); }
108
161
  const messages = session.messages.filter(m => m.role === 'assistant');
109
162
  // The main agent (and the member) learn why a helper failed, not just that it did.
@@ -112,50 +165,65 @@ async function helperAttempt(managed, role, task, signal, harness, snapshot, par
112
165
  if (!messages.length || messages.at(-1).stopReason === 'aborted') throw Error(signal?.aborted ? 'managed-helper-cancelled' : 'managed-helper-failed');
113
166
  return { reply: messages.map(textContent).join('\n'), usage: messages.reduce((n, m) => n + (m.usage?.totalTokens ?? 0), 0) };
114
167
  };
115
- let reply, usage, review = null;
116
- if (!parts || parts.parts.length === 1) {
117
- ({ reply, usage } = await ask(parts ? `\n${parts.parts[0].text}` : ''));
118
- reply = reply.slice(0, 16000);
119
- } else {
168
+ // One helper's answer: the brief (with the whole patch, or the patch in parts for a large review).
169
+ const once = async (note = '', who = key) => {
170
+ if (!parts || parts.parts.length === 1) { const answer = await ask(parts ? `\n${parts.parts[0].text}` : '', note, who); return { ...answer, reply: answer.reply.slice(0, 16000), review: null }; }
120
171
  // A large patch: one reviewer per part, REVIEW_PARALLEL at a time (Studio
121
172
  // admits one request of a role at a time); the first part that fails
122
173
  // stops the others.
123
174
  const answers = new Array(parts.parts.length);
124
175
  let next = 0;
125
176
  const reviewer = async () => {
126
- while (next < parts.parts.length) { const i = next++; answers[i] = await ask(`\n${parts.parts[i].text}`); }
177
+ while (next < parts.parts.length) { const i = next++; answers[i] = await ask(`\n${parts.parts[i].text}`, note, who); }
127
178
  };
128
179
  try { await Promise.all(Array.from({ length: Math.min(REVIEW_PARALLEL, parts.parts.length) }, reviewer)); }
129
180
  catch (error) { next = parts.parts.length; for (const session of sessions) void session.abort(); throw error; }
130
- review = mergeReviews(parts, answers, { findingsOf: reviewFindings, issuesOf: briefIssues });
131
- ({ reply, usage } = review);
181
+ const review = mergeReviews(parts, answers, { findingsOf: reviewFindings, issuesOf: briefIssues });
182
+ return { reply: review.reply, usage: review.usage, review };
183
+ };
184
+ let reply, usage, review = null;
185
+ if (notes.length === 1) ({ reply, usage, review } = await once(notes[0]));
186
+ else {
187
+ // Helpers the member asked for on this one grant, each on its own; the first that fails stops the others.
188
+ let answers;
189
+ try { answers = await Promise.all(notes.map((note, i) => once(note, i ? `${role}#${i + 1}` : key))); }
190
+ catch (error) { for (const session of sessions) void session.abort(); throw error; }
191
+ const merged = mergeCopies(role, setup.label, answers, { findingsOf: reviewFindings, issuesOf: briefIssues, verdictsOf: verifyVerdicts });
192
+ ({ reply, usage } = merged); review = merged.findings ? merged : null;
132
193
  }
133
194
  const from = snapshot?.from ?? null;
134
195
  const stale = snapshot ? (await (from ? readPatchDigest(managed.boundary, null, from.base) : managed.patchDigest())) !== snapshot.digest : false;
135
- const details = { role, runID: grant.run_id, tokens: usage, patchDigest: snapshot?.digest, stale, ...(from ? { since: { kind: from.kind, ref: from.ref, base: from.base } } : {}) };
136
- if (snapshot) {
137
- // Severity-graded findings decide whether the harness sends the agent back.
138
- const findings = review ? review.findings : reviewFindings(reply);
139
- Object.assign(details, { parsed: review ? review.parsed : !!findings, findings: (findings ?? []).slice(0, 40), blocking: findings?.filter(f => f.severity === 'blocking').length ?? 0 });
140
- if (review) Object.assign(details, { parts: parts.parts.length, unread: parts.omitted.map(o => o.path).slice(0, 50) });
141
- if (managed.run) { managed.run.reviews += 1; managed.run.blocking += details.blocking; }
142
- // A review of earlier commits is not the review of this turn's change.
143
- if (!from) managed.review = details;
144
- managed.session.sessionManager.appendCustomEntry('agent-watch-review', details);
145
- }
146
- const verdicts = role === 'verify' ? recordVerdicts(managed.run, reply, harness, details) : '';
147
- report = answeredSettlement(role, reply, details, spec); details.outcome = report.outcome;
148
- await managed.session.sendCustomMessage({ customType: 'agent-watch-helper-receipt', display: true,
149
- content: `${setup.label}: ${usage.toLocaleString('en-US')} tokens${verdicts}${review ? ` · ${parts.parts.length} parts` : ''}${snapshot ? (stale ? ' · review is stale' : ' · patch ' + snapshot.digest.slice(0, 12)) : ''} · ${OUTCOME_WORDS[report.outcome]}.`, details }, { triggerTurn: false });
150
- // An objection to the brief comes with the answer; a stale review has none to act on.
151
- const objection = stale ? '' : await relayObjection(managed, role, reply, harness);
152
- const ending = `Outcome: ${report.outcome}${report.outcome === 'unverifiable' ? ` (${report.summary})` : ''}.\n\n`;
153
- // The helper read untrusted content: its answer is data for the main agent.
154
- return { content: [{ type: 'text', text: ending + (stale ? 'Review is stale: the patch changed during review. Obtain a new review for the current patch.' : fenced(`${setup.label} helper's answer`, reply) + objection) }], details };
196
+ const findings = snapshot ? (review ? review.findings : reviewFindings(reply)) : null;
197
+ report = answeredSettlement(role, reply, snapshot ? { stale, parsed: review ? review.parsed : !!findings, findings: findings ?? [] } : {}, spec);
198
+ return { reply, usage, review, stale, from, grant, report };
155
199
  } catch (error) { report = failedSettlement(role, error, signal, spec); throw error; }
156
200
  finally {
157
201
  for (const session of sessions) session.dispose(); await boundary?.dispose();
158
202
  await settleHelper(managed, renew.lease, report); // with the role's current token, before it closes
159
- await closeRole(managed, role);
203
+ await closeRole(managed, key);
204
+ for (const who of watched) helperState(managed, who, null);
160
205
  }
161
206
  }
207
+
208
+ // The helpers running now and what each is doing, told to the session's
209
+ // listeners (the terminal's subagent panel, the WebUI) as it changes.
210
+ export function helperState(managed, key, state) {
211
+ const states = managed.helperStates ??= new Map();
212
+ if (state) states.set(key, state); else states.delete(key);
213
+ managed.notify?.({ type: 'managed_helper_state', helpers: [...states].map(([k, s]) => ({ key: k, role: baseRole(k), slot: slotArgs(k).slot ?? 1, ...s })) });
214
+ }
215
+ // What a helper is doing, for the terminal's subagent panel: the job and its
216
+ // latest tool step, told to listeners as it changes (company-terminal.mjs).
217
+ function watchHelper(managed, key, session, task, title) {
218
+ const job = String(title || task.split('\n').find(line => line.trim()) || '').trim().slice(0, 120);
219
+ helperState(managed, key, { job, step: 'đọc brief' });
220
+ session.subscribe?.(event => {
221
+ if (event?.type !== 'tool_execution_start') return;
222
+ helperState(managed, key, { job, step: stepText(event.toolName, event.args) });
223
+ });
224
+ }
225
+ const STEP = { read: 'đọc', grep: 'tìm', find: 'tìm file', ls: 'xem thư mục', bash: 'chạy', web_search: 'tìm trên web', web_fetch: 'đọc trang', memory_search: 'tra team memory', memory_ask: 'hỏi team memory' };
226
+ export function stepText(tool, args = {}) {
227
+ const what = args?.path ?? args?.pattern ?? args?.command ?? args?.query ?? args?.url ?? '';
228
+ return `${STEP[tool] ?? tool}${what ? ' ' + String(what).replace(/\s+/g, ' ').slice(0, 80) : ''}`;
229
+ }
@@ -24,7 +24,11 @@ export function delegateParameters() {
24
24
  title: { type: 'string', maxLength: 120, description: 'One line naming the job, for the company logs (for example "Find where login tokens are stored").' },
25
25
  task: { type: 'string', minLength: 1, maxLength: 12000 },
26
26
  ...Object.fromEntries(Object.entries(SPEC_FIELDS).map(([name, description]) => [name, field(description)])),
27
- since: { type: 'string', maxLength: 200, description: 'Review only: the commit, branch or HEAD~N the work to review started from (work committed in earlier turns).' } },
27
+ since: { type: 'string', maxLength: 200, description: 'Review only: the commit, branch or HEAD~N the work to review started from (work committed in earlier turns).' },
28
+ // Several helpers of the role, only at the member's request (copies.mjs).
29
+ copies: { type: 'integer', minimum: 2, maximum: 3, description: 'Only when the member asked for several helpers of this role: how many (2 or 3) run side by side on this brief. Never on your own.' },
30
+ split: { type: 'array', minItems: 2, maxItems: 3, items: { type: 'string', minLength: 1, maxLength: 500 }, description: 'Instead of copies: one line per helper naming its share of the work, when the member asked for several and the work divides.' },
31
+ member_words: { type: 'string', maxLength: 300, description: 'With copies or split: the member\'s own words, verbatim, that ask for that many helpers.' } },
28
32
  required: ['role', 'task'], additionalProperties: false };
29
33
  }
30
34
  export const SPEC_PROMPT = ' Give a helper target and acceptance at least (also change, constraints and ownership when they matter): it answers against them, and its ending is recorded with them.';
@@ -0,0 +1,187 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { briefIssues } from './workflow.mjs';
4
+
5
+ // The objection ledger: what a helper holds against the main agent's work in
6
+ // one run (one member message), and what the main agent decided about it.
7
+ // A helper's objection to its brief, a review finding and a failed verify
8
+ // claim are one kind of item: open until the main agent resolves it with
9
+ // resolve_objection (accepted with what it changed, or rejected with what it
10
+ // checked and why), and a rejection of a required item goes back to the
11
+ // helper once; a disagreement that remains goes to the member. The run's
12
+ // items and decisions are one session entry when the run ends.
13
+ export const LEDGER_ENTRY = 'agent-watch-objections';
14
+ // A brief objection that blocks file changes until it is resolved: the brief
15
+ // rests on a premise the code contradicts, or on a reading the member's words
16
+ // contradict, and the helper pointed at a place that exists.
17
+ const PREMISE = new Set(['wrong_premise', 'conflicts_with_request']);
18
+ const DECISIONS = ['accepted', 'rejected'];
19
+ // After this many refused answers a model that cannot resolve an item goes on;
20
+ // the run reports the item as left without a decision.
21
+ export const LEDGER_REFUSALS = 3;
22
+ const MAX_ITEMS = 60, READ_LIMIT = 5 * 1024 * 1024;
23
+
24
+ // "file:line" in a piece of evidence; null when it names none.
25
+ export function evidenceRef(text) {
26
+ const m = String(text ?? '').match(/(?:^|[\s(`'"])((?:[\w@.-]+\/)*[\w@.-]*[\w-]\.[A-Za-z0-9]+):(\d+)\b/);
27
+ return m ? { file: m[1], line: Number(m[2]) } : null;
28
+ }
29
+ // Whether the place exists in the project: 'ok', 'missing' (no such file, or
30
+ // the file is shorter), or null when there is nothing to check or the file is
31
+ // outside the project or too large to read.
32
+ export function checkRef(cwd, ref) {
33
+ if (!cwd || !ref?.file || path.isAbsolute(ref.file) || ref.file.split(/[\\/]/).includes('..')) return null;
34
+ let real;
35
+ try { real = fs.realpathSync(path.join(cwd, ref.file)); } catch { return 'missing'; }
36
+ const root = fs.realpathSync(cwd);
37
+ if (real !== root && !real.startsWith(root + path.sep)) return null;
38
+ try {
39
+ const stat = fs.statSync(real);
40
+ if (!stat.isFile()) return 'missing';
41
+ if (!ref.line) return 'ok';
42
+ if (stat.size > READ_LIMIT) return null;
43
+ return fs.readFileSync(real, 'utf8').split('\n').length >= ref.line ? 'ok' : 'missing';
44
+ } catch { return null; }
45
+ }
46
+
47
+ const clip = (value, n) => typeof value === 'string' ? value.trim().slice(0, n) : '';
48
+ export const ledgerOf = run => run?.ledger ?? [];
49
+ const open = run => ledgerOf(run).filter(i => i.status === 'open');
50
+ export const blockingItems = run => open(run).filter(i => i.required && i.source === 'brief');
51
+
52
+ // Adds items from one helper answer. `source`: brief (an objection to the
53
+ // brief), review (blocking and major findings) or verify (failed claims).
54
+ export function addItems(managed, source, role, list, { required } = {}) {
55
+ const run = managed.run;
56
+ if (!run || !list?.length) return [];
57
+ const added = [];
58
+ for (const raw of list) {
59
+ if (run.ledger.length >= MAX_ITEMS) break;
60
+ const evidence = clip(raw.evidence, 500), ref = raw.file ? { file: raw.file, line: raw.line ?? null } : evidenceRef(evidence), place = checkRef(managed.cwd, ref);
61
+ const must = source === 'brief' ? PREMISE.has(raw.kind) && !!evidence && place !== 'missing' : source === 'review' ? raw.kind === 'blocking' : !!required;
62
+ const item = { id: `o${run.ledger.length + 1}`, source, role, kind: raw.kind, detail: clip(raw.detail, 500), evidence, ...(ref ? { ref } : {}), ...(place ? { place } : {}), required: must, status: 'open' };
63
+ run.ledger.push(item); added.push(item);
64
+ }
65
+ return added;
66
+ }
67
+ export const itemText = i => `${i.id} [${i.source === 'brief' ? i.kind : i.source === 'review' ? i.kind : 'failed claim'}] ${i.role}: ${i.detail}${i.evidence ? ` (evidence: ${i.evidence}${i.place === 'missing' ? '; the harness found no such place' : ''})` : ''}`;
68
+ export const setStatus = (item, status, extra = {}) => Object.assign(item, { status, ...extra });
69
+ export function saveLedger(managed) {
70
+ const items = ledgerOf(managed.run);
71
+ if (items.length) try { managed.session.sessionManager.appendCustomEntry(LEDGER_ENTRY, { items, at: new Date().toISOString() }); } catch { /* the report still counts them */ }
72
+ }
73
+
74
+ // resolve_objection: the main agent's decision on one item, kept with what it
75
+ // checked. An accepted item names what changed; a rejected one says why.
76
+ export function resolveTool(owner) {
77
+ return { name: 'resolve_objection', label: 'Objection',
78
+ description: 'Record your decision on an objection a helper raised (its id, such as o1): a brief objection, a review finding or a failed verify claim. Check it first at the place its evidence names. accepted: you agree; say what you changed (a corrected brief, the plan, the code). rejected: say what you checked (file:line, a command and its result) and why the objection does not hold; a rejected required objection goes back to the helper, and a disagreement that remains goes to the member.',
79
+ parameters: { type: 'object', properties: { id: { type: 'string', maxLength: 10 }, decision: { type: 'string', enum: DECISIONS }, checked: { type: 'string', minLength: 1, maxLength: 300 },
80
+ change: { type: 'string', maxLength: 300 }, reason: { type: 'string', maxLength: 500 } }, required: ['id', 'decision', 'checked'], additionalProperties: false },
81
+ execute: async (_id, args) => {
82
+ const item = ledgerOf(owner.run).find(i => i.id === args?.id);
83
+ if (!item) throw Error(`managed-objection-unknown: no objection ${String(args?.id).slice(0, 10)} in this message; the open ones are ${open(owner.run).map(i => i.id).join(', ') || 'none'}.`);
84
+ if (!['open', 'noted'].includes(item.status)) throw Error(`managed-objection-settled: ${item.id} is already ${item.status}.`);
85
+ if (!DECISIONS.includes(args.decision) || !clip(args.checked, 300)) throw Error('managed-objection-invalid: send decision accepted or rejected, and what you checked.');
86
+ if (args.decision === 'accepted' && !clip(args.change, 300)) throw Error('managed-objection-invalid: an accepted objection names what you changed (the brief, the plan or the code).');
87
+ if (args.decision === 'rejected' && !clip(args.reason, 500)) throw Error('managed-objection-invalid: a rejected objection says why it does not hold.');
88
+ const resolution = { decision: args.decision, checked: clip(args.checked, 300), ...(args.change ? { change: clip(args.change, 300) } : {}), ...(args.reason ? { reason: clip(args.reason, 500) } : {}) };
89
+ setStatus(item, args.decision, { resolution });
90
+ const back = args.decision === 'rejected' && item.required ? ` The ${item.role} subagent judges your reason before the turn ends.` : '';
91
+ return { content: [{ type: 'text', text: `Recorded: ${item.id} ${args.decision}.${back}` }], details: { id: item.id, decision: args.decision } };
92
+ } };
93
+ }
94
+
95
+ // Before an edit or a command that may change files: an open premise
96
+ // objection is resolved first. `answer` names the model answer the call came
97
+ // from, so calls sent together count once.
98
+ export function ledgerGuard(run, answer) {
99
+ const items = blockingItems(run);
100
+ if (!items.length) return;
101
+ run.ledgerRefusals ??= new Set();
102
+ if (!run.ledgerRefusals.has(answer) && run.ledgerRefusals.size >= LEDGER_REFUSALS) { run.ledgerSkipped = true; return; }
103
+ run.ledgerRefusals.add(answer);
104
+ throw Error(`managed-objection-open: resolve ${items.map(i => i.id).join(', ')} with resolve_objection before changing files.\n${items.map(itemText).join('\n')}`);
105
+ }
106
+
107
+ // The receipt's counts (report version 3): objections to briefs, those the
108
+ // main agent decided, the disagreements handed to the member.
109
+ export function briefCounts(run) {
110
+ const briefs = ledgerOf(run).filter(i => i.source === 'brief');
111
+ return { objections: briefs.length, answered: briefs.filter(i => ['accepted', 'rejected', 'withdrawn'].includes(i.status)).length };
112
+ }
113
+
114
+ // A required rejection the helper has not judged yet; the harness asks it.
115
+ export const toRejudge = run => ledgerOf(run).filter(i => i.source === 'brief' && i.required && i.status === 'rejected' && !i.rejudged);
116
+ export function rejudgeBrief(item) {
117
+ return `You objected to the brief the main agent gave you:\n[${item.kind}] ${item.detail}${item.evidence ? `\nYour evidence: ${item.evidence}` : ''}\n\nThe main agent rejected the objection.\nWhat it checked: ${item.resolution.checked}\nIts reason: ${item.resolution.reason}\n\nJudge its reason against the code and the member's words. When the reason holds, end with an empty brief_issues list. When your objection still holds, repeat it in brief_issues with evidence; the member then decides.`;
118
+ }
119
+
120
+ // One step of the completion gate (true: the gate goes round again). A
121
+ // rejected premise objection goes back to its helper, which withdraws it or
122
+ // keeps it for the member; then the items the main agent never decided are
123
+ // put to it once, with no new review.
124
+ export async function settleLedger(managed, run, { note, send, signal }) {
125
+ const rejected = toRejudge(run)[0];
126
+ if (rejected) {
127
+ rejected.rejudged = true; let reply;
128
+ await note(`Harness: the ${rejected.role} subagent judges the main agent's reason for rejecting ${rejected.id}.`, { phase: 'rejudge', role: rejected.role, id: rejected.id });
129
+ try { reply = (await managed.delegate({ role: rejected.role, task: rejudgeBrief(rejected) }, signal, { harness: true })).content.map(p => p.text).join('\n'); }
130
+ catch { return true; }
131
+ if (!briefIssues(reply)?.length) { setStatus(rejected, 'withdrawn'); return true; }
132
+ setStatus(rejected, 'disputed'); run.disputes.push({ source: rejected.role, brief: true });
133
+ await note(`Harness: the ${rejected.role} subagent keeps its objection; the member decides.\n${itemText(rejected)}`, { phase: 'dispute', role: rejected.role, id: rejected.id });
134
+ return true;
135
+ }
136
+ const pending = undecided(run);
137
+ if (!pending.length || run.decideAsked) return false;
138
+ run.decideAsked = true;
139
+ await send(decideText(pending), { phase: 'answer', ids: pending.map(i => i.id), issues: pending.slice(0, 10).map(i => ({ kind: i.kind, detail: i.detail })) });
140
+ return true;
141
+ }
142
+
143
+ // What the main agent still has to decide before the turn ends: required
144
+ // items, review findings above minor, brief objections it never answered.
145
+ export const undecided = run => open(run).filter(i => i.required || i.source !== 'verify');
146
+ export function decideText(items) {
147
+ return `Harness: ${items.length} objection(s) have no decision yet:\n${items.map(i => '- ' + itemText(i)).join('\n')}\nCheck each at the place its evidence names, then record your decision with resolve_objection: accepted with what you changed, or rejected with what you checked and why. Minor review findings need nothing; no new review follows.`;
148
+ }
149
+ // The final status line's words about the ledger.
150
+ export function ledgerWords(run) {
151
+ const items = ledgerOf(run), count = s => items.filter(i => i.status === s).length, words = [];
152
+ if (!items.length && !run.minorFindings) return null;
153
+ for (const [status, label] of [['accepted', 'accepted'], ['rejected', 'rejected'], ['withdrawn', 'withdrawn by the helper'], ['cleared', 'cleared by a later check'], ['disputed', 'for the member'], ['open', 'left without a decision'], ['noted', 'noted']])
154
+ if (count(status)) words.push(`${count(status)} ${label}`);
155
+ if (run.minorFindings) words.push(`${run.minorFindings} minor finding(s) listed`);
156
+ return `objections: ${words.join(', ')}`;
157
+ }
158
+
159
+ // A review of this turn's patch replaces the findings of the one before it:
160
+ // its blocking and major findings are the items, its minor ones only counted.
161
+ // A finding it repeats keeps its item (and the main agent's decision); one it
162
+ // no longer reports is cleared, or withdrawn when the agent had rejected it.
163
+ const sameFinding = (item, f) => (item.ref?.file ?? null) === (f.file || null) && (item.ref?.line === f.line || item.detail === clip(f.issue, 500));
164
+ export function recordFindings(managed, findings) {
165
+ const run = managed.run;
166
+ if (!run || !findings) return;
167
+ // A re-review after a fix read only the files changed since: an item about another file stands.
168
+ const focus = managed.reviewFocus?.files ? new Set(managed.reviewFocus.files) : null;
169
+ const kept = findings.filter(f => f.severity !== 'minor'), live = ledgerOf(run).filter(i => i.source === 'review' && ['open', 'rejected'].includes(i.status)
170
+ && (!focus || i.kind === 'blocking' || !i.ref?.file || focus.has(i.ref.file)));
171
+ for (const item of live) if (!kept.some(f => f.severity === item.kind && sameFinding(item, f))) setStatus(item, item.status === 'open' ? 'cleared' : 'withdrawn');
172
+ addItems(managed, 'review', 'review', kept.filter(f => !live.some(i => i.kind === f.severity && sameFinding(i, f))).map(f => ({ kind: f.severity, detail: f.issue, evidence: f.evidence, file: f.file || null, line: f.line })));
173
+ run.minorFindings = findings.length - kept.length;
174
+ }
175
+ // The files changed since the last review of this turn's patch, for a
176
+ // re-review after a fix; null when that cannot be told.
177
+ export async function reviewFocus(managed) {
178
+ const before = managed.reviewFiles, now = before ? await managed.changedFiles?.() : null;
179
+ if (!before || !now) return null;
180
+ const files = [...new Set([...before.keys(), ...now.keys()])].filter(name => before.get(name) !== now.get(name)).sort();
181
+ return files.length ? { files: files.slice(0, 200) } : null;
182
+ }
183
+ // The main agent's decisions on items of one source, for the helper that judges them again.
184
+ export function decisionsText(run, source) {
185
+ const decided = ledgerOf(run).filter(i => i.source === source && i.resolution);
186
+ return decided.length ? `\n\nThe main agent's recorded decisions:\n${decided.map(i => `- ${i.detail}: ${i.resolution.decision}; checked: ${i.resolution.checked}${i.resolution.reason ? `; reason: ${i.resolution.reason}` : ''}${i.resolution.change ? `; changed: ${i.resolution.change}` : ''}`).join('\n')}` : '';
187
+ }
@@ -1,10 +1,12 @@
1
1
  import { briefIssues, currentPlan, planText, verifyVerdicts } from './workflow.mjs';
2
+ import { addItems, itemText, ledgerOf, setStatus } from './ledger.mjs';
3
+ export { resolveTool } from './ledger.mjs';
2
4
 
3
5
  // A helper may object to the brief it was given, with evidence. It never
4
- // decides and never does other work instead: the main agent decides and has
5
- // to answer the objection (a corrected brief, another approach, or why the
6
- // objection does not hold). The same helper objecting a second time in a run
7
- // stops the exchange, and the member decides.
6
+ // decides and never does other work instead: the main agent decides and
7
+ // records its decision in the ledger (ledger.mjs): accepted with what it
8
+ // changed, or rejected with what it checked and why. The same helper
9
+ // objecting a second time in a run stops the exchange, and the member decides.
8
10
 
9
11
  // The member's own words for a helper: the current message and, while a
10
12
  // message only continues earlier work ("tiếp tục", a short follow-up), the
@@ -33,41 +35,61 @@ export function anchorText(manager, harness) {
33
35
  }
34
36
 
35
37
  // Before a helper starts: once a helper objected twice in a run the member
36
- // decides, so the main agent cannot send it another brief before then. A
37
- // new brief to a helper that objected answers that objection.
38
- export function beforeDelegate(run, role, harness) {
38
+ // decides, so the main agent cannot send it another brief before then. An
39
+ // open premise objection of the role is resolved with resolve_objection
40
+ // first; a new brief answers any other objection of the role. A review the
41
+ // main agent asks for itself: none under a required review (the harness
42
+ // reviews the final patch), else once, and again only after blocking findings.
43
+ export function beforeDelegate(run, role, harness, { since = null, review = null, changed = false, asked = false } = {}) {
39
44
  if (!run || harness) return;
40
45
  if (run.disputes.some(d => d.source === role && d.brief)) throw Error(`managed-helper-disputed: the ${role} subagent objected to your briefs twice for this message, so the member decides. Tell them what it objects to and what you decided; do not delegate to it again before their next message.`);
41
- for (const objection of run.objections) if (objection.role === role) objection.answered = true;
46
+ const mine = ledgerOf(run).filter(i => i.source === 'brief' && i.role === role && i.status === 'open');
47
+ const blocked = mine.filter(i => i.required);
48
+ if (blocked.length) throw Error(`managed-objection-open: the ${role} subagent objected to your brief; record your decision with resolve_objection before you brief it again.\n${blocked.map(itemText).join('\n')}`);
49
+ // Reviewers the member asked for (copies.mjs) run at their word.
50
+ if (role === 'review' && !since && changed && !asked) {
51
+ if (run.policy.review === 'require') throw Error('managed-review-by-harness: the harness reviews the final patch when you finish; do not ask for a review yourself. Finish the change and its checks.');
52
+ if (run.mainReviews > 0 && !(review?.stale && review.blocking > 0)) throw Error('managed-review-once: this change was reviewed already. Fix its blocking findings and ask again only then; fix major findings or reject them with resolve_objection; minor findings need nothing.');
53
+ }
54
+ if (role === 'review' && !since) run.mainReviews = (run.mainReviews ?? 0) + 1;
55
+ for (const item of mine) setStatus(item, 'accepted', { resolution: { decision: 'accepted', checked: 'sent the role a new brief', change: 'a new brief' } });
42
56
  }
43
57
 
44
- const issueText = (issue, i) => `${i + 1}. [${issue.kind}] ${issue.detail}${issue.evidence ? ` (evidence: ${issue.evidence})` : ''}`;
45
- // After a helper answered: an objection to the main agent's brief is
46
- // recorded, shown to the member and handed to the main agent with the answer.
47
- // Returns the note the main agent reads under the helper's answer ('' when
48
- // there is none). The harness's own briefs (its review, its re-check) are
49
- // fixed text: an objection to one only restates the member's request, so
50
- // it is not relayed.
58
+ // After a helper answered: an objection to the main agent's brief goes into
59
+ // the ledger, is shown to the member and handed to the main agent with the
60
+ // answer. Returns the note the main agent reads under the helper's answer
61
+ // ('' when there is none). The harness's own briefs (its review, its
62
+ // re-check) are fixed text: an objection to one only restates the member's
63
+ // request, so it is not relayed.
51
64
  export async function relayObjection(managed, role, reply, harness) {
52
65
  const run = managed.run, issues = harness ? null : briefIssues(reply);
53
66
  if (!run || !issues?.length) return '';
54
- const second = run.objections.some(o => o.role === role), list = issues.map(issueText).join('\n');
55
- run.objections.push({ role, issues, answered: false, disputed: second });
56
- if (second) run.disputes.push({ source: role, brief: true });
67
+ const second = ledgerOf(run).some(i => i.source === 'brief' && i.role === role), items = addItems(managed, 'brief', role, issues), list = items.map(itemText).join('\n');
68
+ if (second) { run.disputes.push({ source: role, brief: true }); for (const item of items) setStatus(item, 'disputed'); }
57
69
  await managed.session.sendCustomMessage({ customType: 'agent-watch-process', display: true,
58
70
  content: second ? `Harness: the ${role} subagent objected to a brief a second time for this message; the exchange stops and the member decides.\n${list}`
59
71
  : `Harness: the ${role} subagent objected to the brief the main agent gave it:\n${list}`,
60
- details: { phase: second ? 'dispute' : 'objection', role, by: 'main', issues: issues.map(({ kind, detail }) => ({ kind, detail })) } }, { triggerTurn: false });
61
- return second ? `\n\n[Harness] The ${role} subagent objected to your brief again. This is its second objection for this message, so the harness ends the exchange: do not delegate to it again before the member's next message. Tell the member plainly what it objects to and what you decided, and let them decide.`
62
- : `\n\n[Harness] The ${role} subagent objected to your brief (see brief_issues above). You decide, but answer the objection before you act on this answer: send it a corrected brief, change your approach, or tell the member why the objection does not hold. A second objection from it for this message goes to the member.`;
72
+ details: { phase: second ? 'dispute' : 'objection', role, by: 'main', issues: issues.map(({ kind, detail }) => ({ kind, detail })), ids: items.map(i => i.id) } }, { triggerTurn: false });
73
+ if (second) return `\n\n[Harness] The ${role} subagent objected to your brief again. This is its second objection for this message, so the harness ends the exchange: do not delegate to it again before the member's next message. Tell the member plainly what it objects to and what you decided, and let them decide.`;
74
+ const blocking = items.filter(i => i.required);
75
+ return `\n\n[Harness] The ${role} subagent objected to your brief:\n${list}\nCheck each at the place its evidence names, then record your decision with resolve_objection (accepted with what you changed, or rejected with what you checked and why).${blocking.length ? ` Until you do, file changes are refused: ${blocking.map(i => i.id).join(', ')} say${blocking.length === 1 ? 's' : ''} the brief rests on a wrong premise or conflicts with the request.` : ''} A rejected objection goes back to the helper; a second objection from it for this message goes to the member.`;
63
76
  }
64
77
 
65
- // Verify's verdicts, counted for the receipt. Under a required verify the
66
- // failed claims are the run's to settle; once the agent has been sent back
67
- // with them, only the harness's own re-check replaces them.
68
- export function recordVerdicts(run, reply, harness, details) {
69
- const found = verifyVerdicts(reply);
78
+ // Verify's verdicts, counted for the receipt; its failed claims go into the
79
+ // ledger (required under a required verify, noted otherwise). Under a
80
+ // required verify the failed claims are the run's to settle; once the agent
81
+ // has been sent back with them, only the harness's own re-check replaces
82
+ // them, and a claim it no longer fails is cleared.
83
+ export function recordVerdicts(managed, reply, harness, details) {
84
+ const run = managed.run, found = verifyVerdicts(reply);
70
85
  details.verdicts = found ? Object.fromEntries(['pass', 'fail', 'unverifiable'].map(s => [s, found.filter(v => v.status === s).length])) : null;
71
- if (run && found && (harness || !run.claimsAsked)) run.verifyFails = found.filter(v => v.status === 'fail');
86
+ if (run && found && (harness || !run.claimsAsked)) {
87
+ run.verifyFails = found.filter(v => v.status === 'fail');
88
+ const failed = new Set(run.verifyFails.map(v => v.claim)), required = run.policy.verify === 'require';
89
+ for (const item of ledgerOf(run)) if (item.source === 'verify' && ['open', 'rejected', 'noted'].includes(item.status) && !failed.has(item.detail)) setStatus(item, 'cleared');
90
+ const known = new Set(ledgerOf(run).filter(i => i.source === 'verify' && i.status !== 'cleared').map(i => i.detail));
91
+ const added = addItems(managed, 'verify', 'verify', run.verifyFails.filter(v => !known.has(v.claim)).map(v => ({ kind: 'fail', detail: v.claim, evidence: v.evidence })), { required });
92
+ if (!required) for (const item of added) setStatus(item, 'noted');
93
+ }
72
94
  return found ? ' · ' + Object.entries(details.verdicts).filter(([, n]) => n).map(([s, n]) => `${n} ${s}`).join(', ') : '';
73
95
  }
@@ -176,6 +176,15 @@ export function reviewParts(snapshot, size = REVIEW_PART_BYTES) {
176
176
  return { parts: parts.length ? parts : [{ files: [], text: `Patch snapshot ${snapshot.digest}: every changed file is too large to review.${skipped}` }], omitted };
177
177
  }
178
178
 
179
+ // A re-review after a fix (focus: the files changed since the last review)
180
+ // reads only the parts that hold one of them: the other parts were reviewed
181
+ // and have not changed. Without such a part, or in one part, all of it.
182
+ export function focusParts(review, focus) {
183
+ if (!focus?.files?.length || review.parts.length < 2) return review;
184
+ const files = new Set(focus.files), kept = review.parts.filter(part => part.files?.some(file => files.has(file)));
185
+ return kept.length ? { ...review, parts: kept } : review;
186
+ }
187
+
179
188
  // One answer from the review of every part: each part's summary, then the
180
189
  // findings of all parts (most severe first) and their objections to the brief
181
190
  // in one JSON block. `parsed`: every part gave its JSON.