@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,331 @@
1
+ #!/usr/bin/env bash
2
+ # economics-record.sh — per-run kit-economics record emitter (#349, console ADR 0003 calls 1/2/3/6).
3
+ #
4
+ # Assembles ONE immutable `kontour.console.economics` v0.1 record per run — cost, time, iterations,
5
+ # and defects caught — the measurement substrate for the value proof (I32–I35). Modeled byte-for-byte
6
+ # on scripts/liveness/relay.sh (#295): source ${TELEMETRY_DIR}/lib/transport.sh, build the record with
7
+ # a single `jq -c` filter (valid JSON + \u-escaping of every untrusted field), then hand off to the
8
+ # SHARED `console_post_json` core. Do NOT hand-roll a curl; do NOT fork the transport.
9
+ #
10
+ # LOCAL-FIRST (ADR 0003 call 6): the record is ALWAYS written to the local economics log FIRST. The
11
+ # console POST is a detached, opt-in, best-effort fire that can NEVER block or fail a run — every
12
+ # failure path is a quiet `exit 0`. flow-agents emits a per-run FACT, never a rollup (call 3);
13
+ # aggregation/value is a console projection. Tenancy is stamped console-side from the principal (call
14
+ # 2); tenant_id here is self-description only.
15
+ #
16
+ # Ground-truth tokens/cost come from the `session.usage` event's .usage block (parsed upstream by
17
+ # usage_parse_transcript from each assistant message's .message.usage — never re-estimated). Defects
18
+ # join from the review sidecar critique.json; verdict/slug from state.json.
19
+ #
20
+ # Invoked (detached, best-effort) from scripts/telemetry/telemetry.sh after the session.usage event is
21
+ # emitted at run end. Usage:
22
+ # economics-record.sh '<session.usage-event-json>' [--state PATH] [--acceptance PATH]
23
+ # [--critique PATH] [--agents-dir DIR]
24
+ # (the session.usage event may also arrive on stdin instead of $1)
25
+ #
26
+ # --agents-dir DIR (#415 slice 1): the run's `<slug>/agents` directory. When present, the emitter
27
+ # assembles a `delegations[]` FACT — one entry per delegated sub-agent {agent_id, role, resolved_model,
28
+ # summary, escalated_from?} joined from each `<DIR>/<agent-id>/events.jsonl` (latest delegation/
29
+ # escalation event wins). This is a per-run FACT only (ADR 0003 call 3): cost-per-(role,model) rollups
30
+ # and per-delegation outcome are console projections / a later slice, NOT fabricated here — telemetry
31
+ # does not isolate per-sub-agent token usage today, so no per-delegation cost is invented.
32
+ set -uo pipefail
33
+
34
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit 0
35
+
36
+ # transport.sh sources ${TELEMETRY_DIR}/lib/redact.sh at load — point it at the telemetry lib dir,
37
+ # then reuse the SAME console POST core the telemetry mirror + liveness relay use (#356: one core,
38
+ # never forked). config.sh gives us TELEMETRY_DATA_DIR for the local log location.
39
+ export TELEMETRY_DIR="${TELEMETRY_DIR:-$SCRIPT_DIR}"
40
+ [[ -f "$TELEMETRY_DIR/lib/transport.sh" ]] || exit 0
41
+ # shellcheck source=/dev/null
42
+ source "$TELEMETRY_DIR/lib/config.sh" 2>/dev/null || true
43
+ # shellcheck source=/dev/null
44
+ source "$TELEMETRY_DIR/lib/transport.sh" 2>/dev/null || exit 0
45
+
46
+ # jq is mandatory — it is what guarantees valid JSON + \u-escaping of untrusted fields. No jq ⇒ no-op.
47
+ command -v jq >/dev/null 2>&1 || exit 0
48
+
49
+ # --- parse args: the session.usage event (positional or stdin) + optional sidecar paths -------------
50
+ usage_event=""
51
+ state_path=""
52
+ acceptance_path=""
53
+ critique_path=""
54
+ agents_dir=""
55
+ while [[ $# -gt 0 ]]; do
56
+ case "$1" in
57
+ --state) state_path="${2:-}"; shift 2 ;;
58
+ --acceptance) acceptance_path="${2:-}"; shift 2 ;;
59
+ --critique) critique_path="${2:-}"; shift 2 ;;
60
+ --agents-dir) agents_dir="${2:-}"; shift 2 ;;
61
+ --) shift ;;
62
+ *) [[ -z "$usage_event" ]] && usage_event="$1"; shift ;;
63
+ esac
64
+ done
65
+ # Fall back to stdin when no positional event was supplied.
66
+ if [[ -z "$usage_event" && ! -t 0 ]]; then
67
+ usage_event="$(cat 2>/dev/null || true)"
68
+ fi
69
+ [[ -z "$usage_event" ]] && exit 0
70
+
71
+ # --- read sidecars as JSON args (never the token source; join sources only) -------------------------
72
+ # Each is `null` when missing/unreadable/unparseable so the jq filter defaults cleanly.
73
+ read_json_or_null() {
74
+ local p="$1" out
75
+ [[ -n "$p" && -f "$p" ]] || { printf 'null'; return; }
76
+ out="$(jq -c '.' < "$p" 2>/dev/null)" || { printf 'null'; return; }
77
+ [[ -z "$out" ]] && out='null'
78
+ printf '%s' "$out"
79
+ }
80
+ state_json="$(read_json_or_null "$state_path")"
81
+ critique_json="$(read_json_or_null "$critique_path")"
82
+ # acceptance.json is read for future criteria/goal_fit joins; carried but not yet a required field.
83
+ acceptance_json="$(read_json_or_null "$acceptance_path")"
84
+
85
+ # --- delegations[] FACT (#415): per-sub-agent role/model + honestly-derived outcome ------------------
86
+ # Scan each per-agent event log; group ALL events by agent_id. Role/model come from the latest
87
+ # delegation/escalation event (an escalation supersedes the initial delegation, carrying escalated_from).
88
+ #
89
+ # `outcome` is derived ONLY from real recorded signals — NEVER fabricated (see harness-capability-matrix).
90
+ # Crucially these are ORCHESTRATOR-OBSERVABLE — the orchestrator knows what it dispatched, how often it
91
+ # re-dispatched, and how it corrected — so they hold WITHOUT peeking inside the sub-agent (which most
92
+ # harnesses forbid). dispatch_count = how many times this agent_id was (re)dispatched (delegation +
93
+ # escalation events); >1 means the orchestrator re-prompted it.
94
+ # diverged — an explicit supersession marker (kind=="supersession" or status=="diverged") exists.
95
+ # rework — an escalation happened, OR the orchestrator re-dispatched the agent (dispatch_count>1).
96
+ # failed — the latest terminal verdict event (kind evidence/verdict) for the agent is a FAIL.
97
+ # accepted — the latest terminal verdict event is a PASS (and no escalation/redispatch/supersession).
98
+ # unavailable— no terminal verdict was recorded for this agent on this harness. NOT assumed accepted.
99
+ # Per-delegation COST is still not attributed here — token usage IS sub-agent-internal and no runtime
100
+ # isolates it today (see the `signals` block); cost-per-(role,model) is a console projection.
101
+ # Any read/parse failure degrades to an empty array; never fatal (local-first, best-effort).
102
+ delegations_json='[]'
103
+ if [[ -n "$agents_dir" && -d "$agents_dir" ]]; then
104
+ assembled="$(
105
+ { for f in "$agents_dir"/*/events.jsonl; do [[ -f "$f" ]] && cat "$f"; done; } 2>/dev/null \
106
+ | jq -s -c '
107
+ [ .[] | select(type == "object") | select(.agent_id != null) ]
108
+ | group_by(.agent_id)
109
+ | map(
110
+ . as $all
111
+ | [ $all[] | select((.kind == "delegation" or .kind == "escalation")
112
+ and (.role != null) and (.model != null)) ] as $routes
113
+ | select(($routes | length) > 0)
114
+ | ($routes | sort_by(.timestamp // "") | last) as $l
115
+ | ($routes | length) as $dispatch_count
116
+ | (any($all[]; .kind == "escalation")) as $escalated
117
+ | (any($all[]; .kind == "supersession" or .status == "diverged")) as $diverged
118
+ | ([ $all[] | select(.kind == "evidence" or .kind == "verdict") ]
119
+ | sort_by(.timestamp // "") | last) as $verdict
120
+ | (($verdict.status // "") | ascii_downcase) as $vs
121
+ | (if $diverged then "diverged"
122
+ elif ($escalated or $dispatch_count > 1) then "rework"
123
+ elif ($verdict != null and $vs == "fail") then "failed"
124
+ elif ($verdict != null and $vs == "pass") then "accepted"
125
+ else "unavailable" end) as $outcome
126
+ | {
127
+ agent_id: $l.agent_id,
128
+ role: $l.role,
129
+ resolved_model: $l.model,
130
+ summary: ($l.summary // null),
131
+ dispatch_count: $dispatch_count,
132
+ outcome: $outcome
133
+ }
134
+ + (if ($l.escalated_from // null) != null then { escalated_from: $l.escalated_from } else {} end)
135
+ )
136
+ ' 2>/dev/null
137
+ )"
138
+ [[ -n "$assembled" && "$assembled" != "null" ]] && delegations_json="$assembled"
139
+ fi
140
+
141
+ tenant_self="${CONSOLE_TENANT_ID:-${FLOW_AGENTS_CONSOLE_TENANT:-}}"
142
+
143
+ # --- assemble the record with ONE jq -c filter (injection-safe, valid JSON) -------------------------
144
+ # Untrusted fields (task_slug, model names, finding text) flow through jq string handling so hostile
145
+ # control bytes are \u-escaped rather than emitted raw. NEVER printf/concatenate JSON.
146
+ record="$(printf '%s' "$usage_event" | jq -c \
147
+ --argjson state "$state_json" \
148
+ --argjson critique "$critique_json" \
149
+ --argjson acceptance "$acceptance_json" \
150
+ --argjson delegations "$delegations_json" \
151
+ --arg tenant "$tenant_self" '
152
+ . as $e
153
+ | ($e.usage // {}) as $u
154
+ # findings_by_severity: group critique.json .critiques[].findings[] on .severity; missing → low.
155
+ | ([($critique.critiques // [])[].findings[]?
156
+ | (.severity // "low" | ascii_downcase)]
157
+ | reduce .[] as $s (
158
+ {critical:0, high:0, medium:0, low:0};
159
+ if ($s=="critical" or $s=="high" or $s=="medium" or $s=="low")
160
+ then .[$s] += 1 else .low += 1 end)) as $sev
161
+ # distinct caught false-completions: findings flagged as such (category/type/false_completion).
162
+ | ([($critique.critiques // [])[].findings[]?
163
+ | select((.false_completion == true)
164
+ or ((.category // "") | ascii_downcase | test("false[_-]?completion"))
165
+ or ((.type // "") | ascii_downcase | test("false[_-]?completion")))]
166
+ | length) as $cfc
167
+ # verification verdict: explicit sidecar field wins; else null-derived → NOT_VERIFIED.
168
+ | (($critique.verification_verdict // $acceptance.verification_verdict
169
+ // $state.verification_verdict // $state.verify_verdict) // null) as $vv0
170
+ | (if ($vv0 == "PASS" or $vv0 == "FAIL" or $vv0 == "NOT_VERIFIED") then $vv0 else "NOT_VERIFIED" end) as $vv
171
+ # phase attribution: when state.json carries a phase, one bucket for it; else all → unattributed.
172
+ # The single bucket carries the full cost/time totals so the phase-sum invariant holds by construction.
173
+ | (($state.phase // null) | if . == null or . == "" then "unattributed" else . end) as $phase
174
+ | (($u.input_tokens // 0)) as $it
175
+ | (($u.output_tokens // 0)) as $ot
176
+ | (($u.cache_creation_input_tokens // 0)) as $cc
177
+ | (($u.cache_read_input_tokens // 0)) as $cr
178
+ | (($u.estimated_cost_usd // 0)) as $ec
179
+ | (($u.duration_s // 0)) as $wc
180
+ | {
181
+ schema: "kontour.console.economics",
182
+ version: "0.1",
183
+ run_id: ($e.session_id // "unknown"),
184
+ at: ($e.timestamp // null),
185
+ task_slug: ($state.task_slug // null),
186
+ model: ($u.model // null),
187
+ pricing_version: ($u.pricing_version // null),
188
+ cost: {
189
+ input_tokens: $it,
190
+ output_tokens: $ot,
191
+ cache_creation_input_tokens: $cc,
192
+ cache_read_input_tokens: $cr,
193
+ estimated_cost_usd: $ec,
194
+ by_model: ($u.by_model // [])
195
+ },
196
+ time: {
197
+ wall_clock_s: $wc,
198
+ human_wait_s: (($state.human_wait_s // $e.usage.human_wait_s) // 0)
199
+ },
200
+ phases: [ {
201
+ phase: $phase,
202
+ input_tokens: $it,
203
+ output_tokens: $ot,
204
+ cache_creation_input_tokens: $cc,
205
+ cache_read_input_tokens: $cr,
206
+ estimated_cost_usd: $ec,
207
+ wall_clock_s: $wc
208
+ } ],
209
+ iterations: {
210
+ count: (($state.iterations.count // $state.iteration_count) // 1),
211
+ route_backs: (($state.iterations.route_backs // $state.route_backs) // 0)
212
+ },
213
+ defects: {
214
+ gate_fires: (($critique.gate_fires // $state.gate_fires) // 0),
215
+ findings_by_severity: $sev,
216
+ caught_false_completions: (($critique.caught_false_completions // $cfc) // 0),
217
+ verification_verdict: $vv
218
+ },
219
+ delegations: ($delegations // []),
220
+ # signals: what telemetry the CURRENT runtime actually exposed, so a consumer can tell a real zero
221
+ # from a harness-blind gap (never fabricate — see docs/specs/harness-capability-matrix.md).
222
+ # per_delegation_tokens: no runtime isolates per-sub-agent token usage today → cost-per-delegation
223
+ # is unavailable; the console attributes at (role,model) granularity via cost.by_model instead.
224
+ # per_delegation_outcome: coverage of the outcome signal on THIS run — "full" (every delegation has
225
+ # a real outcome), "partial" (some do), "none" (delegations exist but none had a verdict/escalation),
226
+ # "n/a" (no delegations observed).
227
+ signals: {
228
+ runtime: ($e.agent.runtime // null),
229
+ per_delegation_tokens: false,
230
+ per_delegation_outcome: (
231
+ ($delegations // []) as $d
232
+ | ($d | length) as $n
233
+ | if $n == 0 then "n/a"
234
+ else ([ $d[] | select(.outcome != null and .outcome != "unavailable") ] | length) as $known
235
+ | if $known == 0 then "none" elif $known == $n then "full" else "partial" end
236
+ end)
237
+ },
238
+ tenant_id: (if $tenant == "" then null else $tenant end)
239
+ }' 2>/dev/null)" || exit 0
240
+ [[ -z "$record" || "$record" == "null" ]] && exit 0
241
+
242
+ # --- LOCAL-FIRST: append the record to the local economics log BEFORE any network attempt -----------
243
+ # This write must happen and must NOT depend on the POST. TELEMETRY_DATA_DIR comes from config.sh
244
+ # and is ALREADY the fully-qualified `.../.kontourai/telemetry` data dir (lib/config.sh:47), so only
245
+ # `economics.jsonl` is appended to it here — do NOT re-append `.kontourai/telemetry/...` on top of an
246
+ # already-fully-qualified TELEMETRY_DATA_DIR (that doubled the suffix, #469). The TELEMETRY_DIR-
247
+ # relative last resort below only applies when TELEMETRY_DATA_DIR truly isn't set (config.sh failed
248
+ # to source), in which case it legitimately needs the full `.kontourai/telemetry/economics.jsonl`
249
+ # suffix since it resolves from the workspace root, not a data dir.
250
+ if [[ -n "${TELEMETRY_ECONOMICS_LOG_FILE:-}" ]]; then
251
+ economics_log="$TELEMETRY_ECONOMICS_LOG_FILE"
252
+ elif [[ -n "${TELEMETRY_DATA_DIR:-}" ]]; then
253
+ economics_log="${TELEMETRY_DATA_DIR}/economics.jsonl"
254
+ else
255
+ economics_log="${TELEMETRY_DIR}/../../.kontourai/telemetry/economics.jsonl"
256
+ fi
257
+ mkdir -p "$(dirname "$economics_log")" 2>/dev/null || true
258
+ printf '%s\n' "$record" >> "$economics_log" 2>/dev/null || true
259
+
260
+ # --- BEST-EFFORT console relay: opt-in + only when a console endpoint is configured ------------------
261
+ # Off by default (local-first). Endpoint resolution copies relay.sh: explicit override, else base + /records.
262
+ case "${FLOW_AGENTS_CONSOLE_ECONOMICS_RELAY:-}" in
263
+ 1 | true | TRUE | yes | on) ;;
264
+ *) exit 0 ;;
265
+ esac
266
+
267
+ # --- suppression guard: never relay a record with neither attribution nor cost/token/defect signal
268
+ # An "unattributed" $0/no-token/no-defect record carries no signal for the console ROI view and
269
+ # only dilutes cross-run aggregates (firstPassRate, totalCostUsd, defectsCaught). Suppress the
270
+ # POST — NOT the local write above, which stays an unconditional immutable per-run fact — only
271
+ # when task_slug is empty/"unattributed" AND cost is zero AND there is NO token volume (input +
272
+ # output + cache all zero — cost legitimately degrades to null/0 on an unpriced/new model while
273
+ # TOKENS remain source-of-truth, so a token-volume leg is required or real usage on unpriced
274
+ # models is wrongly suppressed) AND there is no defect/gate signal. ANY of real attribution, real
275
+ # cost, real token volume, or a real defect signal is enough to still relay unchanged. This guard
276
+ # is itself best-effort and fails OPEN toward RELAYING: dropping a real record is worse than an
277
+ # extra empty one reaching the console, so we suppress ONLY when jq successfully computed an
278
+ # explicit `false` (a genuinely-empty record); a jq/read failure (non-zero exit, or any output
279
+ # other than the literal "false") falls through and RELAYS unchanged — never toward silently
280
+ # swallowing real data (see docs/specs/economics-record-contract.md).
281
+ has_signal="$(printf '%s' "$record" | jq -r '
282
+ ((.task_slug // "") | ascii_downcase) as $slug
283
+ | ($slug != "" and $slug != "unattributed") as $attributed
284
+ | ((.cost.estimated_cost_usd // 0) > 0) as $has_cost
285
+ | ((((.cost.input_tokens // 0) + (.cost.output_tokens // 0)
286
+ + (.cost.cache_creation_input_tokens // 0)
287
+ + (.cost.cache_read_input_tokens // 0)) > 0)) as $has_tokens
288
+ | ((.defects.gate_fires // 0) > 0) as $has_gate_fires
289
+ | ((.defects.caught_false_completions // 0) > 0) as $has_cfc
290
+ | (([.defects.findings_by_severity[]?] | add // 0) > 0) as $has_findings
291
+ | ($attributed or $has_cost or $has_tokens or $has_gate_fires or $has_cfc or $has_findings)
292
+ ' 2>/dev/null)"
293
+ jq_rc=$?
294
+ if [[ $jq_rc -eq 0 && "$has_signal" == "false" ]]; then
295
+ [[ "${TELEMETRY_ECONOMICS_DEBUG:-}" == "1" ]] && echo "economics-record: suppressing console relay (genuinely-empty record: no attribution/cost/tokens/defects)" >&2
296
+ exit 0
297
+ fi
298
+ # jq error (non-zero rc), or any signal present ($has_signal == "true") -> fall through and RELAY
299
+ # (fail-open toward relaying real data, never toward silently dropping a record on guard failure).
300
+
301
+ # The economics ingress is the shared kind-routed `<origin>/records` endpoint — NOT the same path as
302
+ # the telemetry endpoint (`.../api/telemetry/records`) — so CONSOLE_TELEMETRY_ENDPOINT_URL can only
303
+ # contribute an origin here (its path is stripped), never be reused verbatim (#469 review).
304
+ endpoint="${FLOW_AGENTS_CONSOLE_ECONOMICS_ENDPOINT_URL:-}"
305
+ if [[ -z "$endpoint" ]]; then
306
+ base="${FLOW_AGENTS_CONSOLE_URL:-${CONSOLE_TELEMETRY_URL:-${CONSOLE_URL:-}}}"
307
+ if [[ -z "$base" && -n "${CONSOLE_TELEMETRY_ENDPOINT_URL:-}" ]]; then
308
+ base="$(printf '%s' "$CONSOLE_TELEMETRY_ENDPOINT_URL" | sed -E 's#^(https?://[^/]+).*#\1#')"
309
+ fi
310
+ [[ -z "$base" ]] && exit 0
311
+ endpoint="${base%/}/records"
312
+ fi
313
+
314
+ # Auth reuses the env names console_post_json reads (CONSOLE_TELEMETRY_TOKEN / CONSOLE_TENANT_ID),
315
+ # accepting FLOW_AGENTS_CONSOLE_* aliases and an optional token file — never fatal on a missing token.
316
+ if [[ -z "${CONSOLE_TELEMETRY_TOKEN:-}" ]]; then
317
+ token_file="${FLOW_AGENTS_CONSOLE_TOKEN_FILE:-${CONSOLE_TELEMETRY_TOKEN_FILE:-}}"
318
+ if [[ -n "$token_file" && -r "$token_file" ]]; then
319
+ CONSOLE_TELEMETRY_TOKEN="$(tr -d '\r\n' < "$token_file" 2>/dev/null || true)"
320
+ export CONSOLE_TELEMETRY_TOKEN
321
+ fi
322
+ fi
323
+ if [[ -z "${CONSOLE_TENANT_ID:-}" && -n "${FLOW_AGENTS_CONSOLE_TENANT:-}" ]]; then
324
+ export CONSOLE_TENANT_ID="$FLOW_AGENTS_CONSOLE_TENANT"
325
+ fi
326
+
327
+ # Optional field redaction (default none — jq escaping already neutralizes injection), then the shared
328
+ # best-effort POST. console_post_json enforces the https/localhost endpoint-allow gate + detached fire.
329
+ processed="$(redact_event "$record" "${FLOW_AGENTS_CONSOLE_ECONOMICS_REDACT:-none}")"
330
+ console_post_json "$endpoint" "$processed"
331
+ exit 0
@@ -15,6 +15,10 @@ Options:
15
15
  --console-token-file PATH
16
16
  File containing the bearer token.
17
17
  --console-tenant ID Tenant identifier for hosted Console routing.
18
+ --no-economics-relay Write console_economics_relay=0 to opt out of the
19
+ automatic kit-economics relay (default-on once a
20
+ Console telemetry sink is configured; see
21
+ lib/config.sh).
18
22
  EOF
19
23
  }
20
24
 
@@ -105,6 +109,7 @@ main() {
105
109
  local console_token=""
106
110
  local console_token_file="${FLOW_AGENTS_CONSOLE_TOKEN_FILE:-${CONSOLE_TELEMETRY_TOKEN_FILE:-}}"
107
111
  local console_tenant="${FLOW_AGENTS_CONSOLE_TENANT:-${CONSOLE_TENANT_ID:-}}"
112
+ local no_economics_relay=0
108
113
  telemetry_sinks=()
109
114
  if [[ -n "${FLOW_AGENTS_TELEMETRY_SINKS:-}" ]]; then
110
115
  append_sinks "$FLOW_AGENTS_TELEMETRY_SINKS"
@@ -139,6 +144,10 @@ main() {
139
144
  console_tenant="$2"
140
145
  shift 2
141
146
  ;;
147
+ --no-economics-relay)
148
+ no_economics_relay=1
149
+ shift
150
+ ;;
142
151
  --help|-h)
143
152
  usage
144
153
  exit 0
@@ -181,7 +190,12 @@ main() {
181
190
  done
182
191
  [[ "$console_sink_count" -le 1 ]] || die "select at most one Console telemetry sink"
183
192
 
184
- if [[ -z "$console_url" && -z "$console_endpoint" && -z "$console_token" && -z "$console_tenant" ]]; then
193
+ if [[ "$console_sink_count" -gt 0 && -z "$console_tenant" ]]; then
194
+ echo "warning: install-console-config.sh: a Console telemetry sink was selected with no --console-tenant; records will be untenanted" >&2
195
+ fi
196
+
197
+ if [[ -z "$console_url" && -z "$console_endpoint" && -z "$console_token" && -z "$console_tenant" \
198
+ && "$no_economics_relay" -eq 0 ]]; then
185
199
  return 0
186
200
  fi
187
201
  if [[ -z "$console_url" && -z "$console_endpoint" && ( -n "$console_token" || -n "$console_tenant" ) ]]; then
@@ -199,15 +213,22 @@ main() {
199
213
  set_config_key "$conf" "console_telemetry_endpoint_url" "$console_endpoint"
200
214
  set_config_key "$conf" "console_telemetry_token" "$console_token"
201
215
  set_config_key "$conf" "console_tenant_id" "$console_tenant"
216
+ if [[ "$no_economics_relay" -eq 1 ]]; then
217
+ set_config_key "$conf" "console_economics_relay" "0"
218
+ fi
202
219
  if [[ -n "$console_token" ]]; then
203
220
  chmod 600 "$conf" 2>/dev/null || true
204
221
  fi
205
222
 
206
223
  local target="${console_endpoint:-$console_url}"
207
- if [[ -n "$console_tenant" ]]; then
208
- echo "Configured Console telemetry in $conf for $target (tenant: $console_tenant)"
224
+ if [[ -n "$target" ]]; then
225
+ if [[ -n "$console_tenant" ]]; then
226
+ echo "Configured Console telemetry in $conf for $target (tenant: $console_tenant)"
227
+ else
228
+ echo "Configured Console telemetry in $conf for $target"
229
+ fi
209
230
  else
210
- echo "Configured Console telemetry in $conf for $target"
231
+ echo "Recorded economics-relay opt-out in $conf"
211
232
  fi
212
233
  }
213
234
 
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env bash
2
+ # learning-review-decide.sh — record a human ratify/reject/defer decision against a
3
+ # learning-review-proposals.sh ledger entry, in place (#352).
4
+ #
5
+ # Never mutates kit/gate/flow config; only rewrites the matching ledger line's `decision`
6
+ # object (and `follow_on_ref`, ratify-only). Writes atomically (temp file + mv). Refuses,
7
+ # with no write, to: record a decision for an unknown proposal-id; accept more than one of
8
+ # --ratify/--reject/--defer; or accept --follow-on-ref without --ratify (AC3's
9
+ # ratify-before-follow-on ordering guard).
10
+ #
11
+ # Usage:
12
+ # learning-review-decide.sh <ledger-path> <proposal-id> --ratify|--reject|--defer \
13
+ # --decided-by NAME [--rationale TEXT] [--follow-on-ref REF]
14
+ set -uo pipefail
15
+
16
+ usage() {
17
+ cat <<'USAGE'
18
+ Usage: learning-review-decide.sh <ledger-path> <proposal-id> --ratify|--reject|--defer \
19
+ --decided-by NAME [--rationale TEXT] [--follow-on-ref REF]
20
+
21
+ Rewrites the matching ledger line's `decision` object in place (status, decided_by,
22
+ decided_at, rationale). `--follow-on-ref` is only accepted together with `--ratify`
23
+ (AC3: a follow-on work item may never be linked to an unratified proposal). Exits non-zero,
24
+ writing nothing, if: <proposal-id> is not found in <ledger-path>; more than one of
25
+ --ratify/--reject/--defer is given; or --follow-on-ref is given without --ratify.
26
+
27
+ See docs/specs/learning-review-proposals-contract.md for the full contract.
28
+ USAGE
29
+ }
30
+
31
+ command -v jq >/dev/null 2>&1 || { echo "learning-review-decide: jq is required" >&2; exit 1; }
32
+
33
+ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage; exit 0; fi
34
+
35
+ ledger_path="${1:-}"
36
+ proposal_id="${2:-}"
37
+ if [[ -z "$ledger_path" || -z "$proposal_id" ]]; then
38
+ echo "learning-review-decide: <ledger-path> and <proposal-id> are required" >&2
39
+ usage >&2
40
+ exit 1
41
+ fi
42
+ shift 2 || true
43
+
44
+ status=""
45
+ decided_by=""
46
+ rationale=""
47
+ follow_on_ref=""
48
+ have_follow_on_ref=0
49
+ decision_flags=0
50
+
51
+ while [[ "$#" -gt 0 ]]; do
52
+ case "$1" in
53
+ --ratify) status="ratified"; decision_flags=$((decision_flags + 1)); shift ;;
54
+ --reject) status="rejected"; decision_flags=$((decision_flags + 1)); shift ;;
55
+ --defer) status="deferred"; decision_flags=$((decision_flags + 1)); shift ;;
56
+ --decided-by) decided_by="${2:-}"; shift 2 ;;
57
+ --rationale) rationale="${2:-}"; shift 2 ;;
58
+ --follow-on-ref) follow_on_ref="${2:-}"; have_follow_on_ref=1; shift 2 ;;
59
+ -h|--help) usage; exit 0 ;;
60
+ *) echo "learning-review-decide: unknown argument: $1" >&2; usage >&2; exit 1 ;;
61
+ esac
62
+ done
63
+
64
+ if [[ "$decision_flags" -ne 1 ]]; then
65
+ echo "learning-review-decide: exactly one of --ratify/--reject/--defer is required" >&2
66
+ exit 1
67
+ fi
68
+
69
+ if [[ -z "$decided_by" ]]; then
70
+ echo "learning-review-decide: --decided-by NAME is required" >&2
71
+ exit 1
72
+ fi
73
+
74
+ if [[ "$have_follow_on_ref" -eq 1 && "$status" != "ratified" ]]; then
75
+ echo "learning-review-decide: --follow-on-ref requires --ratify (AC3: no follow-on before a recorded ratify decision)" >&2
76
+ exit 1
77
+ fi
78
+
79
+ if [[ ! -f "$ledger_path" ]]; then
80
+ echo "learning-review-decide: ledger not found: $ledger_path" >&2
81
+ exit 1
82
+ fi
83
+
84
+ found="$(jq -c --arg pid "$proposal_id" 'select(.proposal_id == $pid)' "$ledger_path" 2>/dev/null | head -n1)"
85
+ if [[ -z "$found" ]]; then
86
+ echo "learning-review-decide: proposal-id not found in ledger: $proposal_id" >&2
87
+ exit 1
88
+ fi
89
+
90
+ now_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
91
+ ledger_dir="$(dirname "$ledger_path")"
92
+ tmp_ledger="$(mktemp "${ledger_dir}/.learning-review-decide.XXXXXX")" || {
93
+ echo "learning-review-decide: failed to create temp file" >&2
94
+ exit 1
95
+ }
96
+
97
+ if ! jq -c \
98
+ --arg pid "$proposal_id" \
99
+ --arg status "$status" \
100
+ --arg decided_by "$decided_by" \
101
+ --arg decided_at "$now_iso" \
102
+ --arg rationale "$rationale" \
103
+ --argjson have_rationale "$([[ -n "$rationale" ]] && echo true || echo false)" \
104
+ --arg follow_on_ref "$follow_on_ref" \
105
+ --argjson have_follow_on_ref "$([[ "$have_follow_on_ref" -eq 1 ]] && echo true || echo false)" \
106
+ '
107
+ if .proposal_id == $pid then
108
+ .decision = {
109
+ status: $status,
110
+ decided_by: $decided_by,
111
+ decided_at: $decided_at,
112
+ rationale: (if $have_rationale then $rationale else null end)
113
+ }
114
+ | if $have_follow_on_ref and $status == "ratified" then .follow_on_ref = $follow_on_ref else . end
115
+ else . end
116
+ ' "$ledger_path" > "$tmp_ledger"; then
117
+ echo "learning-review-decide: failed to rewrite ledger" >&2
118
+ rm -f "$tmp_ledger"
119
+ exit 1
120
+ fi
121
+
122
+ mv "$tmp_ledger" "$ledger_path"
123
+ echo "learning-review-decide: proposal_id=$proposal_id decision.status=$status recorded" >&2
124
+ jq -c --arg pid "$proposal_id" 'select(.proposal_id == $pid)' "$ledger_path"