@event4u/agent-config 15.0.0 → 16.1.0

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 (283) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +301 -347
  5. package/CONTRIBUTING.md +9 -0
  6. package/README.md +2 -2
  7. package/dist/agent-src/commands/analyze/inbox.md +26 -0
  8. package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
  9. package/dist/agent-src/commands/challenge-me/closure.md +178 -0
  10. package/dist/agent-src/commands/challenge-me.md +9 -2
  11. package/dist/agent-src/commands/council/default.md +25 -5
  12. package/dist/agent-src/commands/feature/plan.md +28 -1
  13. package/dist/agent-src/commands/feature/roadmap.md +28 -1
  14. package/dist/agent-src/commands/implement-ticket.md +27 -0
  15. package/dist/agent-src/commands/jira-ticket.md +27 -0
  16. package/dist/agent-src/commands/pr/create.md +29 -0
  17. package/dist/agent-src/commands/pr/merge.md +60 -9
  18. package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
  19. package/dist/agent-src/commands/roadmap/create.md +77 -29
  20. package/dist/agent-src/commands/roadmap/materialize.md +27 -0
  21. package/dist/agent-src/commands/roadmap/process-full.md +123 -18
  22. package/dist/agent-src/commands/team.md +22 -0
  23. package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +97 -0
  25. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
  27. package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
  28. package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
  29. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
  30. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
  31. package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  32. package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  33. package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
  34. package/dist/agent-src/rules/autonomous-execution.md +15 -8
  35. package/dist/agent-src/rules/delegation-policy.md +23 -45
  36. package/dist/agent-src/rules/design-fidelity.md +32 -37
  37. package/dist/agent-src/rules/evaluator-independence.md +43 -49
  38. package/dist/agent-src/rules/test-first.md +41 -0
  39. package/dist/agent-src/rules/user-interaction.md +31 -30
  40. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
  41. package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
  42. package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
  43. package/dist/agent-src/skills/database/data/manifest.json +4 -4
  44. package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
  45. package/dist/agent-src/skills/design-review/SKILL.md +50 -0
  46. package/dist/agent-src/skills/error-handling-patterns/SKILL.md +9 -0
  47. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
  48. package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
  49. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  50. package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
  51. package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
  52. package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
  53. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
  54. package/dist/agent-src/templates/agent-settings.md +1 -1
  55. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  56. package/dist/agent-src/templates/roadmaps.md +188 -0
  57. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  58. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  59. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  60. package/dist/cli-delegate/check_update_banner.js +40 -2
  61. package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
  62. package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
  63. package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
  64. package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
  65. package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
  66. package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
  67. package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
  68. package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
  69. package/dist/cli-delegate/chunk-NUHLI2RX.js +80 -0
  70. package/dist/cli-delegate/{chunk-HZ52BRGG.js → chunk-P6ZHP5W2.js} +15 -90
  71. package/dist/cli-delegate/{chunk-TYSY33MD.js → chunk-QJWAUMJW.js} +12 -2
  72. package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
  73. package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
  74. package/dist/cli-delegate/{chunk-2KKXJW25.js → chunk-S3VFXMTM.js} +1 -1
  75. package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
  76. package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
  77. package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
  78. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  79. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  80. package/dist/cli-delegate/cmd_conformance.js +918 -325
  81. package/dist/cli-delegate/cmd_doctor.js +7 -5
  82. package/dist/cli-delegate/cmd_explain.js +5 -3
  83. package/dist/cli-delegate/cmd_export.js +9 -7
  84. package/dist/cli-delegate/cmd_fleet.js +4 -2
  85. package/dist/cli-delegate/cmd_handoff.js +6 -6
  86. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  87. package/dist/cli-delegate/cmd_migrate.js +2 -2
  88. package/dist/cli-delegate/cmd_packs_active.js +3 -2
  89. package/dist/cli-delegate/cmd_preflight.js +4 -2
  90. package/dist/cli-delegate/cmd_prune.js +10 -8
  91. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  92. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  93. package/dist/cli-delegate/cmd_session_recycle.js +4 -4
  94. package/dist/cli-delegate/cmd_settings_get.js +28 -2
  95. package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
  96. package/dist/cli-delegate/cmd_settings_set.js +14 -1
  97. package/dist/cli-delegate/cmd_sync.js +10 -8
  98. package/dist/cli-delegate/cmd_uninstall.js +16 -13
  99. package/dist/cli-delegate/cmd_update.js +1 -1
  100. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  101. package/dist/cli-delegate/cmd_validate.js +12 -10
  102. package/dist/cli-delegate/cmd_versions.js +1 -1
  103. package/dist/discovery/deprecation-report.md +1 -1
  104. package/dist/discovery/discovery-manifest.json +87 -40
  105. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  106. package/dist/discovery/discovery-manifest.summary.md +4 -4
  107. package/dist/discovery/dormancy-report.md +1 -1
  108. package/dist/discovery/orphan-report.md +1 -1
  109. package/dist/discovery/packs.json +10 -8
  110. package/dist/discovery/trust-report.md +3 -3
  111. package/dist/discovery/workspaces.json +8 -6
  112. package/dist/hooks/dispatch.js +2601 -1073
  113. package/dist/install/claudeRuleRewrite.js +14 -3
  114. package/dist/install/conflict.js +60 -10
  115. package/dist/install/conflictTracker.js +218 -0
  116. package/dist/install/fsPrimitives.js +54 -0
  117. package/dist/install/install.mjs +2008 -1481
  118. package/dist/install/manifestFiles.js +259 -0
  119. package/dist/install/preflight.js +14 -2
  120. package/dist/install/preserve.js +157 -0
  121. package/dist/install/recordedOwnership.js +132 -0
  122. package/dist/install/txlog.js +11 -2
  123. package/dist/mcp/registry-manifest.json +3 -3
  124. package/dist/mcp/server.json +2 -2
  125. package/dist/mcp/server.mjs +1612 -667
  126. package/dist/router.json +1 -1
  127. package/dist/scripts/_lib/agent_settings.js +3 -2
  128. package/dist/scripts/_lib/fs_atomic.js +167 -0
  129. package/dist/scripts/_lib/installed_lock.js +1 -1
  130. package/dist/scripts/_lib/installed_tools.js +460 -0
  131. package/dist/scripts/_lib/lean_projection_mode.js +3 -2
  132. package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
  133. package/dist/server/io/yamlIO.js +50 -0
  134. package/dist/server/routes/install.js +8 -0
  135. package/dist/server/routes/wizard.js +9 -3
  136. package/dist/server/schemas/settings.js +17 -5
  137. package/dist/shared/missionExecution.js +97 -0
  138. package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
  139. package/dist/ui/index.html +1 -1
  140. package/docs/MIGRATION.md +103 -0
  141. package/docs/contracts/settings-classes.md +26 -7
  142. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  143. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  144. package/docs/guidelines/design-fidelity-routing.md +29 -0
  145. package/package.json +1 -1
  146. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  147. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  148. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  149. package/src/config/agent-settings.template.yml +139 -27
  150. package/src/config/auto-rule-budget.json +16 -0
  151. package/src/config/ci-local-parity.yml +18 -5
  152. package/src/config/continuity-surface.json +28 -0
  153. package/src/config/gate-coverage.yml +146 -13
  154. package/src/config/gate-violation-baselines.json +6 -6
  155. package/src/config/hook-token-budget.json +2 -0
  156. package/src/config/host-injection-effect.json +1 -1
  157. package/src/config/loop-surfaces.yaml +215 -0
  158. package/src/config/platform-anchor.json +3 -2
  159. package/src/config/preamble-payload-budget.json +12 -7
  160. package/src/config/preamble-payload-exceptions.json +28 -0
  161. package/src/config/release-gate-locality.yml +11 -0
  162. package/src/config/routing-coverage-seed.json +18 -4
  163. package/src/config/rule-enforcement-baseline.json +0 -1
  164. package/src/install/claudeRuleRewrite.ts +16 -2
  165. package/src/install/conflict.ts +65 -9
  166. package/src/install/conflictTracker.ts +299 -0
  167. package/src/install/fsPrimitives.ts +54 -0
  168. package/src/install/manifestFiles.ts +312 -0
  169. package/src/install/preflight.ts +16 -2
  170. package/src/install/preserve.ts +205 -0
  171. package/src/install/recordedOwnership.ts +149 -0
  172. package/src/install/txlog.ts +11 -2
  173. package/src/install/types.ts +19 -4
  174. package/src/scripts/_cli/cmd_conformance.ts +88 -15
  175. package/src/scripts/_cli/cmd_doctor.ts +4 -3
  176. package/src/scripts/_cli/cmd_route_explain.ts +9 -2
  177. package/src/scripts/_cli/cmd_settings_get.ts +44 -0
  178. package/src/scripts/_cli/doctor_execution.ts +90 -0
  179. package/src/scripts/_dispatch.bash +3 -1
  180. package/src/scripts/_lib/activation_payload.ts +247 -0
  181. package/src/scripts/_lib/agent_settings.ts +4 -2
  182. package/src/scripts/_lib/authority_path.ts +102 -0
  183. package/src/scripts/_lib/base_ref_payload.ts +315 -0
  184. package/src/scripts/_lib/cascade_base.ts +94 -0
  185. package/src/scripts/_lib/changelog_eras.ts +7 -2
  186. package/src/scripts/_lib/continuation_ladder.ts +149 -2
  187. package/src/scripts/_lib/council_transport.ts +129 -0
  188. package/src/scripts/_lib/delivery_ready.ts +112 -0
  189. package/src/scripts/_lib/dropped_decision.ts +142 -0
  190. package/src/scripts/_lib/forge_protection.ts +267 -0
  191. package/src/scripts/_lib/hook_platform_keys.ts +27 -0
  192. package/src/scripts/_lib/installed_lock.ts +1 -1
  193. package/src/scripts/_lib/json_python_parity.ts +127 -0
  194. package/src/scripts/_lib/lean_projection_mode.ts +14 -3
  195. package/src/scripts/_lib/loop_surfaces.ts +382 -0
  196. package/src/scripts/_lib/measured_payload_ceiling.ts +424 -0
  197. package/src/scripts/_lib/mission_record.ts +190 -0
  198. package/src/scripts/_lib/obligation_frequency.ts +217 -0
  199. package/src/scripts/_lib/obligations.ts +402 -0
  200. package/src/scripts/_lib/orchestration_record.ts +11 -20
  201. package/src/scripts/_lib/payload_catalogue_completeness.ts +211 -0
  202. package/src/scripts/_lib/release_findings_ingest.ts +324 -0
  203. package/src/scripts/_lib/release_highlights.ts +199 -10
  204. package/src/scripts/_lib/release_holds.ts +493 -0
  205. package/src/scripts/_lib/release_migration_gate.ts +184 -0
  206. package/src/scripts/_lib/release_tag_dispatch.ts +63 -0
  207. package/src/scripts/_lib/review_baseline.ts +254 -0
  208. package/src/scripts/_lib/review_skipped_record.ts +61 -1
  209. package/src/scripts/_lib/rides_along.ts +102 -0
  210. package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
  211. package/src/scripts/_lib/standing_bound_ratchet.ts +216 -103
  212. package/src/scripts/_lib/test_delta.ts +166 -0
  213. package/src/scripts/_lib/test_provenance.ts +171 -0
  214. package/src/scripts/_lib/turn_end_refusals.ts +51 -4
  215. package/src/scripts/_lib/typed_op_grant.ts +246 -0
  216. package/src/scripts/_lib/typed_op_watch.ts +185 -0
  217. package/src/scripts/ai_council/config.ts +8 -11
  218. package/src/scripts/ai_council/fallback_config.ts +5 -3
  219. package/src/scripts/ai_council/ownership_classes.ts +118 -0
  220. package/src/scripts/ask_block_census.ts +285 -15
  221. package/src/scripts/check_beta_review_markers.ts +35 -4
  222. package/src/scripts/check_enforcement_matrix.ts +624 -0
  223. package/src/scripts/check_finding_dispositions.ts +167 -16
  224. package/src/scripts/check_gate_reachability.ts +173 -10
  225. package/src/scripts/check_generator_sync.ts +954 -0
  226. package/src/scripts/check_held_object_arrivals.ts +381 -0
  227. package/src/scripts/check_preamble_payload_budget.ts +474 -124
  228. package/src/scripts/check_release_highlights.ts +33 -1
  229. package/src/scripts/check_release_holds.ts +567 -0
  230. package/src/scripts/check_release_published.ts +1 -1
  231. package/src/scripts/check_reply_consistency.ts +86 -17
  232. package/src/scripts/check_rule_projection_integrity.ts +22 -12
  233. package/src/scripts/check_test_delta.ts +294 -0
  234. package/src/scripts/check_test_weakening.ts +299 -0
  235. package/src/scripts/closure_scan.ts +333 -0
  236. package/src/scripts/compile_hook_manifest.ts +43 -3
  237. package/src/scripts/council_record_shape.ts +209 -0
  238. package/src/scripts/hook_manifest.json +1 -1
  239. package/src/scripts/hook_manifest.yaml +127 -12
  240. package/src/scripts/hooks/block_kernel_rule_writes.ts +12 -2
  241. package/src/scripts/hooks/concern_registry.ts +6 -0
  242. package/src/scripts/hooks/design_pass_hook.ts +128 -3
  243. package/src/scripts/hooks/end_review_nudge_hook.ts +101 -21
  244. package/src/scripts/hooks/evidence_independence.ts +96 -0
  245. package/src/scripts/hooks/memory_recall_hook.ts +540 -0
  246. package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
  247. package/src/scripts/hooks/review_baseline_hook.ts +130 -0
  248. package/src/scripts/hooks/rule_inject_hook.ts +49 -1
  249. package/src/scripts/hooks/run_continuation_hook.ts +17 -20
  250. package/src/scripts/hooks/turn_end_gate_hook.ts +232 -14
  251. package/src/scripts/hooks_doctor.ts +89 -1
  252. package/src/scripts/hooks_status.ts +19 -0
  253. package/src/scripts/install.ts +107 -207
  254. package/src/scripts/interruption_report.ts +8 -2
  255. package/src/scripts/janitor.ts +15 -1
  256. package/src/scripts/lint_decision_classes.ts +464 -0
  257. package/src/scripts/lint_hook_manifest.ts +11 -0
  258. package/src/scripts/lint_major_migration_sections.ts +450 -0
  259. package/src/scripts/lint_plan_risk_register.ts +47 -3
  260. package/src/scripts/lint_roadmap_blockers.ts +61 -0
  261. package/src/scripts/lint_roadmap_producers.ts +202 -0
  262. package/src/scripts/measure_rule_budget.ts +101 -5
  263. package/src/scripts/measure_turn_end_gate.ts +302 -5
  264. package/src/scripts/new_roadmap.ts +37 -0
  265. package/src/scripts/orchestration_record.ts +0 -5
  266. package/src/scripts/release.ts +66 -162
  267. package/src/scripts/release_drill.ts +460 -15
  268. package/src/scripts/release_env.ts +1 -1
  269. package/src/scripts/release_publication.ts +52 -1
  270. package/src/scripts/report_enforcement_drift.ts +566 -0
  271. package/src/scripts/report_held_object_arrivals.ts +456 -0
  272. package/src/scripts/report_host_injection_effect.ts +3 -11
  273. package/src/scripts/report_standing_payload_by_host.ts +26 -152
  274. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  275. package/src/scripts/schemas/command.schema.json +4 -0
  276. package/src/scripts/scope_growth.ts +219 -0
  277. package/src/scripts/sync_agent_settings.ts +140 -18
  278. package/src/scripts/trigger_eval_grandfather.json +0 -1
  279. package/src/scripts/ui_conformance_probe.ts +622 -0
  280. package/src/server/io/yamlIO.ts +48 -0
  281. package/src/server/schemas/settings.ts +31 -7
  282. package/src/shared/missionExecution.ts +126 -0
  283. package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
@@ -9,7 +9,7 @@
9
9
  * is deliberately not another reminder. It is a check at the point of
10
10
  * delivery that can say no.
11
11
  *
12
- * FOUR detectors ride on one guard, because building the unsafe part twice
12
+ * FIVE detectors ride on one guard, because building the unsafe part twice
13
13
  * is how a second detector becomes a second outage:
14
14
  *
15
15
  * A — promissory closing (FC-5, 20 measured occurrences)
@@ -22,11 +22,18 @@
22
22
  * only, so tool activity is invisible to A and B.)
23
23
  * D — completion claim (a claim of done carrying no fresh evidence; landed
24
24
  * under conformance round 7 § Phase 1.)
25
+ * E — pending decision (an earlier reply in the SAME user turn put numbered
26
+ * options to the user and the closing reply carries
27
+ * none — the turn asked and then dropped the ask.
28
+ * Read off `assistantTurnTexts`, which is why that
29
+ * field exists: every other detector reads
30
+ * `lastAssistant` alone and therefore cannot see a
31
+ * question that was asked one reply earlier.)
25
32
  *
26
- * TWO of the four are CONDITIONAL, and saying "unconditional" here would be the
33
+ * TWO of the five are CONDITIONAL, and saying "unconditional" here would be the
27
34
  * same stale-header defect this block corrects below. A and D are the
28
35
  * completion-adjacent pair an open subagent dispatch excuses, so `main()` runs
29
- * them only when `dispatchOpen` is false; B and C run on every turn-end. That
36
+ * them only when `dispatchOpen` is false; B, C and E run on every turn-end. That
30
37
  * narrowing is deliberate (Phase 3 Step 2, narrowed again by R2 round 2) and is
31
38
  * the third allow path, alongside the two re-entrancy layers — see the
32
39
  * per-detector list in `main()`.
@@ -35,7 +42,7 @@
35
42
  * below has carried four since round 7. `DETECTOR_IDS` in
36
43
  * `_lib/turn_end_refusals.ts` is read off that union rather than off this
37
44
  * comment for exactly that reason, and the count is corrected here rather than
38
- * left as a stale header the next reader has to disbelieve.
45
+ * left as a stale header the next reader has to disbelieve. E made it five.
39
46
  *
40
47
  * ## Removal condition
41
48
  *
@@ -157,6 +164,23 @@ import {
157
164
  // agreement invisible when the producer's layout moved. A builder makes the move
158
165
  // a type error.
159
166
  import { statePathFor as ciStatePathFor } from '../before_complete_hook.js';
167
+ // The spec-backed options-block reading, imported rather than re-derived:
168
+ // `user-interaction` Iron Law 1's definition of an ask — a block PLUS its
169
+ // recommendation line — lives in exactly one place and detector E reads it from
170
+ // there.
171
+ //
172
+ // WHAT KEEPS IT FROM RUNNING ON IMPORT, stated precisely because the obvious
173
+ // answer is wrong: that module is `_isCliEntry`-guarded, but WITHOUT the
174
+ // `__AGENT_CONFIG_BUNDLE__` early-out this file carries. Inside
175
+ // `dist/hooks/dispatch.js` every module shares one `import.meta.url`, so the
176
+ // guard alone would not settle it; what does is the `.__direct__` argv rewrite
177
+ // in the `build:hooks` banner. Named here so a future change to that banner
178
+ // does not silently start running a lint's `main()` on every hook dispatch.
179
+ // Detector E, extracted to `_lib` where its prose costs no ratchet debt.
180
+ // Re-exported so every existing importer of this module is unchanged.
181
+ import { detectDroppedDecision } from '../_lib/dropped_decision.js';
182
+
183
+ export { detectDroppedDecision };
160
184
  import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
161
185
  import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
162
186
  import { readHookStdin } from './hook_stdin.js';
@@ -190,7 +214,13 @@ const EXIT_ALLOW = 0;
190
214
  export const TRANSCRIPT_READ_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
191
215
 
192
216
  /** Detector identity, used in the state marker and the refusal text. */
193
- export type DetectorId = 'promissory' | 'language' | 'verification' | 'completion';
217
+ export type DetectorId =
218
+ | 'promissory'
219
+ | 'language'
220
+ | 'verification'
221
+ | 'completion'
222
+ | 'pending-decision'
223
+ | 'untested';
194
224
 
195
225
  export interface Finding {
196
226
  detector: DetectorId;
@@ -723,6 +753,110 @@ export function detectUnverifiedEdit(toolCalls: readonly ToolCall[]): Finding |
723
753
  };
724
754
  }
725
755
 
756
+ // ---------------------------------------------------------------------------
757
+ // Detector F — a completion claim over production code no test accompanies
758
+ // ---------------------------------------------------------------------------
759
+
760
+ /**
761
+ * WHY THIS EXISTS, and why detector C did not already cover it.
762
+ *
763
+ * Detector C asks "did ANY verification command run after the last edit". It is
764
+ * satisfied by `eslint`. That is deliberate — see `isVerificationCommand`, which
765
+ * is narrow about what counts as a command and says nothing about what the
766
+ * command COVERS. So a turn can write four hundred lines of feature code, run
767
+ * the linter, claim done, and pass every guard in this file.
768
+ *
769
+ * Measured consequence, reported by the maintainer 2026-09-11 about a consumer
770
+ * project: a feature shipped whose detail view crashed on open, whose flyouts
771
+ * were broken, and for which the maintainer then had to hand-enumerate what
772
+ * should have been exercised — every view, every CRUD path, drag-and-drop,
773
+ * mobile, filters. Their words: *"Solche Fehler können nur auftreten, weil Du
774
+ * nicht TDD gearbeitet ... hast."* Nothing in the suite refused that turn,
775
+ * because nothing in the suite asks whether the change is TESTED — only whether
776
+ * something ran.
777
+ *
778
+ * THE SIGNAL, and why it is this one. "Is the change covered" is undecidable
779
+ * from a transcript: coverage lives in the consumer's tooling, which this hook
780
+ * cannot run. What IS decidable is the cheapest possible proxy, and it happens
781
+ * to be the exact shape of the reported failure — **production source changed,
782
+ * no test file touched, done claimed**. A proxy is worth shipping here precisely
783
+ * because the population it flags is small and the failure it catches is total:
784
+ * a feature with zero new test lines is not under-tested, it is untested.
785
+ *
786
+ * WHY IT IS GATED ON THE CLAIM rather than on the edit. Firing on every edit
787
+ * would refuse the first turn of every red-green-refactor cycle — the discipline
788
+ * it exists to encourage — and a guard that fires on the majority of turns is a
789
+ * guard that gets switched off. The obligation belongs to the CLAIM, which is
790
+ * also where `verify-before-complete` puts it and where detector D already sits.
791
+ *
792
+ * WHAT IT CANNOT DO. It cannot tell a good test from a token one: a turn that
793
+ * touches any test file clears it. That is a deliberate floor rather than an
794
+ * oversight — `testing-anti-patterns` owns assertion quality, and a guard that
795
+ * tried to judge it from a path would be judging something it cannot see. The
796
+ * honest claim is narrow: this refuses the case where the agent wrote NO test at
797
+ * all and said it was finished.
798
+ */
799
+
800
+ /** Extensions that carry behaviour a test can be owed for. */
801
+ const _SOURCE_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|vue|svelte|php|py|rb|go|rs|java|kt|swift|cs|scala|ex|exs)$/i;
802
+
803
+ /**
804
+ * Paths that ARE tests, across the conventions this suite's stacks use.
805
+ *
806
+ * Deliberately generous: a false NEGATIVE here (calling a test file a test) only
807
+ * silences the detector, while a false positive would refuse a turn that did
808
+ * write its tests — the direction that gets a guard disabled.
809
+ */
810
+ const _TEST_PATH_RE =
811
+ /(?:^|\/)(?:tests?|specs?|__tests__|__specs__|e2e|cypress|playwright|features)\//i;
812
+ const _TEST_FILE_RE =
813
+ /(?:\.(?:test|spec)\.[a-z]+$)|(?:_test\.[a-z]+$)|(?:(?:^|\/)test_[^/]+$)|(?:Test\.php$)|(?:Spec\.php$)|(?:\.feature$)/i;
814
+
815
+ function _isTestPath(p: string): boolean {
816
+ return _TEST_PATH_RE.test(p) || _TEST_FILE_RE.test(p);
817
+ }
818
+
819
+ function _isProductionSource(p: string): boolean {
820
+ return _SOURCE_RE.test(p) && !_isTestPath(p);
821
+ }
822
+
823
+ /**
824
+ * Fire when the turn changed production code, touched no test, and said done.
825
+ *
826
+ * All three conditions are load-bearing. Drop the first and it fires on a docs
827
+ * turn; drop the second and it fires on a turn that did its job; drop the third
828
+ * and it fires mid-cycle on the red step of red-green-refactor.
829
+ */
830
+ export function detectUntestedChange(reply: string, toolCalls: readonly ToolCall[]): Finding | null {
831
+ const edited = toolCalls
832
+ .filter((c) => _EDIT_TOOLS.has(c.name) && c.path !== undefined)
833
+ .map((c) => c.path as string);
834
+ const source = edited.filter(_isProductionSource);
835
+ if (source.length === 0) return null;
836
+ if (edited.some(_isTestPath)) return null;
837
+
838
+ // The claim gate, reusing detector D's pair rather than a second dialect of
839
+ // "done" — two lists of completion phrasings would drift, and the negation
840
+ // check is what keeps an honest "noch nicht fertig" from being refused.
841
+ const prose = visibleProse(reply);
842
+ const m = _COMPLETION_RE.exec(prose);
843
+ if (!m) return null;
844
+ if (_NEGATED_CLAIM_RE.test(_lineAround(prose, m.index))) return null;
845
+
846
+ const shown = source.slice(0, 3).join(', ');
847
+ return {
848
+ detector: 'untested',
849
+ evidence: shown + (source.length > 3 ? ` (+${String(source.length - 3)} more)` : ''),
850
+ reason:
851
+ 'a completion claim over production code this turn changed, with NO test file ' +
852
+ 'touched anywhere in the turn — running a linter is not evidence the change ' +
853
+ 'works. Write the test that would have caught the failure, and enumerate the ' +
854
+ 'states the change actually has (every view, every CRUD path, the empty and ' +
855
+ 'error states) rather than the happy one. If a test genuinely does not belong ' +
856
+ 'here, say which file carries the coverage instead',
857
+ };
858
+ }
859
+
726
860
  // ---------------------------------------------------------------------------
727
861
  // Re-entrancy — the guard, keyed on the turn, not on the reply
728
862
  // ---------------------------------------------------------------------------
@@ -811,6 +945,7 @@ function markRefusedTurn(
811
945
  sessionKey: string,
812
946
  turnOrdinal: number,
813
947
  detectors: readonly DetectorId[],
948
+ promptId: string,
814
949
  ): void {
815
950
  if (is_replay_mode()) return;
816
951
  const file = sessionStateFile(workspaceRoot, sessionKey);
@@ -835,6 +970,7 @@ function markRefusedTurn(
835
970
  turnOrdinal,
836
971
  at: new Date().toISOString(),
837
972
  version,
973
+ promptId,
838
974
  }) as unknown as Record<string, unknown>,
839
975
  );
840
976
  } catch {
@@ -868,6 +1004,20 @@ export interface ToolCall {
868
1004
 
869
1005
  export interface TranscriptTail {
870
1006
  lastAssistant: string;
1007
+ /**
1008
+ * Every assistant text of the CURRENT turn, in order — reset at each genuine
1009
+ * user prompt exactly like `toolCalls`, and for the same reason: a question
1010
+ * asked three turns ago was answered, a question asked earlier in THIS turn
1011
+ * was not. Detector E reads it; the other four read `lastAssistant`, which is
1012
+ * always the last element when this is non-empty.
1013
+ *
1014
+ * A tool-only assistant entry contributes NOTHING here. `_messageText`
1015
+ * returns null for it and the collector skips it, so the ordinary shape of a
1016
+ * working turn — prose, tool call, prose — produces two elements and not
1017
+ * three. That is the whole false-positive surface of detector E, and it is
1018
+ * closed here rather than in the detector.
1019
+ */
1020
+ assistantTurnTexts: string[];
871
1021
  /**
872
1022
  * How many GENUINE user prompts the transcript carries — harness-injected
873
1023
  * user-role entries excluded via `isSyntheticPrompt`. This is the turn's
@@ -899,7 +1049,12 @@ export function readTranscriptTail(
899
1049
  transcriptPath: string,
900
1050
  opts: { homeDir?: string; maxBytes?: number } = {},
901
1051
  ): TranscriptTail {
902
- const empty: TranscriptTail = { lastAssistant: '', turnOrdinal: 0, toolCalls: [] };
1052
+ const empty: TranscriptTail = {
1053
+ lastAssistant: '',
1054
+ turnOrdinal: 0,
1055
+ toolCalls: [],
1056
+ assistantTurnTexts: [],
1057
+ };
903
1058
  if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
904
1059
  let lines: string[];
905
1060
  try {
@@ -919,6 +1074,7 @@ export function readTranscriptTail(
919
1074
  let lastAssistant = '';
920
1075
  let turnOrdinal = 0;
921
1076
  let toolCalls: ToolCall[] = [];
1077
+ let assistantTurnTexts: string[] = [];
922
1078
  for (const rawLine of lines) {
923
1079
  const line = rawLine.trim();
924
1080
  if (!line) continue;
@@ -944,12 +1100,13 @@ export function readTranscriptTail(
944
1100
  // only a tool_use block has no text at all, so `continue`-ing on a null
945
1101
  // text would drop exactly the entries this detector exists to see.
946
1102
  if (role === 'assistant') {
947
- toolCalls.push(..._toolCalls(content));
1103
+ toolCalls.push(...extractToolCalls(content));
948
1104
  }
949
1105
  const text = _messageText(content);
950
1106
  if (text === null) continue;
951
1107
  if (role === 'assistant') {
952
1108
  lastAssistant = text;
1109
+ assistantTurnTexts.push(text);
953
1110
  } else if (!isSyntheticPrompt(text)) {
954
1111
  turnOrdinal += 1;
955
1112
  // A genuine user prompt starts a new turn, so the previous turn's
@@ -957,17 +1114,34 @@ export function readTranscriptTail(
957
1114
  // run three turns ago would vouch for an edit made now — the
958
1115
  // "fresh" in edit-without-FRESH-verification is this line.
959
1116
  toolCalls = [];
1117
+ // Same boundary, same reason, for detector E: an options block the
1118
+ // user has since replied to is answered, not dropped. Resetting on
1119
+ // the identical line is what makes "in the SAME user turn" true of
1120
+ // the array rather than merely intended.
1121
+ assistantTurnTexts = [];
960
1122
  }
961
1123
  }
962
- return { lastAssistant: lastAssistant.trim(), turnOrdinal, toolCalls };
1124
+ return {
1125
+ lastAssistant: lastAssistant.trim(),
1126
+ turnOrdinal,
1127
+ toolCalls,
1128
+ assistantTurnTexts,
1129
+ };
963
1130
  }
964
1131
 
965
1132
  /**
966
1133
  * Extract this entry's tool calls, keeping only name, shell command and target
967
1134
  * path. A tool input can hold a whole file body; nothing but those three fields
968
1135
  * is carried forward.
1136
+ *
1137
+ * Exported because `measure_turn_end_gate` scores detectors C and E over a real
1138
+ * transcript corpus and therefore has to rebuild the same `ToolCall[]` the gate
1139
+ * sees. A second extractor there would be a second dialect of "what the turn
1140
+ * did", and the measurement would then be of that dialect rather than of the
1141
+ * shipped gate — the exact population-parity defect that script's own header
1142
+ * documents twice.
969
1143
  */
970
- function _toolCalls(content: unknown): ToolCall[] {
1144
+ export function extractToolCalls(content: unknown): ToolCall[] {
971
1145
  if (!Array.isArray(content)) return [];
972
1146
  const out: ToolCall[] = [];
973
1147
  for (const blk of content) {
@@ -1074,11 +1248,30 @@ export function main(): number {
1074
1248
  // in production while its comment claimed it was enforced — the same
1075
1249
  // fixed-the-definition-not-the-caller shape as two other findings in that
1076
1250
  // round, which is why this line exists rather than a default parameter.
1077
- const { lastAssistant, turnOrdinal, toolCalls } = readTranscriptTail(transcriptPath, {
1078
- maxBytes: TRANSCRIPT_READ_MAX_BYTES,
1079
- });
1251
+ const { lastAssistant, turnOrdinal, toolCalls, assistantTurnTexts } = readTranscriptTail(
1252
+ transcriptPath,
1253
+ { maxBytes: TRANSCRIPT_READ_MAX_BYTES },
1254
+ );
1080
1255
  if (!lastAssistant) return EXIT_ALLOW;
1081
1256
 
1257
+ // The closing reply comes from the field the host hands us when it supplies
1258
+ // one, not only from a file the host writes asynchronously. The transcript
1259
+ // may lag the in-memory conversation, and a tail that is one assistant entry
1260
+ // short is silence for `detectDroppedDecision`, whose early return needs two
1261
+ // texts — i.e. the detector goes quiet in exactly the shape it exists for.
1262
+ // `suggestion_capture_hook.ts` reads the same field for the same reason.
1263
+ // Appended, never substituted: `lastAssistant` and the four other detectors
1264
+ // are untouched, and a host that supplies no such field is unchanged.
1265
+ const payloadClosing = str(
1266
+ (payload['last_assistant_message'] ?? payload['lastAssistantMessage']) as
1267
+ | JsonValue
1268
+ | undefined,
1269
+ );
1270
+ const closingFromPayload = payloadClosing !== '' && !assistantTurnTexts.includes(payloadClosing);
1271
+ const turnTexts = closingFromPayload
1272
+ ? [...assistantTurnTexts, payloadClosing]
1273
+ : assistantTurnTexts;
1274
+
1082
1275
  // Layer 2 — keyed on the turn's ORDINAL, never on the prompt's text.
1083
1276
  // A host that sends no `session_id` shares one bucket AND one small-integer
1084
1277
  // ordinal namespace, so an unrelated session whose ordinal matches a stored
@@ -1092,6 +1285,13 @@ export function main(): number {
1092
1285
  // producer keys that on the raw `session_id`. Passing `sessionKey` here would
1093
1286
  // read a path nothing writes — the same shape as the STATE_FILE break this
1094
1287
  // parameter exists to close.
1288
+ // The host's own id for the prompt being processed. Read here and recorded
1289
+ // beside the ordinal in the refusal marker — never used as the guard key.
1290
+ // The ordinal's drift is what both re-entrancy layers exist for, so a later
1291
+ // reading needs the pair to tell a drifted ordinal from a second prompt.
1292
+ const promptId = str(
1293
+ (payload['prompt_id'] ?? payload['promptId']) as JsonValue | undefined,
1294
+ );
1095
1295
  const rawSessionId = str(envelope['session_id'] as JsonValue | undefined) || '';
1096
1296
  const sessionKey = deriveSessionKey(rawSessionId || 'unknown-session');
1097
1297
  if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
@@ -1129,6 +1329,22 @@ export function main(): number {
1129
1329
  dispatchOpen
1130
1330
  ? null
1131
1331
  : detectCompletionClaim(lastAssistant, readCiSettled(workspaceRoot, rawSessionId)),
1332
+ // Detector E is UNCONDITIONAL, and that is the opposite choice from A
1333
+ // and D one line above rather than an oversight. An open dispatch
1334
+ // excuses a promissory closing because the work is genuinely still
1335
+ // running; it excuses nothing about a question the user was already
1336
+ // asked. Worse: a dispatch — or the `end-review-nudge` stop concern
1337
+ // that produced the measured failure — IS the continuation that drops
1338
+ // the decision, so narrowing E the same way would silence it in exactly
1339
+ // the case it exists for.
1340
+ detectDroppedDecision(turnTexts, closingFromPayload),
1341
+ // Detector F takes the OTHER side of that same question, and the two
1342
+ // sitting adjacent is the clearest place to say why. E fires on a
1343
+ // question already asked, which an open dispatch cannot excuse. F fires
1344
+ // on a completion CLAIM, and a turn waiting on a subagent has not
1345
+ // finished — so its closing is not the claim F is about. Same slot,
1346
+ // opposite trigger, opposite treatment of `dispatchOpen`.
1347
+ dispatchOpen ? null : detectUntestedChange(lastAssistant, toolCalls),
1132
1348
  ]) {
1133
1349
  if (f) findings.push(f);
1134
1350
  }
@@ -1139,6 +1355,7 @@ export function main(): number {
1139
1355
  sessionKey,
1140
1356
  turnOrdinal,
1141
1357
  findings.map((f) => f.detector),
1358
+ promptId,
1142
1359
  );
1143
1360
 
1144
1361
  const lines = findings.map(
@@ -1147,8 +1364,9 @@ export function main(): number {
1147
1364
  process.stderr.write(
1148
1365
  `turn-end-gate: REFUSED — this turn is not finished.\n${lines.join('\n')}\n` +
1149
1366
  ' Do the promised work now, correct the language, run the ' +
1150
- 'verification the edit needs, or read the CI verdict before claiming ' +
1151
- 'it — then end the turn.\n' +
1367
+ 'verification the edit needs, read the CI verdict before claiming ' +
1368
+ 'it, or re-present the options block this turn dropped — then end ' +
1369
+ 'the turn.\n' +
1152
1370
  ' This turn will not be refused a second time.\n',
1153
1371
  );
1154
1372
  return EXIT_BLOCK;
@@ -40,10 +40,12 @@ import * as hooks_status from "./hooks_status.js";
40
40
  import {
41
41
  DETECTOR_IDS,
42
42
  collectRefusalStats,
43
+ emptyCounts,
43
44
  readInstallBoundary,
44
45
  type InstallBoundary,
45
46
  type RefusalStats,
46
47
  } from "./_lib/turn_end_refusals.js";
48
+ import { ledgerWritable, readDelivered } from "./_lib/obligations.js";
47
49
 
48
50
  // src/scripts/hooks_doctor.ts → parents[2] (../../..) is the repo root,
49
51
  // mirroring the Python `Path(__file__).resolve().parents[2]`.
@@ -190,10 +192,35 @@ export interface DoctorPayload {
190
192
  * concern's health is already read, so this is where the number belongs.
191
193
  */
192
194
  turn_end_refusals: RefusalStats;
195
+ /**
196
+ * The obligation ledger's two readiness figures.
197
+ *
198
+ * Reported here and nowhere else, because the doctor is where a concern's
199
+ * health is already read and the ledger's failure mode is silence: it never
200
+ * throws, never refuses and never fails a turn, so an unwritable ledger
201
+ * looks exactly like a session that triggered no rules. These two lines are
202
+ * the difference between those, and they are the ONLY consumer the ledger
203
+ * has — reporting a count is not acting on the rows.
204
+ */
205
+ obligation_ledger: ObligationLedgerStats;
193
206
  /** The machine's install stamp, for reading the version split below it. */
194
207
  install_boundary: InstallBoundary;
195
208
  }
196
209
 
210
+ /** What the doctor can say about the ledger without interpreting a single row. */
211
+ export interface ObligationLedgerStats {
212
+ /** Probed by writing and removing a sentinel, not by reading mode bits. */
213
+ writable: boolean;
214
+ /**
215
+ * Rows for THIS session, or `null` when no session id was resolvable.
216
+ *
217
+ * `null` and `0` are different answers and are rendered differently: `0`
218
+ * means this session was delivered no rule, `null` means the doctor could
219
+ * not tell which session it is in and so has nothing to count.
220
+ */
221
+ rows_this_session: number | null;
222
+ }
223
+
197
224
  /** Build the doctor payload — JSON-serialisable. */
198
225
  export function collect(
199
226
  project_root: string,
@@ -268,7 +295,9 @@ export function collect(
268
295
  turn_end_refusals = {
269
296
  sessionsWithRefusals: 0,
270
297
  total: 0,
271
- byDetector: { promissory: 0, language: 0, verification: 0, completion: 0 },
298
+ // Derived, never a literal: a fifth detector made the old four-key object
299
+ // a type error here and a silent `NaN` in the aggregator it mirrors.
300
+ byDetector: emptyCounts(),
272
301
  byPeriod: [],
273
302
  legacyRecords: 0,
274
303
  unversionedRecords: 0,
@@ -285,10 +314,67 @@ export function collect(
285
314
  trampolines,
286
315
  dispatch_issues: issues,
287
316
  turn_end_refusals,
317
+ obligation_ledger: collectLedgerStats(project_root),
288
318
  install_boundary: readInstallBoundary(),
289
319
  };
290
320
  }
291
321
 
322
+ /**
323
+ * The ledger's two figures, never throwing.
324
+ *
325
+ * The session id comes from the host's own environment variable, which is the
326
+ * same source the concerns key their state on. Absent means the doctor is
327
+ * being run outside a session — by a human at a shell, most often — and the
328
+ * honest answer is `null` rather than 0, because there is no session whose
329
+ * rows could be counted.
330
+ */
331
+ export function collectLedgerStats(project_root: string): ObligationLedgerStats {
332
+ let writable = false;
333
+ try {
334
+ writable = ledgerWritable(project_root);
335
+ } catch {
336
+ writable = false;
337
+ }
338
+ const session = (process.env["CLAUDE_CODE_SESSION_ID"] ?? "").trim();
339
+ if (session === "") return { writable, rows_this_session: null };
340
+ try {
341
+ return { writable, rows_this_session: readDelivered(project_root, session).length };
342
+ } catch {
343
+ return { writable, rows_this_session: null };
344
+ }
345
+ }
346
+
347
+ /**
348
+ * The ledger section.
349
+ *
350
+ * Deliberately states what a row is NOT. The council lock this ledger sits
351
+ * under says installation proves availability and not exposure, so a reader
352
+ * who sees a healthy row count and concludes the rules were followed has made
353
+ * the one claim these records may never support. The line is cheap; the
354
+ * misreading it prevents is the whole reason the ledger needed a non-goal.
355
+ */
356
+ export function _render_ledger(payload: DoctorPayload): string[] {
357
+ const s = payload.obligation_ledger;
358
+ const lines: string[] = [];
359
+ lines.push("Obligation ledger");
360
+ lines.push("-".repeat(60));
361
+ lines.push(
362
+ `${s.writable ? "✅ " : "❌ "}writable ${
363
+ s.writable ? "agents/runtime/state/obligations/" : "cannot write agents/runtime/state/obligations/"
364
+ }`,
365
+ );
366
+ if (s.rows_this_session === null) {
367
+ lines.push("· rows this session: no session id resolved — nothing to count");
368
+ } else {
369
+ lines.push(`· rows this session: ${s.rows_this_session}`);
370
+ }
371
+ lines.push(
372
+ " Rows record that an emitter ran. They are not evidence that a rule",
373
+ );
374
+ lines.push(" reached the model, and may not be read as compliance.");
375
+ return lines;
376
+ }
377
+
292
378
  function _strVal(v: JsonValue | undefined): string {
293
379
  return v === undefined || v === null ? "undefined" : String(v);
294
380
  }
@@ -412,6 +498,8 @@ export function _render_table(payload: DoctorPayload): string {
412
498
  }
413
499
  lines.push("");
414
500
  lines.push(..._render_refusals(payload));
501
+ lines.push("");
502
+ lines.push(..._render_ledger(payload));
415
503
  // Dispatch-issues detail — last 20 grouped by concern.
416
504
  if (payload.dispatch_issues.length > 0) {
417
505
  lines.push("");
@@ -73,11 +73,22 @@ export const PLATFORM_BRIDGES: Record<string, [string, string]> = {
73
73
  copilot: ["", "rule-only fallback — this package binds no hook here"],
74
74
  };
75
75
 
76
+ /** The shape of a host's user-facing question primitive. */
77
+ export type AskShape = "native" | "text";
78
+
76
79
  export interface PlatformRow {
77
80
  platform: string;
78
81
  status: string;
79
82
  bridge_path: string | null;
80
83
  fallback_only: boolean;
84
+ /**
85
+ * DECLARED in the manifest, never probed. A host with no row reads `text`:
86
+ * the conservative answer, and the one that is true of every host that has
87
+ * not said otherwise. Absent must not read as "unknown" here — an unknown
88
+ * that the contract then treats as native is exactly the silent degrade the
89
+ * row exists to prevent.
90
+ */
91
+ ask: AskShape;
81
92
  bindings: Record<string, string[]>;
82
93
  hint: string | null;
83
94
  }
@@ -122,6 +133,7 @@ export function collect(project_root: string, manifest: JsonObject): StatusMatri
122
133
  const blockRaw = platforms[platform];
123
134
  const block = _isObject(blockRaw) ? blockRaw : {};
124
135
  const fallback_only = Boolean(block["fallback_only"]);
136
+ const ask: AskShape = block["ask"] === "native" ? "native" : "text";
125
137
  const bindings: Record<string, string[]> = {};
126
138
  if (!fallback_only) {
127
139
  for (const [ev, c] of Object.entries(block)) {
@@ -138,6 +150,7 @@ export function collect(project_root: string, manifest: JsonObject): StatusMatri
138
150
  status,
139
151
  bridge_path: rel || null,
140
152
  fallback_only,
153
+ ask,
141
154
  bindings,
142
155
  hint: ["missing", "empty", "degraded", "n/a"].includes(status)
143
156
  ? hint
@@ -166,6 +179,12 @@ export function _render_table(matrix: StatusMatrix): string {
166
179
  head += ` (${row.bridge_path})`;
167
180
  }
168
181
  lines.push(head);
182
+ lines.push(
183
+ ` ask → ${row.ask}` +
184
+ (row.ask === "native"
185
+ ? " (structured-ask tool; the recommendation is its default option)"
186
+ : " (numbered text block — the fallback, and it says so)"),
187
+ );
169
188
  if (row.fallback_only) {
170
189
  lines.push(
171
190
  " degraded: rule-only fallback " +