@piagent/platform 1.46.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 (72) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/docs/command-reference-vietnamese.md +2 -2
  3. package/docs/distribution-standard.md +6 -6
  4. package/docs/en/architecture.md +1 -1
  5. package/docs/harness-conformance.md +22 -5
  6. package/docs/languages.json +2 -2
  7. package/docs/operator-manual-vietnamese.md +7 -7
  8. package/docs/publishing-for-teams.md +1 -0
  9. package/docs/release-install-policy.md +2 -2
  10. package/docs/team-onboarding.md +2 -2
  11. package/docs/vi/architecture.md +1 -1
  12. package/docs-site/architecture.html +1 -1
  13. package/docs-site/assets/search-en.json +1 -1
  14. package/docs-site/assets/search-vi.json +1 -1
  15. package/docs-site/benchmark.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/context-engine.html +1 -1
  18. package/docs-site/ecosystem.html +1 -1
  19. package/docs-site/en/architecture.html +1 -1
  20. package/docs-site/en/benchmark.html +1 -1
  21. package/docs-site/en/commands.html +1 -1
  22. package/docs-site/en/context-engine.html +1 -1
  23. package/docs-site/en/ecosystem.html +1 -1
  24. package/docs-site/en/index.html +9 -9
  25. package/docs-site/en/mcp-approval.html +1 -1
  26. package/docs-site/en/mcp-auth.html +1 -1
  27. package/docs-site/en/mcp-servers.html +1 -1
  28. package/docs-site/en/mcp.html +1 -1
  29. package/docs-site/en/permissions.html +1 -1
  30. package/docs-site/en/profiles.html +1 -1
  31. package/docs-site/en/quickstart.html +1 -1
  32. package/docs-site/en/runtime.html +1 -1
  33. package/docs-site/en/scope.html +1 -1
  34. package/docs-site/en/security.html +1 -1
  35. package/docs-site/en/team.html +1 -1
  36. package/docs-site/en/update.html +3 -3
  37. package/docs-site/en/whats-new.html +27 -13
  38. package/docs-site/en/windows.html +1 -1
  39. package/docs-site/en/workflows.html +1 -1
  40. package/docs-site/index.html +9 -9
  41. package/docs-site/mcp-approval.html +1 -1
  42. package/docs-site/mcp-auth.html +1 -1
  43. package/docs-site/mcp-servers.html +1 -1
  44. package/docs-site/mcp.html +1 -1
  45. package/docs-site/permissions.html +1 -1
  46. package/docs-site/profiles.html +1 -1
  47. package/docs-site/quickstart.html +1 -1
  48. package/docs-site/releases.json +44 -0
  49. package/docs-site/runtime.html +1 -1
  50. package/docs-site/scope.html +1 -1
  51. package/docs-site/security.html +1 -1
  52. package/docs-site/team.html +1 -1
  53. package/docs-site/update.html +3 -3
  54. package/docs-site/whats-new.html +27 -13
  55. package/docs-site/windows.html +1 -1
  56. package/docs-site/workflows.html +1 -1
  57. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  58. package/package.json +1 -1
  59. package/packages/piagent-core/managed/copies.mjs +61 -0
  60. package/packages/piagent-core/managed/grant-share.mjs +9 -1
  61. package/packages/piagent-core/managed/helper-roles.mjs +5 -3
  62. package/packages/piagent-core/managed/helper-run.mjs +120 -52
  63. package/packages/piagent-core/managed/helper-settlement.mjs +5 -1
  64. package/packages/piagent-core/managed/ledger.mjs +187 -0
  65. package/packages/piagent-core/managed/objections.mjs +49 -27
  66. package/packages/piagent-core/managed/patch-snapshot.mjs +9 -0
  67. package/packages/piagent-core/managed/session.mjs +14 -14
  68. package/packages/piagent-core/managed/toolchain.mjs +7 -3
  69. package/packages/piagent-core/managed/workflow.mjs +32 -26
  70. package/packages/piagent-core/package.json +1 -1
  71. package/packages/piagent-core/runtime/managed-failure.mjs +8 -0
  72. package/scripts/company-terminal.mjs +19 -1
@@ -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.
@@ -8,7 +8,7 @@ import { ensureSearchTools, trustSystemCertificates } from './toolchain.mjs';
8
8
  import { inlineExtensions, managedResourceLoader, projectInstructions } from './resource-loader.mjs';
9
9
  import { fetchPublicPage, fetchLimits } from './web-fetch.mjs'; import { guardFetch } from './fetch-guard.mjs'; import { UNTRUSTED_RULE } from './untrusted.mjs';
10
10
  import { searchForRole } from './web-search.mjs';
11
- import { shareGrant, releaseGrant } from './grant-share.mjs';
11
+ import { shareGrant, releaseGrant, slotArgs } from './grant-share.mjs';
12
12
  import { managedThinkingLevels, nearestLevel } from './capabilities.mjs';
13
13
  import { nativeManagedModel, isVendor, piProvider, studioAPI, studioPath } from './native-catalog.mjs';
14
14
  import { fetchOriginTool } from './repository-operation.mjs';
@@ -24,7 +24,7 @@ import { readPatchDigest, readPatchSnapshot, readChangedFiles, readHead } from '
24
24
  import { memberTurn } from './continuation.mjs';
25
25
  import { workflowPolicy, repositoryChecks, sessionPlanTool, currentPlan, workflowPrompt, RunProcess, completionGate, pendingBaseline, pendingPaths, pendingHead, processEditTools } from './workflow.mjs';
26
26
  import { workflowContextTool, workflowContextExtension } from './workflow-context.mjs';
27
- import { beforeDelegate } from './objections.mjs';
27
+ import { beforeDelegate, resolveTool } from './objections.mjs'; import { fanOut } from './copies.mjs';
28
28
  import { HELPER_CALLS, HELPER_ROLES, helperRoles, helperPrompt, webPrompt, delegateDescription, mainWebLookups, limitedWebTools } from './helper-roles.mjs'; import { delegateParameters, delegateArgs, SPEC_PROMPT } from './helper-settlement.mjs';
29
29
  import { loadAgentResources } from './agent-skills.mjs';
30
30
  import { loadPiagentThemes } from '../runtime/terminal/terminal-style.mjs';
@@ -75,8 +75,9 @@ async function patiently(ask, times) {
75
75
  catch (error) { if (attempt >= times || !BUSY.test(String(error?.message))) throw error; await sleep(1500 * attempt); }
76
76
  }
77
77
  }
78
- function verifyGrant(grant, manifest, role) {
79
- if (!grant || !sameAuthority(grant, manifest.authority) || grant.role !== role || !Number.isSafeInteger(grant.fence) || grant.fence < 1
78
+ function verifyGrant(grant, manifest, key) {
79
+ const { role, slot = 1 } = slotArgs(key); // a helper beside another of its role: role#slot (grant-share.mjs)
80
+ if (!grant || !sameAuthority(grant, manifest.authority) || grant.role !== role || (grant.slot ?? 1) !== slot || !Number.isSafeInteger(grant.fence) || grant.fence < 1
80
81
  || !/^as_run_[a-f0-9-]{36}_[\w-]{43}$/.test(grant.token) || !(['claude', 'codex'].includes(grant.provider) || isVendor(grant.provider))
81
82
  || !manifest.models.some(m => m.id === grant.model_id && m.owned_by === grant.provider && m.provider_model_id === grant.provider_model_id)) throw Error('managed-grant-invalid');
82
83
  }
@@ -128,7 +129,7 @@ export class ManagedSession {
128
129
  if (roles.length) customTools.push({ name: 'delegate', label: 'Subagent', description: delegateDescription(roles) + SPEC_PROMPT,
129
130
  parameters: delegateParameters(), // the brief and its structured fields (helper-settlement.mjs)
130
131
  execute: (_id, args, signal) => self.delegate(delegateArgs(self, args), signal) });
131
- customTools.push(sessionPlanTool(self));
132
+ customTools.push(sessionPlanTool(self), ...(roles.length ? [resolveTool(self)] : [])); // the objection ledger's decisions (ledger.mjs)
132
133
  // The member decides what the request leaves open: numbered options, or their own words.
133
134
  customTools.push(workflowContextTool(() => self.session.sessionManager));
134
135
  customTools.push(askTool(self), ...memoryToolsFor(self, agentDir)); // team memory's tools, once Studio answered memory here (memory.mjs)
@@ -459,20 +460,19 @@ export class ManagedSession {
459
460
  this.review = { ...this.review, stale: true };
460
461
  this.session.sessionManager.appendCustomEntry('agent-watch-review', this.review);
461
462
  await this.session.sendCustomMessage({ customType: 'agent-watch-review-status', display: true,
462
- content: 'Review is stale: code changed after review. Obtain a new review for the current patch.', details: this.review }, { triggerTurn: false });
463
+ content: 'Review is stale: code changed after review. Ask for another review only after fixing blocking findings; under a required review the harness reviews the final patch.', details: this.review }, { triggerTurn: false });
463
464
  }
464
465
  // `harness`: the completion gate asks, not the main agent (its brief, its turn).
465
- async delegate({ role, task, title, since }, signal, { harness = false } = {}) {
466
+ async delegate({ role, task, title, since, ...more }, signal, { harness = false } = {}) {
466
467
  if (!this.grant || !HELPER_ROLES.includes(role) || typeof task !== 'string' || !task.trim() || task.length > 12000 || this.helpers.has(role)) throw Error('managed-helper-unavailable');
467
468
  const enabled = helperRoles(this.manifest);
468
469
  if (!enabled.includes(role)) throw Error(`managed-helper-not-configured: the company Harness has no ${role} subagent${enabled.length ? `; use ${enabled.join(', ')}` : ''}.`);
469
- // Studio gives a run each helper role once. Say so at once instead of
470
- // letting the main agent retry a call that cannot succeed.
471
470
  // Studio lets each helper role run HELPER_CALLS times per run (one user
472
- // message). Say so at once instead of asking for a grant it refuses.
473
- if ((this.helperCalls.get(role) ?? 0) >= HELPER_CALLS) throw Error(`managed-helper-limit: the ${role} subagent already ran ${HELPER_CALLS} times for this user message. Go on with what it returned, or use it again after the next user message.`);
474
- beforeDelegate(this.run, role, harness);
475
- const job = this.runHelper(role, task, signal, harness, title, role === 'review' && typeof since === 'string' && since.trim() ? since.trim() : null); this.helpers.set(role, job); this.publishHelpers();
471
+ // message), each helper the member asked for counted (copies.mjs). Say so at once.
472
+ const fan = fanOut(this.session?.sessionManager, role, more, harness);
473
+ if ((this.helperCalls.get(role) ?? 0) + (fan?.n ?? 1) > HELPER_CALLS) throw Error(`managed-helper-limit: the ${role} subagent already ran ${HELPER_CALLS} times for this user message. Go on with what it returned, or use it again after the next user message.`);
474
+ beforeDelegate(this.run, role, harness, { since, review: this.review, changed: (this.ownPaths?.size ?? 0) > 0, asked: !!fan });
475
+ const job = this.runHelper(role, task, signal, harness, title, role === 'review' && typeof since === 'string' && since.trim() ? since.trim() : null, fan); this.helpers.set(role, job); this.publishHelpers();
476
476
  // While a helper works (a verify running a long suite) the main agent is
477
477
  // quiet: a beat each minute tells the dashboard's watchdog, which stops a
478
478
  // turn after 15 minutes without an event, that the turn is working.
@@ -490,7 +490,7 @@ export class ManagedSession {
490
490
  notify(event) {
491
491
  for (const listener of [...this.listeners]) { try { listener(event); } catch { /* a listener's failure is its own */ } }
492
492
  }
493
- runHelper(role, task, signal, harness, title, since = null) { return runHelper(this, role, task, signal, harness, { verifyGrant, patiently, title: typeof title === 'string' ? title : '', since }); }
493
+ runHelper(role, task, signal, harness, title, since = null, fan = null) { return runHelper(this, role, task, signal, harness, { verifyGrant, patiently, title: typeof title === 'string' ? title : '', since, fan }); }
494
494
  async dispose() {
495
495
  if (this.disposed) return;
496
496
  this.disposed = true;
@@ -74,13 +74,17 @@ export function searchToolPath(name, userHome = os.homedir()) {
74
74
  return null;
75
75
  }
76
76
  export async function ensureSearchTools(sdkRoot, userHome = os.homedir(), timeoutMs = 60_000) {
77
- const missing = ['rg', 'fd'].filter(name => !searchToolPath(name, userHome));
78
- if (!missing.length) return;
79
77
  let manager;
80
78
  try { manager = await import(pathToFileURL(path.join(sdkRoot, 'dist/utils/tools-manager.js')).href); } catch { return; }
79
+ // The company terminal looks fd up the way Pi does (its bin folder, then a
80
+ // PATH of /usr/bin:/bin) and, offline, never downloads it: without fd there
81
+ // @ file completion shows nothing, even with fd under /opt/homebrew/bin.
82
+ const missing = ['rg', 'fd'].filter(name => !searchToolPath(name, userHome) || !manager.getToolPath?.(name));
83
+ if (!missing.length) return;
81
84
  await Promise.all(missing.map(async name => {
82
85
  try {
83
- const found = await Promise.race([manager.ensureTool(name, true), new Promise(resolve => setTimeout(resolve, timeoutMs).unref())]);
86
+ // Pi 1.3 takes a status callback here; the former `silent` flag (true) made it throw before downloading.
87
+ const found = await Promise.race([manager.ensureTool(name), new Promise(resolve => setTimeout(resolve, timeoutMs).unref())]);
84
88
  if (typeof found === 'string' && path.isAbsolute(found) && executable(found)) downloaded.set(name, found);
85
89
  } catch { /* grep/find fall back to the built-in search */ }
86
90
  }));
@@ -6,6 +6,7 @@ import { TsDiagnostics } from './ts-diagnostics.mjs';
6
6
  import { checkpointsOf } from './checkpoints.mjs';
7
7
  import { discoverDeliveryContext } from './delivery-context.mjs';
8
8
  import { LANE_LIMITS, patchStats, changeLane, lanePolicy } from './risk-lanes.mjs';
9
+ import { briefCounts, decisionsText, ledgerGuard, ledgerOf, ledgerWords, reviewFocus, saveLedger, setStatus, settleLedger } from './ledger.mjs';
9
10
 
10
11
  // The process layer of the company harness: a plan for multi-step work, the
11
12
  // repository's checks after a code change, an independent review of the patch.
@@ -149,8 +150,10 @@ export function workflowPrompt(policy, checks) {
149
150
  const list = checks.commands.length ? `${checks.commands.map(c => '`' + c + '`').join(', ')}${checks.source === 'detected' ? ' (detected; the repository declares none in AGENTS.md "## Checks")' : ''}` : 'none declared: choose the tests, type check or build that cover the change';
150
151
  lines.push(`- Verify: after changing code, run the repository checks that cover the change and fix failures before you finish. Repository checks: ${list}.${policy.verify === 'require' ? ' The turn does not end until a check has passed on the final code.' : ''}`);
151
152
  }
152
- if (policy.review !== 'off') lines.push(policy.review === 'require' ? '- Review: the harness reviews the final patch with an independent reviewer; blocking findings come back to you to fix, or to answer when a finding is wrong: the reviewer then judges your answer, and a disagreement that remains goes to the member.'
153
- : '- Review: before you finish a code change, ask the review helper (delegate role "review") to review the patch, then fix blocking findings.');
153
+ // What each severity asks: blocking is fixed or argued, major decided, minor only listed.
154
+ const severities = ' Blocking findings: fix them, or reject one with resolve_objection and the reviewer judges your reason; a disagreement that remains goes to the member. Major findings: fix them or reject them with resolve_objection; no new review follows. Minor findings are listed for the member and need nothing.';
155
+ if (policy.review !== 'off') lines.push(policy.review === 'require' ? `- Review: when you finish, the harness reviews the final patch with an independent reviewer, and after a fix only what you changed; do not ask for a review yourself.${severities}`
156
+ : `- Review: when your code change is finished, ask the review helper (delegate role "review") once; ask again only after fixing blocking findings.${severities}`);
154
157
  if (policy.lanes && (policy.verify !== 'off' || policy.review !== 'off')) {
155
158
  const L = LANE_LIMITS;
156
159
  lines.push(`- Size: the process follows the size of your change. A small change (at most ${L.tinyFiles} files and ${L.tinyLines} changed lines, no migration, auth, security, CI, dependency or AGENTS.md file) ends after its checks, without a review: do not ask for one. A risky change (a complex request, one of those files, more than ${L.riskyFiles} files or ${L.riskyLines} lines) must pass the checks${policy.review !== 'off' ? ' and the harness review' : ''} even where they are only suggested.`);
@@ -242,11 +245,12 @@ export class RunProcess {
242
245
  Object.assign(this, { policy, request: String(request ?? ''), complex, checks, startDigest: null, mutations: 0, lastCheck: null, checksRun: 0, checksFailed: 0,
243
246
  reviews: 0, blocking: 0, blockingOpen: 0, fixLoops: 0, planUpdated: false, planRefusals: new Set(), planSkipped: false, unplannedChange: false,
244
247
  unknownTools: 0, changed: false, verified: false, docsOnly: false, approvalDeclined: false, reviewed: false, reviewUnavailable: false, outcome: null, legacyOutcome: null,
245
- // Helper objections to a brief, disagreements handed to the member, the
246
- // claims verify marked failed, and where the harness last sent the
247
- // agent back (to read its answer and see whether the code moved).
248
- objections: [], disputes: [], verifyFails: [], claimsAsked: false, claimsAt: null, claimsRechecked: false, claimsDone: false,
249
- sentAt: null, sentDigest: null, rejudged: false, reviewSettled: false, objectionsAsked: false,
248
+ // The objection ledger (ledger.mjs), disagreements handed to the
249
+ // member, the claims verify marked failed, the reviews the main agent
250
+ // asked for, and where the harness last sent the agent back (to read
251
+ // its answer and see whether the code moved).
252
+ ledger: [], disputes: [], verifyFails: [], claimsAsked: false, claimsAt: null, claimsRechecked: false, claimsDone: false, mainReviews: 0, minorFindings: 0,
253
+ sentAt: null, sentDigest: null, rejudged: false, reviewSettled: false, decideAsked: false,
250
254
  // The patch's risk lane (workflow.lanes) and the main agent's own web lookups.
251
255
  lane: null, laneStats: null, laneDigest: null, webLookups: 0 });
252
256
  }
@@ -295,7 +299,7 @@ export class RunProcess {
295
299
  verified: this.verified, reviews: count(this.reviews), reviewed: this.reviewed, blocking: count(this.blocking), blocking_open: Math.min(this.blockingOpen, this.blocking, 1000),
296
300
  fix_loops: this.fixLoops, outcome: (version === 3 ? this.outcome : this.legacyOutcome) ?? fallback,
297
301
  ...(version >= 2 ? { plan_skipped: this.planSkipped, unknown_tools: count(this.unknownTools) } : {}),
298
- ...(version === 3 ? { objections: count(this.objections.length), objections_answered: count(this.objections.filter(o => o.answered && !o.disputed).length), disputes: count(this.disputes.length) } : {}) };
302
+ ...(version === 3 ? { objections: count(briefCounts(this).objections), objections_answered: count(briefCounts(this).answered), disputes: count(this.disputes.length) } : {}) };
299
303
  }
300
304
  }
301
305
 
@@ -355,15 +359,18 @@ export async function completionGate(managed, run, signal) {
355
359
  }
356
360
  if (policy.review === 'require' && !fresh && !run.reviewSettled) {
357
361
  // After a round of findings the reviewer also reads the agent's answer to them.
358
- const answer = run.sentAt !== null ? answerSince(run.sentAt) : '';
359
- const brief = `Review the current patch against the member's request.\n\nChecks: ${run.verified ? 'a repository check passed on this code' : 'no check has passed on this code'}.${answer ? `\n\nThe main agent's answer to your previous blocking findings (the code changed after it):\n${answer}` : ''}`;
362
+ const answer = run.sentAt !== null ? answerSince(run.sentAt) + decisionsText(run, 'review') : '';
363
+ // After a fix the reviewer checks its blocking findings and the files changed since.
364
+ const focus = run.sentAt !== null ? await reviewFocus(managed) : null, previous = (review?.findings ?? []).filter(f => f.severity === 'blocking').map((f, i) => `${i + 1}. ${findingText(f)}`).join('\n');
365
+ const brief = `Review the current patch against the member's request.\n\nChecks: ${run.verified ? 'a repository check passed on this code' : 'no check has passed on this code'}.${answer ? `\n\nThe main agent's answer to your previous blocking findings (the code changed after it):\n${answer}` : ''}${focus ? `\n\nSince your last review only these files changed: ${focus.files.join(', ')}. Judge whether each previous blocking finding is fixed (repeat it when it is not) and review the changes in these files; the rest of the patch was reviewed and has not changed.${previous ? `\nYour previous blocking findings:\n${previous}` : ''}` : ''}`;
366
+ managed.reviewFocus = focus;
360
367
  try { await managed.delegate({ role: 'review', task: brief }, signal, { harness: true }); }
361
368
  catch (error) {
362
369
  run.reviewUnavailable = !stopped(); run.reviewUnavailableReason = reviewUnavailableReason(error?.message); interrupted = stopped();
363
370
  // Why the review could not run, as a code only (the report says "failed").
364
371
  if (run.reviewUnavailable) try { session.sessionManager.appendCustomEntry('agent-watch-failure-detail', { role: 'review', code: failureCode(error?.message) }); } catch { /* the report still says it */ }
365
372
  break;
366
- }
373
+ } finally { managed.reviewFocus = null; }
367
374
  continue;
368
375
  }
369
376
  if (policy.review === 'require' && fresh && (review.blocking ?? 0) > 0 && !run.reviewSettled) {
@@ -373,7 +380,7 @@ export async function completionGate(managed, run, signal) {
373
380
  // reviewer reads that answer once and judges its findings again.
374
381
  run.rejudged = true;
375
382
  await note('Harness: the main agent answered the review instead of changing the code; the reviewer judges its answer.', { phase: 'rejudge', role: 'review' });
376
- const brief = `Judge your previous blocking findings on this patch again. The main agent answered them without changing the code:\n${answerSince(run.sentAt) || '(no answer)'}\n\nYour previous blocking findings:\n${list}\n\nKeep a finding as blocking when the answer does not hold against the code; leave it out when the answer is right.`;
383
+ const brief = `Judge your previous blocking findings on this patch again. The main agent answered them without changing the code:\n${answerSince(run.sentAt) || '(no answer)'}${decisionsText(run, 'review')}\n\nYour previous blocking findings:\n${list}\n\nKeep a finding as blocking when the answer does not hold against the code; leave it out when the answer is right.`;
377
384
  try { await managed.delegate({ role: 'review', task: brief }, signal, { harness: true }); }
378
385
  catch { if (stopped()) { interrupted = true; break; } run.reviewSettled = true; }
379
386
  continue;
@@ -381,11 +388,12 @@ export async function completionGate(managed, run, signal) {
381
388
  if (run.sentDigest === digest) {
382
389
  // It heard the answer and kept its findings: the member decides.
383
390
  run.reviewSettled = true; run.disputes.push({ source: 'review' });
391
+ for (const item of ledgerOf(run)) if (item.source === 'review' && item.kind === 'blocking' && ['open', 'rejected'].includes(item.status)) setStatus(item, 'disputed');
384
392
  await note(`Harness: the main agent and the review subagent still disagree after two rounds; the member decides.\n${list}`, { phase: 'dispute', role: 'review', findings: blocking.slice(0, 10) });
385
393
  } else if (run.fixLoops >= policy.maxFixLoops) run.reviewSettled = true;
386
394
  else {
387
395
  run.fixLoops += 1; run.sentDigest = digest; run.sentAt = session.messages.length;
388
- await send(`Harness review of the current patch found ${blocking.length} blocking issue(s):\n${list}\nFix them, or explain why an issue is not real: an answer without a code change goes back to the reviewer, who judges it. The patch is reviewed again after your change.`,
396
+ await send(`Harness review of the current patch found ${blocking.length} blocking issue(s):\n${list}\nFix them, or reject one with resolve_objection (what you checked and why): a rejection without a code change goes back to the reviewer, who judges it. After your fix the reviewer checks these findings and the files you changed, not the whole patch again.`,
389
397
  { phase: 'review', loop: run.fixLoops, maxLoops: policy.maxFixLoops, findings: blocking.slice(0, 10) });
390
398
  continue;
391
399
  }
@@ -398,30 +406,25 @@ export async function completionGate(managed, run, signal) {
398
406
  const fails = run.verifyFails, list = fails.map((v, i) => `${i + 1}. ${v.claim}${v.evidence ? `\n Evidence: ${v.evidence}` : ''}`).join('\n'), claims = fails.slice(0, 10).map(v => ({ claim: v.claim }));
399
407
  if (!run.claimsAsked) {
400
408
  run.claimsAsked = true; run.claimsAt = session.messages.length;
401
- await send(`Harness: the verify subagent marked ${fails.length} claim(s) as failed:\n${list}\nFix the cause, or explain why a verdict is wrong. The verify subagent then checks these claims again with your answer.`, { phase: 'claims', claims });
409
+ await send(`Harness: the verify subagent marked ${fails.length} claim(s) as failed:\n${list}\nFix the cause, or reject a verdict with resolve_objection (what you checked and why). The verify subagent then checks these claims again with your answer.`, { phase: 'claims', claims });
402
410
  continue;
403
411
  }
404
412
  if (!run.claimsRechecked) {
405
413
  run.claimsRechecked = true;
406
414
  await note("Harness: the verify subagent checks the failed claims again with the main agent's answer.", { phase: 'rejudge', role: 'verify' });
407
- try { await managed.delegate({ role: 'verify', task: `Check these claims again on the current code; they were marked failed before:\n${list}\n\nThe main agent's answer:\n${answerSince(run.claimsAt) || '(no answer)'}\n\nA claim passes only on evidence you see yourself. It stays failed when it does not hold and the result the member asked for depends on it. When the answer shows the claim is not part of what the member asked and the result does not depend on it, mark it unverifiable and say why.` }, signal, { harness: true }); }
415
+ try { await managed.delegate({ role: 'verify', task: `Check these claims again on the current code; they were marked failed before:\n${list}\n\nThe main agent's answer:\n${answerSince(run.claimsAt) || '(no answer)'}${decisionsText(run, 'verify')}\n\nA claim passes only on evidence you see yourself. It stays failed when it does not hold and the result the member asked for depends on it. When the answer shows the claim is not part of what the member asked and the result does not depend on it, mark it unverifiable and say why.` }, signal, { harness: true }); }
408
416
  catch { if (stopped()) { interrupted = run.changed; break; } }
409
417
  continue;
410
418
  }
411
419
  run.claimsDone = true; run.disputes.push({ source: 'verify' });
420
+ for (const item of ledgerOf(run)) if (item.source === 'verify' && ['open', 'accepted', 'rejected'].includes(item.status)) setStatus(item, 'disputed');
412
421
  await note(`Harness: the main agent and the verify subagent still disagree after two rounds; the member decides.\n${list}`, { phase: 'dispute', role: 'verify', claims });
413
422
  }
414
423
  // Plan "require": a checklist left with open steps is the next round's
415
424
  // (continuation.mjs), after this run's checks and review.
416
- // An objection no corrected brief followed is answered to the member, once.
417
- const open = run.objections.filter(o => !o.answered && !o.disputed);
418
- if (open.length && !run.objectionsAsked) {
419
- run.objectionsAsked = true;
420
- await send(`Harness: ${open.length} objection(s) to a brief got no corrected brief:\n${open.map(o => `- ${o.role} subagent: ${o.issues.map(i => `[${i.kind}] ${i.detail}`).join('; ')}`).join('\n')}\nIn your answer to the member, say in one or two sentences what you decided about ${open.length === 1 ? 'it' : 'each'} and why.`,
421
- { phase: 'answer', role: open[0].role, issues: open.flatMap(o => o.issues).slice(0, 10) });
422
- for (const o of open) o.answered = true;
423
- continue;
424
- }
425
+ // A rejected premise objection goes back to its helper; then what the
426
+ // main agent never decided is asked once (ledger.mjs).
427
+ if (await settleLedger(managed, run, { note, send, signal })) continue;
425
428
  break;
426
429
  }
427
430
  // However the code was changed, a complex task under plan "require" that
@@ -434,14 +437,15 @@ export async function completionGate(managed, run, signal) {
434
437
  session.sessionManager.appendCustomEntry(BASELINE_ENTRY, { digest: settled ? digest : run.startDigest, settled, outcome: run.outcome, ...(managed.base ? { head: managed.base } : {}),
435
438
  ...(settled ? {} : { paths: [...(managed.ownPaths ?? [])].slice(0, 2000) }) });
436
439
  if (run.checkpoint) await checkpointsOf(managed).finish(run);
437
- if (!run.changed && !run.disputes.length) return;
440
+ saveLedger(managed);
441
+ if (!run.changed && !run.disputes.length && !ledgerOf(run).length) return;
438
442
  const finalPlan = currentPlan(session.sessionManager), planOpen = run.planUpdated ? finalPlan?.plan?.filter(p => p.status === 'pending' || p.status === 'in_progress').length ?? 0 : 0;
439
443
  const status = { verified: run.verified, reviewed: run.reviewed, blockingOpen: run.blockingOpen, checks: run.checksRun, fixLoops: run.fixLoops,
440
444
  ...(planOpen ? { planOpen } : {}), ...(run.planSkipped ? { planSkipped: true } : {}), ...(run.disputes.length ? { disputes: run.disputes.length } : {}), ...(run.changed ? {} : { unchanged: true }) };
441
445
  const words = [...(run.changed ? [run.verified ? 'a check passed on the final code' : policy.verify === 'off' ? null : run.docsOnly ? 'only documentation changed: no check needed' : 'no check passed on the final code',
442
446
  run.reviewed ? (run.blockingOpen ? `review: ${run.blockingOpen} blocking issue(s) still open` : 'review: no blocking issue') : policy.review === 'off' ? null : run.reviewUnavailable ? `review could not run (${run.reviewUnavailableReason})` : 'not reviewed',
443
447
  run.planSkipped ? 'code was changed before the required plan existed' : null, planOpen ? `plan: ${planOpen} step(s) not completed` : null] : []),
444
- run.disputes.length ? `${run.disputes.length} disagreement(s) between the main agent and a subagent go to the member` : null].filter(Boolean);
448
+ run.disputes.length ? `${run.disputes.length} disagreement(s) between the main agent and a subagent go to the member` : null, ledgerWords(run)].filter(Boolean);
445
449
  await session.sendCustomMessage({ customType: 'agent-watch-process', content: `Process status for this turn: ${run.changed ? 'code changed' : 'no code changed'}; ${words.join('; ') || 'no process step configured'}.`, display: true,
446
450
  details: { phase: 'final', outcome: run.outcome, ...status, ...(run.reviewUnavailable ? { reviewUnavailable: run.reviewUnavailableReason } : {}), policy: { verify: policy.verify, review: policy.review },
447
451
  ...(configured.lanes && run.lane ? { lane: run.lane, ...(run.laneStats ? { size: run.laneStats } : {}) } : {}) } }, { triggerTurn: false });
@@ -457,6 +461,8 @@ export function processEditTools(session, tools) {
457
461
  return { ...tool, execute: async (...args) => {
458
462
  const plan = currentPlan(session.session.sessionManager);
459
463
  session.run?.beforeEdit(tool.name, plan, session.mainAnswers);
464
+ // An open premise objection is resolved before files change (ledger.mjs).
465
+ if (tool.name !== 'bash' || !isReadOnlyCommand(args[1]?.command)) ledgerGuard(session.run, session.mainAnswers);
460
466
  // While a required plan is missing, a command that changes files
461
467
  // counts as an edit: the code is compared after each command.
462
468
  const watch = tool.name === 'bash' && !!session.run?.planMissing(plan) && !session.run.unplannedChange && session.run.startDigest !== null;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.46.0",
3
+ "version": "1.46.1",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",