@kontourai/flow-agents 3.3.0 → 3.4.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 (256) hide show
  1. package/.github/workflows/add-to-project.yml +15 -0
  2. package/.github/workflows/ci.yml +161 -0
  3. package/CHANGELOG.md +48 -0
  4. package/CONTEXT.md +5 -1
  5. package/README.md +19 -8
  6. package/build/src/builder-flow-run-adapter.d.ts +80 -0
  7. package/build/src/builder-flow-run-adapter.js +241 -0
  8. package/build/src/builder-flow-runtime.d.ts +16 -0
  9. package/build/src/builder-flow-runtime.js +290 -0
  10. package/build/src/cli/builder-run.d.ts +1 -0
  11. package/build/src/cli/builder-run.js +27 -0
  12. package/build/src/cli/effective-backlog-settings.js +70 -2
  13. package/build/src/cli/init.d.ts +34 -0
  14. package/build/src/cli/init.js +341 -61
  15. package/build/src/cli/kit.js +55 -12
  16. package/build/src/cli/pull-work-provider.js +346 -5
  17. package/build/src/cli/skill-drift-check.d.ts +1 -0
  18. package/build/src/cli/skill-drift-check.js +165 -0
  19. package/build/src/cli/telemetry-doctor.d.ts +37 -0
  20. package/build/src/cli/telemetry-doctor.js +53 -6
  21. package/build/src/cli/validate-hook-influence.js +37 -7
  22. package/build/src/cli/workflow-sidecar.d.ts +93 -8
  23. package/build/src/cli/workflow-sidecar.js +1175 -158
  24. package/build/src/cli.js +5 -0
  25. package/build/src/flow-kit/validate.d.ts +54 -34
  26. package/build/src/flow-kit/validate.js +237 -26
  27. package/build/src/index.d.ts +2 -0
  28. package/build/src/index.js +1 -0
  29. package/build/src/lib/console-connect-options.d.ts +97 -0
  30. package/build/src/lib/console-connect-options.js +199 -0
  31. package/build/src/lib/console-telemetry-validate.d.ts +49 -0
  32. package/build/src/lib/console-telemetry-validate.js +91 -0
  33. package/build/src/lib/flow-resolver.d.ts +56 -3
  34. package/build/src/lib/flow-resolver.js +151 -11
  35. package/build/src/lib/fs.d.ts +17 -0
  36. package/build/src/lib/fs.js +172 -0
  37. package/build/src/lib/local-artifact-root.d.ts +44 -1
  38. package/build/src/lib/local-artifact-root.js +131 -3
  39. package/build/src/runtime-adapters.d.ts +39 -3
  40. package/build/src/runtime-adapters.js +77 -31
  41. package/build/src/tools/build-universal-bundles.js +40 -2
  42. package/build/src/tools/codex-agent-routing.d.ts +2 -0
  43. package/build/src/tools/codex-agent-routing.js +49 -0
  44. package/build/src/tools/generate-context-map.js +1 -0
  45. package/build/src/tools/validate-source-tree.js +27 -1
  46. package/context/scripts/hooks/lib/kit-catalog.js +235 -0
  47. package/context/scripts/hooks/lib/runnable-command.js +177 -0
  48. package/context/scripts/hooks/stop-goal-fit.js +278 -48
  49. package/context/scripts/hooks/workflow-steering.js +121 -21
  50. package/context/scripts/package.json +3 -0
  51. package/context/scripts/telemetry/install-console-config.sh +25 -4
  52. package/context/scripts/telemetry/lib/config.sh +102 -12
  53. package/context/scripts/telemetry/lib/pricing.sh +50 -0
  54. package/context/scripts/telemetry/lib/session.sh +3 -0
  55. package/context/scripts/telemetry/lib/transport.sh +87 -0
  56. package/context/scripts/telemetry/lib/usage.sh +205 -4
  57. package/context/scripts/telemetry/telemetry.conf +6 -0
  58. package/context/scripts/telemetry/telemetry.sh +48 -0
  59. package/context/settings/workspace-backlog-provider-settings.example.json +48 -0
  60. package/docs/agent-usage-feedback-loop.md +35 -0
  61. package/docs/architecture-engine-and-kits.md +110 -0
  62. package/docs/context-map.md +2 -0
  63. package/docs/decisions/embeddable-engine.md +152 -0
  64. package/docs/decisions/index.md +3 -1
  65. package/docs/decisions/trust-ledger-retention.md +88 -0
  66. package/docs/decisions/workflow-enforcement.md +31 -9
  67. package/docs/fixture-ownership.md +3 -0
  68. package/docs/implementing-trust-reconciliation.md +129 -0
  69. package/docs/index.md +19 -9
  70. package/docs/integrations/flow-agents-console.md +167 -0
  71. package/docs/kit-authoring-guide.md +52 -21
  72. package/docs/spec/builder-flow-runtime.md +80 -0
  73. package/docs/spec/runtime-hook-surface.md +45 -1
  74. package/docs/specs/economics-record-contract.md +270 -0
  75. package/docs/specs/harness-capability-matrix.md +74 -0
  76. package/docs/specs/learning-review-proposals-contract.md +340 -0
  77. package/docs/specs/routing-efficiency-review.md +59 -0
  78. package/docs/verifiable-trust.md +74 -25
  79. package/docs/workflow-usage-guide.md +10 -0
  80. package/evals/acceptance/prove-capture-teeth.sh +132 -0
  81. package/evals/ci/antigaming-suite.sh +1 -0
  82. package/evals/ci/run-baseline.sh +72 -4
  83. package/evals/fixtures/economics/acceptance.json +12 -0
  84. package/evals/fixtures/economics/agents/tool-worker-1/events.jsonl +2 -0
  85. package/evals/fixtures/economics/agents/tool-worker-2/events.jsonl +2 -0
  86. package/evals/fixtures/economics/agents/tool-worker-3/events.jsonl +2 -0
  87. package/evals/fixtures/economics/agents/tool-worker-4/events.jsonl +1 -0
  88. package/evals/fixtures/economics/agents/tool-worker-5/events.jsonl +2 -0
  89. package/evals/fixtures/economics/critique.json +22 -0
  90. package/evals/fixtures/economics/expected-record.json +71 -0
  91. package/evals/fixtures/economics/session-usage-event.json +1 -0
  92. package/evals/fixtures/economics/state.json +11 -0
  93. package/evals/fixtures/economics/transcript.jsonl +3 -0
  94. package/evals/fixtures/hook-influence/cases.json +7 -7
  95. package/evals/fixtures/learning-review-proposals/balanced/economics.jsonl +6 -0
  96. package/evals/fixtures/learning-review-proposals/effect-follow-up/economics.jsonl +5 -0
  97. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-1/trust.bundle +21 -0
  98. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-2/trust.bundle +21 -0
  99. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-3/trust.bundle +21 -0
  100. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-4/trust.bundle +21 -0
  101. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-5/trust.bundle +21 -0
  102. package/evals/fixtures/learning-review-proposals/pattern-present/economics.jsonl +6 -0
  103. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.json +30 -0
  104. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.md +66 -0
  105. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/gate-review.inquiries.json +26 -0
  106. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/trust.bundle +21 -0
  107. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/gate-review.inquiries.json +26 -0
  108. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/trust.bundle +21 -0
  109. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/gate-review.inquiries.json +26 -0
  110. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/trust.bundle +21 -0
  111. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/gate-review.inquiries.json +26 -0
  112. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/trust.bundle +21 -0
  113. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-5/trust.bundle +21 -0
  114. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-6/trust.bundle +21 -0
  115. package/evals/fixtures/learning-review-proposals/repeat-window/economics.jsonl +6 -0
  116. package/evals/fixtures/learning-review-proposals/under-threshold/economics.jsonl +3 -0
  117. package/evals/fixtures/telemetry/usage-transcript-sample.jsonl +4 -0
  118. package/evals/fixtures/trust-reconcile-exploits/mcp-degrade.json +42 -0
  119. package/evals/integration/test_builder_entry_enforcement.sh +241 -0
  120. package/evals/integration/test_builder_step_producers.sh +18 -10
  121. package/evals/integration/test_bundle_install.sh +172 -0
  122. package/evals/integration/test_console_tenant_isolation.sh +167 -0
  123. package/evals/integration/test_critique_supersession_roundtrip.sh +4 -1
  124. package/evals/integration/test_dual_emit_flow_step.sh +10 -4
  125. package/evals/integration/test_economics_record.sh +674 -0
  126. package/evals/integration/test_effective_backlog_settings.sh +1 -1
  127. package/evals/integration/test_evidence_capture_hook.sh +17 -2
  128. package/evals/integration/test_exemption_usage_review.sh +198 -0
  129. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  130. package/evals/integration/test_flow_kit_install_git.sh +83 -0
  131. package/evals/integration/test_flowdef_session_activation.sh +0 -1
  132. package/evals/integration/test_flowdef_session_history_preservation.sh +13 -3
  133. package/evals/integration/test_gate_lockdown.sh +7 -0
  134. package/evals/integration/test_gate_review_inquiry_records.sh +9 -1
  135. package/evals/integration/test_goal_fit_hook.sh +2031 -0
  136. package/evals/integration/test_hook_category_behaviors.sh +8 -1
  137. package/evals/integration/test_hook_influence_cases.sh +25 -1
  138. package/evals/integration/test_install_merge.sh +227 -2
  139. package/evals/integration/test_kit_conformance_levels.sh +6 -6
  140. package/evals/integration/test_learning_review_proposals.sh +329 -0
  141. package/evals/integration/test_liveness_conflict_injection.sh +26 -22
  142. package/evals/integration/test_liveness_console_relay.sh +166 -0
  143. package/evals/integration/test_liveness_heartbeat.sh +17 -17
  144. package/evals/integration/test_liveness_worktree_root.sh +575 -0
  145. package/evals/integration/test_phase_map_and_gate_claim.sh +6 -1
  146. package/evals/integration/test_publish_delivery.sh +331 -1
  147. package/evals/integration/test_pull_work_board.sh +200 -0
  148. package/evals/integration/test_pull_work_provider.sh +1 -1
  149. package/evals/integration/test_record_check.sh +378 -0
  150. package/evals/integration/test_routing_efficiency.sh +71 -0
  151. package/evals/integration/test_runtime_adapter_activation.sh +28 -0
  152. package/evals/integration/test_session_resume_roundtrip.sh +16 -19
  153. package/evals/integration/test_skill_drift_check.sh +870 -0
  154. package/evals/integration/test_telemetry.sh +445 -0
  155. package/evals/integration/test_telemetry_doctor.sh +66 -0
  156. package/evals/integration/test_telemetry_usage_pipeline.sh +228 -0
  157. package/evals/integration/test_trust_reconcile_negatives.sh +30 -13
  158. package/evals/integration/test_trust_reconcile_trailer_diagnostic.sh +247 -0
  159. package/evals/integration/test_usage_cost.sh +61 -0
  160. package/evals/integration/test_workflow_sidecar_writer.sh +1395 -0
  161. package/evals/integration/test_workflow_steering_hook.sh +157 -16
  162. package/evals/integration/test_workspace_settings.sh +176 -0
  163. package/evals/lib/env.sh +26 -0
  164. package/evals/lib/node.sh +8 -0
  165. package/evals/run.sh +29 -0
  166. package/evals/static/test_ci_integration_coverage.sh +115 -0
  167. package/evals/static/test_declared_scope_forms_documented.sh +114 -0
  168. package/evals/static/test_universal_bundles.sh +34 -0
  169. package/evals/static/test_validate_source_kit_asset_scope.sh +259 -0
  170. package/evals/static/test_workflow_skills.sh +1 -1
  171. package/kits/builder/flows/build.flow.json +9 -18
  172. package/kits/builder/flows/publish-learn.flow.json +5 -1
  173. package/kits/builder/kit.json +120 -0
  174. package/kits/builder/skills/deliver/SKILL.md +42 -0
  175. package/kits/builder/skills/evidence-gate/SKILL.md +12 -0
  176. package/kits/builder/skills/execute-plan/SKILL.md +9 -0
  177. package/kits/builder/skills/learning-review/SKILL.md +51 -0
  178. package/kits/builder/skills/plan-work/SKILL.md +17 -20
  179. package/kits/builder/skills/pull-work/SKILL.md +21 -0
  180. package/kits/builder/skills/release-readiness/SKILL.md +12 -0
  181. package/kits/knowledge/kit.json +9 -0
  182. package/kits/veritas-governance/docs/README.md +35 -7
  183. package/kits/veritas-governance/fixtures/exemption-review/mixed-fresh-stale.DECLARED.json +14 -0
  184. package/kits/veritas-governance/kit.json +14 -0
  185. package/kits/veritas-governance/skills/exemption-usage-review/SKILL.md +128 -0
  186. package/kits/veritas-governance/skills/exemption-usage-review/review-exemptions.mjs +231 -0
  187. package/package.json +2 -2
  188. package/packaging/manifest.json +29 -0
  189. package/schemas/backlog-provider-settings.schema.json +13 -0
  190. package/schemas/workflow-state.schema.json +44 -0
  191. package/scripts/README.md +4 -0
  192. package/scripts/check-content-boundary.cjs +8 -1
  193. package/scripts/ci/trust-reconcile.js +136 -0
  194. package/scripts/hooks/codex-hook-adapter.js +77 -2
  195. package/scripts/hooks/evidence-capture.js +38 -5
  196. package/scripts/hooks/lib/codex-exit-code.js +316 -0
  197. package/scripts/hooks/lib/kit-catalog.js +235 -0
  198. package/scripts/hooks/lib/liveness-write.js +28 -1
  199. package/scripts/hooks/lib/local-artifact-paths.js +97 -1
  200. package/scripts/hooks/lib/runnable-command.js +177 -0
  201. package/scripts/hooks/lib/skill-drift.js +350 -0
  202. package/scripts/hooks/stop-goal-fit.js +278 -48
  203. package/scripts/hooks/workflow-steering.js +121 -21
  204. package/scripts/install-codex-home.sh +97 -47
  205. package/scripts/install-merge.js +72 -14
  206. package/scripts/install-owned-files.js +178 -0
  207. package/scripts/liveness/relay.sh +84 -0
  208. package/scripts/telemetry/economics-record.schema.json +145 -0
  209. package/scripts/telemetry/economics-record.sh +331 -0
  210. package/scripts/telemetry/install-console-config.sh +25 -4
  211. package/scripts/telemetry/learning-review-decide.sh +124 -0
  212. package/scripts/telemetry/learning-review-proposals.schema.json +161 -0
  213. package/scripts/telemetry/learning-review-proposals.sh +484 -0
  214. package/scripts/telemetry/lib/config.sh +102 -12
  215. package/scripts/telemetry/lib/pricing.sh +14 -6
  216. package/scripts/telemetry/lib/session.sh +3 -0
  217. package/scripts/telemetry/lib/transport.sh +133 -15
  218. package/scripts/telemetry/lib/usage.sh +121 -28
  219. package/scripts/telemetry/routing-efficiency.sh +0 -0
  220. package/scripts/telemetry/telemetry.conf +6 -0
  221. package/scripts/telemetry/telemetry.sh +48 -0
  222. package/src/builder-flow-run-adapter.ts +357 -0
  223. package/src/builder-flow-runtime.ts +348 -0
  224. package/src/cli/builder-flow-run-adapter.test.mjs +495 -0
  225. package/src/cli/builder-flow-runtime.test.mjs +213 -0
  226. package/src/cli/builder-run.ts +28 -0
  227. package/src/cli/codex-agent-routing.test.mjs +44 -0
  228. package/src/cli/codex-exit-code.test.mjs +207 -0
  229. package/src/cli/console-connect-options.test.mjs +329 -0
  230. package/src/cli/console-telemetry-validate.test.mjs +157 -0
  231. package/src/cli/effective-backlog-settings.ts +68 -2
  232. package/src/cli/flow-resolver-composition.test.mjs +101 -0
  233. package/src/cli/init.test.mjs +161 -0
  234. package/src/cli/init.ts +407 -62
  235. package/src/cli/kit-metadata-security.test.mjs +443 -0
  236. package/src/cli/kit.ts +50 -12
  237. package/src/cli/pull-work-provider.ts +377 -3
  238. package/src/cli/sidecar-pure-helpers.test.mjs +64 -0
  239. package/src/cli/skill-drift-check.ts +196 -0
  240. package/src/cli/telemetry-doctor.test.mjs +53 -0
  241. package/src/cli/telemetry-doctor.ts +50 -7
  242. package/src/cli/validate-hook-influence.ts +37 -6
  243. package/src/cli/workflow-sidecar.ts +1150 -151
  244. package/src/cli.ts +5 -0
  245. package/src/flow-kit/validate.ts +277 -38
  246. package/src/index.ts +19 -0
  247. package/src/lib/console-connect-options.ts +261 -0
  248. package/src/lib/console-telemetry-validate.ts +88 -0
  249. package/src/lib/flow-resolver.ts +153 -10
  250. package/src/lib/fs.ts +160 -0
  251. package/src/lib/local-artifact-root.ts +129 -3
  252. package/src/runtime-adapters.ts +113 -33
  253. package/src/tools/build-universal-bundles.ts +36 -2
  254. package/src/tools/codex-agent-routing.ts +48 -0
  255. package/src/tools/generate-context-map.ts +1 -0
  256. package/src/tools/validate-source-tree.ts +26 -1
@@ -0,0 +1,161 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://kontourai.dev/schemas/kontour.learning-review-proposals/0.1.json",
4
+ "title": "kontour.learning-review-proposals",
5
+ "description": "Per-run kit/gate tuning proposal document + ledger-line shape (flow-agents #352). Advisory only, never auto-applied. evidence.cost and evidence.defect are co-required on every proposal (R7-style Goodhart guard, mirrors kontour.console.economics's cost+defects co-requirement).",
6
+ "type": "object",
7
+ "additionalProperties": true,
8
+ "required": ["schema", "version", "window", "outcome", "aggregates", "proposals"],
9
+ "properties": {
10
+ "schema": { "const": "kontour.learning-review-proposals" },
11
+ "version": { "const": "0.1" },
12
+ "window": {
13
+ "type": "object",
14
+ "additionalProperties": true,
15
+ "required": ["since", "until"],
16
+ "properties": {
17
+ "since": { "type": ["number", "string", "null"] },
18
+ "until": { "type": ["number", "string", "null"] }
19
+ }
20
+ },
21
+ "records_considered": { "type": "number" },
22
+ "outcome": {
23
+ "type": "string",
24
+ "enum": ["ok", "insufficient-data"]
25
+ },
26
+ "aggregates": {
27
+ "type": "object",
28
+ "additionalProperties": true,
29
+ "required": ["by_kit", "by_gate", "partial"],
30
+ "properties": {
31
+ "by_kit": {
32
+ "type": "array",
33
+ "items": {
34
+ "type": "object",
35
+ "additionalProperties": true,
36
+ "required": ["kit_id", "runs"],
37
+ "properties": {
38
+ "kit_id": { "type": "string" },
39
+ "runs": { "type": "number" },
40
+ "first_half_avg_cost_usd": { "type": ["number", "null"] },
41
+ "second_half_avg_cost_usd": { "type": ["number", "null"] },
42
+ "cost_trend_pct": { "type": ["number", "null"] },
43
+ "first_half_findings_total": { "type": ["number", "null"] },
44
+ "second_half_findings_total": { "type": ["number", "null"] },
45
+ "findings_delta_pct": { "type": ["number", "null"] },
46
+ "avg_wall_clock_s": { "type": ["number", "null"] },
47
+ "avg_human_wait_s": { "type": ["number", "null"] },
48
+ "route_back_rate": { "type": ["number", "null"] },
49
+ "caught_false_completions_total": { "type": ["number", "null"] }
50
+ }
51
+ }
52
+ },
53
+ "by_gate": {
54
+ "type": "array",
55
+ "items": {
56
+ "type": "object",
57
+ "additionalProperties": true,
58
+ "required": ["gate_id", "fire_count"],
59
+ "properties": {
60
+ "gate_id": { "type": "string" },
61
+ "fire_count": { "type": "number" },
62
+ "correct_count": { "type": "number" },
63
+ "false_block_count": { "type": "number" },
64
+ "missed_block_count": { "type": "number" },
65
+ "false_block_rate": { "type": ["number", "null"] },
66
+ "avg_wall_clock_s_when_fired": { "type": ["number", "null"] },
67
+ "avg_human_wait_s_when_fired": { "type": ["number", "null"] }
68
+ }
69
+ }
70
+ },
71
+ "partial": { "type": "boolean" }
72
+ }
73
+ },
74
+ "proposals": {
75
+ "type": "array",
76
+ "items": { "$ref": "#/$defs/proposal" }
77
+ },
78
+ "notes": {
79
+ "type": "array",
80
+ "items": { "type": "string" }
81
+ }
82
+ },
83
+ "$defs": {
84
+ "proposal": {
85
+ "type": "object",
86
+ "additionalProperties": true,
87
+ "required": [
88
+ "proposal_id",
89
+ "target",
90
+ "pattern",
91
+ "proposed_change",
92
+ "evidence",
93
+ "expected_effect",
94
+ "decision"
95
+ ],
96
+ "properties": {
97
+ "proposal_id": { "type": "string", "minLength": 1 },
98
+ "target": {
99
+ "type": "object",
100
+ "additionalProperties": true,
101
+ "required": ["kind", "id"],
102
+ "properties": {
103
+ "kind": { "type": "string", "enum": ["kit", "gate"] },
104
+ "id": { "type": "string" }
105
+ }
106
+ },
107
+ "pattern": {
108
+ "type": "string",
109
+ "enum": ["kit-review-cost-inflation", "gate-false-block-review", "gate-well-calibrated"]
110
+ },
111
+ "proposed_change": { "type": "string", "minLength": 1 },
112
+ "severity": { "const": "advisory" },
113
+ "evidence": {
114
+ "type": "object",
115
+ "additionalProperties": true,
116
+ "required": ["cost", "defect"],
117
+ "description": "Goodhart guard (structural): cost and defect are co-required — no proposal may cite a cost/time signal without also pairing a defect signal, mirroring kontour.console.economics's own cost+defects co-requirement (R7).",
118
+ "properties": {
119
+ "cost": { "type": "object", "additionalProperties": true },
120
+ "defect": { "type": "object", "additionalProperties": true }
121
+ }
122
+ },
123
+ "expected_effect": {
124
+ "type": "object",
125
+ "additionalProperties": true,
126
+ "required": ["metric", "direction", "description"],
127
+ "properties": {
128
+ "metric": { "type": "string", "minLength": 1 },
129
+ "direction": { "type": "string", "enum": ["decrease", "increase", "maintain"] },
130
+ "description": { "type": "string", "minLength": 1 }
131
+ }
132
+ },
133
+ "decision": {
134
+ "type": "object",
135
+ "additionalProperties": true,
136
+ "required": ["status", "decided_by", "decided_at", "rationale"],
137
+ "properties": {
138
+ "status": { "type": "string", "enum": ["proposed", "ratified", "rejected", "deferred"] },
139
+ "decided_by": { "type": ["string", "null"] },
140
+ "decided_at": { "type": ["string", "null"] },
141
+ "rationale": { "type": ["string", "null"] }
142
+ }
143
+ },
144
+ "follow_on_ref": { "type": ["string", "null"] },
145
+ "effect_observed": {
146
+ "type": ["object", "null"],
147
+ "additionalProperties": true,
148
+ "required": ["measured_at", "metric", "before", "after", "moved"],
149
+ "properties": {
150
+ "measured_at": { "type": "string" },
151
+ "metric": { "type": "string" },
152
+ "before": { "type": ["number", "null"] },
153
+ "after": { "type": ["number", "null"] },
154
+ "moved": { "type": "string", "enum": ["improved", "worsened", "unchanged"] }
155
+ }
156
+ },
157
+ "already_proposed": { "type": "boolean" }
158
+ }
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env bash
2
+ # learning-review-proposals.sh — turn recorded kontour.console.economics outcomes into per-kit/per-gate
3
+ # tuning PROPOSALS (#352), generalizing the routing-efficiency.sh (#415) shape from per-(role,model) to
4
+ # per-kit/per-gate.
5
+ #
6
+ # Reads a window of economics.jsonl records, joins each record's kit identity (trust.bundle
7
+ # claims[].claimType prefix) and gate identity (gate-review.inquiries.json InquiryRecord calibration),
8
+ # computes per-kit/per-gate aggregates, and emits ADVISORY proposals into a durable, idempotent ledger.
9
+ # NEVER writes to kits/**, .datum/config.json, or any gate/flow config file (ADR 0002/0003 call 5,
10
+ # ADR 0008/0010 consume-never-fork) — this is a pure downstream consumer of fa#349's record and
11
+ # gate-review's InquiryRecord output; neither is ever invoked or modified by this script.
12
+ #
13
+ # See docs/specs/learning-review-proposals-contract.md for the full formula/ledger/effect-fill spec.
14
+ #
15
+ # Usage:
16
+ # learning-review-proposals.sh [economics.jsonl ...] # defaults to the local economics log
17
+ # cat economics.jsonl | learning-review-proposals.sh - # records on stdin
18
+ # learning-review-proposals.sh --since ISO_OR_EPOCH --until ISO_OR_EPOCH \
19
+ # --sessions-root DIR --ledger PATH
20
+ #
21
+ # Tunables (env): LR_MIN_WINDOW_SAMPLE (5), LR_MIN_KIT_SAMPLE (6), LR_MIN_GATE_SAMPLE (3),
22
+ # LR_COST_RISE_PCT (25), LR_FLAT_FINDINGS_PCT (10), LR_GATE_FALSE_BLOCK_RATE (0.5),
23
+ # LR_GATE_WELL_CALIBRATED_RATE (0.9).
24
+ set -uo pipefail
25
+
26
+ usage() {
27
+ cat <<'USAGE'
28
+ Usage: learning-review-proposals.sh [economics.jsonl ...] [--since VAL] [--until VAL]
29
+ [--sessions-root DIR] [--ledger PATH]
30
+ cat economics.jsonl | learning-review-proposals.sh -
31
+
32
+ --since VAL ISO-8601 (no fractional seconds) or epoch-ms lower bound on record .at
33
+ --until VAL ISO-8601 (no fractional seconds) or epoch-ms upper bound on record .at
34
+ --sessions-root DIR root for <task_slug>/trust.bundle + <task_slug>/gate-review.inquiries.json
35
+ joins (default: .kontourai/flow-agents)
36
+ --ledger PATH proposal ledger path (default: .kontourai/telemetry/learning-review-proposals.jsonl,
37
+ override via LEARNING_REVIEW_PROPOSALS_LEDGER)
38
+ -h, --help show this help
39
+
40
+ Advisory only — never writes kits/**, .datum/config.json, or any gate/flow config file.
41
+ See docs/specs/learning-review-proposals-contract.md for the full contract.
42
+ USAGE
43
+ }
44
+
45
+ command -v jq >/dev/null 2>&1 || {
46
+ echo '{"schema":"kontour.learning-review-proposals","version":"0.1","window":{"since":null,"until":null},"records_considered":0,"outcome":"insufficient-data","aggregates":{"by_kit":[],"by_gate":[],"partial":true},"proposals":[],"notes":["jq unavailable — no analysis"]}'
47
+ exit 0
48
+ }
49
+
50
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit 0
51
+ default_log="${TELEMETRY_ECONOMICS_LOG_FILE:-${TELEMETRY_DATA_DIR:-${SCRIPT_DIR}/../..}/.kontourai/telemetry/economics.jsonl}"
52
+ default_sessions_root="${SCRIPT_DIR}/../../.kontourai/flow-agents"
53
+ default_ledger="${LEARNING_REVIEW_PROPOSALS_LEDGER:-${TELEMETRY_DATA_DIR:-${SCRIPT_DIR}/../..}/.kontourai/telemetry/learning-review-proposals.jsonl}"
54
+
55
+ since_arg=""
56
+ until_arg=""
57
+ sessions_root="$default_sessions_root"
58
+ ledger_path="$default_ledger"
59
+ sources=()
60
+
61
+ while [[ "$#" -gt 0 ]]; do
62
+ case "$1" in
63
+ --since) since_arg="${2:-}"; shift 2 ;;
64
+ --until) until_arg="${2:-}"; shift 2 ;;
65
+ --sessions-root) sessions_root="${2:-}"; shift 2 ;;
66
+ --ledger) ledger_path="${2:-}"; shift 2 ;;
67
+ -h|--help) usage; exit 0 ;;
68
+ --) shift; while [[ "$#" -gt 0 ]]; do sources+=("$1"); shift; done ;;
69
+ *) sources+=("$1"); shift ;;
70
+ esac
71
+ done
72
+
73
+ min_window_sample="${LR_MIN_WINDOW_SAMPLE:-5}"
74
+ min_kit_sample="${LR_MIN_KIT_SAMPLE:-6}"
75
+ min_gate_sample="${LR_MIN_GATE_SAMPLE:-3}"
76
+ cost_rise_pct="${LR_COST_RISE_PCT:-25}"
77
+ flat_findings_pct="${LR_FLAT_FINDINGS_PCT:-10}"
78
+ gate_false_block_rate="${LR_GATE_FALSE_BLOCK_RATE:-0.5}"
79
+ gate_well_calibrated_rate="${LR_GATE_WELL_CALIBRATED_RATE:-0.9}"
80
+
81
+ # Shared to_epoch_ms jq def (single source of truth, reused by both the --since/--until
82
+ # pre-validation below and the main analysis pipeline, so the two can never drift out of sync).
83
+ # Parses (in order): epoch-ms number/digit-string, date-only (YYYY-MM-DD, normalized to midnight
84
+ # UTC), full ISO-8601 with optional fractional seconds and a literal "Z" offset, full ISO-8601
85
+ # with optional fractional seconds and an explicit "+00:00" UTC offset (fractional seconds and
86
+ # the offset are both stripped/converted before parsing, since fromdateiso8601 only understands
87
+ # a bare "%Y-%m-%dT%H:%M:%SZ" — never the other direction, which would silently turn a parseable
88
+ # value into an unparseable one). Any other string raises an explicit jq error (never a silent
89
+ # wrong epoch, never swallowed into a fabricated result).
90
+ to_epoch_ms_def="$(cat <<'JQDEF'
91
+ def to_epoch_ms:
92
+ if . == null or . == "" then null
93
+ elif (. | type) == "number" then .
94
+ elif (. | test("^[0-9]+$")) then (. | tonumber)
95
+ elif (. | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) then (. + "T00:00:00Z" | fromdateiso8601 * 1000)
96
+ elif (. | test("\\.[0-9]+Z$")) then (. | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601 * 1000)
97
+ elif (. | test("Z$")) then (. | fromdateiso8601 * 1000)
98
+ elif (. | test("\\.[0-9]+\\+00:00$")) then (. | sub("\\.[0-9]+\\+00:00$"; "+00:00") | sub("\\+00:00$"; "Z") | fromdateiso8601 * 1000)
99
+ elif (. | test("\\+00:00$")) then (. | sub("\\+00:00$"; "Z") | fromdateiso8601 * 1000)
100
+ else error("unrecognized timestamp format: \\(.)")
101
+ end;
102
+ JQDEF
103
+ )"
104
+
105
+ # FAIL LOUD on a malformed --since/--until BEFORE any analysis or ledger I/O: a bad CLI window
106
+ # argument is operator-correctable input and must never masquerade as "insufficient-data" (which
107
+ # would look like an honest thin-data result rather than a usage error).
108
+ validate_time_arg() {
109
+ local label="$1" val="$2"
110
+ [[ -z "$val" ]] && return 0
111
+ if ! jq -e -n --arg v "$val" "${to_epoch_ms_def}
112
+ (\$v | to_epoch_ms) != null" >/dev/null 2>&1; then
113
+ echo "learning-review-proposals: invalid --${label} value: '${val}' (expected epoch-ms, YYYY-MM-DD, or full ISO-8601 with a Z or +00:00 offset; fractional seconds optional)" >&2
114
+ exit 1
115
+ fi
116
+ }
117
+ validate_time_arg "since" "$since_arg"
118
+ validate_time_arg "until" "$until_arg"
119
+
120
+ # Gather records: explicit args (a '-' means stdin), else stdin when piped, else the default local log.
121
+ records=""
122
+ if [[ "${#sources[@]}" -gt 0 ]]; then
123
+ for src in "${sources[@]}"; do
124
+ if [[ "$src" == "-" ]]; then records+="$(cat 2>/dev/null)"$'\n'
125
+ elif [[ -f "$src" ]]; then records+="$(cat "$src" 2>/dev/null)"$'\n'; fi
126
+ done
127
+ elif [[ ! -t 0 ]]; then
128
+ records="$(cat 2>/dev/null)"
129
+ elif [[ -f "$default_log" ]]; then
130
+ records="$(cat "$default_log" 2>/dev/null)"
131
+ fi
132
+
133
+ fallback_envelope() {
134
+ echo '{"schema":"kontour.learning-review-proposals","version":"0.1","window":{"since":null,"until":null},"records_considered":0,"outcome":"insufficient-data","aggregates":{"by_kit":[],"by_gate":[],"partial":true},"proposals":[],"notes":["no parseable records"]}'
135
+ }
136
+
137
+ records_json="$(printf '%s' "$records" | jq -s -c '[ .[] | select(type == "object" and .schema == "kontour.console.economics") ]' 2>/dev/null)"
138
+ [[ -n "$records_json" ]] || { fallback_envelope; exit 0; }
139
+
140
+ # Distinct, non-null task_slugs present in the gathered records — the join keys we need to resolve.
141
+ slugs="$(printf '%s' "$records_json" | jq -r '[ .[] | .task_slug | select(. != null) ] | unique[]' 2>/dev/null)"
142
+
143
+ # Build the session join array: one entry per distinct task_slug, {task_slug, kit_id, gate_rows}.
144
+ session_join="[]"
145
+ if [[ -n "$slugs" ]]; then
146
+ join_entries=()
147
+ while IFS= read -r slug; do
148
+ [[ -n "$slug" ]] || continue
149
+ bundle="${sessions_root}/${slug}/trust.bundle"
150
+ inquiries="${sessions_root}/${slug}/gate-review.inquiries.json"
151
+ kit_id="unattributed"
152
+ if [[ -f "$bundle" ]]; then
153
+ resolved="$(jq -r '
154
+ [ .claims[]?.claimType | select(. != null) | split(".")[0] ]
155
+ | if length == 0 then "unattributed"
156
+ else (group_by(.) | max_by(length) | .[0]) end
157
+ ' "$bundle" 2>/dev/null)"
158
+ [[ -n "$resolved" && "$resolved" != "null" ]] && kit_id="$resolved"
159
+ fi
160
+ gate_rows="[]"
161
+ if [[ -f "$inquiries" ]]; then
162
+ resolved_rows="$(jq -c '
163
+ [ .[]? | select(.inquiry.target.fieldOrBehavior != null) |
164
+ { gate_id: .inquiry.target.fieldOrBehavior,
165
+ outcome: .answer.value.calibration,
166
+ fired: (.answer.value.gateFired // false) } ]
167
+ ' "$inquiries" 2>/dev/null)"
168
+ [[ -n "$resolved_rows" ]] && gate_rows="$resolved_rows"
169
+ fi
170
+ join_entries+=("$(jq -c -n --arg slug "$slug" --arg kit_id "$kit_id" --argjson gate_rows "$gate_rows" \
171
+ '{task_slug: $slug, kit_id: $kit_id, gate_rows: $gate_rows}')")
172
+ done <<< "$slugs"
173
+ if [[ "${#join_entries[@]}" -gt 0 ]]; then
174
+ session_join="$(printf '%s\n' "${join_entries[@]}" | jq -s -c '.')"
175
+ fi
176
+ fi
177
+
178
+ now_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
179
+
180
+ # Main analysis pass: window filter, kit/gate join, aggregates, proposal rules. The filter text is
181
+ # assembled from the shared $to_epoch_ms_def (single source of truth with the pre-validation
182
+ # above) concatenated with the rest of the program, then passed as ONE filter argument to jq
183
+ # (jq only accepts a single FILTER argument; passing the def as a separate positional argument
184
+ # would be mis-parsed as an input FILE, not appended to the filter).
185
+ main_jq_program="$to_epoch_ms_def"
186
+ # shellcheck disable=SC2016
187
+ # ^ this and every other single-quoted append below is a literal jq program fragment (its "$vars"
188
+ # are jq variables, bound later via --arg/--argjson) — never intended for bash expansion, the
189
+ # same deliberate idiom every other jq invocation in this file already uses as a direct argument.
190
+ main_jq_program+='
191
+ def round4: (. * 10000 | round) / 10000;
192
+
193
+ ($since_raw | to_epoch_ms) as $since_ms
194
+ | ($until_raw | to_epoch_ms) as $until_ms
195
+
196
+ # window-filtered records, each carrying its kit_id + gate_rows join
197
+ | [ $recs[]
198
+ | select(
199
+ (try (.at | to_epoch_ms) catch null) as $at_ms
200
+ | ($since_ms == null or $at_ms == null or $at_ms >= $since_ms)
201
+ and ($until_ms == null or $at_ms == null or $at_ms <= $until_ms)
202
+ )
203
+ | . as $rec
204
+ | ( [ $sessions[] | select(.task_slug == $rec.task_slug) ] | first ) as $join
205
+ | {
206
+ rec: $rec,
207
+ at_ms: (try (.at | to_epoch_ms) catch null),
208
+ kit_id: ($join.kit_id // "unattributed"),
209
+ gate_rows: ($join.gate_rows // [])
210
+ }
211
+ ] as $windowed
212
+
213
+ | ($windowed | length) as $records_considered
214
+ | (if $records_considered < $min_window_sample then "insufficient-data" else "ok" end) as $outcome
215
+
216
+ # effective window bounds: explicit arg else observed min/max .at
217
+ | ( [ $windowed[] | .at_ms | select(. != null) ] ) as $at_values
218
+ | (if $since_ms != null then $since_ms elif ($at_values | length) > 0 then ($at_values | min) else null end) as $eff_since
219
+ | (if $until_ms != null then $until_ms elif ($at_values | length) > 0 then ($at_values | max) else null end) as $eff_until
220
+
221
+ # ---- by_kit aggregates ----
222
+ | ( $windowed
223
+ | group_by(.kit_id)
224
+ | map(
225
+ . as $g
226
+ | ($g[0].kit_id) as $kit_id
227
+ | ($g | length) as $runs
228
+ | ($g | sort_by(.at_ms // 0)) as $sorted
229
+ | (($runs / 2) | floor) as $half_point
230
+ | ($sorted[0:$half_point]) as $first_half
231
+ | ($sorted[$half_point:$runs]) as $second_half
232
+ | (if ($first_half | length) > 0 then (($first_half | map(.rec.cost.estimated_cost_usd // 0) | add) / ($first_half | length)) else null end) as $fh_cost
233
+ | (if ($second_half | length) > 0 then (($second_half | map(.rec.cost.estimated_cost_usd // 0) | add) / ($second_half | length)) else null end) as $sh_cost
234
+ | (if $fh_cost == null or $fh_cost == 0 then null else (((($sh_cost // 0) - $fh_cost) / $fh_cost) * 100) end) as $cost_trend_pct
235
+ | ($first_half | map(.rec.defects.findings_by_severity as $f | ($f.critical // 0) + ($f.high // 0) + ($f.medium // 0) + ($f.low // 0)) | add // 0) as $fh_findings
236
+ | ($second_half | map(.rec.defects.findings_by_severity as $f | ($f.critical // 0) + ($f.high // 0) + ($f.medium // 0) + ($f.low // 0)) | add // 0) as $sh_findings
237
+ | (if $fh_findings == 0 then (if $sh_findings == 0 then 0 else null end) else ((($sh_findings - $fh_findings) / $fh_findings) * 100) end) as $findings_delta_pct
238
+ | ($g | map(.rec.time.wall_clock_s // 0) | add / $runs) as $avg_wall_clock_s
239
+ | ($g | map(.rec.time.human_wait_s // 0) | add / $runs) as $avg_human_wait_s
240
+ | ($g | map(.rec.iterations.count // 0) | add) as $count_sum
241
+ | ($g | map(.rec.iterations.route_backs // 0) | add) as $route_back_sum
242
+ | (if $count_sum == 0 then null else ($route_back_sum / $count_sum) end) as $route_back_rate
243
+ | ($g | map(.rec.defects.caught_false_completions // 0) | add) as $caught_false_completions_total
244
+ | {
245
+ kit_id: $kit_id,
246
+ runs: $runs,
247
+ first_half_avg_cost_usd: ($fh_cost | if . == null then null else round4 end),
248
+ second_half_avg_cost_usd: ($sh_cost | if . == null then null else round4 end),
249
+ cost_trend_pct: ($cost_trend_pct | if . == null then null else round4 end),
250
+ first_half_findings_total: $fh_findings,
251
+ second_half_findings_total: $sh_findings,
252
+ findings_delta_pct: ($findings_delta_pct | if . == null then null else round4 end),
253
+ avg_wall_clock_s: ($avg_wall_clock_s | round4),
254
+ avg_human_wait_s: ($avg_human_wait_s | round4),
255
+ route_back_rate: ($route_back_rate | if . == null then null else round4 end),
256
+ caught_false_completions_total: $caught_false_completions_total
257
+ }
258
+ )
259
+ | sort_by(.kit_id)
260
+ ) as $by_kit
261
+
262
+ # ---- by_gate aggregates ----
263
+ # Each flattened gate row carries the ORIGINAL record'\''s index in $windowed (._idx) as a stable
264
+ # per-record identity key for the "when fired" dedupe below — never a (value-tuple) key, which
265
+ # would wrongly collapse distinct records that happen to share identical timing values.
266
+ | ( [ $windowed | to_entries[] | .key as $idx | .value as $w
267
+ | $w.gate_rows[] | . + { _idx: $idx, _wall_clock_s: $w.rec.time.wall_clock_s, _human_wait_s: $w.rec.time.human_wait_s } ]
268
+ ) as $gate_rows_flat
269
+ | ( $gate_rows_flat
270
+ | group_by(.gate_id)
271
+ | map(
272
+ . as $g
273
+ | ($g[0].gate_id) as $gate_id
274
+ | ($g | length) as $fire_count
275
+ | ([ $g[] | select(.outcome == "correct") ] | length) as $correct_count
276
+ | ([ $g[] | select(.outcome == "false_block") ] | length) as $false_block_count
277
+ | ([ $g[] | select(.outcome == "missed_block") ] | length) as $missed_block_count
278
+ | (if $fire_count == 0 then null else ($false_block_count / $fire_count) end) as $false_block_rate
279
+ | ( [ $g[] | select(.fired == true) | { idx: ._idx, wall_clock_s: ._wall_clock_s, human_wait_s: ._human_wait_s } ] | unique_by(.idx) ) as $fired_records
280
+ | (if ($fired_records | length) > 0 then (($fired_records | map(.wall_clock_s // 0) | add) / ($fired_records | length)) else null end) as $avg_wall_clock_s_when_fired
281
+ | (if ($fired_records | length) > 0 then (($fired_records | map(.human_wait_s // 0) | add) / ($fired_records | length)) else null end) as $avg_human_wait_s_when_fired
282
+ | {
283
+ gate_id: $gate_id,
284
+ fire_count: $fire_count,
285
+ correct_count: $correct_count,
286
+ false_block_count: $false_block_count,
287
+ missed_block_count: $missed_block_count,
288
+ false_block_rate: ($false_block_rate | if . == null then null else round4 end),
289
+ avg_wall_clock_s_when_fired: ($avg_wall_clock_s_when_fired | if . == null then null else round4 end),
290
+ avg_human_wait_s_when_fired: ($avg_human_wait_s_when_fired | if . == null then null else round4 end)
291
+ }
292
+ )
293
+ | sort_by(.gate_id)
294
+ ) as $by_gate
295
+
296
+ | (if $outcome == "insufficient-data" then [] else (
297
+ ($by_kit | map(
298
+ select(.runs >= $min_kit_sample and .cost_trend_pct != null and .cost_trend_pct >= $cost_rise_pct
299
+ and .findings_delta_pct != null and .findings_delta_pct <= $flat_findings_pct)
300
+ | {
301
+ target: { kind: "kit", id: .kit_id },
302
+ pattern: "kit-review-cost-inflation",
303
+ proposed_change: ("Review " + .kit_id + "'\''s recent-run review depth/scope: cost per run rose " + (.cost_trend_pct | tostring) + "% while findings caught stayed flat/declined (" + (.findings_delta_pct | tostring) + "%)."),
304
+ severity: "advisory",
305
+ evidence: {
306
+ cost: { first_half_avg_cost_usd: .first_half_avg_cost_usd, second_half_avg_cost_usd: .second_half_avg_cost_usd, cost_trend_pct: .cost_trend_pct },
307
+ defect: { first_half_findings_total: .first_half_findings_total, second_half_findings_total: .second_half_findings_total, findings_delta_pct: .findings_delta_pct, caught_false_completions_total: .caught_false_completions_total }
308
+ },
309
+ expected_effect: { metric: "avg_cost_usd", direction: "decrease", description: "cost should come back down without a corresponding rise in escaped defects" }
310
+ }
311
+ ))
312
+ + ($by_gate | map(
313
+ select(.fire_count >= $min_gate_sample and .false_block_rate != null and .false_block_rate >= $gate_false_block_rate)
314
+ | {
315
+ target: { kind: "gate", id: .gate_id },
316
+ pattern: "gate-false-block-review",
317
+ proposed_change: ("Review gate \"" + .gate_id + "\": false_block_rate is " + (.false_block_rate | tostring) + " over " + (.fire_count | tostring) + " fires — it is blocking on already-passing claims more often than not."),
318
+ severity: "advisory",
319
+ evidence: {
320
+ cost: { avg_wall_clock_s_when_fired: .avg_wall_clock_s_when_fired, avg_human_wait_s_when_fired: .avg_human_wait_s_when_fired },
321
+ defect: { fire_count: .fire_count, correct_count: .correct_count, false_block_count: .false_block_count, missed_block_count: .missed_block_count }
322
+ },
323
+ expected_effect: { metric: "false_block_rate", direction: "decrease", description: "false_block_rate should fall toward correctly-calibrated blocking" }
324
+ }
325
+ ))
326
+ + ($by_gate | map(
327
+ select(.fire_count >= $min_gate_sample and .correct_count != null and .fire_count > 0
328
+ and (.correct_count / .fire_count) >= $gate_well_calibrated_rate
329
+ and .false_block_count == 0 and .missed_block_count == 0)
330
+ | {
331
+ target: { kind: "gate", id: .gate_id },
332
+ pattern: "gate-well-calibrated",
333
+ proposed_change: ("Gate \"" + .gate_id + "\" is well-calibrated over " + (.fire_count | tostring) + " fires (" + (.correct_count | tostring) + " correct, 0 false-block, 0 missed-block) — no change needed."),
334
+ severity: "advisory",
335
+ evidence: {
336
+ cost: { avg_wall_clock_s_when_fired: .avg_wall_clock_s_when_fired, avg_human_wait_s_when_fired: .avg_human_wait_s_when_fired },
337
+ defect: { fire_count: .fire_count, correct_count: .correct_count, false_block_count: .false_block_count, missed_block_count: .missed_block_count }
338
+ },
339
+ expected_effect: { metric: "false_block_rate", direction: "maintain", description: "gate is well-calibrated; no change expected or needed" }
340
+ }
341
+ ))
342
+ ) end) as $raw_proposals
343
+
344
+ | ($raw_proposals | map(
345
+ . as $p
346
+ | ("\($since_label)_\($until_label)_\($p.target.kind)-\($p.target.id)_\($p.pattern)"
347
+ | ascii_downcase
348
+ | gsub("[^a-z0-9]+"; "-")
349
+ | sub("^-"; "") | sub("-$"; "")) as $proposal_id
350
+ | $p + {
351
+ proposal_id: $proposal_id,
352
+ decision: { status: "proposed", decided_by: null, decided_at: null, rationale: null },
353
+ follow_on_ref: null,
354
+ effect_observed: null,
355
+ window: { since: $eff_since, until: $eff_until }
356
+ }
357
+ )) as $proposals
358
+
359
+ | ( [ $windowed[] | select((.rec.task_slug != null) and (.gate_rows | length) == 0) ] | length ) as $sessions_without_gate_join
360
+
361
+ | {
362
+ schema: "kontour.learning-review-proposals",
363
+ version: "0.1",
364
+ window: { since: $eff_since, until: $eff_until },
365
+ records_considered: $records_considered,
366
+ outcome: $outcome,
367
+ aggregates: { by_kit: $by_kit, by_gate: $by_gate, partial: ($outcome == "insufficient-data") },
368
+ proposals: $proposals,
369
+ notes: (
370
+ ["Advisory only — human-ratified via learning-review-decide.sh; never auto-applied to kits/**, .datum/config.json, or any gate/flow config."]
371
+ + (if $outcome == "insufficient-data" then ["records_considered (\($records_considered)) below LR_MIN_WINDOW_SAMPLE (\($min_window_sample)) — insufficient data for proposals; aggregates shown for transparency only."] else [] end)
372
+ + (if $sessions_without_gate_join > 0 then ["\($sessions_without_gate_join) record(s) with a task_slug but no gate-review.inquiries.json join (sessions without a gate-review pass)."] else [] end)
373
+ )
374
+ }
375
+ '
376
+
377
+ current_output="$(jq -c -n \
378
+ --argjson recs "$records_json" \
379
+ --argjson sessions "$session_join" \
380
+ --arg since_raw "$since_arg" \
381
+ --arg until_raw "$until_arg" \
382
+ --argjson min_window_sample "$min_window_sample" \
383
+ --argjson min_kit_sample "$min_kit_sample" \
384
+ --argjson min_gate_sample "$min_gate_sample" \
385
+ --argjson cost_rise_pct "$cost_rise_pct" \
386
+ --argjson flat_findings_pct "$flat_findings_pct" \
387
+ --argjson gate_false_block_rate "$gate_false_block_rate" \
388
+ --argjson gate_well_calibrated_rate "$gate_well_calibrated_rate" \
389
+ --arg since_label "${since_arg:-all}" \
390
+ --arg until_label "${until_arg:-all}" \
391
+ --arg now_iso "$now_iso" \
392
+ "$main_jq_program" 2>/dev/null)"
393
+
394
+ [[ -n "$current_output" ]] || { fallback_envelope; exit 0; }
395
+
396
+ # ---- Ledger read + de-dupe + effect-fill (shell + jq per-line, not a single jq pass) ----
397
+ ledger_dir="$(dirname "$ledger_path")"
398
+ mkdir -p "$ledger_dir" 2>/dev/null || true
399
+
400
+ existing_ledger="[]"
401
+ if [[ -s "$ledger_path" ]]; then
402
+ # FAIL LOUD, NO WRITE on a corrupted ledger: a malformed line here must never be silently
403
+ # treated as "[]" (which would make the later mv overwrite and destroy the entire durable
404
+ # ledger, including recorded human decisions). Only a genuinely empty/absent ledger is "[]".
405
+ if ! existing_ledger="$(jq -s -c '.' "$ledger_path" 2>/dev/null)"; then
406
+ echo "learning-review-proposals: ledger at '$ledger_path' failed to parse (jq -s) — refusing to run to avoid destroying existing decisions. Inspect/repair the ledger file and retry; nothing was written." >&2
407
+ exit 1
408
+ fi
409
+ fi
410
+
411
+ new_proposals="$(printf '%s' "$current_output" | jq -c '.proposals')"
412
+ aggregates="$(printf '%s' "$current_output" | jq -c '.aggregates')"
413
+ eff_since="$(printf '%s' "$current_output" | jq -c '.window.since')"
414
+
415
+ ledger_result="$(jq -c -n \
416
+ --argjson existing "$existing_ledger" \
417
+ --argjson new_proposals "$new_proposals" \
418
+ --argjson aggregates "$aggregates" \
419
+ --argjson eff_since "$eff_since" \
420
+ --arg now_iso "$now_iso" \
421
+ '
422
+ def moved_dir($direction; $before; $after):
423
+ if $before == null or $after == null then null
424
+ elif $after == $before then "unchanged"
425
+ elif $direction == "decrease" then (if $after < $before then "improved" else "worsened" end)
426
+ elif $direction == "increase" then (if $after > $before then "improved" else "worsened" end)
427
+ else "worsened"
428
+ end;
429
+
430
+ ($existing | map(.proposal_id)) as $existing_ids
431
+
432
+ | ($existing | map(
433
+ if (.decision.status == "ratified") and (.effect_observed == null)
434
+ and ($eff_since != null) and (.window.until != null) and (.window.until < $eff_since)
435
+ then
436
+ . as $entry
437
+ | ( if $entry.target.kind == "kit"
438
+ then ($aggregates.by_kit[]? | select(.kit_id == $entry.target.id) | .second_half_avg_cost_usd)
439
+ else ($aggregates.by_gate[]? | select(.gate_id == $entry.target.id) | .false_block_rate)
440
+ end ) as $after
441
+ | (if $after == null then $entry else (
442
+ ( if $entry.target.kind == "kit"
443
+ then $entry.evidence.cost.second_half_avg_cost_usd
444
+ else (if ($entry.evidence.defect.fire_count // 0) > 0 then ($entry.evidence.defect.false_block_count / $entry.evidence.defect.fire_count) else null end)
445
+ end ) as $before
446
+ | $entry + {
447
+ effect_observed: {
448
+ measured_at: $now_iso,
449
+ metric: (if $entry.target.kind == "kit" then "avg_cost_usd" else "false_block_rate" end),
450
+ before: $before,
451
+ after: $after,
452
+ moved: moved_dir($entry.expected_effect.direction; $before; $after)
453
+ }
454
+ }
455
+ ) end)
456
+ else .
457
+ end
458
+ )) as $updated_existing
459
+
460
+ | ($new_proposals | map(. + { already_proposed: ((.proposal_id as $pid | ($existing_ids | index($pid))) != null) })) as $annotated_new
461
+ | ($annotated_new | map(select(.already_proposed == false) | del(.already_proposed))) as $to_append
462
+
463
+ | { ledger_lines: ($updated_existing + $to_append), final_proposals: $annotated_new }
464
+ ' 2>/dev/null)"
465
+
466
+ if [[ -z "$ledger_result" ]]; then
467
+ # Ledger stage failed to parse (should not happen); fall back to the unledgered current output.
468
+ printf '%s\n' "$current_output"
469
+ exit 0
470
+ fi
471
+
472
+ ledger_lines="$(printf '%s' "$ledger_result" | jq -c '.ledger_lines[]' 2>/dev/null)"
473
+ final_proposals="$(printf '%s' "$ledger_result" | jq -c '.final_proposals')"
474
+
475
+ if [[ -n "$ledger_lines" ]]; then
476
+ tmp_ledger="$(mktemp "${ledger_dir}/.learning-review-proposals.XXXXXX")" || tmp_ledger=""
477
+ if [[ -n "$tmp_ledger" ]]; then
478
+ printf '%s\n' "$ledger_lines" > "$tmp_ledger" && mv "$tmp_ledger" "$ledger_path"
479
+ fi
480
+ else
481
+ : > "$ledger_path"
482
+ fi
483
+
484
+ printf '%s' "$current_output" | jq -c --argjson proposals "$final_proposals" '.proposals = $proposals'