agentfootprint 9.85.0 → 9.86.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 (163) hide show
  1. package/CHANGELOG.md +547 -24
  2. package/canonical-notes.json +1 -1
  3. package/dist/core/Agent.js +15 -4
  4. package/dist/core/Agent.js.map +1 -1
  5. package/dist/core/agent/AgentBuilder.js.map +1 -1
  6. package/dist/core/agent/buildAgentChart.js +5 -0
  7. package/dist/core/agent/buildAgentChart.js.map +1 -1
  8. package/dist/core/agent/buildDynamicAgentChart.js +22 -4
  9. package/dist/core/agent/buildDynamicAgentChart.js.map +1 -1
  10. package/dist/core/agent/buildToolRegistry.js +12 -1
  11. package/dist/core/agent/buildToolRegistry.js.map +1 -1
  12. package/dist/core/agent/coverage/ledger.js +8 -2
  13. package/dist/core/agent/coverage/ledger.js.map +1 -1
  14. package/dist/core/agent/selfCallNotice.js +20 -7
  15. package/dist/core/agent/selfCallNotice.js.map +1 -1
  16. package/dist/core/agent/skillBrains.js +3 -2
  17. package/dist/core/agent/skillBrains.js.map +1 -1
  18. package/dist/core/agent/stages/stepNudge.js +13 -4
  19. package/dist/core/agent/stages/stepNudge.js.map +1 -1
  20. package/dist/core/agent/stages/toolCalls.js +484 -64
  21. package/dist/core/agent/stages/toolCalls.js.map +1 -1
  22. package/dist/core/agent/stages/wrapUp.js +54 -6
  23. package/dist/core/agent/stages/wrapUp.js.map +1 -1
  24. package/dist/core/agent/window/currentRequest.js +4 -2
  25. package/dist/core/agent/window/currentRequest.js.map +1 -1
  26. package/dist/core/runbook/recording.js +3 -2
  27. package/dist/core/runbook/recording.js.map +1 -1
  28. package/dist/core/slots/buildToolsSlot.js +20 -4
  29. package/dist/core/slots/buildToolsSlot.js.map +1 -1
  30. package/dist/doors/skill-graph.js +4 -1
  31. package/dist/doors/skill-graph.js.map +1 -1
  32. package/dist/esm/core/Agent.js +15 -4
  33. package/dist/esm/core/Agent.js.map +1 -1
  34. package/dist/esm/core/agent/AgentBuilder.d.ts +2 -1
  35. package/dist/esm/core/agent/AgentBuilder.js.map +1 -1
  36. package/dist/esm/core/agent/buildAgentChart.js +5 -0
  37. package/dist/esm/core/agent/buildAgentChart.js.map +1 -1
  38. package/dist/esm/core/agent/buildDynamicAgentChart.js +22 -4
  39. package/dist/esm/core/agent/buildDynamicAgentChart.js.map +1 -1
  40. package/dist/esm/core/agent/buildToolRegistry.d.ts +20 -0
  41. package/dist/esm/core/agent/buildToolRegistry.js +12 -1
  42. package/dist/esm/core/agent/buildToolRegistry.js.map +1 -1
  43. package/dist/esm/core/agent/coverage/ledger.d.ts +5 -0
  44. package/dist/esm/core/agent/coverage/ledger.js +8 -2
  45. package/dist/esm/core/agent/coverage/ledger.js.map +1 -1
  46. package/dist/esm/core/agent/selfCallNotice.d.ts +20 -7
  47. package/dist/esm/core/agent/selfCallNotice.js +20 -7
  48. package/dist/esm/core/agent/selfCallNotice.js.map +1 -1
  49. package/dist/esm/core/agent/skillBrains.d.ts +12 -4
  50. package/dist/esm/core/agent/skillBrains.js +3 -2
  51. package/dist/esm/core/agent/skillBrains.js.map +1 -1
  52. package/dist/esm/core/agent/stages/stepNudge.d.ts +13 -4
  53. package/dist/esm/core/agent/stages/stepNudge.js +13 -4
  54. package/dist/esm/core/agent/stages/stepNudge.js.map +1 -1
  55. package/dist/esm/core/agent/stages/toolCalls.d.ts +185 -0
  56. package/dist/esm/core/agent/stages/toolCalls.js +480 -63
  57. package/dist/esm/core/agent/stages/toolCalls.js.map +1 -1
  58. package/dist/esm/core/agent/stages/wrapUp.d.ts +44 -2
  59. package/dist/esm/core/agent/stages/wrapUp.js +52 -5
  60. package/dist/esm/core/agent/stages/wrapUp.js.map +1 -1
  61. package/dist/esm/core/agent/types.d.ts +22 -4
  62. package/dist/esm/core/agent/window/currentRequest.d.ts +4 -2
  63. package/dist/esm/core/agent/window/currentRequest.js +4 -2
  64. package/dist/esm/core/agent/window/currentRequest.js.map +1 -1
  65. package/dist/esm/core/runbook/recording.js +3 -2
  66. package/dist/esm/core/runbook/recording.js.map +1 -1
  67. package/dist/esm/core/slots/buildToolsSlot.d.ts +1 -1
  68. package/dist/esm/core/slots/buildToolsSlot.js +20 -4
  69. package/dist/esm/core/slots/buildToolsSlot.js.map +1 -1
  70. package/dist/esm/doors/skill-graph.d.ts +1 -1
  71. package/dist/esm/doors/skill-graph.js +4 -1
  72. package/dist/esm/doors/skill-graph.js.map +1 -1
  73. package/dist/esm/events/payloads.d.ts +34 -2
  74. package/dist/esm/index.js +6 -5
  75. package/dist/esm/index.js.map +1 -1
  76. package/dist/esm/lib/injection-engine/index.d.ts +1 -1
  77. package/dist/esm/lib/injection-engine/index.js +4 -1
  78. package/dist/esm/lib/injection-engine/index.js.map +1 -1
  79. package/dist/esm/lib/injection-engine/skillGraph.d.ts +56 -0
  80. package/dist/esm/lib/injection-engine/skillGraph.js +29 -3
  81. package/dist/esm/lib/injection-engine/skillGraph.js.map +1 -1
  82. package/dist/esm/lib/injection-engine/skillSteps.d.ts +20 -1
  83. package/dist/esm/lib/injection-engine/skillSteps.js +28 -3
  84. package/dist/esm/lib/injection-engine/skillSteps.js.map +1 -1
  85. package/dist/esm/lib/injection-engine/skillToolDescriptors.d.ts +18 -0
  86. package/dist/esm/lib/injection-engine/skillToolDescriptors.js +103 -10
  87. package/dist/esm/lib/injection-engine/skillToolDescriptors.js.map +1 -1
  88. package/dist/esm/lib/injection-engine/types.d.ts +12 -10
  89. package/dist/esm/lib/injection-engine/types.js +5 -4
  90. package/dist/esm/lib/injection-engine/types.js.map +1 -1
  91. package/dist/esm/lib/saidByPerson.d.ts +48 -15
  92. package/dist/esm/lib/saidByPerson.js +52 -17
  93. package/dist/esm/lib/saidByPerson.js.map +1 -1
  94. package/dist/esm/lib/spokenIds.d.ts +52 -0
  95. package/dist/esm/lib/spokenIds.js +11 -0
  96. package/dist/esm/lib/spokenIds.js.map +1 -0
  97. package/dist/esm/lib/trace-toolpack/traceToolpack.js +33 -19
  98. package/dist/esm/lib/trace-toolpack/traceToolpack.js.map +1 -1
  99. package/dist/index.js +6 -5
  100. package/dist/index.js.map +1 -1
  101. package/dist/lib/injection-engine/index.js +5 -2
  102. package/dist/lib/injection-engine/index.js.map +1 -1
  103. package/dist/lib/injection-engine/skillGraph.js +31 -4
  104. package/dist/lib/injection-engine/skillGraph.js.map +1 -1
  105. package/dist/lib/injection-engine/skillSteps.js +28 -3
  106. package/dist/lib/injection-engine/skillSteps.js.map +1 -1
  107. package/dist/lib/injection-engine/skillToolDescriptors.js +103 -10
  108. package/dist/lib/injection-engine/skillToolDescriptors.js.map +1 -1
  109. package/dist/lib/injection-engine/types.js +5 -4
  110. package/dist/lib/injection-engine/types.js.map +1 -1
  111. package/dist/lib/saidByPerson.js +53 -18
  112. package/dist/lib/saidByPerson.js.map +1 -1
  113. package/dist/lib/spokenIds.js +15 -0
  114. package/dist/lib/spokenIds.js.map +1 -0
  115. package/dist/lib/trace-toolpack/traceToolpack.js +33 -19
  116. package/dist/lib/trace-toolpack/traceToolpack.js.map +1 -1
  117. package/dist/types/core/Agent.d.ts.map +1 -1
  118. package/dist/types/core/agent/AgentBuilder.d.ts +2 -1
  119. package/dist/types/core/agent/AgentBuilder.d.ts.map +1 -1
  120. package/dist/types/core/agent/buildAgentChart.d.ts.map +1 -1
  121. package/dist/types/core/agent/buildDynamicAgentChart.d.ts.map +1 -1
  122. package/dist/types/core/agent/buildToolRegistry.d.ts +20 -0
  123. package/dist/types/core/agent/buildToolRegistry.d.ts.map +1 -1
  124. package/dist/types/core/agent/coverage/ledger.d.ts +5 -0
  125. package/dist/types/core/agent/coverage/ledger.d.ts.map +1 -1
  126. package/dist/types/core/agent/selfCallNotice.d.ts +20 -7
  127. package/dist/types/core/agent/selfCallNotice.d.ts.map +1 -1
  128. package/dist/types/core/agent/skillBrains.d.ts +12 -4
  129. package/dist/types/core/agent/skillBrains.d.ts.map +1 -1
  130. package/dist/types/core/agent/stages/stepNudge.d.ts +13 -4
  131. package/dist/types/core/agent/stages/stepNudge.d.ts.map +1 -1
  132. package/dist/types/core/agent/stages/toolCalls.d.ts +185 -0
  133. package/dist/types/core/agent/stages/toolCalls.d.ts.map +1 -1
  134. package/dist/types/core/agent/stages/wrapUp.d.ts +44 -2
  135. package/dist/types/core/agent/stages/wrapUp.d.ts.map +1 -1
  136. package/dist/types/core/agent/types.d.ts +22 -4
  137. package/dist/types/core/agent/types.d.ts.map +1 -1
  138. package/dist/types/core/agent/window/currentRequest.d.ts +4 -2
  139. package/dist/types/core/agent/window/currentRequest.d.ts.map +1 -1
  140. package/dist/types/core/runbook/recording.d.ts.map +1 -1
  141. package/dist/types/core/slots/buildToolsSlot.d.ts +1 -1
  142. package/dist/types/core/slots/buildToolsSlot.d.ts.map +1 -1
  143. package/dist/types/doors/skill-graph.d.ts +1 -1
  144. package/dist/types/doors/skill-graph.d.ts.map +1 -1
  145. package/dist/types/events/payloads.d.ts +34 -2
  146. package/dist/types/events/payloads.d.ts.map +1 -1
  147. package/dist/types/index.d.ts.map +1 -1
  148. package/dist/types/lib/injection-engine/index.d.ts +1 -1
  149. package/dist/types/lib/injection-engine/index.d.ts.map +1 -1
  150. package/dist/types/lib/injection-engine/skillGraph.d.ts +56 -0
  151. package/dist/types/lib/injection-engine/skillGraph.d.ts.map +1 -1
  152. package/dist/types/lib/injection-engine/skillSteps.d.ts +20 -1
  153. package/dist/types/lib/injection-engine/skillSteps.d.ts.map +1 -1
  154. package/dist/types/lib/injection-engine/skillToolDescriptors.d.ts +18 -0
  155. package/dist/types/lib/injection-engine/skillToolDescriptors.d.ts.map +1 -1
  156. package/dist/types/lib/injection-engine/types.d.ts +12 -10
  157. package/dist/types/lib/injection-engine/types.d.ts.map +1 -1
  158. package/dist/types/lib/saidByPerson.d.ts +48 -15
  159. package/dist/types/lib/saidByPerson.d.ts.map +1 -1
  160. package/dist/types/lib/spokenIds.d.ts +53 -0
  161. package/dist/types/lib/spokenIds.d.ts.map +1 -0
  162. package/dist/types/lib/trace-toolpack/traceToolpack.d.ts.map +1 -1
  163. package/package.json +1 -1
@@ -49,9 +49,11 @@ import { typedEmit } from '../../../recorders/core/typedEmit.js';
49
49
  import { extractSequence } from '../../../security/extractSequence.js';
50
50
  import { skillTarget } from '../../../security/skillTarget.js';
51
51
  import { menuOutstanding } from '../../../lib/injection-engine/routingPolicy.js';
52
+ import { classifySkillTarget, } from '../../../lib/injection-engine/skillGraph.js';
52
53
  import { skillActivationConfirmation } from '../../../lib/injection-engine/skillToolDescriptors.js';
53
54
  import { selfCallNotice, selfSkillTools } from '../selfCallNotice.js';
54
55
  import { parkedMemberIds } from '../../../maps/engagement/types.js';
56
+ import { spoken } from '../../../lib/spokenIds.js';
55
57
  import { unconfiguredCredentialProvider } from '../../../identity/types.js';
56
58
  import { bindArtifacts, unconfiguredArtifacts, } from '../../../artifacts/capability.js';
57
59
  import { resolveToolWants, wantsNeedsStoreRefusal } from '../../../artifacts/wants.js';
@@ -130,60 +132,228 @@ function reportingCredentials(provider, scope, toolName) {
130
132
  };
131
133
  }
132
134
  /**
133
- * The re-prompt a refused `read_skill` gets back. It is the model's only feedback,
134
- * so it names what IS allowed rather than only what isn't.
135
+ * Is this `read_skill` target the cursor's own skill AND is that cursor
136
+ * MOUNTED — the one owner of "this call would change nothing" (9.86.0).
135
137
  *
136
- * Three shapes, because "not reachable" has three different reasons and only the
137
- * first two share a fix:
138
- * • something is reachable → name it, and ask for one of those;
139
- * • a decision `tree()` → nothing is EVER reachable by `read_skill`, because a
140
- * tree has no cursor to move (8.5.0). Saying "no skills are reachable from
141
- * here" would invite the model to try again from somewhere else; there is no
142
- * "elsewhere", so the message explains the tree instead;
143
- * • a flat graph that happens to be at a dead end → the original message.
138
+ * ── WHY POSITION ALONE IS NOT THE ANSWER ────────────────────────────────
139
+ *
140
+ * `classifySkillTarget` answers a question about POSITION: is the target where
141
+ * the cursor stands? For most of the loop that settles it — the cursor's body
142
+ * is in that call's system prompt and its tools are on that call's wire, so
143
+ * asking to read it activates nothing, moves nothing and reveals nothing the
144
+ * request did not already carry.
145
+ *
146
+ * A PARKED map breaks that. Parking suppresses a map's contribution and leaves
147
+ * the cursor exactly where it was, so at a parked cursor the very same id is a
148
+ * RE-ENGAGEMENT: the gate admits it, the map engages, and the member's body and
149
+ * tools ride again on the next pass. That call moves something, which makes it
150
+ * a capability, which makes it the policy's question to answer.
151
+ *
152
+ * The dispatch gate has known this since 9.59.0 — it tests re-engagement BEFORE
153
+ * its self-call arm, which is this same fact written as two ordered arms. The
154
+ * `skill_read` permission gate, 480 lines upstream, has no arms to order: it
155
+ * asks one question and either spends the policy check or skips it, and it was
156
+ * asking `classifySkillTarget` alone. So a role whose checker hid a skill could
157
+ * have the model un-park and re-activate it. This function is the question that
158
+ * gate now asks, over the parked set `parkedNow` hands both of them.
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * atMountedCursor({ cursor: 'audit', target: 'audit' }); // true — a stay
163
+ * atMountedCursor({ cursor: 'audit', target: 'audit', parked: new Set(['audit']) }); // false — a re-engagement
164
+ * atMountedCursor({ cursor: 'audit', target: 'billing' }); // false — a move
165
+ * ```
144
166
  */
145
- function skillRefusal(requestedId, allowed, isTree) {
146
- const head = `read_skill("${requestedId}") is not reachable from here. `;
147
- if (allowed.length > 0) {
148
- return `${head}Reachable skills: ${allowed.join(', ')}. Pick one of these, or finish.`;
149
- }
150
- if (isTree) {
151
- return (`read_skill("${requestedId}") cannot move a decision tree. A tree routes by ` +
152
- 'predicate on every iteration — it has no cursor to jump, so this skill would ' +
153
- 'not activate even though the tool accepted the name. Answer with the skill the ' +
154
- 'tree routed to, or finish.');
167
+ export function atMountedCursor(args) {
168
+ const stay = classifySkillTarget({
169
+ ...(args.cursor !== undefined && { cursor: args.cursor }),
170
+ target: args.target,
171
+ }) === 'self';
172
+ return stay && args.parked?.has(args.target) !== true;
173
+ }
174
+ /**
175
+ * What a call to a name nothing can dispatch gets back (9.86.0).
176
+ *
177
+ * `Unknown tool: X` was the whole sentence, on both dispatch doors, while
178
+ * `mcpServe` — the same library, one directory away — had been answering
179
+ * "Unknown tool 'rm_rf'. Served tools: echo, delete_account." for releases. The
180
+ * model that mistyped a name, or named one from a restored transcript, was told
181
+ * only that it was wrong, never what would have worked, and the cheapest repair
182
+ * (read the roster) was the one it could not make.
183
+ *
184
+ * The roster is the DISPATCH set — `registryByName` plus the provider cache —
185
+ * because that is the true statement about resolution: these are the names that
186
+ * answered to something. It is deliberately NOT the offer: the wire is narrowed
187
+ * by steps, parks and postures, and a held-out tool still dispatches by name, so
188
+ * an offer-shaped list would be a different (and here, false) claim.
189
+ *
190
+ * ── AND IT SAYS "RESOLVED", NOT "COULD BE DISPATCHED" ───────────────────
191
+ *
192
+ * The first draft said "Tools that could be dispatched on that call". Two gates
193
+ * sit between resolution and a tool running — the `tool_call` permission check
194
+ * and the middleware chain — so a name in this map can resolve and still be
195
+ * refused, and the sentence promised the model a dispatch the very next gate
196
+ * would decline. Neither gate is asked here: a `PermissionChecker` is a port
197
+ * (a hub call per name, with its own events and its own audit rows), and
198
+ * spending a whole permission sweep to phrase an error is a cost the sentence
199
+ * does not need. It reports what this map DID — resolve a name — and claims
200
+ * nothing about what would have happened next.
201
+ *
202
+ * The roster is role-filtered before it gets here (`dispatchRoster`), so a tool
203
+ * belonging to a skill the caller's policy hides is never named; and when the
204
+ * filter empties a stocked roster the clause is DROPPED, because "no tool name
205
+ * resolved" would be a denial of what the dispatch map is holding.
206
+ *
207
+ * Anchored past tense, like every other persistent result: "on that call" binds
208
+ * the roster to the finished call the result answers, so a later iteration that
209
+ * adds or drops a provider tool cannot make this sentence wrong. No exhortation
210
+ * — the model does not need to be told to try again.
211
+ *
212
+ * Exported for the producer registry, and used by BOTH dispatch doors so they
213
+ * cannot drift.
214
+ */
215
+ export function unknownToolResult(toolName, dispatchable) {
216
+ const head = `Unknown tool '${toolName}' on that call.`;
217
+ if (dispatchable.named.length > 0) {
218
+ return (`${head} Tool names that resolved to an implementation on that ` +
219
+ `call: ${dispatchable.named.join(', ')}.`);
155
220
  }
156
- return `${head}No skills are reachable from here — answer with the current skill, or finish.`;
221
+ // Held-but-unnameable: the map had entries this caller may not be told about.
222
+ // Omission is free; the negative below would be false.
223
+ return dispatchable.held
224
+ ? head
225
+ : `${head} No tool name resolved to an implementation on that call.`;
157
226
  }
158
227
  /**
159
- * The re-prompt a POSTURE-refused `read_skill` gets back (SG-C `strictness`).
160
- * The pick was REACHABLE — the graph would have granted it — so the message
161
- * must teach the posture, not the map: who routes here, and what the model may
162
- * still do (open skills, staying, finishing).
228
+ * THE ONE COMPOSER of every `read_skill` refusal (9.86.0) — reachability,
229
+ * posture and tree, from one set of inputs.
230
+ *
231
+ * ── WHY ONE ─────────────────────────────────────────────────────────────
232
+ *
233
+ * There were two, and they disagreed. `skillRefusal` named "Reachable skills:
234
+ * beta" from the graph's raw hop set; `postureRefusal`, forty lines below it in
235
+ * the same gate, answered a model that took that offer up with "read_skill here
236
+ * reaches only the open skills: gamma". Same call, same turn, opposite claims —
237
+ * and the second one cost a refusal from the escalation budget to discover.
238
+ * Neither had heard of the role filter, so both could name a skill the caller's
239
+ * policy hides. One composer, handed the FILTERED sets and the posture that
240
+ * will judge the next pick, cannot contradict itself that way.
241
+ *
242
+ * ── THE TENSE DISCIPLINE (the law `selfCallNotice` is written against) ──
243
+ *
244
+ * A tool result is composed on iteration N and re-read by the model on every
245
+ * later call of the turn, including the tool-less wrap-up. So every clause here
246
+ * is a PAST fact about ONE named call — the call the model made `read_skill`
247
+ * on — anchored by the phrase "that call", never by deixis ("this turn", "the
248
+ * call you just made") and never by a forecast ("you can call", "pick one of
249
+ * these"). What the model may do NEXT is owned by the `read_skill` DESCRIPTION,
250
+ * which is recomposed for every single request and may therefore speak in the
251
+ * present. Two owners, one tense each, and they can no longer disagree.
252
+ *
253
+ * ── AND WHY IT NEVER NAMES A HOP THE POSTURE WOULD DECLINE ──────────────
254
+ *
255
+ * Under `'rails'` the model does not route at all; under `'guard'` it routes
256
+ * only from an outstanding menu. Naming hops there is an offer the very next
257
+ * arm of this gate refuses. So those arms name no hop: they say who DID route,
258
+ * and (when there are any) which open skills were admitted anyway — open skills
259
+ * never reach the posture arm, so that clause cannot be contradicted.
260
+ *
261
+ * ── AND WHY AN EMPTY LIST IS NEVER A DENIAL ─────────────────────────────
262
+ *
263
+ * Every set arrives as {@link SpokenIds}, which carries whether the UNFILTERED
264
+ * set held anything. That is the difference between "nothing was reachable" and
265
+ * "nothing reachable may be named", and this composer used to say the first
266
+ * when the second was true — over a graph that was routing the cursor the whole
267
+ * time. Where the filter empties a set, the clause is omitted. A model told the
268
+ * map is a dead end stops asking for the door it may not be shown.
269
+ *
270
+ * Exported so a producer registry can list it beside the other model-facing
271
+ * composers rather than re-deriving what it says.
163
272
  */
164
- function postureRefusal(requestedId, posture, turnRoute, currentSkillId, openIds) {
165
- const openClause = openIds.length > 0
166
- ? ` read_skill here reaches only the open skills: ${openIds.join(', ')}.`
273
+ export function composeReadSkillRefusal(args) {
274
+ const { requestedId, targetClass, cursorId, hops, openIds, isTree, posture, menuOffered, turnStartedBy, cursorWithheld, } = args;
275
+ const head = `read_skill("${requestedId}") was not granted on that call: `;
276
+ // Open skills are the one list every arm may name: they are admitted from
277
+ // every cursor and no posture governs them (the posture arm below is reached
278
+ // only by hops), so naming them cannot be falsified by the next call. There is
279
+ // no negative arm here and there never was — an unnamed open set is simply
280
+ // omitted, which is the shape the two arms below now share.
281
+ const openClause = openIds.named.length > 0
282
+ ? ` Open skills were admitted on that call: ${openIds.named.join(', ')}.`
167
283
  : '';
168
- if (posture === 'rails') {
169
- return (`read_skill("${requestedId}") was declined: this graph runs on rails — turn starts ` +
170
- `resolve by declared rule or scorer and transitions by declared routes; the model ` +
171
- `does not route itself.${openClause} Continue with the skill you are in` +
172
- `${currentSkillId !== undefined ? ` ('${currentSkillId}')` : ''}, or finish.`);
284
+ if (posture !== undefined) {
285
+ if (posture === 'rails') {
286
+ return (`${head}this graph's 'rails' posture reserves routing to the framework — turn ` +
287
+ `starts resolve by declared rule or scorer and transitions by declared routes — ` +
288
+ `so a model pick was not admitted on that call.${openClause}`);
289
+ }
290
+ if (menuOffered !== undefined && menuOffered.named.length > 0) {
291
+ return (`${head}this graph's 'guard' posture admits a routing pick only from the menu the ` +
292
+ `framework offered, and '${requestedId}' was not on it. The menu outstanding when ` +
293
+ `that call was made: ${menuOffered.named.join(', ')}.${openClause}`);
294
+ }
295
+ // A menu WAS outstanding and not one of its ids may be named to this
296
+ // caller. Neither of the other two sentences is available: naming the menu
297
+ // would leak it, and "no menu was outstanding" is false — as is the
298
+ // "declared routes moved the cursor instead" clause that rides with it,
299
+ // which is a second assertion about a turn that did not route that way. So
300
+ // this arm says the one thing that is true of that call and stops.
301
+ if (menuOffered !== undefined && menuOffered.held) {
302
+ return (`${head}this graph's 'guard' posture admits a routing pick only from the menu the ` +
303
+ `framework offered, and '${requestedId}' was not admitted on that call.${openClause}`);
304
+ }
305
+ // What the gate KNOWS about why no menu was outstanding is `TurnRoute.by`,
306
+ // and each value is a different finished fact. Nothing is asserted about
307
+ // how the cursor came to be where it was beyond that — the old tail,
308
+ // "Declared routes moved the cursor instead.", was composed for every
309
+ // value and was true of none of them in particular.
310
+ const startClause = turnStartedBy === 'entry' || turnStartedBy === 'intent'
311
+ ? " — the turn's start had already been resolved decisively"
312
+ : turnStartedBy === 'continuity'
313
+ ? ' — the cursor had been carried over from the previous turn'
314
+ : turnStartedBy === 'menu'
315
+ ? ' — the menu had already been resolved by an earlier pick'
316
+ : turnStartedBy === 'decider'
317
+ ? " — the menu had been resolved by the configured decider before the turn's first call"
318
+ : '';
319
+ return (`${head}this graph's 'guard' posture admits a routing pick only while the framework ` +
320
+ `has declared ambiguity, and no menu was outstanding when that call was made` +
321
+ `${startClause}.${openClause}`);
173
322
  }
174
- // guard — either no menu is outstanding, or the pick was off it.
175
- if (turnRoute?.offered !== undefined && menuOutstanding(turnRoute, currentSkillId)) {
176
- return (`read_skill("${requestedId}") was declined: under this graph's 'guard' posture a ` +
177
- `routing pick must come from the offered menu — ${turnRoute.offered.join(', ')} — or ` +
178
- `stay (answer without calling read_skill).${openClause}`);
323
+ if (isTree === true) {
324
+ return (`${head}this map is a decision tree. A tree routes by predicate on every iteration ` +
325
+ `and keeps no cursor, so there was nothing for that pick to move and ` +
326
+ `'${requestedId}' was not activated by it.${openClause}`);
179
327
  }
180
- const decidedBy = turnRoute?.by === 'intent' || turnRoute?.by === 'entry'
181
- ? `the turn's start was resolved decisively this turn`
182
- : `no routing ambiguity is open this turn`;
183
- return (`read_skill("${requestedId}") was declined: this graph's 'guard' posture admits a ` +
184
- `routing pick only while the framework has declared ambiguity (an offered menu), and ` +
185
- `${decidedBy}; declared routes handle transitions.${openClause} Continue with the ` +
186
- `skill you are in${currentSkillId !== undefined ? ` ('${currentSkillId}')` : ''}, or finish.`);
328
+ // Reachability. `targetClass` is 'unreachable' by the time the gate calls
329
+ // this arm — 'self' is answered by the notice, 'hop' and 'open' are admitted
330
+ // — and it is carried rather than re-derived so the sentence and the verdict
331
+ // read the same classification.
332
+ // Three anchors, not two: a named cursor, a cursor this caller may not be
333
+ // told the name of, and a genuine cold start. The middle one used to print
334
+ // the raw id — the one name the description's own law withholds.
335
+ const from = cursorId !== undefined
336
+ ? `'${cursorId}'`
337
+ : cursorWithheld === true
338
+ ? 'the skill the cursor stood in'
339
+ : "the turn's start";
340
+ // The gate reaches this arm only with `'unreachable'`; the weaker sentence is
341
+ // for a caller that refused an admissible class for a reason of its own, and
342
+ // it is deliberately not an invented explanation of one.
343
+ const reason = targetClass === 'unreachable'
344
+ ? `'${requestedId}' was not reachable from ${from}.`
345
+ : `'${requestedId}' was not admitted from ${from}.`;
346
+ // OMIT, NEVER DENY. Three cases, not two: hops to name, no hops at all, and
347
+ // hops the graph holds that this caller may not be told about. The third used
348
+ // to fall into the second and assert "No skill was reachable from 'alpha'"
349
+ // while the graph routed 'alpha' to a skill the role hides — a denial of what
350
+ // the run holds, and the one shape omission is always safe against.
351
+ const hopClause = hops.named.length > 0
352
+ ? ` Skills reachable from ${from} when that call was made: ${hops.named.join(', ')}.`
353
+ : hops.held
354
+ ? ''
355
+ : ` No skill was reachable from ${from} when that call was made.`;
356
+ return `${head}${reason}${hopClause}${openClause}`;
187
357
  }
188
358
  /**
189
359
  * The args a `pauseHere` / `askHuman` call was running with, read back on the
@@ -775,10 +945,95 @@ export function buildToolCallsHandler(deps) {
775
945
  }
776
946
  const currentSkillId = scope.currentSkillId;
777
947
  const hops = deps.allowedSkillIds(currentSkillId);
778
- if (!hops.includes(target)) {
948
+ const targetClass = classifySkillTarget({
949
+ ...(currentSkillId !== undefined && { cursor: currentSkillId }),
950
+ target,
951
+ hops,
952
+ });
953
+ // ── A PROPOSAL TO THE CURSOR'S OWN SKILL IS A STAY (9.86.0) ──────
954
+ // `makeReachableSkills` filters the cursor out of its own successor
955
+ // set — right for a MOVE, and this judge read it as "not reachable"
956
+ // and refused. A tool saying "stay where you are" asks for the state
957
+ // the run is already in: there is no cursor to move, no activation to
958
+ // append, and nothing to refuse. It is accepted as a no-op — no
959
+ // `pendingToolTransition`, no cursor move, and no `[tool effect
960
+ // refused: …]` suffix on a result that proposed nothing wrong.
961
+ //
962
+ // The event carries the existing `'accepted'` outcome plus an
963
+ // ADDITIVE `stay: true`, deliberately not a new enum member: an
964
+ // exhaustive switch over `outcome` in a consumer must keep compiling.
965
+ //
966
+ // A STAY COMPETES FOR THE SLOT LIKE ANY OTHER ACCEPTED PROPOSAL
967
+ // (9.86.1). The batch law is "first ACCEPTED proposal wins, later
968
+ // proposals to OTHER targets are superseded", and a stay is accepted
969
+ // — so a stay judged first holds the slot (with nothing written to
970
+ // `pendingToolTransition`, because a stay moves nothing) and a later
971
+ // sibling proposing a hop is `'superseded'` and lands in the
972
+ // `route_conflict` losers, exactly as two conflicting hops do. Before
973
+ // this the stay `continue`d past the bookkeeping, so the tool that
974
+ // judged its data first lost to whichever sibling came second, with
975
+ // two `'accepted'` events in one batch and no conflict on the record.
976
+ // The same law in the other order: a stay proposed AFTER an accepted
977
+ // hop is superseded by it, not accepted beside it.
978
+ if (targetClass === 'self') {
979
+ if (state.winner !== undefined && state.winner.targetSkillId !== target) {
980
+ state.losers.push({
981
+ toolCallId: call.toolCallId,
982
+ toolName: call.toolName,
983
+ target,
984
+ });
985
+ typedEmit(scope, 'agentfootprint.tools.effect', {
986
+ kind: 'propose-transition',
987
+ outcome: 'superseded',
988
+ toolName: call.toolName,
989
+ toolCallId: call.toolCallId,
990
+ iteration: call.iteration,
991
+ targetSkillId: target,
992
+ reason: effect.reason,
993
+ supersededBy: 'earlier-proposal',
994
+ });
995
+ continue;
996
+ }
997
+ if (state.winner === undefined) {
998
+ state.winner = {
999
+ targetSkillId: target,
1000
+ toolName: call.toolName,
1001
+ toolCallId: call.toolCallId,
1002
+ reason: effect.reason,
1003
+ iteration: call.iteration,
1004
+ };
1005
+ }
1006
+ typedEmit(scope, 'agentfootprint.tools.effect', {
1007
+ kind: 'propose-transition',
1008
+ outcome: 'accepted',
1009
+ stay: true,
1010
+ toolName: call.toolName,
1011
+ toolCallId: call.toolCallId,
1012
+ iteration: call.iteration,
1013
+ targetSkillId: target,
1014
+ reason: effect.reason,
1015
+ });
1016
+ continue;
1017
+ }
1018
+ if (targetClass === 'unreachable') {
1019
+ // ── THE REFUSAL SPEAKS WITH THE FILTERED SETS (9.86.1) ──────────
1020
+ // This sentence is appended to the tool result the model reads and
1021
+ // rides the `tools.effect` payload, and it named the raw hop set and
1022
+ // the raw cursor — the leak the `read_skill` refusals closed one
1023
+ // function up. Same filter, same law: the hidden set the tools slot
1024
+ // resolved for this iteration, and OMIT, NEVER DENY — a hop set the
1025
+ // filter emptied composes no reachable clause rather than an empty
1026
+ // one. Admission is still judged on the raw `hops`.
1027
+ const hiddenIds = new Set(scope.hiddenSkillIds ?? []);
1028
+ const mayName = (id) => !hiddenIds.has(id);
1029
+ const reachable = spoken(hops, mayName);
1030
+ const from = currentSkillId === undefined
1031
+ ? 'the turn start'
1032
+ : mayName(currentSkillId)
1033
+ ? `'${currentSkillId}'`
1034
+ : 'the skill the cursor stood in';
779
1035
  refuse('propose-transition', `propose-transition → '${target}' was refused: '${target}' is not reachable from ` +
780
- `${currentSkillId !== undefined ? `'${currentSkillId}'` : 'the turn start'} per ` +
781
- `the graph's own law${hops.length > 0 ? ` (reachable: ${hops.join(', ')})` : ''}. The graph decides — a proposal is evidence, never authority.`, { targetSkillId: target, reason: effect.reason });
1036
+ `${from} per the graph's own law${reachable.named.length > 0 ? ` (reachable: ${reachable.named.join(', ')})` : ''}. The graph decides — a proposal is evidence, never authority.`, { targetSkillId: target, reason: effect.reason });
782
1037
  continue;
783
1038
  }
784
1039
  if (state.winner !== undefined && state.winner.targetSkillId !== target) {
@@ -1424,6 +1679,62 @@ export function buildToolCallsHandler(deps) {
1424
1679
  const cached = providerToolCache?.current ?? [];
1425
1680
  return cached.find((t) => t.schema.name === toolName);
1426
1681
  };
1682
+ /**
1683
+ * Every member id of every map the kernel currently holds PARKED (9.86.0
1684
+ * fix pass) — asked of `parkedMemberIds`, the kernel's own owner of the fact.
1685
+ *
1686
+ * ONE reader for TWO gates. The `skill_read` permission gate and the graph's
1687
+ * dispatch gate both have to tell a stay from a re-engagement, and they sit
1688
+ * 480 lines apart; the second computed this and the first did not, which is
1689
+ * exactly how a role-hidden parked member became re-activatable. Answers with
1690
+ * `undefined` when no engagement plan is mounted, which is every agent that
1691
+ * never called `.maps()`.
1692
+ */
1693
+ const parkedNow = (scope) => deps.engagementPlan === undefined
1694
+ ? undefined
1695
+ : parkedMemberIds(deps.engagementPlan, scope.mapEngagement);
1696
+ /**
1697
+ * Every name `lookupTool` would resolve, in resolution order, through the
1698
+ * role filter (9.86.0) — the roster `unknownToolResult` names.
1699
+ *
1700
+ * Derived from the SAME two sources the lookup reads, so a name it reports is
1701
+ * a name that resolved. Then filtered by the SAME fact every other sentence in
1702
+ * this stage filters by: `scope.hiddenSkillIds`, the per-iteration answer to
1703
+ * "which skills may this caller be told about". The first draft applied
1704
+ * neither, and named a hidden skill's tool to a role that may not see the
1705
+ * skill — the leak the `read_skill` refusals had just closed, reappearing one
1706
+ * sentence over because the roster read the dispatch map raw.
1707
+ *
1708
+ * A tool is unnameable only when EVERY skill declaring it is hidden
1709
+ * (`deps.toolDeclaringSkills`, the build-time owner of that fact). A tool two
1710
+ * skills share is still named when either is visible — the same
1711
+ * sole-owner rule the step hold-out uses, for the same reason: a shared name
1712
+ * is somebody's escape hatch. Names nobody declares from a skill — static
1713
+ * `.tool()` registrations, `read_skill`, `skip_step`, provider-delivered
1714
+ * tools — have no owning skill and are never filtered.
1715
+ *
1716
+ * DISPATCH IS UNTOUCHED. `lookupTool` still resolves every name in the map:
1717
+ * a narrowing may take a name out of a sentence, never out of the dispatch
1718
+ * map. This filter governs only what the model is TOLD.
1719
+ */
1720
+ const dispatchRoster = (scope) => {
1721
+ const names = [...registryByName.keys()];
1722
+ const seen = new Set(names);
1723
+ for (const t of externalToolProvider ? providerToolCache?.current ?? [] : []) {
1724
+ if (seen.has(t.schema.name))
1725
+ continue;
1726
+ seen.add(t.schema.name);
1727
+ names.push(t.schema.name);
1728
+ }
1729
+ const hidden = new Set(scope.hiddenSkillIds ?? []);
1730
+ if (hidden.size === 0)
1731
+ return { named: names, held: names.length > 0 };
1732
+ const declaredBy = deps.toolDeclaringSkills;
1733
+ return spoken(names, (name) => {
1734
+ const owners = declaredBy?.get(name);
1735
+ return owners === undefined || owners.some((id) => !hidden.has(id));
1736
+ });
1737
+ };
1427
1738
  /**
1428
1739
  * The after-tool moment: the chain's last word, on a call that RAN.
1429
1740
  *
@@ -1481,7 +1792,7 @@ export function buildToolCallsHandler(deps) {
1481
1792
  // resume can't re-pause (resume returns void), so that is surfaced as an error.
1482
1793
  const resolveCredentialAndExecute = async (scope, tool, toolName, args, toolCallId, iteration, env) => {
1483
1794
  if (!tool)
1484
- return { result: `Unknown tool: ${toolName}`, error: true };
1795
+ return { result: unknownToolResult(toolName, dispatchRoster(scope)), error: true };
1485
1796
  // Declared artifact arguments (9.22.0) — the same resolution the batch
1486
1797
  // loop applies, at this door: a resumed call's refs are judged exactly
1487
1798
  // as an inline call's, BEFORE credentials, and a refusal means the tool
@@ -2114,7 +2425,37 @@ export function buildToolCallsHandler(deps) {
2114
2425
  checkerGoverns(permissionChecker, 'skill_read')) {
2115
2426
  const requestedSkillId = callArgs.id;
2116
2427
  if (typeof requestedSkillId === 'string' && requestedSkillId.length > 0) {
2117
- await askCapability('skill_read', skillTarget(requestedSkillId), `Skill '${requestedSkillId}' is not available in this context.`);
2428
+ // ── A SELF-CALL AT A MOUNTED CURSOR EXERCISES NO CAPABILITY ───
2429
+ // (9.86.0.) A MOUNTED cursor's own skill is already served: its
2430
+ // body is in that call's system prompt and its tools are on that
2431
+ // call's wire. Asking to read it activates nothing, moves nothing,
2432
+ // and reveals nothing the request did not already carry — so there
2433
+ // is no capability here for a policy to grant or deny, and denying
2434
+ // it told the model its current skill was "not available in this
2435
+ // context" while it was standing in it. That is the exact sentence
2436
+ // the self-call arm exists to stop the model reading. The gate's
2437
+ // self-call arm answers instead.
2438
+ //
2439
+ // A PARKED cursor is the one case where the same id is not a
2440
+ // no-op, and the skip is scoped to exclude it (9.86.0 fix pass):
2441
+ // parking suppressed the map's contribution, so the dispatch gate
2442
+ // 480 lines below reads that call as a RE-ENGAGEMENT and serves the
2443
+ // body and tools again on the next pass. That moves something, so
2444
+ // the policy keeps the question. `atMountedCursor` is where that
2445
+ // rule lives; the dispatch gate writes the same rule as two ordered
2446
+ // arms over the same `parkedNow` set. Every OTHER id still goes
2447
+ // through the policy, unchanged — this narrows nothing a role could
2448
+ // see.
2449
+ const stay = atMountedCursor({
2450
+ ...(typeof scope.currentSkillId === 'string' && {
2451
+ cursor: scope.currentSkillId,
2452
+ }),
2453
+ target: requestedSkillId,
2454
+ parked: parkedNow(scope),
2455
+ });
2456
+ if (!stay) {
2457
+ await askCapability('skill_read', skillTarget(requestedSkillId), `Skill '${requestedSkillId}' is not available in this context.`);
2458
+ }
2118
2459
  }
2119
2460
  }
2120
2461
  // ── Check-in gate (evidence-carrying human consent) ──────────────
@@ -2318,7 +2659,7 @@ export function buildToolCallsHandler(deps) {
2318
2659
  if (!credentialBlocked && !wantsBlocked) {
2319
2660
  try {
2320
2661
  if (!tool)
2321
- throw new Error(`Unknown tool: ${tc.name}`);
2662
+ throw new Error(unknownToolResult(tc.name, dispatchRoster(scope)));
2322
2663
  // Set BEFORE the await: a tool that throws has still run, and a
2323
2664
  // tool that does not exist has not. This flag is the entire
2324
2665
  // precondition of the after-tool moment below.
@@ -2490,9 +2831,53 @@ export function buildToolCallsHandler(deps) {
2490
2831
  const reqId = callArgs.id;
2491
2832
  if (typeof reqId === 'string' && reqId.length > 0) {
2492
2833
  const currentSkillId = scope.currentSkillId;
2834
+ // ── ADMISSION reads the graph; every SENTENCE reads the filter ──
2835
+ // Two sets, deliberately (9.86.0). `admissible` is the routing law:
2836
+ // what the graph and the mount actually grant, unfiltered, because a
2837
+ // narrowing may take a schema off the wire but never a name out of
2838
+ // the dispatch map — role visibility governs what the model is TOLD,
2839
+ // not what the gate admits. `hopsNamed` / `openNamed` are what any
2840
+ // sentence, and the `skill.rejected` payload, may NAME: the same
2841
+ // hidden set the description filtered itself with, resolved once by
2842
+ // the tools slot and carried here on scope. Without it the refusals
2843
+ // read the raw sets and could name a skill this role may never see —
2844
+ // the leak the description closed, one stage downstream. (A hidden
2845
+ // skill cannot reach this gate anyway: the `skill_read` permission
2846
+ // gate above denies it first, on the same checker. The filter is
2847
+ // what keeps that true by construction rather than by coincidence.)
2848
+ const hiddenIds = new Set(scope.hiddenSkillIds ?? []);
2849
+ const mayName = (id) => !hiddenIds.has(id);
2493
2850
  const hops = deps.allowedSkillIds(currentSkillId);
2494
- skillHop = hops.includes(reqId);
2495
- const allowed = dedupeIds([...hops, ...(deps.openSkillIds ?? [])]);
2851
+ const openAll = deps.openSkillIds ?? [];
2852
+ // `spoken` keeps BOTH halves — what may be named, and whether the
2853
+ // graph held anything before the filter ran. A composer handed only
2854
+ // the filtered array cannot tell "nothing was reachable" from
2855
+ // "nothing reachable may be named", and it used to assert the first.
2856
+ const hopsSpoken = spoken(hops, mayName);
2857
+ const openSpoken = spoken(openAll, mayName);
2858
+ // The cursor through the SAME filter (9.86.1). The description
2859
+ // withholds a hidden cursor's name and every refusal below printed
2860
+ // it raw in two clauses; `cursorWithheld` keeps the sentence's
2861
+ // anchor honest ("the skill the cursor stood in") without the id.
2862
+ const cursorSpoken = currentSkillId === undefined
2863
+ ? {}
2864
+ : mayName(currentSkillId)
2865
+ ? { cursorId: currentSkillId }
2866
+ : { cursorWithheld: true };
2867
+ const hopsNamed = hopsSpoken.named;
2868
+ const openNamed = openSpoken.named;
2869
+ // The ONE owner of "is this target the cursor?" — the same function
2870
+ // the description, the tool-effects judge and the permission gate
2871
+ // switch on, so a stay means the same thing in all four.
2872
+ const targetClass = classifySkillTarget({
2873
+ ...(currentSkillId !== undefined && { cursor: currentSkillId }),
2874
+ target: reqId,
2875
+ hops,
2876
+ open: openAll,
2877
+ });
2878
+ skillHop = targetClass === 'hop';
2879
+ const admissible = dedupeIds([...hops, ...openAll]);
2880
+ const allowed = dedupeIds([...hopsNamed, ...openNamed]);
2496
2881
  // ── The RE-ENGAGEMENT arm (9.59.0) — a third admission class ──
2497
2882
  // A pick must be routed by INTENT. The reachable set deliberately
2498
2883
  // excludes the node the cursor already occupies (`makeReachableSkills`
@@ -2510,15 +2895,13 @@ export function buildToolCallsHandler(deps) {
2510
2895
  // false, `pendingSkillPick` stays unwritten, and the pick lands on
2511
2896
  // `acceptedSkillPicks`, which the kernel's renewal feed reads as
2512
2897
  // explicit evidence and re-engages on the very next pass.
2513
- const parked = deps.engagementPlan === undefined
2514
- ? undefined
2515
- : parkedMemberIds(deps.engagementPlan, scope.mapEngagement);
2898
+ const parked = parkedNow(scope);
2516
2899
  const reengaging = parked?.has(reqId) === true;
2517
2900
  if (reengaging) {
2518
2901
  // Admitted. Nothing to refuse and no posture to apply: this is
2519
2902
  // not a hop, and a posture governs routing, not engagement.
2520
2903
  }
2521
- else if (currentSkillId !== undefined && reqId === currentSkillId) {
2904
+ else if (targetClass === 'self') {
2522
2905
  // ── The SELF-CALL arm (9.84.0) — BEFORE reachability ─────────
2523
2906
  // The cursor is in neither half of `allowed`, by construction:
2524
2907
  // `makeReachableSkills` filters it out of its own successor set
@@ -2593,9 +2976,16 @@ export function buildToolCallsHandler(deps) {
2593
2976
  });
2594
2977
  noteSkillRefusal(scope, iteration);
2595
2978
  }
2596
- else if (!allowed.includes(reqId)) {
2979
+ else if (!admissible.includes(reqId)) {
2597
2980
  skillRejected = true;
2598
- result = skillRefusal(reqId, allowed, deps.skillGraphIsTree === true);
2981
+ result = composeReadSkillRefusal({
2982
+ requestedId: reqId,
2983
+ targetClass,
2984
+ ...cursorSpoken,
2985
+ hops: hopsSpoken,
2986
+ openIds: openSpoken,
2987
+ ...(deps.skillGraphIsTree === true && { isTree: true }),
2988
+ });
2599
2989
  typedEmit(scope, 'agentfootprint.skill.rejected', {
2600
2990
  requestedId: reqId,
2601
2991
  ...(currentSkillId !== undefined && { currentSkillId }),
@@ -2617,7 +3007,30 @@ export function buildToolCallsHandler(deps) {
2617
3007
  if (refusedByPosture) {
2618
3008
  skillRejected = true;
2619
3009
  skillHop = false; // a refused pick must not move the cursor below
2620
- result = postureRefusal(reqId, deps.skillStrictness, turnRoute, currentSkillId, deps.openSkillIds ?? []);
3010
+ // The posture arm names NO hop — under 'rails' the model does
3011
+ // not route, and under 'guard' it routes only from a menu — so
3012
+ // the composer is handed the menu when there IS one and an
3013
+ // empty hop list either way. Open skills are still named:
3014
+ // they never reach this arm, so nothing here can decline them.
3015
+ const menuNamed = turnRoute?.offered !== undefined && menuOutstanding(turnRoute, currentSkillId)
3016
+ ? spoken(turnRoute.offered, mayName)
3017
+ : undefined;
3018
+ result = composeReadSkillRefusal({
3019
+ requestedId: reqId,
3020
+ targetClass,
3021
+ ...cursorSpoken,
3022
+ // No hop is named under a posture, and the graph's hop set
3023
+ // is not this arm's subject — `held: false` is the honest
3024
+ // shape, not a claim that nothing was reachable (this arm
3025
+ // never composes the reachability clause at all).
3026
+ hops: { named: [], held: false },
3027
+ openIds: openSpoken,
3028
+ posture: deps.skillStrictness,
3029
+ ...(menuNamed !== undefined && { menuOffered: menuNamed }),
3030
+ // The whole verdict, not a boolean derived from two of its
3031
+ // six values: the composer says one past fact per value.
3032
+ ...(turnRoute !== undefined && { turnStartedBy: turnRoute.by }),
3033
+ });
2621
3034
  typedEmit(scope, 'agentfootprint.skill.rejected', {
2622
3035
  requestedId: reqId,
2623
3036
  ...(currentSkillId !== undefined && { currentSkillId }),
@@ -3062,10 +3475,14 @@ export function buildToolCallsHandler(deps) {
3062
3475
  // silently invents a decision, for the same reason it never silently
3063
3476
  // drops one.
3064
3477
  error = true;
3478
+ // A past fact about the resumed call, not a forecast about the
3479
+ // turn (9.86.1): "cannot be retried this turn … Answer without it,
3480
+ // or finish" was a prediction plus a standing order on a result
3481
+ // that is re-read on every later call.
3065
3482
  result =
3066
- `tool '${toolName}' was not executed and cannot be retried this turn: it declares ` +
3067
- `its own checkIn consent gate, that gate trips for these arguments, and a resumed ` +
3068
- `dispatch has no second checkpoint to ask on. Answer without it, or finish. (To ` +
3483
+ `tool '${toolName}' was not executed on that call, and the resumed dispatch had ` +
3484
+ `no second checkpoint to retry it on: it declares its own checkIn consent gate, ` +
3485
+ `and that gate tripped for those arguments. (To ` +
3069
3486
  `the agent's author: the middleware '${askedBy}' and the tool's checkIn ask ` +
3070
3487
  `different questions — one is the rule's, one is the tool's with the evidence ` +
3071
3488
  `pack attached — so approving one is not answering the other. Keep one gate for ` +