@aiwg/cli 2026.10.0 → 2026.10.3

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 (168) hide show
  1. package/agentic/code/providers/capability-matrix.yaml +270 -0
  2. package/agentic/code/providers/model-capabilities.v1.json +3091 -24
  3. package/bin/aiwg.mjs +86 -21
  4. package/dist/src/a2a/codecs.js +9 -0
  5. package/dist/src/artifacts/corpus-tools/vision-extract.js +105 -15
  6. package/dist/src/artifacts/repair.js +9 -4
  7. package/dist/src/channel/manager.mjs +59 -10
  8. package/dist/src/cli/child-process-result.js +40 -0
  9. package/dist/src/cli/env.js +38 -16
  10. package/dist/src/cli/flag-parsing.js +236 -0
  11. package/dist/src/cli/handlers/decision.js +297 -2
  12. package/dist/src/cli/handlers/diagnose.js +9 -2
  13. package/dist/src/cli/handlers/feedback.js +14 -5
  14. package/dist/src/cli/handlers/init.js +245 -30
  15. package/dist/src/cli/handlers/mc.js +23 -0
  16. package/dist/src/cli/handlers/models.js +69 -6
  17. package/dist/src/cli/handlers/ralph.js +3 -0
  18. package/dist/src/cli/handlers/refresh.js +16 -0
  19. package/dist/src/cli/handlers/regenerate.js +14 -0
  20. package/dist/src/cli/handlers/runtime-info.js +24 -9
  21. package/dist/src/cli/handlers/script-runner.js +16 -6
  22. package/dist/src/cli/handlers/session.js +396 -62
  23. package/dist/src/cli/handlers/sessions.js +146 -40
  24. package/dist/src/cli/handlers/setup-manifest.js +439 -39
  25. package/dist/src/cli/handlers/setup.js +139 -39
  26. package/dist/src/cli/handlers/subcommands.js +3 -1
  27. package/dist/src/cli/handlers/team.js +18 -8
  28. package/dist/src/cli/handlers/use.js +13 -0
  29. package/dist/src/cli/handlers/utilities.js +106 -23
  30. package/dist/src/cli/prompt-utils.js +72 -0
  31. package/dist/src/cli/provider-resolution.js +11 -0
  32. package/dist/src/config/aiwg-config.js +268 -4
  33. package/dist/src/config/cli.js +146 -19
  34. package/dist/src/config/dispatch-thresholds-runtime.mjs +38 -0
  35. package/dist/src/config/project-artifacts-health.mjs +14 -3
  36. package/dist/src/config/user-config.js +36 -10
  37. package/dist/src/decision/adapters/jev.js +80 -28
  38. package/dist/src/decision/ask.js +352 -0
  39. package/dist/src/decision/driver.js +4 -0
  40. package/dist/src/decision/entry.js +38 -6
  41. package/dist/src/decision/heldout/collector.js +10 -1
  42. package/dist/src/decision/index.js +3 -0
  43. package/dist/src/decision/interop.js +60 -8
  44. package/dist/src/decision/jev-credentials.js +121 -0
  45. package/dist/src/decision/ledger.js +839 -0
  46. package/dist/src/decision/validate.js +6 -3
  47. package/dist/src/effects/dispatch-verifier.js +252 -0
  48. package/dist/src/effects/index.js +1 -0
  49. package/dist/src/features/installer.js +2 -2
  50. package/dist/src/flow/graph-metadata.js +5 -0
  51. package/dist/src/gates/providers/loader.js +3 -1
  52. package/dist/src/installation/manager-command.mjs +1 -1
  53. package/dist/src/installation/manager.mjs +17 -2
  54. package/dist/src/lint/runner.js +2 -2
  55. package/dist/src/mcp/adapters/codex-runtime.js +360 -56
  56. package/dist/src/mcp/cli.mjs +587 -77
  57. package/dist/src/mcp/config-file.mjs +279 -0
  58. package/dist/src/mcp/config-layers.mjs +348 -0
  59. package/dist/src/mcp/credentials.mjs +484 -0
  60. package/dist/src/mcp/grok-build-config.mjs +16 -26
  61. package/dist/src/mcp/helpers.mjs +106 -2
  62. package/dist/src/mcp/omp-config.mjs +50 -29
  63. package/dist/src/mcp/profiles.js +97 -23
  64. package/dist/src/mcp/profiles.mjs +99 -27
  65. package/dist/src/mcp/registry.js +264 -57
  66. package/dist/src/mcp/registry.mjs +198 -45
  67. package/dist/src/mcp/server.mjs +403 -20
  68. package/dist/src/mcp/toml-string.mjs +9 -0
  69. package/dist/src/mcp/toml-strip-mcp.mjs +135 -0
  70. package/dist/src/mcp/tool-filters.mjs +357 -0
  71. package/dist/src/mcp/tools/agentic-sandbox.mjs +3 -1
  72. package/dist/src/mcp/tools/decision.mjs +58 -0
  73. package/dist/src/mcp/tools/orchestration.mjs +38 -0
  74. package/dist/src/mcp/tools/subsystems.mjs +39 -8
  75. package/dist/src/mcp/tools/worker-invoke.mjs +1539 -0
  76. package/dist/src/mcp/tools/worker-shell-control.mjs +258 -0
  77. package/dist/src/mcp/tools/worker-stdin-runtime.mjs +79 -0
  78. package/dist/src/memory/project-registry.js +2 -2
  79. package/dist/src/mission-protocol/codecs.js +639 -1
  80. package/dist/src/mission-protocol/types.js +110 -0
  81. package/dist/src/mission-protocol/worker-status.js +2 -0
  82. package/dist/src/models/config-loader.js +70 -13
  83. package/dist/src/models/grok-build-models.js +93 -41
  84. package/dist/src/models/model-capabilities.v1.json +3091 -24
  85. package/dist/src/models/model-discovery.js +862 -63
  86. package/dist/src/models/provider-policy.js +625 -11
  87. package/dist/src/models/qualification-digest.mjs +17 -0
  88. package/dist/src/models/resolver.js +259 -0
  89. package/dist/src/models/worker-direct-readiness.js +529 -0
  90. package/dist/src/models/worker-dispatch-offer.js +169 -0
  91. package/dist/src/models/worker-health-readiness.js +131 -0
  92. package/dist/src/models/worker-installation.mjs +879 -0
  93. package/dist/src/models/wrapper-route.js +18 -0
  94. package/dist/src/ops/registry.js +9 -14
  95. package/dist/src/plugins/standalone-packager.js +4 -3
  96. package/dist/src/process/bounded-child-process.mjs +387 -0
  97. package/dist/src/process/cli-command.mjs +75 -0
  98. package/dist/src/providers/capability-matrix.js +8 -0
  99. package/dist/src/providers/capability-matrix.yaml +270 -0
  100. package/dist/src/providers/grok-build-headless.js +43 -21
  101. package/dist/src/providers/provider-definitions.js +9 -1
  102. package/dist/src/providers/provider-definitions.mjs +9 -2
  103. package/dist/src/serve/executor-registry.js +16 -24
  104. package/dist/src/serve/fleet-mission-conductor.js +9 -0
  105. package/dist/src/serve/mission-conductor.js +9 -0
  106. package/dist/src/serve/shared-host-scheduler.js +2 -0
  107. package/dist/src/serve/worker-invocation.js +538 -0
  108. package/dist/src/serve/worker-stdin-frame.mjs +107 -0
  109. package/dist/src/sessions/adapter-registry.js +45 -0
  110. package/dist/src/sessions/adapters/generic.js +37 -3
  111. package/dist/src/sessions/adapters/grokbot-locator.js +111 -0
  112. package/dist/src/sessions/adapters/grokbot.js +423 -24
  113. package/dist/src/sessions/adapters/muse-workspace-scan.js +108 -0
  114. package/dist/src/sessions/adapters/muse.js +68 -29
  115. package/dist/src/sessions/adapters/pi.js +1 -0
  116. package/dist/src/sessions/batch-contracts.js +13 -1
  117. package/dist/src/sessions/batch-import.js +23 -42
  118. package/dist/src/sessions/candidates.js +47 -11
  119. package/dist/src/sessions/contracts.js +35 -0
  120. package/dist/src/sessions/coverage-preflight.js +134 -0
  121. package/dist/src/sessions/discovery.js +3 -0
  122. package/dist/src/sessions/grok-build-cli.js +67 -5
  123. package/dist/src/sessions/importer.js +24 -8
  124. package/dist/src/sessions/index.js +2 -0
  125. package/dist/src/sessions/repository.js +54 -13
  126. package/dist/src/sessions/workspace-discovery.js +99 -37
  127. package/dist/src/skills/adapters/clawhub.js +10 -8
  128. package/dist/src/skills/adapters/openclaw.js +10 -7
  129. package/dist/src/smiths/context-pipeline/parallelism-section.js +192 -3
  130. package/dist/src/smiths/context-pipeline/workspace-context.js +116 -9
  131. package/dist/src/smiths/hook-bridge/codex-translator.js +6 -5
  132. package/dist/src/smiths/mcpsmith/example.js +1 -1
  133. package/dist/src/smiths/toolsmith/runtime-discovery.mjs +10 -14
  134. package/dist/src/storage/backends/logseq.js +68 -7
  135. package/dist/src/storage/fortemi-qualification-receipt.js +2 -2
  136. package/dist/src/testing/generators/test-code-generator.js +57 -30
  137. package/dist/src/uhp/client.js +11 -1
  138. package/dist/src/uhp/mission.js +5 -0
  139. package/dist/src/update/checker.mjs +5 -5
  140. package/dist/src/update/notifier.mjs +54 -2
  141. package/dist/src/writing/fidelity.js +18 -1
  142. package/package.json +18 -1
  143. package/schemas/mission-v1.schema.json +2429 -0
  144. package/schemas/models/model-catalog.v1.schema.json +301 -0
  145. package/schemas/models/model-config-compatibility.v1.schema.json +281 -0
  146. package/schemas/models/model-policy.v1.schema.json +14 -0
  147. package/schemas/models/provider-model-capabilities.v1.schema.json +1066 -0
  148. package/tools/_resolve-impl.mjs +39 -0
  149. package/tools/agents/deploy-agents.mjs +15 -2
  150. package/tools/agents/providers/base.mjs +63 -43
  151. package/tools/agents/providers/grokbot.mjs +11 -12
  152. package/tools/agents/providers/hermes.mjs +6 -3
  153. package/tools/agents/providers/omp.mjs +5 -2
  154. package/tools/agents/providers/openclaw-translator.mjs +19 -5
  155. package/tools/providers/deepseek-harness-transport.mjs +358 -0
  156. package/tools/providers/omp-transport.mjs +192 -0
  157. package/tools/ralph-external/lib/claude-adapter.mjs +389 -0
  158. package/tools/ralph-external/lib/codex-adapter.mjs +432 -0
  159. package/tools/ralph-external/lib/deepseek-harness-adapter.mjs +397 -0
  160. package/tools/ralph-external/lib/factory-adapter.mjs +503 -0
  161. package/tools/ralph-external/lib/muse-adapter.mjs +364 -0
  162. package/tools/ralph-external/lib/omp-adapter.mjs +315 -0
  163. package/tools/ralph-external/lib/opencode-adapter.mjs +575 -0
  164. package/tools/ralph-external/lib/openhuman-adapter.mjs +75 -0
  165. package/tools/ralph-external/lib/pi-adapter.mjs +333 -0
  166. package/tools/ralph-external/lib/provider-adapter.mjs +690 -0
  167. package/tools/ralph-external/lib/worker-health.mjs +439 -0
  168. package/vscode-extension/schemas/aiwg.config.v1.json +1953 -0
@@ -0,0 +1,839 @@
1
+ /**
2
+ * Decision ask ledger — append-only call record for `aiwg decision ask`.
3
+ *
4
+ * Extends the existing decision CLI surface (#2946) rather than adding a new
5
+ * subsystem: the ledger is one JSON-lines file under the project AIWG
6
+ * artifact root (`.aiwg/decision/ledger.jsonl`, resolved the same way as
7
+ * `aiwg artifacts path`).
8
+ *
9
+ * Writing the ledger is intentionally decoupled from `askDecision()` itself
10
+ * (`./ask.js`): `recordDecisionAsk` is a small wrapper the CLI `ask` handler
11
+ * and the MCP `decision-ask` tool both call after they already have a
12
+ * `DecisionAskResult`, tagged with their transport. This keeps `askDecision`
13
+ * free of I/O and keeps internal reuse (e.g. `setupJev`'s `--verify` probe)
14
+ * out of the ledger: only completed `ask` calls — `answered` or `fallback` —
15
+ * are recorded. CLI/MCP usage errors (bad flags, oversized context) never
16
+ * reach a `DecisionAskResult` and are never recorded.
17
+ *
18
+ * Trust boundary (review r1/r2 #2946): this file defends against an
19
+ * ACCIDENTAL hazard — a stray symlink, a hard link, loose permissions left
20
+ * by another tool, a previous crash's partial write — all under the
21
+ * assumption that the artifact root is owned and written by the SAME local
22
+ * user running `aiwg`. It does not and cannot defend against a HOSTILE
23
+ * process running as that same user: such a process already has read/write
24
+ * access to everything the ledger could protect. There is no cross-process
25
+ * lock and no privilege boundary here, by design — adding one would be the
26
+ * "second subsystem" this feature deliberately avoids. Every filesystem
27
+ * check below uses only portable Node primitives (no `/proc`, no handle
28
+ * anchoring): `mkdir`/`lstat` for the directory, `open`+`fstat` for the
29
+ * file, with `O_NOFOLLOW` applied when the platform defines it (POSIX; not
30
+ * Windows). On a POSIX platform a small TOCTOU window exists between the
31
+ * directory `lstat` and the file `open`; on Windows the symlink check is a
32
+ * plain `lstat` pre-check with no kernel-enforced equivalent to
33
+ * `O_NOFOLLOW`. Both are accepted given the trust boundary above. An
34
+ * existing file or directory that fails a safety check is never modified —
35
+ * recording is skipped and reported once via `warn`, never "fixed" by
36
+ * chmod or truncation.
37
+ *
38
+ * Record size is capped at a level that stays below the conservative
39
+ * single-write atomicity boundary most platforms also honor for `PIPE_BUF`
40
+ * (4096 bytes on Linux): this makes a single, non-interleaved write the
41
+ * overwhelmingly likely outcome for one record, but it is not a guarantee.
42
+ * There is no cross-process append lock, so two concurrent writers can in
43
+ * rare cases interleave and produce one malformed line; `history`/`stats`
44
+ * detect and skip a malformed line (counted, never silently merged into a
45
+ * neighboring record) rather than assuming it is impossible.
46
+ *
47
+ * Recording — attribution resolution and the write — is bounded by a short
48
+ * deadline (`DECISION_LEDGER_RECORD_TIMEOUT_MS`). On timeout, the in-flight
49
+ * attempt is not cancelled (Node has no way to abort an in-flight `open`),
50
+ * so its outcome is genuinely unknown: the record may or may not eventually
51
+ * land. The warning says exactly that ("completion unknown"), and no
52
+ * further stage of that same attempt starts once the deadline has fired
53
+ * (checked before each later `await`), so a slow attribution lookup cannot
54
+ * itself delay the subsequent write past the deadline.
55
+ */
56
+ import { randomUUID, createHash } from 'node:crypto';
57
+ import { constants as fsConstants, createReadStream, existsSync } from 'node:fs';
58
+ import { lstat, mkdir, open } from 'node:fs/promises';
59
+ import { createInterface } from 'node:readline';
60
+ import { join } from 'node:path';
61
+ import { projectAiwgPath, projectAiwgWritePath } from '../config/project-artifacts.js';
62
+ import { redactText } from '../governance/redaction.js';
63
+ import { CanonicalJsonByteEstimator } from './context-plan.js';
64
+ import { resolveActiveProvider } from '../cli/provider-resolution.js';
65
+ export const DECISION_LEDGER_SCHEMA = 'aiwg-decision-ledger-record/v1';
66
+ const DECISION_LEDGER_DIR_NAME = 'decision';
67
+ const DECISION_LEDGER_FILE_NAME = 'ledger.jsonl';
68
+ const DECISION_LEDGER_KINDS = ['yes-no', 'choices', 'scale'];
69
+ const DECISION_LEDGER_STATUSES = ['answered', 'fallback'];
70
+ /**
71
+ * Per-decision-kind reasoning/output token allowance folded into the
72
+ * "tokens avoided" estimate for answered calls. Named and recorded with
73
+ * every estimate (not just referenced) so the figure can be recomputed
74
+ * later per the issue's proposal item 3.
75
+ */
76
+ export const DECISION_SAVINGS_REASONING_ALLOWANCE_TOKENS = {
77
+ 'yes-no': 50,
78
+ choices: 120,
79
+ scale: 80,
80
+ };
81
+ export const DECISION_SAVINGS_ESTIMATION_METHOD = 'aiwg-decision-savings/v1: answered = d06-canonical-json-utf8-ceil(question+context) + per-kind reasoning allowance; '
82
+ + 'fallback = -(jev usage.inputTokens + usage.outputTokens), 0 when no Jev call was attempted (e.g. not-configured)';
83
+ // ---------------------------------------------------------------------------
84
+ // Attribution identifiers — `--caller`/`--session` use a conservative
85
+ // identifier shape that still covers documented forms like
86
+ // `address-issues:worker-pick` (colon-separated namespacing) and
87
+ // `flow/step@host` (review r2 finding 11). The CLI rejects a value outside
88
+ // this shape with exit 2; the MCP tool drops it to `null` instead (there is
89
+ // no CLI exit code to report through MCP). Either way the value is also
90
+ // redacted before being persisted — the shape restricts delimiters, not
91
+ // content.
92
+ // ---------------------------------------------------------------------------
93
+ export const MAX_ATTRIBUTION_FIELD_LENGTH = 96;
94
+ export const ATTRIBUTION_FIELD_PATTERN = /^[A-Za-z0-9_.:/@-]{1,96}$/;
95
+ // ---------------------------------------------------------------------------
96
+ // Record-size and metadata-field caps. Every persisted string is redacted
97
+ // (never just the two opt-in text fields) and capped, so neither a
98
+ // secret-shaped attribution tag nor a long path/choice list can grow a
99
+ // record past the atomicity-safe size ceiling below.
100
+ // ---------------------------------------------------------------------------
101
+ /** Post-redaction cap for provider/sessionId/projectRoot/caller/model/answer-as-string. */
102
+ const MAX_METADATA_FIELD_BYTES = 256;
103
+ /** The ledger records at most this many choice ids, regardless of how many the `ask` call itself allowed (up to 255); see `choiceCount`/`choiceIdsTruncated`. */
104
+ export const MAX_RECORDED_CHOICE_IDS = 10;
105
+ /** Post-redaction cap per recorded choice id. */
106
+ const MAX_CHOICE_ID_BYTES = 80;
107
+ /** Post-redaction, post-truncation cap per opt-in text field (`--record-text`). */
108
+ export const MAX_RECORD_TEXT_BYTES = 600;
109
+ /**
110
+ * Hard cap on one serialized record line. Kept below 4096 (`PIPE_BUF` on
111
+ * Linux) as a conservative single-write-atomicity margin — see the module
112
+ * doc comment. Not a guarantee: see the module doc comment on concurrent
113
+ * writers.
114
+ */
115
+ export const MAX_LEDGER_RECORD_BYTES = 4000;
116
+ /** Bounds how long `recordDecisionAsk` waits on attribution resolution and the write. On timeout the outcome is reported as unknown, not "abandoned" — see the module doc comment. */
117
+ export const DECISION_LEDGER_RECORD_TIMEOUT_MS = 250;
118
+ const LEDGER_DIR_MODE = 0o700;
119
+ const LEDGER_FILE_MODE = 0o600;
120
+ const IS_WINDOWS = process.platform === 'win32';
121
+ const HAS_O_NOFOLLOW = typeof fsConstants.O_NOFOLLOW === 'number';
122
+ function errorMessage(error) {
123
+ return error instanceof Error ? error.message : String(error);
124
+ }
125
+ function sha256Hex(text) {
126
+ return createHash('sha256').update(text, 'utf8').digest('hex');
127
+ }
128
+ function truncateUtf8(text, maxBytes) {
129
+ const buffer = Buffer.from(text, 'utf8');
130
+ if (buffer.length <= maxBytes)
131
+ return { text, truncated: false };
132
+ let end = maxBytes;
133
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80)
134
+ end -= 1; // back out of a multi-byte UTF-8 sequence
135
+ return { text: buffer.subarray(0, end).toString('utf8'), truncated: true };
136
+ }
137
+ /** Redact, then cap to `maxBytes`. Applied to EVERY persisted string field, not only opt-in text. */
138
+ function sanitizeField(value, maxBytes = MAX_METADATA_FIELD_BYTES) {
139
+ if (value === null || value === undefined)
140
+ return null;
141
+ const redacted = redactText(value, { limits: { maxInputBytes: 64 * 1024 } }).text;
142
+ return truncateUtf8(redacted, maxBytes).text;
143
+ }
144
+ /** A choice is `id` or `id=description`; only the id is recorded (see parseChoice in ./ask.ts). */
145
+ function choiceId(entry) {
146
+ const separator = entry.indexOf('=');
147
+ return (separator < 0 ? entry : entry.slice(0, separator)).trim();
148
+ }
149
+ function fullChoiceIds(mode) {
150
+ if (mode.kind === 'yes-no')
151
+ return null;
152
+ return mode.kind === 'choices'
153
+ ? mode.choices.map(choiceId)
154
+ : Array.from({ length: mode.high - mode.low + 1 }, (_, index) => String(mode.low + index));
155
+ }
156
+ function choiceCountFor(mode) {
157
+ const ids = fullChoiceIds(mode);
158
+ return ids === null ? null : ids.length;
159
+ }
160
+ /** Index of the selected answer in the caller's full choice/scale list — identity that survives redaction (review r2 finding 7). */
161
+ function answerIndexFor(mode, rawAnswer) {
162
+ if (mode.kind === 'yes-no')
163
+ return null;
164
+ if (mode.kind === 'choices') {
165
+ if (typeof rawAnswer !== 'string')
166
+ return null;
167
+ const index = mode.choices.map(choiceId).indexOf(rawAnswer);
168
+ return index < 0 ? null : index;
169
+ }
170
+ if (typeof rawAnswer !== 'number' || !Number.isInteger(rawAnswer) || rawAnswer < mode.low || rawAnswer > mode.high)
171
+ return null;
172
+ return rawAnswer - mode.low;
173
+ }
174
+ /** Mirrors ask.ts's private contextMessage(): what is actually sent to Jev as input. */
175
+ function contextMessageForEstimate(input) {
176
+ return input.context === undefined || input.context === '' ? input.question : `${input.question}\n\nContext:\n${input.context}`;
177
+ }
178
+ function estimateSavings(input, result) {
179
+ if (result.status === 'answered') {
180
+ const inputTokens = new CanonicalJsonByteEstimator().estimate(contextMessageForEstimate(input)).tokens;
181
+ const allowance = DECISION_SAVINGS_REASONING_ALLOWANCE_TOKENS[input.mode.kind];
182
+ return { estimatedLlmTokensAvoided: inputTokens + allowance, estimationMethod: DECISION_SAVINGS_ESTIMATION_METHOD };
183
+ }
184
+ const jevCost = (result.usage?.inputTokens ?? 0) + (result.usage?.outputTokens ?? 0);
185
+ // Avoid signed zero (`-0`): a fallback that never called Jev (e.g. not-configured) costs nothing.
186
+ return { estimatedLlmTokensAvoided: jevCost === 0 ? 0 : -jevCost, estimationMethod: DECISION_SAVINGS_ESTIMATION_METHOD };
187
+ }
188
+ /**
189
+ * `--session`, then `AIWG_SESSION_ID`, else the literal "unknown" (no
190
+ * harness-specific session env helper exists in this repo yet). Either
191
+ * source is rejected down to "unknown" if it does not match the
192
+ * attribution identifier shape — this is the safety net for the
193
+ * environment-derived value, which (unlike `--session`) has no CLI exit
194
+ * code to reject through.
195
+ */
196
+ export function resolveDecisionSessionId(explicitSessionId, env) {
197
+ const explicit = explicitSessionId?.trim();
198
+ if (explicit && ATTRIBUTION_FIELD_PATTERN.test(explicit))
199
+ return explicit;
200
+ const fromEnv = env.AIWG_SESSION_ID?.trim();
201
+ if (fromEnv && ATTRIBUTION_FIELD_PATTERN.test(fromEnv))
202
+ return fromEnv;
203
+ return 'unknown';
204
+ }
205
+ function withinRecordBudget(record) {
206
+ return Buffer.byteLength(JSON.stringify(record), 'utf8') <= MAX_LEDGER_RECORD_BYTES;
207
+ }
208
+ /** Drops optional text fields (context first, then question) until the record fits the hard size cap. */
209
+ function enforceRecordBudget(record) {
210
+ if (withinRecordBudget(record))
211
+ return record;
212
+ if (record.contextText !== undefined) {
213
+ const { contextText, ...rest } = record;
214
+ record = { ...rest, textTruncated: true };
215
+ if (withinRecordBudget(record))
216
+ return record;
217
+ }
218
+ if (record.questionText !== undefined) {
219
+ const { questionText, ...rest } = record;
220
+ record = { ...rest, textTruncated: true };
221
+ }
222
+ return record;
223
+ }
224
+ /** Pure, synchronous and side-effect free: builds the record this call would append, fully sanitized. */
225
+ export function buildDecisionLedgerRecord(input, result, context) {
226
+ const timestamp = new Date((context.now ?? Date.now)()).toISOString();
227
+ const { estimatedLlmTokensAvoided, estimationMethod } = estimateSavings(input, result);
228
+ const caller = context.caller && ATTRIBUTION_FIELD_PATTERN.test(context.caller) ? context.caller : null;
229
+ const rawAnswer = result.answer;
230
+ const answer = typeof rawAnswer === 'string' ? sanitizeField(rawAnswer) : rawAnswer;
231
+ const fullIds = fullChoiceIds(input.mode);
232
+ const choiceCount = choiceCountFor(input.mode);
233
+ const choiceIdsTruncated = choiceCount !== null && choiceCount > MAX_RECORDED_CHOICE_IDS;
234
+ const choiceIds = fullIds === null ? null : fullIds.slice(0, MAX_RECORDED_CHOICE_IDS).map(id => sanitizeField(id, MAX_CHOICE_ID_BYTES) ?? '');
235
+ let record = {
236
+ schema: DECISION_LEDGER_SCHEMA,
237
+ id: (context.id ?? randomUUID)(),
238
+ timestamp,
239
+ transport: context.transport,
240
+ provider: sanitizeField(context.provider) ?? 'unknown',
241
+ sessionId: sanitizeField(context.sessionId) ?? 'unknown',
242
+ projectRoot: sanitizeField(context.projectRoot) ?? '',
243
+ decisionKind: input.mode.kind,
244
+ choiceIds,
245
+ answerIndex: answerIndexFor(input.mode, rawAnswer),
246
+ choiceCount,
247
+ choiceIdsTruncated,
248
+ questionDigest: sha256Hex(input.question),
249
+ contextDigest: input.context === undefined ? null : sha256Hex(input.context),
250
+ status: result.status,
251
+ answer,
252
+ confidence: result.confidence,
253
+ threshold: input.threshold ?? 0.8,
254
+ fallbackReason: result.status === 'fallback' ? result.reason : null,
255
+ model: typeof result.model === 'string' ? sanitizeField(result.model) : result.model,
256
+ usage: result.usage,
257
+ latencyMs: result.latencyMs,
258
+ estimatedLlmTokensAvoided,
259
+ estimationMethod,
260
+ caller: sanitizeField(caller, MAX_METADATA_FIELD_BYTES),
261
+ };
262
+ if (context.recordText) {
263
+ const question = truncateUtf8(redactText(input.question).text, MAX_RECORD_TEXT_BYTES);
264
+ record = { ...record, questionText: question.text, textTruncated: question.truncated };
265
+ if (input.context !== undefined) {
266
+ const contextField = truncateUtf8(redactText(input.context).text, MAX_RECORD_TEXT_BYTES);
267
+ record = { ...record, contextText: contextField.text, textTruncated: record.textTruncated || contextField.truncated };
268
+ }
269
+ }
270
+ return enforceRecordBudget(record);
271
+ }
272
+ function ledgerReadPath(projectRoot) {
273
+ return projectAiwgPath(projectRoot, DECISION_LEDGER_DIR_NAME, DECISION_LEDGER_FILE_NAME);
274
+ }
275
+ function defaultWarn(message) {
276
+ process.stderr.write(`${message}\n`);
277
+ }
278
+ /** At most one warning per call; the emitter itself never throws. */
279
+ function makeOnceWarn(custom) {
280
+ let warned = false;
281
+ return (message) => {
282
+ if (warned)
283
+ return;
284
+ warned = true;
285
+ try {
286
+ (custom ?? defaultWarn)(message);
287
+ }
288
+ catch {
289
+ // Warning emission must never escape — a throwing warn callback must not change the result.
290
+ }
291
+ };
292
+ }
293
+ function currentUid() {
294
+ return typeof process.getuid === 'function' ? process.getuid() : null;
295
+ }
296
+ /**
297
+ * Create (if needed) the owner-only `decision` directory and verify it is
298
+ * safe to use: a real directory (not a symlink), and on POSIX owned by the
299
+ * current user. An existing directory that fails either check is never
300
+ * modified (no chmod) — recording is skipped.
301
+ */
302
+ async function ensurePrivateDecisionDir(projectRoot, warn) {
303
+ const artifactRoot = projectAiwgWritePath(projectRoot);
304
+ const decisionDir = join(artifactRoot, DECISION_LEDGER_DIR_NAME);
305
+ try {
306
+ await mkdir(decisionDir, { recursive: true, mode: LEDGER_DIR_MODE });
307
+ }
308
+ catch (error) {
309
+ warn(`warning: cannot create decision ledger directory: ${errorMessage(error)}`);
310
+ return null;
311
+ }
312
+ let stat;
313
+ try {
314
+ stat = await lstat(decisionDir);
315
+ }
316
+ catch (error) {
317
+ warn(`warning: cannot stat decision ledger directory: ${errorMessage(error)}`);
318
+ return null;
319
+ }
320
+ if (!stat.isDirectory()) {
321
+ warn('warning: decision ledger directory is not a real directory (possibly a symlink); skipping record');
322
+ return null;
323
+ }
324
+ if (!IS_WINDOWS) {
325
+ const uid = currentUid();
326
+ if (uid !== null && stat.uid !== uid) {
327
+ warn('warning: decision ledger directory is not owned by the current user; skipping record');
328
+ return null;
329
+ }
330
+ }
331
+ return decisionDir;
332
+ }
333
+ /**
334
+ * Open (creating if needed) `ledger.jsonl` for append and verify it is safe
335
+ * to write: a regular file with exactly one hard link (rejects a
336
+ * pre-planted hard link to an unrelated file, which `O_NOFOLLOW` alone does
337
+ * not catch), and on POSIX owned by the current user with no group/other
338
+ * permission bits. `O_NOFOLLOW` is applied when the platform defines it
339
+ * (refusing a pre-planted symlink at open time on POSIX); on platforms
340
+ * without it (Windows) a prior plain `lstat` is the only symlink check —
341
+ * see the module doc comment on the trust boundary. A file that fails any
342
+ * check is closed untouched (no chmod, no write) and recording is skipped
343
+ * with exactly one warning naming the reason.
344
+ */
345
+ async function openLedgerFileForAppend(ledgerPath, warn) {
346
+ try {
347
+ const pre = await lstat(ledgerPath);
348
+ if (pre.isSymbolicLink()) {
349
+ warn('warning: decision ledger file is a symlink; skipping record');
350
+ return null;
351
+ }
352
+ }
353
+ catch (error) {
354
+ if (error.code !== 'ENOENT') {
355
+ warn(`warning: cannot stat decision ledger file: ${errorMessage(error)}`);
356
+ return null;
357
+ }
358
+ }
359
+ const flags = fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_WRONLY | (HAS_O_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
360
+ let handle;
361
+ try {
362
+ handle = await open(ledgerPath, flags, LEDGER_FILE_MODE);
363
+ }
364
+ catch (error) {
365
+ warn(`warning: cannot open decision ledger file: ${errorMessage(error)}`);
366
+ return null;
367
+ }
368
+ try {
369
+ const stat = await handle.stat();
370
+ if (!stat.isFile()) {
371
+ warn('warning: decision ledger path is not a regular file; skipping record');
372
+ await handle.close();
373
+ return null;
374
+ }
375
+ if (stat.nlink !== 1) {
376
+ warn('warning: decision ledger file has more than one hard link; skipping record');
377
+ await handle.close();
378
+ return null;
379
+ }
380
+ if (!IS_WINDOWS) {
381
+ const uid = currentUid();
382
+ if (uid !== null && stat.uid !== uid) {
383
+ warn('warning: decision ledger file is not owned by the current user; skipping record');
384
+ await handle.close();
385
+ return null;
386
+ }
387
+ if ((stat.mode & 0o077) !== 0) {
388
+ warn('warning: decision ledger file has group or other permission bits set; skipping record');
389
+ await handle.close();
390
+ return null;
391
+ }
392
+ }
393
+ return handle;
394
+ }
395
+ catch (error) {
396
+ await handle.close().catch(() => { });
397
+ warn(`warning: cannot verify decision ledger file safety: ${errorMessage(error)}`);
398
+ return null;
399
+ }
400
+ }
401
+ /** True when the ledger's last byte is not `\n` (so a prior partial/crashed write cannot absorb the next record). Read through a separate read-only handle; best-effort — any failure here means "do not prepend", never a hard error. */
402
+ async function needsLeadingNewline(ledgerPath) {
403
+ let handle;
404
+ try {
405
+ handle = await open(ledgerPath, fsConstants.O_RDONLY | (HAS_O_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0));
406
+ const stat = await handle.stat();
407
+ if (stat.size === 0)
408
+ return false;
409
+ const buffer = Buffer.alloc(1);
410
+ const { bytesRead } = await handle.read({ buffer, position: stat.size - 1, length: 1 });
411
+ return bytesRead !== 1 || buffer[0] !== 0x0a;
412
+ }
413
+ catch {
414
+ return false;
415
+ }
416
+ finally {
417
+ await handle?.close().catch(() => { });
418
+ }
419
+ }
420
+ async function defaultWriteChunk(handle, buffer) {
421
+ const { bytesWritten } = await handle.write(buffer);
422
+ return bytesWritten;
423
+ }
424
+ /** Writes every byte of `buffer`, looping on a short write rather than assuming one `write()` call finishes the job. */
425
+ async function writeFully(handle, buffer, writeChunk = defaultWriteChunk) {
426
+ let offset = 0;
427
+ while (offset < buffer.length) {
428
+ const written = await writeChunk(handle, buffer.subarray(offset));
429
+ if (!Number.isFinite(written) || written <= 0)
430
+ throw new Error('decision ledger: write made no progress (short write)');
431
+ offset += written;
432
+ }
433
+ }
434
+ /**
435
+ * Best-effort append. A failure here must never change the decision result
436
+ * or exit code — every failure is caught and reported through `warn` (at
437
+ * most once; see `makeOnceWarn`), never thrown. Returns `true` once the
438
+ * record is durably appended, `false` on a handled (warned) failure.
439
+ */
440
+ export async function appendDecisionLedgerRecord(record, options) {
441
+ const warn = makeOnceWarn(options.warn);
442
+ const line = `${JSON.stringify(record)}\n`;
443
+ if (Buffer.byteLength(line, 'utf8') > MAX_LEDGER_RECORD_BYTES) {
444
+ warn(`warning: decision ledger record exceeds the ${MAX_LEDGER_RECORD_BYTES}-byte size cap and was not written`);
445
+ return false;
446
+ }
447
+ const decisionDir = await ensurePrivateDecisionDir(options.projectRoot, warn);
448
+ if (!decisionDir)
449
+ return false;
450
+ const ledgerPath = join(decisionDir, DECISION_LEDGER_FILE_NAME);
451
+ const handle = await openLedgerFileForAppend(ledgerPath, warn);
452
+ if (!handle)
453
+ return false;
454
+ try {
455
+ const prefix = (await needsLeadingNewline(ledgerPath)) ? '\n' : '';
456
+ await writeFully(handle, Buffer.from(prefix + line, 'utf8'), options.writeChunk);
457
+ return true;
458
+ }
459
+ catch (error) {
460
+ warn(`warning: failed to append decision ledger record: ${errorMessage(error)}`);
461
+ return false;
462
+ }
463
+ finally {
464
+ await handle.close().catch(() => { });
465
+ }
466
+ }
467
+ /** Never throws: a provider-detection failure (missing config, unreadable capability matrix, …) degrades to "unknown". */
468
+ async function resolveProviderSafely(options) {
469
+ try {
470
+ const resolve = options.resolveProvider ?? resolveActiveProvider;
471
+ const resolution = await resolve({ cwd: options.projectRoot, explicitProvider: options.explicitProvider, env: options.env ?? process.env });
472
+ return resolution.provider ?? 'unknown';
473
+ }
474
+ catch {
475
+ return 'unknown';
476
+ }
477
+ }
478
+ async function performRecord(input, result, options, warn, flag) {
479
+ try {
480
+ const provider = await resolveProviderSafely(options);
481
+ if (flag.cancelled)
482
+ return null;
483
+ const sessionId = resolveDecisionSessionId(options.explicitSessionId, options.env ?? process.env);
484
+ const record = buildDecisionLedgerRecord(input, result, {
485
+ transport: options.transport,
486
+ provider,
487
+ sessionId,
488
+ projectRoot: options.projectRoot,
489
+ caller: options.caller,
490
+ recordText: options.recordText,
491
+ now: options.now,
492
+ id: options.id,
493
+ });
494
+ if (flag.cancelled)
495
+ return null;
496
+ const appended = await appendDecisionLedgerRecord(record, { projectRoot: options.projectRoot, warn, writeChunk: options.writeChunk });
497
+ return appended ? record : null;
498
+ }
499
+ catch (error) {
500
+ warn(`warning: failed to record decision ledger entry: ${errorMessage(error)}`);
501
+ return null;
502
+ }
503
+ }
504
+ const TIMED_OUT = Symbol('decision-ledger-record-timeout');
505
+ /**
506
+ * Build and append exactly one ledger record for one `askDecision()` call.
507
+ * Called by the CLI `ask` handler and the MCP `decision-ask` tool — the
508
+ * "same function" referenced in the issue's MCP-parity requirement — each
509
+ * tagging its own `transport`. Attribution resolution (provider/session) and
510
+ * the write both happen inside this one bounded step: never throws, warns at
511
+ * most once, and never waits longer than `timeoutMs` (default
512
+ * `DECISION_LEDGER_RECORD_TIMEOUT_MS`) before returning `null` to the
513
+ * caller. The in-flight attempt itself is not cancelled on timeout (Node
514
+ * cannot abort an in-flight filesystem call); its own later stages simply
515
+ * never start (checked via a cancellation flag before each one), and its
516
+ * eventual settlement — successful or not — is swallowed rather than
517
+ * reported. The caller's own decision result and exit code are therefore
518
+ * always untouched, whether this returns the appended record or `null`.
519
+ */
520
+ export async function recordDecisionAsk(input, result, options) {
521
+ const warn = makeOnceWarn(options.warn);
522
+ const timeoutMs = options.timeoutMs ?? DECISION_LEDGER_RECORD_TIMEOUT_MS;
523
+ const flag = { cancelled: false };
524
+ const work = performRecord(input, result, options, warn, flag);
525
+ let timer;
526
+ const timedOut = new Promise(resolve => {
527
+ timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs);
528
+ timer.unref?.();
529
+ });
530
+ try {
531
+ const outcome = await Promise.race([work, timedOut]);
532
+ if (outcome === TIMED_OUT) {
533
+ flag.cancelled = true;
534
+ warn(`warning: decision ledger recording exceeded ${timeoutMs}ms; completion unknown`);
535
+ work.catch(() => { }); // let the background attempt settle without an unhandled rejection
536
+ return null;
537
+ }
538
+ return outcome;
539
+ }
540
+ finally {
541
+ if (timer)
542
+ clearTimeout(timer);
543
+ }
544
+ }
545
+ // ---------------------------------------------------------------------------
546
+ // Read-back: history and stats. Both stream the ledger line-by-line rather
547
+ // than loading and splitting the whole file, validate each record strictly
548
+ // (reporting a `skipped` count rather than trusting a malformed line), and
549
+ // aggregate incrementally (no array spread into Math.min/max).
550
+ // ---------------------------------------------------------------------------
551
+ function isFiniteNumber(value) {
552
+ return typeof value === 'number' && Number.isFinite(value);
553
+ }
554
+ function isValidCalendarComponents(year, month1, day, hour, minute, second) {
555
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59)
556
+ return false;
557
+ const date = new Date(Date.UTC(year, month1 - 1, day));
558
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month1 - 1 && date.getUTCDate() === day;
559
+ }
560
+ // Our own writer always emits exactly this shape via Date#toISOString().
561
+ const RECORD_TIMESTAMP_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.\d{3}Z$/;
562
+ /** Strict: the exact `toISOString()` shape, with calendar/time components validated (review r2 finding 10/12). */
563
+ function isStrictRecordTimestamp(value) {
564
+ if (typeof value !== 'string')
565
+ return false;
566
+ const match = RECORD_TIMESTAMP_PATTERN.exec(value);
567
+ if (!match)
568
+ return false;
569
+ const [, y, mo, d, h, mi, s] = match;
570
+ return isValidCalendarComponents(Number(y), Number(mo), Number(d), Number(h), Number(mi), Number(s));
571
+ }
572
+ function isUnitIntervalOrNull(value) {
573
+ return value === null || (isFiniteNumber(value) && value >= 0 && value <= 1);
574
+ }
575
+ function isNonNegativeOrNull(value) {
576
+ return value === null || (isFiniteNumber(value) && value >= 0);
577
+ }
578
+ function isUsageShape(value) {
579
+ if (value === null)
580
+ return true;
581
+ if (typeof value !== 'object')
582
+ return false;
583
+ const usage = value;
584
+ return isNonNegativeOrNull(usage.inputTokens) && isNonNegativeOrNull(usage.outputTokens);
585
+ }
586
+ /** Validates required fields, enums, the timestamp and every numeric field (with meaningful ranges) before a record is trusted. */
587
+ function isDecisionLedgerRecord(value) {
588
+ if (!value || typeof value !== 'object')
589
+ return false;
590
+ const record = value;
591
+ if (record.schema !== DECISION_LEDGER_SCHEMA)
592
+ return false;
593
+ if (typeof record.id !== 'string' || record.id.length === 0)
594
+ return false;
595
+ if (!isStrictRecordTimestamp(record.timestamp))
596
+ return false;
597
+ if (record.transport !== 'cli' && record.transport !== 'mcp')
598
+ return false;
599
+ if (typeof record.provider !== 'string')
600
+ return false;
601
+ if (typeof record.sessionId !== 'string')
602
+ return false;
603
+ if (typeof record.projectRoot !== 'string')
604
+ return false;
605
+ if (!DECISION_LEDGER_KINDS.includes(record.decisionKind))
606
+ return false;
607
+ if (record.choiceIds !== null && !(Array.isArray(record.choiceIds) && record.choiceIds.every(id => typeof id === 'string')))
608
+ return false;
609
+ if (record.answerIndex !== null && !(Number.isInteger(record.answerIndex) && record.answerIndex >= 0))
610
+ return false;
611
+ if (record.choiceCount !== null && !(Number.isInteger(record.choiceCount) && record.choiceCount >= 0))
612
+ return false;
613
+ if (typeof record.choiceIdsTruncated !== 'boolean')
614
+ return false;
615
+ if (!/^[a-f0-9]{64}$/.test(record.questionDigest))
616
+ return false;
617
+ if (record.contextDigest !== null && !/^[a-f0-9]{64}$/.test(record.contextDigest))
618
+ return false;
619
+ if (!DECISION_LEDGER_STATUSES.includes(record.status))
620
+ return false;
621
+ if (!['string', 'boolean', 'number'].includes(typeof record.answer) && record.answer !== null)
622
+ return false;
623
+ if (!isUnitIntervalOrNull(record.confidence))
624
+ return false;
625
+ if (!isUnitIntervalOrNull(record.threshold) || record.threshold === null)
626
+ return false;
627
+ if (record.fallbackReason !== null && typeof record.fallbackReason !== 'string')
628
+ return false;
629
+ if (record.model !== null && typeof record.model !== 'string')
630
+ return false;
631
+ if (!isUsageShape(record.usage))
632
+ return false;
633
+ if (!isNonNegativeOrNull(record.latencyMs) || record.latencyMs === null)
634
+ return false;
635
+ if (!isFiniteNumber(record.estimatedLlmTokensAvoided))
636
+ return false; // may be negative
637
+ if (typeof record.estimationMethod !== 'string')
638
+ return false;
639
+ if (record.caller !== null && typeof record.caller !== 'string')
640
+ return false;
641
+ return true;
642
+ }
643
+ async function* streamDecisionLedgerLines(path) {
644
+ if (!existsSync(path))
645
+ return;
646
+ const rl = createInterface({ input: createReadStream(path, { encoding: 'utf8' }), crlfDelay: Infinity });
647
+ try {
648
+ for await (const line of rl)
649
+ yield line;
650
+ }
651
+ finally {
652
+ rl.close();
653
+ }
654
+ }
655
+ /** Streams parsed-and-validated records one at a time; invalid lines yield `null` so callers can count them as skipped. */
656
+ async function* streamDecisionLedgerRecords(projectRoot) {
657
+ for await (const line of streamDecisionLedgerLines(ledgerReadPath(projectRoot))) {
658
+ const trimmed = line.trim();
659
+ if (!trimmed)
660
+ continue;
661
+ try {
662
+ const parsed = JSON.parse(trimmed);
663
+ yield isDecisionLedgerRecord(parsed) ? parsed : null;
664
+ }
665
+ catch {
666
+ yield null;
667
+ }
668
+ }
669
+ }
670
+ function matchesFilters(record, filters) {
671
+ if (filters.since && new Date(record.timestamp).getTime() < filters.since.getTime())
672
+ return false;
673
+ if (filters.provider !== undefined && record.provider !== filters.provider)
674
+ return false;
675
+ if (filters.sessionId !== undefined && record.sessionId !== filters.sessionId)
676
+ return false;
677
+ if (filters.kind !== undefined && record.decisionKind !== filters.kind)
678
+ return false;
679
+ if (filters.status !== undefined && record.status !== filters.status)
680
+ return false;
681
+ if (filters.noCaller)
682
+ return record.caller === null;
683
+ if (filters.caller !== undefined && record.caller !== filters.caller)
684
+ return false;
685
+ if (filters.callerPrefix !== undefined && (record.caller === null || !record.caller.startsWith(filters.callerPrefix)))
686
+ return false;
687
+ return true;
688
+ }
689
+ /** Exposed for direct unit testing; `decisionLedgerHistory`/`decisionLedgerStats` apply this per streamed record instead. */
690
+ export function filterDecisionLedgerRecords(records, filters) {
691
+ return records.filter(record => matchesFilters(record, filters));
692
+ }
693
+ /** Reads, validates and filters the ledger offline, streaming line-by-line; no network, no Jev call. Tolerates a missing ledger file (empty result). */
694
+ export async function decisionLedgerHistory(projectRoot, filters = {}) {
695
+ const records = [];
696
+ let skipped = 0;
697
+ for await (const record of streamDecisionLedgerRecords(projectRoot)) {
698
+ if (!record) {
699
+ skipped += 1;
700
+ continue;
701
+ }
702
+ if (matchesFilters(record, filters))
703
+ records.push(record);
704
+ }
705
+ records.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
706
+ return { schema: 'aiwg-decision-ledger-history/v1', count: records.length, skipped, records, filters: { ...filters } };
707
+ }
708
+ function groupKeyFor(record, groupBy) {
709
+ if (!groupBy)
710
+ return 'all';
711
+ if (groupBy === 'provider')
712
+ return record.provider;
713
+ if (groupBy === 'session')
714
+ return record.sessionId;
715
+ if (groupBy === 'kind')
716
+ return record.decisionKind;
717
+ return record.caller; // may be null; kept distinct from the literal string "unattributed"
718
+ }
719
+ function newAccumulator() {
720
+ return {
721
+ totalCalls: 0, answered: 0, fallback: 0, fallbackCountByReason: {},
722
+ confidenceCount: 0, confidenceSum: 0, confidenceMin: null, confidenceMax: null,
723
+ jevInputTokens: 0, jevOutputTokens: 0, answeredJevInputTokens: 0, answeredJevOutputTokens: 0,
724
+ estimatedLlmTokensAvoidedTotal: 0,
725
+ };
726
+ }
727
+ function accumulate(acc, record) {
728
+ acc.totalCalls += 1;
729
+ const inputTokens = record.usage?.inputTokens ?? 0;
730
+ const outputTokens = record.usage?.outputTokens ?? 0;
731
+ if (record.status === 'answered') {
732
+ acc.answered += 1;
733
+ if (record.confidence !== null) {
734
+ acc.confidenceCount += 1;
735
+ acc.confidenceSum += record.confidence;
736
+ acc.confidenceMin = acc.confidenceMin === null ? record.confidence : Math.min(acc.confidenceMin, record.confidence);
737
+ acc.confidenceMax = acc.confidenceMax === null ? record.confidence : Math.max(acc.confidenceMax, record.confidence);
738
+ }
739
+ acc.answeredJevInputTokens += inputTokens;
740
+ acc.answeredJevOutputTokens += outputTokens;
741
+ }
742
+ else {
743
+ acc.fallback += 1;
744
+ const reason = record.fallbackReason ?? 'unknown';
745
+ acc.fallbackCountByReason[reason] = (acc.fallbackCountByReason[reason] ?? 0) + 1;
746
+ }
747
+ acc.jevInputTokens += inputTokens;
748
+ acc.jevOutputTokens += outputTokens;
749
+ acc.estimatedLlmTokensAvoidedTotal += record.estimatedLlmTokensAvoided;
750
+ }
751
+ function finalizeGroup(key, acc) {
752
+ const fallbackRateByReason = {};
753
+ for (const [reason, count] of Object.entries(acc.fallbackCountByReason)) {
754
+ fallbackRateByReason[reason] = acc.totalCalls ? count / acc.totalCalls : 0;
755
+ }
756
+ return {
757
+ key,
758
+ totalCalls: acc.totalCalls,
759
+ answered: acc.answered,
760
+ fallback: acc.fallback,
761
+ fallbackRate: acc.totalCalls ? acc.fallback / acc.totalCalls : 0,
762
+ fallbackCountByReason: acc.fallbackCountByReason,
763
+ fallbackRateByReason,
764
+ confidence: {
765
+ count: acc.confidenceCount,
766
+ mean: acc.confidenceCount ? acc.confidenceSum / acc.confidenceCount : null,
767
+ min: acc.confidenceMin,
768
+ max: acc.confidenceMax,
769
+ },
770
+ jevUsage: { inputTokens: acc.jevInputTokens, outputTokens: acc.jevOutputTokens },
771
+ estimatedLlmTokensAvoidedTotal: acc.estimatedLlmTokensAvoidedTotal,
772
+ netSavingsTokens: acc.estimatedLlmTokensAvoidedTotal - (acc.answeredJevInputTokens + acc.answeredJevOutputTokens),
773
+ };
774
+ }
775
+ /** Reads, validates, filters and aggregates the ledger offline, streaming line-by-line with incremental min/max; no network, no Jev call. Tolerates a missing ledger file (empty result). */
776
+ export async function decisionLedgerStats(projectRoot, filters = {}) {
777
+ const groups = new Map();
778
+ let skipped = 0;
779
+ for await (const record of streamDecisionLedgerRecords(projectRoot)) {
780
+ if (!record) {
781
+ skipped += 1;
782
+ continue;
783
+ }
784
+ if (!matchesFilters(record, filters))
785
+ continue;
786
+ const key = groupKeyFor(record, filters.groupBy);
787
+ let acc = groups.get(key);
788
+ if (!acc) {
789
+ acc = newAccumulator();
790
+ groups.set(key, acc);
791
+ }
792
+ accumulate(acc, record);
793
+ }
794
+ const sortedKeys = [...groups.keys()].sort((a, b) => (a ?? '').localeCompare(b ?? ''));
795
+ return {
796
+ schema: 'aiwg-decision-ledger-stats/v1',
797
+ groupBy: filters.groupBy ?? null,
798
+ skipped,
799
+ groups: sortedKeys.map(key => finalizeGroup(key, groups.get(key))),
800
+ filters: { ...filters },
801
+ };
802
+ }
803
+ // ---------------------------------------------------------------------------
804
+ // Since-date parsing — strict ISO-8601, offsetless timestamps and bare dates
805
+ // both interpreted as UTC, invalid calendar dates rejected (including when
806
+ // an explicit offset is present: the LOCAL calendar/time components are
807
+ // validated before the offset is applied).
808
+ // ---------------------------------------------------------------------------
809
+ export class DecisionLedgerSinceError extends Error {
810
+ constructor(message) { super(message); this.name = 'DecisionLedgerSinceError'; }
811
+ }
812
+ const BARE_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
813
+ const TIMESTAMP_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/;
814
+ /** Strict `--since` parser: bare `YYYY-MM-DD` and offsetless timestamps are UTC; invalid calendar dates are rejected regardless of whether an offset is present. */
815
+ export function parseDecisionLedgerSince(raw) {
816
+ const bare = BARE_DATE_PATTERN.exec(raw);
817
+ if (bare) {
818
+ const [, y, mo, d] = bare.map(Number);
819
+ if (!isValidCalendarComponents(y, mo, d, 0, 0, 0))
820
+ throw new DecisionLedgerSinceError(`--since is not a valid calendar date: ${raw}`);
821
+ return new Date(Date.UTC(y, mo - 1, d));
822
+ }
823
+ const timestamp = TIMESTAMP_PATTERN.exec(raw);
824
+ if (timestamp) {
825
+ const [, y, mo, d, h, mi, s, frac, offset] = timestamp;
826
+ // Validate the LOCAL calendar/time components as written, before any offset is applied —
827
+ // an explicit offset must not let an impossible date (e.g. 2026-02-30) through.
828
+ if (!isValidCalendarComponents(Number(y), Number(mo), Number(d), Number(h), Number(mi), Number(s))) {
829
+ throw new DecisionLedgerSinceError(`--since is not a valid calendar date: ${raw}`);
830
+ }
831
+ const normalized = `${y}-${mo}-${d}T${h}:${mi}:${s}${frac ?? ''}${offset ?? 'Z'}`;
832
+ const date = new Date(normalized);
833
+ if (Number.isNaN(date.getTime()))
834
+ throw new DecisionLedgerSinceError(`--since is not a valid timestamp: ${raw}`);
835
+ return date;
836
+ }
837
+ throw new DecisionLedgerSinceError(`--since must be an ISO-8601 date (YYYY-MM-DD) or timestamp: ${raw}`);
838
+ }
839
+ //# sourceMappingURL=ledger.js.map