skip to main content

Harvey — Architecture & UX Decision Log

Frozen 2026-09-23 (see decisions/0002-*.md). This file is history and no longer receives new entries. New decisions are authored as decision records: kb record new --project harvey --dir harvey/decisions, then indexed with kb ingest and kb index, so kb search reaches them. An entry below that is still load-bearing can be converted to a record on demand; there is no bulk backfill.

This file records significant architectural and UX decisions, their rationale, and known trade-offs. Entries were added at the top. Each entry names the decision, the context that prompted it, the chosen approach, the rejected alternatives, and the consequences.


2026-07-27 — knowledge.go extracted into its own module, github.com/rsdoiel/knowledge

Context. Step 2 of knowledge_db_merge_design.md’s sequencing (UUID migration → SQL merge → module split → JSON-L export, in that order), unblocked by the cross-machine merge completing earlier the same day (previous entry). Motivation: henry and antennaApp both want to write structured observations into agents/knowledge.db but were limited to raw sqlite3 CLI inserts, since the typed CRUD API was locked inside harvey’s own module. Full audit in the new repo’s module-extraction-design.md; phased plan in module-extraction-plan.md.

Decision. knowledge.go, knowledge_merge.go, knowledge_test.go, knowledge_merge_test.go, and cmd/kbmerge all moved verbatim into ~/Laboratory/knowledge (new repo, scaffolded via CMTools — codemeta.json, cmt --init go, LICENSE, go.mod). The only harvey-specific coupling found in the audit was OpenKnowledgeBase(ws *Workspace, customPath string)’s dependence on Workspace.AbsPath for default-path resolution — replaced with Open(dbPath string) plus a DefaultPath(root string) string helper, so the library no longer needs to know what a “workspace” is. IdentifierType (used by callers) stays in harvey — confirmed unreferenced by the knowledge-base code itself. Kept KnowledgeBase/OpenKnowledgeBase-style naming as-is rather than also cleaning up the knowledge.KnowledgeBase stutter in the same pass — deliberately deferred (see Rejected). harvey/go.mod uses replace github.com/rsdoiel/knowledge => ../knowledge until the new repo is pushed/tagged.

Two real gaps surfaced only once compiling harvey against the actual package boundary — not caught by the design doc’s audit, which could only inspect source, not enforce it: 1. commands_kb.go’s /kb observe called the then-unexported isValidKind/used ValidObservationKinds — the module’s own package boundary made this a real compile error, not a style nit. Exported as IsValidKind (with a proper doc comment) in the knowledge module. 2. commands_kb.go’s /kb show reached directly into KnowledgeBase’s unexported db field to hand-run a query fetching one observation by id — an encapsulation break that was completely invisible while both files shared a package, and only became a compile error once separated. Added ObservationByID (with its own test) to the knowledge module as the proper public API, rather than exposing the field.

A third issue — a leftover OpenKnowledgeBase(ws, "") call in commands_test.go’s newTestAgentWithKB — wasn’t caught by go build at all (test files aren’t part of a build), only surfaced via go vet/go test. Worth remembering for any future extraction of this shape: go build passing is not sufficient evidence that a package-boundary move is complete.

Rejected. Renaming KnowledgeBase/OpenKnowledgeBase to drop the knowledge.KnowledgeBase stutter (e.g. knowledge.Base/knowledge.Open) in the same pass — combining a repo move, a signature change, and a rename into one change makes it harder to tell which part caused a problem if one appears; a pure cosmetic rename is low-risk enough to do later, on its own. Fixing root CLAUDE.md’s separate, pre-existing experiments/experiment_* schema staleness (documented in knowledge_db_schema_stale.md) while touching nearby docs — confirmed that section doesn’t actually reference harvey/knowledge.go or any implementation file at all (it’s schema/SQL-only), so there was no “module location” reason to touch it; the older staleness issue is out of this change’s scope and wasn’t fixed.

Consequences. go build/go vet/go test clean in both repos; bin/harvey builds. Smoke-tested twice against real data: bin/kbmerge (moved, import-only change) merging two copies of the live production db produced identical counts to its pre-move behavior; a throwaway test exercising harvey’s actual initKnowledgeBase code path against the real, live agents/knowledge.db confirmed all 5 real projects (harvey, henry, antennaApp, sparqlset, audiobox) round-trip correctly through the moved code. harvey/CLAUDE.md’s three-silo table updated to name the external module. Still open: publishing github.com/rsdoiel/knowledge to GitHub and dropping the replace directive; the KnowledgeBase/Open stutter rename; JSON-L export (step 3 of the sequencing, deferred, unstarted).


2026-07-27 — Cross-machine knowledge.db merge completed and placed on both machines

Context. With both fixes verified against a copy of macmini’s data (previous entry), completed the actual cross-machine sync this whole thread existed for. First, over SSH: both ~/Laboratory (root repo) and ~/Laboratory/harvey on macmini-rd.local were behind origin/main (root repo was also ahead by one local, unpushed commit touching only knowledge_tool.md — no overlap with incoming changes). Fetched and diffed the incoming commits before pulling to confirm no conflicts; harvey fast-forwarded cleanly to 8aaaea4, root Laboratory merged cleanly (merge commit e418c8e, local commit preserved). Backed up macmini’s live agents/knowledge.db (agents/knowledge.db.pre-experiments-migration-20260727), then triggered both the concepts.created_at fix and the experiments→projects migration against it directly (same throwaway go run pattern used on the copy) — verified identical results to the earlier copy-based test (4 projects, 0 unmigrated observations, 0 null created_at, legacy tables intact).

Decision. scp’d macmini’s now-migrated live database to wren, ran bin/kbmerge -a <wren's live db> -b <macmini's live db> -force locally on wren, and independently verified the output before placing it anywhere: PRAGMA integrity_check clean, zero dangling rows across all six join-table combinations, every row has a non-empty uuid. Backed up wren’s pre-merge live database (agents/knowledge.db.pre-merge-20260727), replaced wren’s live agents/knowledge.db with the merged file, then scp’d that same merged file to replace macmini’s live agents/knowledge.db. Confirmed byte-identical via checksum on both machines afterward (06c7ecf...) — true parity, not just “close enough.”

Rejected. scp-ing wren’s original, unmerged knowledge.db directly over macmini’s — raised and rejected explicitly (by the user) before doing this, since it would have silently discarded macmini’s unique data (the newly-migrated harvey/sparqlset/audiobox project data) instead of merging it in. bin/kbmerge never overwrites its inputs by design, so parity requires placing its output on both ends, not just moving one input on top of the other.

Consequences. Both machines now have an identical, fully-merged agents/knowledge.db: 5 projects (harvey, henry, antennaApp, sparqlset, audiobox), 46 concepts, 181 observations — no data lost (88 from wren + 93 from macmini = 181, exact). Two real name/uuid collisions (harvey, henry — both genuinely the same real-world project independently tracked on each machine) were found and reconciled via -force, same as the dry-run test earlier. Backups exist on both machines at every step (.pre-cleanup-20260727, .pre-experiments-migration-20260727, .pre-merge-20260727) in case anything needs to be unwound. Not pushed to git: macmini’s root Laboratory merge commit e418c8e remains local-only (agents/knowledge.db itself is untracked, unaffected by this). Remaining steps from ../knowledge_db_merge_design.md’s sequencing — knowledge-base module extraction (step 3), JSON-L export (step 4, deferred) — are unstarted; not part of this session’s scope.


2026-07-27 — experiments → projects legacy data migration; real cross-machine bin/kbmerge succeeds end-to-end

Context. The concepts.created_at fix (previous entry) got a real bin/kbmerge attempt against macmini’s data past concepts and into the next, deferred blocker: no such column: o.project_id. Auditing this (rather than assuming “harvey renamed experiments to projects and forgot a migration”) found the actual origin: harvey/knowledge.go has used projects/project_id since its very first commit (5b1d802) — it has never contained the word “experiment.” The experiments/experiment_concepts/experiment_summary schema documented in root CLAUDE.md describes an older, hand-run sqlite3 convention that predates harvey’s SQL-backed implementation and was never updated once harvey superseded it (matches the existing knowledge_db_schema_stale.md memory, filed 2026-07-05, which flagged this as a docs staleness issue — this is the same gap showing up as live, real data on one machine). Checked every reachable knowledge.db (wren’s live file, harvey/agents/knowledge.db, harvey/.harvey/knowledge.db, and the copy of macmini’s file): only macmini’s has experiments tables, holding 93 real observations and 35 concepts across 4 real projects (harvey, sparqlset, henry, audiobox) invisible to current harvey (nothing in knowledge.go reads experiments). henry existed in both experiments (real data) and projects (an empty stub, created at some point after harvey started being used on that machine) — the central collision case the migration had to resolve correctly.

Full audit and confirmed decisions in experiments-migration-design.md; phased TDD plan in experiments-migration-plan.md.

Decision. migrateExperimentsToProjects (new function in knowledge.go, called from OpenKnowledgeBase right after kb is constructed): (1) no-op, checked via sqlite_master, if no experiments table exists — the common case, unaffected; (2) for each experiments row, match name against an existing projects row and reuse it if found (resolves the henry collision — its real observations attach to the pre-existing stub project, not a new duplicate), else create one via the existing AddProject path, folding language/repo_url into description (e.g. "Go — https://github.com/rsdoiel/Laboratory") since projects has no equivalent columns; (3) translate observations.experiment_id → the resolved project_id, guarded by project_id IS NULL for idempotency; (4) translate experiment_concepts → project_concepts via INSERT OR IGNORE, deduped by the join table’s own primary key. Added ALTER TABLE observations ADD COLUMN project_id INTEGER REFERENCES projects(id) to kbAlterStmts (nullable, no default — unlike the concepts.created_at case this needed no special handling, since a plain NULL default has no non-empty-table restriction). experiments/experiment_concepts/experiment_summary are never modified or dropped — inert once migrated, sidestepping observations.experiment_id’s NOT NULL REFERENCES experiments(id) constraint entirely rather than attempting a DROP COLUMN.

TDD-first: five tests in knowledge_test.go (TestOpenKnowledgeBase_MigratesExperimentsWithoutCollision, _WithCollision, _MigrationIdempotent, _MigrationLeavesLegacyTablesIntact, _NoExperimentsTableIsNoOp) — three confirmed red (missing project creation, missing project_id column) before implementation, all five green after, on the first implementation attempt.

Rejected. Dropping the legacy tables/column after migration — no correctness benefit, one-way, and avoids the DROP COLUMN/FK complication entirely (see design doc decision 5). Inventing a new projects column for language/repo_url — unnecessary for data that exists on exactly one legacy database and is superseded going forward by codemeta.json-based tracking anyway (folding into description was your explicit call over the alternative of just dropping it).

Consequences. go vet ./... and go test ./... clean. Verified against the real copy of macmini’s knowledge.db (macmini-rd.local-agents/knowledge.db on wren, migration triggered via a throwaway go run, then removed): 3 new projects created (harvey, sparqlset, audiobox) with folded descriptions, henry’s pre-existing description left untouched, zero unmigrated observations, legacy tables intact and unchanged, and the observation/concept counts per project (69/10/9/5, summing to the expected 93) match the pre-migration experiments data exactly. Then ran the real cross-machine merge this whole thread exists for: bin/kbmerge -a <wren's live db> -b <migrated macmini copy> -force succeeded completely — found and reconciled 2 real name/uuid collisions (harvey and henry, both genuinely the same real-world project independently tracked on each machine — harvey colliding was new information, only possible once macmini’s harvey project existed at all), merged 3+4→5 projects, 17+35→46 concepts, 87+93→180 observations (no data lost — 87+93 sums exactly to 180), and wrote a reviewable merged database. Not yet done: macmini’s actual live agents/knowledge.db still needs this same code fix applied via its own git pull + reopen (only the copy on wren was exercised); the merged output database has not been copied into place over either machine’s real file — bin/kbmerge deliberately never does that automatically, per its own design.


2026-07-27 — concepts.created_at lazy-migration gap fixed; real cross-machine bin/kbmerge attempt surfaces the next (deferred) blocker

Context. With both machines UUID-migrated (previous entry) and a real copy of macmini’s agents/knowledge.db placed on wren (macmini-rd.local-agents/knowledge.db, copied by the user via the agents/ directory tree), ran an actual bin/kbmerge -a <wren's live db> -b <macmini's copy> -force. It failed: knowledge: merge concepts from b: SQL logic error: no such column: created_at. Auditing macmini’s live schema (over SSH, before the copy existed) had already shown its concepts table predates created_at being added to the base CREATE TABLE DDL in schema (knowledge.go) — and kbAlterStmts had no ALTER TABLE concepts ADD COLUMN created_at ... to backfill it, an oversight from whenever created_at was added to the DDL (every other column added since then — identifier_type, identifier_value, uuid, origin_host — got a matching ALTER; created_at did not). Nothing in the live app ever reads concepts.created_at (confirmed via grep), so this was invisible until knowledge_merge.go’s hardcoded column list for the concepts copy step needed it.

Decision. Added ALTER TABLE concepts ADD COLUMN created_at DATETIME to kbAlterStmts (no DEFAULT CURRENT_TIMESTAMP clause) plus a new one-time, idempotent UPDATE concepts SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL run right after the kbAlterStmts loop. TDD-first per convention: TestOpenKnowledgeBase_BackfillsLegacyConceptsCreatedAt (knowledge_test.go) builds a raw legacy-shape concepts table (no created_at) matching macmini’s real schema, seeds one row, then opens it via OpenKnowledgeBase and asserts created_at is backfilled to a non-empty value — confirmed red (no such column: created_at) before the fix, green after.

A second, more interesting bug surfaced while diagnosing the first attempt at this fix, worth recording since it explains why the fix isn’t a plain ADD COLUMN ... DEFAULT CURRENT_TIMESTAMP: SQLite itself (not a driver quirk — reproduced identically against the real sqlite3 CLI) rejects ALTER TABLE ADD COLUMN with a non-constant default (CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, or any expression) whenever the table already has at least one row — confirmed by bisecting five probe tests (empty table: succeeds; table with one pre-existing row: Cannot add a column with non-constant default). Every real-world legacy concepts table this fix will ever run against necessarily has rows (that’s the whole reason the column is missing), so the original naive fix (mirroring the DDL’s own DEFAULT CURRENT_TIMESTAMP) would have failed at the exact moment it mattered and been masked again by kbAlterStmts’ blanket _, _ = db.Exec(stmt) error-swallowing — exactly the kind of silent-migration-failure this project’s convention of writing a red test against the real reproduction case first is meant to catch.

Rejected. Giving the ALTER statement DEFAULT CURRENT_TIMESTAMP directly — fails on any non-empty table, i.e. every real one. Rewriting AddConceptWithIdentifier’s INSERT to explicitly supply created_at — unnecessary: fresh tables (created via the current schema DDL) already get DEFAULT CURRENT_TIMESTAMP at the column-definition level for free, and concepts.created_at isn’t read anywhere in the live app today, so a legacy-altered table’s new rows briefly showing NULL until the next session’s backfill catches up is an acceptable, low-stakes gap for a column with no consumer — consistent with the project’s own precedent of using best-effort sentinels (origin_host="unknown") rather than fabricating precise provenance for pre-existing data.

Consequences. go vet ./... and go test ./... clean. Re-ran the real cross-machine merge after this fix and after re-triggering the lazy migration on the (pre-fix-snapshot) copy of macmini’s db: it now gets past the concepts step and fails at observations instead — no such column: o.project_id — which is the already-known, already-deferred experiments/experiment_id vs projects/project_id legacy-schema gap (see TODO.md; not addressed here, per explicit scope decision to fix only concepts.created_at today). This confirms the fix is complete and correctly scoped: the merge tool now fails at the next real, distinct, already-documented blocker, not a re-occurrence of this one. Macmini’s actual live agents/knowledge.db (as opposed to the copy on wren) still needs this same code fix applied via its own git pull + reopen — not done in this session, since it wasn’t asked for and only the copy was available for testing.


2026-07-27 — Correction: 2026-07-26 UUID migration work happened on wren, not macmini-rd.local; macmini migrated separately, genuinely divergent data confirmed

Context. Following the same-day cleanup below, checked macmini-rd.local over SSH for the same dangling-row issue and to confirm its UUID migration state, since TODO.md/DECISIONS.md’s 2026-07-26 entries said the migration was “verified on this machine (macmini-rd.local)”. Its live agents/knowledge.db had no uuid column at all — the migration had never run there. Cross-referencing timestamps (the bin/kbmerge build time, the db file mtime, and the origin_host='wren' rows already found on wren) makes clear the entire 2026-07-26 UUID-migration/merge-tool thread actually ran on wren; “macmini-rd.local” in the prior write-up was a mislabel (probably carried over from the design docs, which use macmini-rd.local as the example machine name throughout), not a real machine identification.

Decision. git pull on macmini (was one commit behind, clean tree, fast-forwarded to 394b499), then triggered the migration directly: wrote a throwaway cmd/migrateonce/main.go that calls harvey.NewWorkspace + harvey.OpenKnowledgeBase against the real /Users/rsdoiel/Laboratory workspace, ran it once with go run, then deleted the directory (git status confirmed clean afterward — no trace left in the repo). Chose this over launching bin/harvey interactively over SSH, which would have blocked on stdin.

Consequences. Macmini’s agents/knowledge.db is now migrated: all rows in projects/observations/concepts have non-empty uuids (macmini has no sources rows yet). Confirmed macmini has zero dangling join-table rows already — the historical-delete bug fixed below is specific to wren’s file, not universal. More importantly, macmini’s data is confirmed genuinely divergent from wren’s: macmini has only the henry project (35 concepts, 93 observations, 0 sources), while wren has harvey/henry/antennaApp (17 concepts, 85 observations, 6 sources) — a real test case for bin/kbmerge, not the same-machine stand-in used in the 2026-07-26 verification. Both machines are now genuinely migrated; the next actionable step for ../knowledge_db_merge_design.md’s sequencing is transferring one machine’s knowledge.db to the other (or running kbmerge with both files reachable from one machine) to do a first real cross-machine merge.


2026-07-27 — Dangling observation_concepts rows cleaned up on wren; UUID migration confirmed already applied there

Context. TODO.md’s Bugs section (added 2026-07-26 on macmini-rd.local) flagged 24 of 101 observation_concepts rows in agents/knowledge.db referencing observation_ids that no longer existed — found while manually verifying the merge tool. Picked up on wren (2026-07-27). Also relevant: TODO.md’s “Update next” item still said the UUID migration hadn’t run on wren yet; checking the live file directly showed otherwise.

Decision. Delete the dangling rows directly (DELETE FROM observation_concepts WHERE observation_id NOT IN (SELECT id FROM observations)), after taking a file-level backup (agents/knowledge.db.pre-cleanup-20260727), rather than adding any code path — the root cause is a historical write outside OpenKnowledgeBase’s single pinned connection (db.SetMaxOpenConns(1) plus PRAGMA foreign_keys=ON applied at every open means the current Go code enforces FKs correctly on every insert/delete it performs; a raw sqlite3 CLI session, which does not enable foreign_keys by default, is the more likely origin). No Delete* function exists anywhere in knowledge.go today, ruling out the application code as the source. Also checked the other five parent→child combinations (observation_concepts→concepts, project_concepts→projects, project_concepts→concepts, observation_sources→observations, observation_sources→sources) before stopping — all were already clean, so the fix is scoped to exactly the one table TODO.md named.

Additionally confirmed (same session): wren’s agents/knowledge.db already has the UUID migration fully applied — all 3 projects / 84 observations / 17 concepts / 6 sources have non-empty uuids, and 4 observations already carry origin_host='wren', meaning the lazy migration ran automatically here on some prior OpenKnowledgeBase call and new rows have been written since. TODO.md’s “hasn’t run there yet” language (written from macmini-rd.local, which cannot observe wren’s state) is now corrected in place.

Rejected. Adding a PRAGMA foreign_keys=ON guard or connection-init hook to knowledge.go — the existing SetMaxOpenConns(1) + schema-time pragma already makes every code path through OpenKnowledgeBase FK-safe; the dangling rows predate that, or came from outside the Go code entirely, so a code change would not have fixed anything that’s still capable of recurring through the app.

Consequences. This machine’s agents/knowledge.db now has zero dangling join-table rows across all six checked combinations. macmini-rd.local’s copy was not touched and needs the same check run there independently — its dangling-row count (if any) is unknown until someone runs the same query on that machine. Both machines are now confirmed UUID-migrated, which was the actual remaining blocker on a real cross-machine bin/kbmerge run (see TODO.md) — but that run itself still needs a physical transfer of one machine’s knowledge.db to the other, which hasn’t happened.


2026-07-26 — Cross-machine knowledge.db merge tool: SQL/ATTACH, cmd/kbmerge binary (design only, not yet implemented)

Context. Step 2 of the ../knowledge_db_merge_design.md sequencing, unblocked by the same-day UUID migration (previous entry): reconcile agents/knowledge.db after it drifts independently on macmini-rd.local and wren.local. Full design in merge-tool-design.md; phased plan (M1–M8, TDD-first) in merge-tool-plan.md.

Decision. MergeKnowledgeBases(aPath, bPath, mergedPath string) ([]MergeTableSummary, error) in a new knowledge_merge.go (package harvey): creates a fresh, fully-migrated mergedPath by reusing OpenKnowledgeBase, ATTACH DATABASEs both sources read-only, copies parent tables (projects, concepts, sources) with an explicit column list excluding id so dedup happens only via the real uuid/name UNIQUE constraints (not an accidental raw-id collision — see the correction below), translates observations.project_id and all three join tables through uuid joins on both sides, and reopens mergedPath once more afterward so the existing rebuildFTSIfNeeded (knowledge.go:811) populates the merged FTS index for free. A separate CollisionReport(aPath, bPath) ([]NameCollision, error) surfaces the known pre-migration edge case (same projects.name/concepts.name, two different uuids) before a real merge runs, rather than letting INSERT OR IGNORE resolve it silently. Orchestration (WAL checkpoint, file copy with -wal/-shm sidecars, backup, printing the summary) lives in a new cmd/kbmerge binary — core logic stays in the tested package, mirroring cmd/assay’s relationship to the root package.

Correction to the umbrella design, caught while writing this doc. ../knowledge_db_merge_design.md’s original parent-table step said INSERT OR IGNORE INTO merged.<table> SELECT * FROM a.<table>. Since id is INTEGER PRIMARY KEY AUTOINCREMENT in every table, a literal SELECT * would copy a’s raw ids into merged first; b’s copy running afterward could then be silently dropped by INSERT OR IGNORE whenever a b row’s raw id happened to coincide with an already-inserted a row — a false dedup on primary key, not the intended one on uuid. Fixed by using an explicit column list that excludes id everywhere, letting merged assign fresh autoincrement ids per row. Corrected in both documents.

Rejected. Nothing new — cmd/kbmerge as a standalone binary (vs. an interactive /kb merge slash command) follows directly from the umbrella design’s own stated precedent (cmd/assay’s relationship to the root package) and from this being an offline, cross-machine, file-level operation rather than something that belongs in a live chat session’s /kb surface (harvey-kb.7.md).

Consequences. Implemented same-day, M1–M7 per merge-tool-plan.md (M8 full verification still open): CollisionReport, MergeKnowledgeBases, and cmd/kbmerge all in place with passing tests. A real data-loss bug was caught during manual smoke-testing of cmd/kbmerge (not by the unit tests, which didn’t cover this until added after the fact): the originally-planned “-force resolves a collision by keeping the first-seen row” silently dropped every child row (observations, links) attached to the losing side’s row too, not just the duplicate metadata row — because those children’s uuid join has no merged parent left to attach to. Fixed with a new ReconcileCollisions(bPath, collisions) (M1.5, knowledge_merge.go), called by cmd/kbmerge before MergeKnowledgeBases whenever -force is set: rewrites b‘s colliding rows to share a’s uuid, so both sides’ children correctly merge into the one surviving parent. Reconfirmed via a real end-to-end smoke test through the actual bin/kbmerge binary (not just unit tests): before the fix, 2 source observations on a collided project merged down to 1; after, both survive. merge-tool-design.md and merge-tool-plan.md both corrected in place to document ReconcileCollisions and the collision-handling rationale. M8 complete same day: go build/go vet/go test ./... all clean; go test -race blocked by the same pre-existing Raspberry Pi ThreadSanitizer/VMA-width issue noted in the UUID migration entry (not a regression). Manual two-copy merge against the real, already-migrated agents/knowledge.db (two copies, one distinguishing observation added to each) merged cleanly with correct counts throughout — and surfaced an unrelated, pre-existing data-quality finding: 24 of 101 observation_concepts rows in the live database reference observation_ids that no longer exist (dangling links, most likely from some historical delete that ran without PRAGMA foreign_keys=ON active on that connection — SQLite’s FK enforcement is per-connection, not persisted in the file). MergeKnowledgeBases’s join-based copy naturally excludes unresolvable links rather than propagating them, so this didn’t affect the merge’s correctness, but the 24 orphaned rows still exist in the live, unmerged agents/knowledge.db today — logged separately as a finding observation in agents/knowledge.db itself; not fixed here, out of scope for this session. Still blocked on actually running the merge against wren.local’s real data until that machine also has the UUID migration applied (that machine hasn’t run it yet, per the previous entry’s consequences).


2026-07-26 — knowledge.go UUID migration: v7 ids, origin_host sentinel, index-after-backfill

Context. agents/knowledge.db drifts independently between macmini-rd.local and wren.local because every table uses a plain autoincrement integer PK with no cross-machine identity — full problem statement in ../knowledge_db_merge_design.md. This is step 1 of that document’s sequencing (UUID migration → SQL/ATTACH merge → module split → JSON-L export): give every row in projects, observations, concepts, sources a stable, globally-unique id at creation time so a future merge is an idempotent set-union instead of best-effort content matching. Design and decisions reviewed against the live code on this date — no drift since the 2026-07-25 design session (no uuid column exists yet, github.com/google/uuid still indirect in go.mod).

Decision. Add uuid TEXT NOT NULL DEFAULT '' to all four entity tables (not the join tables) via the existing kbAlterStmts lazy-migration idiom, backfilled in Go with UUID v7 (github.com/google/uuid, already a go.mod dependency) since SQLite has no built-in generator — mirrors the existing one-time source_doi → sources backfill already in OpenKnowledgeBase, idempotent via a WHERE uuid = '' guard. A UNIQUE index is created only after backfill completes, since ADD COLUMN sets every existing row to the same '' default at once and an earlier index would fail immediately. Also add origin_host, backfilling pre-existing rows to an explicit "unknown" sentinel — never the current machine’s os.Hostname(), which would misattribute rows actually created on the other machine to whichever machine happens to run the migration first; only new rows stamp the real hostname, at insert time. Full design in uuid-migration-design.md; phased implementation plan (W1–W6, TDD-first) in uuid-migration-plan.md.

Rejected. UUID v4 for some/all tables — v7’s time-ordering is a free bonus with no downside for internal ids, so there was no reason to mix schemes. Backfilling origin_host to the current machine’s real hostname — would silently misattribute every pre-migration row, including ones from the other machine, to whichever machine happens to migrate first.

Consequences. Implemented same-day, W1–W6 per uuid-migration-plan.md: nine new kbAlterStmts/kbSourcesAlterStmts entries, a backfillUUIDs helper (backfill + unique index per table, in OpenKnowledgeBase), and uuid.NewV7() + os.Hostname() stamped into all four Add* insert paths. github.com/google/uuid promoted from // indirect to a direct go.mod dependency. Nine new tests in knowledge_test.go (four backfill/idempotency, four insert-path, one conflict-preserves-uuid) confirmed red before implementation, green after. Verified against the real agents/knowledge.db on this machine: all 105 existing rows (3 projects, 79 observations, 17 concepts, 6 sources) backfilled to a non-empty uuid, and all 3 pre-existing projects correctly show origin_host="unknown". go test ./... and go vet ./... clean. go test -race could not be verified on this machine — ThreadSanitizer fails immediately with unsupported VMA range: Found 47 - Supported 48, a kernel/hardware limitation on this Raspberry Pi confirmed pre-existing (identical failure on unmodified main via git stash), unrelated to this change. No merge tool, module extraction, or JSON-L export yet — all remain out of scope and deferred per the sequencing already decided in ../knowledge_db_merge_design.md.


2026-07-25 — AI HAT+ 2 / hailo-ollama as a candidate fourth backend (proposed, not committed)

Context. AI Projects with Raspberry Pi (Hattersley & Jepson, 2026), reviewed in ../AI_Pi_Projects_Lessons.md, describes the Raspberry Pi AI HAT+ 2: a Hailo-10H NPU (40 TOPS, INT4) with its own dedicated 8GB onboard RAM, entirely separate from the host Pi’s RAM and CPU, supporting local LLMs/VLMs up to roughly 6B parameters. It’s driven by a local server process, hailo-ollama, exposing a REST API shaped like Ollama’s own (/api/pull, /api/chat, streaming JSON with the same done/eval_count/done_reason fields) on port 8000 instead of Ollama’s 11434 — specifically so the two don’t collide when both are present.

This is directly relevant to the CPU-only-inference pain already documented across TODO.md and this log (the GPULayers default fix, the multi-hour /read-chunks runs, the per-model timing benchmarking still in progress): a hardware accelerator with dedicated RAM that competes with the host for neither CPU nor memory would sidestep that class of problem structurally rather than by further tuning CPU-only settings.

Decision. Not yet committed. Given Harvey’s existing pluggable backend architecture (backend_ollama.go, backend_llamafile.go, backend_llamacpp.go, all wired through any-llm-go), adding AI HAT+ 2 support is plausibly closer to “point the existing Ollama client at http://localhost:8000 instead of :11434” than a new backend implementation — but this is unconfirmed. A design spike is needed to verify actual API compatibility (does hailo-ollama support everything Harvey’s backend_ollama.go calls, e.g. /api/show for model metadata, streaming semantics, embeddings) before any implementation work is scheduled. Model choice on Hailo is also constrained to Hailo-optimised models (e.g. deepseek_r1_distill_qwen:1.5b, llama3.2:3b, qwen2.5-coder:1.5b), which is a real trade-off against the open model selection Harvey’s other three backends allow.

Consequences. No code change. Logged here and as a hypothesis-kind observation in agents/knowledge.db (project harvey, tagged with a new hardware-acceleration concept) so the idea persists across sessions. Requires hardware Harvey’s current test environment doesn’t have (an AI HAT+ 2 board) before the design spike can actually run.


2026-07-13 — Preflight reachability check in RunChunkedAnalysis (TODO.md bug fix)

Context. TODO.md, found 2026-07-06 via a real debug-log trace: with an unreachable backend (server died mid-session), every chunk in RunChunkedAnalysis’s map phase fired its own “connection refused,” was recorded as a per-chunk failure (by design — a single chunk failure doesn’t abort the map phase, so genuinely-partial failures still get a synthesis attempt over what succeeded), and the run only actually errored out at the synthesis call. On a multi-chunk document this burns through the whole file’s worth of failed HTTP calls before surfacing what is really one root-cause problem. The TODO’s own suggested fix: “a cheap preflight reachability probe… at the top of cmdReadChunks/RunChunkedAnalysis.”

Decision. Added probeClientReachable(client LLMClient) (reachable, checked bool) (chunk_analyzer.go) — type-asserts to *AnyLLMClient and dispatches on ProviderName() to the same local health-probe functions already used elsewhere in the codebase (ProbeOllama for “ollama”; ProbeLlamafile/probeLlamaCpp — both via LlamafileHealthURL(ac.BackendURL()), since BackendURL() for these two already carries a /v1 suffix the bare-base-URL probes don’t expect — for “llamafile”/“llamacpp”). checked=false for cloud providers (no local health endpoint to probe) and any non-*AnyLLMClient (test doubles) — callers must treat checked=false as “nothing to verify,” never as a failure. Called once, at the very top of RunChunkedAnalysis, before the map-phase loop begins — not duplicated at each of the three call sites (cmdReadChunks, injectOrChunk, read_file’s chunking guard). All three already have their own reasonable error-handling path for a RunChunkedAnalysis error, so an early, single error return flows through each unmodified, giving the “fail immediately with one clear message” behavior the TODO asked for without touching any of the three call sites at all.

Rejected. Duplicating the probe at each call site instead of once inside RunChunkedAnalysis — would triple the code for no benefit, and risks the three copies drifting (the same class of problem this whole thread has repeatedly found and fixed elsewhere, e.g. the tool-call-dispatch and context-percentage duplication fixed in the harness-prerequisite-refactor cycle).

Consequences. TDD-first: TestRunChunkedAnalysis_FailsFastWhenBackendUnreachable (chunk_analyzer_test.go), using a real *AnyLLMClient pointed at a guaranteed-unreachable address (http://127.0.0.1:1) — confirmed red first (all chunks + synthesis attempted, ~4s of real HTTP timeouts before erroring, matching the exact reported symptom), then green (fails immediately, no chunk-processing progress lines at all). Every other existing RunChunkedAnalysis/read_file/injectOrChunk/cmdReadChunks test — all built on mockLLMClient/seqMockClient test doubles, not *AnyLLMClient — passes unmodified, since probeClientReachable correctly returns checked=false for them and the preflight is a no-op. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in every entry above. go vet ./... and gofmt -l both clean.


2026-07-13 — startAndUseLlamafile registers an adopted-server entry (closing the gap deferred alongside adoptExternalServer)

Context. TODO.md/DECISIONS.md (2026-07-05 entry) named this as a known, deferred gap at the time the sibling bug was fixed: startAndUseLlamafile (backend_startup.go) probes an already-running llamafile server’s actual served model; when that detected name differs from the configured entry, it adopts the detected name via useLlamafileEntry and returned immediately — never reaching the registration block (used by the non-adopted, fresh-start branch) that calls ProbeLlamafileContextLength and stores a LlamafileEntry. Same failure mode as the already-fixed adoptExternalServer: a model reached this way has ContextLength stuck at 0 for the whole session, since effectiveContextLimit() has no LlamafileEntry to read it from.

Decision. After useLlamafileEntry(useName, out) succeeds (its error is now checked, where it was previously returned directly), register a LlamafileEntry{Name: useName} — probing ContextLength via ProbeLlamafileContextLength — when a.Config.LlamafileEntryByName(useName) == nil. Path is left empty, exactly matching adoptExternalServer’s own precedent (llamafile.go:159, LlamafileEntry{Name: name, Path: ""}) — the adopted server’s actual model file path is genuinely unknown to Harvey, since it wasn’t the one that launched the process. Applied uniformly regardless of whether the detected name differed from the configured one (not gated to only the “differs” sub-case), matching the existing non-adopted branch’s own unconditional registration check — simpler than special-casing, and harmless when an entry already exists (the == nil guard is a no-op then, same as the pre-existing branch).

Rejected. Nothing — direct application of the same fix already reviewed and shipped for adoptExternalServer, to the one remaining call site with the identical gap.

Consequences. TDD-first: TestStartAndUseLlamafile_AdoptedDifferentName_RegistersEntry (llamafile_test.go), mirroring TestAdoptExternalServer_probesContextLength’s fake-/v1/models-server pattern exactly — confirmed red (no entry registered) then green (ContextLength probed and stored). Existing TestStartAndUseLlamafile_staleServerSameModel/_staleServerDifferentModel and all TestAdoptExternalServer_*/TestPickBackend_* tests pass unmodified. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in every entry above. go vet ./... and gofmt -l both clean.


2026-07-13 — read_file’s chunking guard also fixed for @mention (third occurrence of Direction D’s Bug 1)

Context. Flagged as a related-but-out-of-scope finding while fixing the chunk-analysis double-logging bug (previous entry, same date): builtin_tools.go’s read_file tool has its own pre-read chunking guard (fires when a requested file exceeds the context budget), and it carried the exact same cosmetic-only @mention defect as Bug 1 in subagent-dispatch-design.md (Direction D) — it parsed @mention out of the chunk instruction only to relabel ChunkAnalysisParams.Model for recording, then always dispatched RunChunkedAnalysis via a.Client, never the mentioned model. Direction D’s original audit found and fixed this same defect in cmdReadChunks and injectOrChunk but missed this third call site entirely — read_file’s chunking guard is a separate, less obvious trigger path (fires from a tool call mid-conversation, not a slash command) that wasn’t part of that audit’s search.

Decision. Applied the identical fix pattern used for the other two call sites: resolve @mention via resolveDispatchTarget(a, mentionName, a.Out) instead of just relabeling; use the resolved target.Client (introduced as a new local client variable, defaulting to a.Client) for the RunChunkedAnalysis call; defer target.Restore() when resolution succeeds. One naming note: the handler already had a local variable named resolved (the resolved absolute file path from resolveWorkspacePath), so the dispatch-resolution boolean is named dispatchOK here instead of resolved (the name used in cmdReadChunks) to avoid shadowing.

Rejected. Nothing — this is a direct, mechanical application of an already-established, already-reviewed fix pattern to a call site that was simply missed the first time.

Consequences. TDD-first: TestReadFile_MentionDispatchesToNamedModel (tools_test.go), mirroring TestCmdReadChunks_MentionDispatchesToNamedModel’s pattern (using attemptModelSwitchOverride to make the resolved client’s reply distinguishable from a.Client’s without spawning a real process) — confirmed red against the pre-fix code, then green. All existing read_file/chunking tests (TestReadFile_UnderBudget, TestReadFile_OverBudget_*, TestReadFile_ChunkingEnabled*) pass unmodified. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in every entry above. go vet ./... and gofmt -l both clean.

With this fix, all three of Harvey’s chunk-analysis trigger points (cmdReadChunks, injectOrChunk, read_file’s pre-read guard) now consistently resolve @mention to a real dispatch target via resolveDispatchTarget, closing out Bug 1 completely rather than leaving one call site with the original defect.


2026-07-13 — Chunk-analysis debug-log double-logging fixed via resolveDispatchTarget DebugLog wiring, not a caller-side deletion (TODO.md bug fix)

Context. TODO.md: RunChunkedAnalysis (chunk_analyzer.go) called dbg.LogLLMRequest/LogLLMResponse/LogError itself around every chunk and synthesis client.Chat call, while AnyLLMClient.chatInternal (anyllm_client.go) already logs the same request/response internally — agents/logs/*.jsonl got two near-identical lines per actual HTTP call. The TODO item’s own suggested fix was “drop the caller-side logging in chunk_analyzer.go, since chatInternal already logs unconditionally.”

Checked that premise before applying it. chatInternal’s logging goes through the client’s own DebugLog field (a.DebugLog.LogLLMRequest(...), anyllm_client.go:162) — nil-safe, but only correct when that field is actually wired to the real, open *DebugLog. Traced every path that can produce the client passed into RunChunkedAnalysis from cmdReadChunks/injectOrChunk (both use resolveDispatchTarget, Direction D): a.Client (the live agent client) is wired once at startup and is fine, but resolveDispatchTarget’s route-registry branch (clientForEndpoint) and local-switch branch (attemptModelSwitch’s Ollama case, newOllamaLLMClient with no DebugLog param) both construct fresh *AnyLLMClients that are never wired to anything. Naively deleting the caller-side logging per the TODO’s literal suggestion would have silently taken these paths from “logged once (by the caller)” to “logged never” — trading a real, reported bug for a quieter, worse one, specifically for @mention-dispatched chunk analysis.

Decision. Two-part fix. (1) dispatch_target.go gained wireDebugLog(client LLMClient, dbg *DebugLog) LLMClient — sets .DebugLog when client is an *AnyLLMClient, no-ops otherwise (e.g. test doubles) — applied at all three of resolveDispatchTarget’s return points (route branch, already-active branch, local-switch branch), so every client it can ever hand back is guaranteed to log its own calls correctly before any caller-side logging is removed. (2) Only then: removed the now-redundant dbg *DebugLog parameter entirely from RunChunkedAnalysis (not just its call sites — the parameter itself, since after removing every use of it the parameter would have been dead), and all six internal dbg.LogLLMRequest/LogError/LogLLMResponse call sites (map-phase request/error/response, reduce-phase request/error/response). Updated all three production callers (commands.go, file_inject.go, builtin_tools.go) and all eleven test call sites (chunk_analyzer_test.go) to match the new five-argument signature.

Rejected. Applying the TODO’s literal suggestion as-is (delete caller-side logging, leave resolveDispatchTarget unwired) — would have introduced a silent logging regression for the @mention-dispatch path, discovered only by tracing where client actually comes from rather than assuming the two call sites always see the same, already-wired client.

Consequences. TDD-first: TestResolveDispatchTarget_RouteEndpoint_WiresDebugLog, TestResolveDispatchTarget_LocalSwitch_WiresDebugLog (both confirmed red — ac.DebugLog was nil after resolution — then green). chunk_analyzer_test.go’s existing eleven tests (map/reduce/error/recorder/progress/message-isolation/system-prompt coverage) all pass unmodified in behavior, only their call signatures updated — confirming the removal is behavior-preserving for the actual chunk-analysis logic. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in every entry above. go vet ./... and gofmt -l both clean.

Related finding, not fixed (out of scope for this session’s ask): builtin_tools.go’s read_file tool has its own pre-read chunking guard with the exact same cosmetic-only @mention bug (Bug 1) that Direction D fixed for /read-chunks and injectOrChunk — it parses @mention to relabel ChunkAnalysisParams.Model for recording, but always dispatches via a.Client, never the mentioned model. Missed during Direction D’s original audit (only two of the three chunk-analysis-triggering call sites were found and fixed at the time). Noted in TODO.md as a follow-up; not fixed here since it wasn’t part of what was asked this session.


2026-07-13 — pickBackend startup picker gains .gguf/llama.cpp models (TODO.md bug fix)

Context. TODO.md: “I have both Llamafile and gguf models in ~/Models on my Mac, but the gguf models are not listed as an option (llama.cpp is installed).” Traced the exact code path before proposing a fix. selectBackend (backend_startup.go) has three cases: Case 0 adopts an already-running llama-server via a live health probe (Detect()); Case 2 (pickBackend) shows a combined picker whenever any llamafile is registered. Initial hypothesis was that Case 0’s gate condition (len(a.Config.LlamaCpp.ModelsDir) > 0 || a.Config.LlamaCpp.URL != "") was too narrow — checked more carefully and found this was wrong: DefaultConfig() always sets LlamaCpp.URL = "http://127.0.0.1:8081", and the YAML-merge logic (config.go:899-900) only ever overwrites it with a non-empty value, never clears it — so the gate is satisfied in practice regardless of what the user’s harvey.yaml says. Case 0 already runs its probe every startup; it just silently finds nothing when no server is running, which is the common case for a user who has .gguf files on disk but hasn’t started llama-server themselves. Corrected the diagnosis before implementing rather than fixing a gate that wasn’t actually broken.

The real, sole root cause: pickBackend’s option-building only ever considers registered llamafiles, disk-scanned unregistered llamafiles, and live Ollama models — there is no code path for .gguf/llama.cpp models at all. /model list//model use (aggregateModels, model_picker.go) already correctly combine all three backends (confirmed via the existing, passing TestCmdModelList_ShowsGGUFEntries) — the startup flow simply predates that later unification and was never brought in line with it.

Decision. Added a .gguf disk-scan branch to pickBackend, mirroring the existing “llamafiles found on disk but not yet registered” block exactly: scan via NewLlamaCppBackend(a.Config, agentsDir).ListModels(), dedupe against seenPaths, append as option{kind: "llamacpp", ...}. The option struct’s kind field now documents three values instead of two. When the user picks a "llamacpp" option, the chosen-option switch calls the already-existing startLlamaCppModelPath(a, chosen.path, out) (backend_llamacpp.go:462) — no new start handler needed, since that function already does everything required (stop current backend, start llama-server for the given path, wire a.Client/a.Backend, clear stale Llamafile.Active, probe+cache).

Rejected. “Fixing” Case 0’s gate condition — considered per the initial hypothesis, but tracing DefaultConfig()/the YAML merge logic showed there was no real bug there; would have been a no-op change justified by a mistaken premise.

Consequences. TDD-first: TestPickBackend_ListsGGUFModels (llamafile_test.go), mirroring TestPickBackend_ListsUnregisteredDiskModels’s existing pattern (temp ModelsDir, unreachable URLs elsewhere to isolate the picker’s output). Confirmed red first — the test’s initial failure was itself revealing: with no llamafile/Ollama/gguf options at all, pickBackend fell into its “no models found” first-run wizard branch rather than the numbered picker, confirming the gap was structural, not cosmetic. Confirmed green after the fix. Full suite green except one already-diagnosed, unrelated pre-existing failure (TestCmdModelList_ShowsLlamafileEntries) — noted below as a related but out-of-scope finding, not fixed as part of this change since it wasn’t asked for. go vet ./... and gofmt -l both clean.

Related finding, not fixed (out of scope for this session’s ask): TestCmdModelList_ShowsLlamafileEntries — the one failure that has been called “pre-existing, unrelated” throughout every prior entry in this log — turns out to have a diagnosable root cause, found while working in this same area: it doesn’t set a.Config.Llamafile.ModelsDir to an isolated temp directory the way TestPickBackend_ListsUnregisteredDiskModels/TestPickBackend_ListsGGUFModels do, so aggregateModels’s llamafile disk-scan leaks whatever real .llamafile files happen to sit in $HOME/Models on the machine running the test — which on this sandbox includes real entries from prior Henry-related benchmarking work, none of which is literally "bonsai-8b" (the fixture’s exact name), hence the mismatch. A one-line fix (isolate ModelsDir to t.TempDir(), matching the sibling tests) would resolve it; not applied here since it wasn’t part of what was asked.


2026-07-13 — Direction F (harness-template skills): blocked on a prerequisite that doesn’t exist — not pursued

Context. harness-engineering-exploration.md Direction F: bundle guides and sensor scaffolding together into an invokable skill, so loading a skill can provision sensor configuration into the workspace, not just inject markdown into the system prompt. The exploration doc itself flagged this as sequenced last, downstream of Direction A having more sensors to template — but auditing before designing found the gap is more fundamental than “not enough sensors yet.”

Checked commands_skill.go’s skillLoad (the /skill load implementation): it does exactly one thing — a.AddMessage("user", "[skill: name]\n\n"+skill.Body). Pure markdown-into-context injection; no hook exists anywhere for a skill to provision anything else into the workspace. More fundamentally, checked whether any sensor in Harvey today is config-driven at all: gofmt Check() and go vet (Direction A’s only two shipped sensors, computational-sensors-design.md) are both hardcoded — neither reads an external ruleset file. The only sensor-related field anywhere in Config is SensorInjectFormatFindings, a single visibility-gating bool, not a ruleset. There is no such thing as “sensor configuration” in Harvey yet, for any sensor — so “scaffold sensor config alongside guide markdown” has no artifact to scaffold, independent of how many sensors exist.

Decision. Confirmed with the user (2026-07-13): stop here, document the finding, build nothing. Direction F is blocked on a genuine prerequisite — a configurable sensor (e.g. a gocyclo-style complexity check with a per-project threshold, named as a Direction A candidate but never built) — not merely “more sensors to choose from.” Building F’s mechanism now, against a hypothetical config shape with nothing real to provision, was considered and rejected: it would be speculative infrastructure with no working example to validate its design against, the same anti-pattern already avoided in Direction B (item 1, content-aware guide selection — no path-scoped skill existed to gate) and Direction D (chunk analysis correctly left tool-free rather than force-fit into the consolidation).

Rejected. Expanding scope to build a configurable sensor first, then F on top of it — a real, legitimate path, but two increments’ worth of work the user did not ask for this session; left as the natural next step whenever Direction A itself grows a config-driven sensor for its own reasons. Designing F’s provisioning mechanism generically without a real sensor to prove it against — rejected for the same reason Direction E’s “mechanism only” choice was not applied here: Direction E had --rag-compare’s existing shape to mirror as a concrete template; F has no analogous existing mechanism in Harvey to mirror, so building it now would be closer to invention than extraction.

Consequences. No code changes. harness-engineering-exploration.md’s Directions A–F are now all either implemented (A, B, D, E) or explicitly resolved as blocked-not-pursued (F) — this closes out the thread this document opened. Revisit F only if/when a config-driven sensor exists in Harvey for independent reasons; at that point F would have a first real artifact to scaffold and the “mechanism only” pattern from Direction E likely applies then.


2026-07-13 — assay --guide-compare: the mechanism only, no real experiment run yet (Direction E)

Context. guide-compare-design.md / guide-compare-plan.md. Direction E’s own framing (“once with a guide’s prose included in the system prompt and once with it stripped but the corresponding sensor active”) assumes cmd/assay already has a system-prompt path to toggle. Auditing cmd/assay/main.go in full found it doesn’t: every dispatch is a bare []harvey.Message{{Role: "user", Content: promptText}} (main.go:939 pre-change) — no system message of any kind, ever. A second finding: checked HARVEY.md’s guide sections against Direction A’s two shipped sensors (gofmt Check(), go vet) — neither corresponds. Harvey’s formatting is silent/automatic (the guide never told the model to format manually, so there’s no prose to test removing), and nothing in HARVEY.md addresses go vet-style correctness bugs. The closest candidate (“Documentation conventions,” doc-comment presence) has no sensor at all. Confirmed with the user (2026-07-13): this increment builds the mechanism only — no real “does removing this guide hurt” experiment exists to run yet, and manufacturing one (new guide prose to match an existing sensor, or a new sensor to match existing prose, purely to have a subject) would defeat the point.

Decision. Added --guide-compare (bool) and --guide-file PATH (string) to cmd/assay, mirroring --rag-compare’s existing shape as closely as possible per the exploration doc’s own open question about the smallest viable assay change: no corpus-format changes at all — the guide is a whole-run, system-prompt-level concern, not a per-prompt one, so Prompt/Checks are untouched; only new top-level flags plus new AssayResults/report fields, exactly mirroring how RagCompare/RagChunks were added. New pure function buildGuideMessages(guideText, promptText string, useGuide bool) []harvey.Message (mirroring buildRAGContext’s existing extracted-helper pattern) — the “base” variant is byte-identical to today’s existing default dispatch; the “guide” variant prepends a system message built from --guide-file’s content. The guide file is treated as an opaque, externally-supplied string, never derived from Harvey’s own HARVEY.md/skills-catalog assembly — respects cmd/assay/main.go’s established “otherwise self-contained” boundary (CLAUDE.md). variant struct gained a third useGuide bool field rather than a new enum. --guide-compare requires --guide-file and is mutually exclusive with --rag-compare (a combined guide×RAG comparison would need a 2×2 variant matrix the report format isn’t shaped for — deferred, not this increment’s). writeReport gained a third branch (mirroring RagCompare’s structure exactly, minus the “chunks injected” row, which has no guide analog) in both the summary-table and per-prompt-detail sections.

Rejected. Sharing a writeReport helper between the RagCompare and GuideCompare branches — the two differ in header label and one row; extracting a helper now would mean parameterizing for a third mode that doesn’t exist yet, against this repo’s own “don’t design for hypothetical future requirements” convention. Re-deriving the guide text from Harvey’s own system-prompt assembly inside assay — would cross cmd/assay’s self-contained boundary.

Consequences. TDD-first: TestBuildGuideMessages_WithGuide, TestBuildGuideMessages_WithoutGuide, TestBuildGuideMessages_EmptyGuideTextFallsBackToPlain, and TestWriteReport_GuideCompare_RendersDeltaTable — the last of these is the first direct test of writeReport’s rendering logic at all (none existed before this, for RagCompare either); all confirmed red then green. Flag validation (--guide-compare requires --guide-file; mutual exclusivity with --rag-compare) stays inline in main(), uncovered by a unit test — matching the existing precedent that --rag-compare requires --rag-db’s validation has no test either, not introducing a new testing standard for one flag pair when the directly analogous existing one doesn’t have it. Manually verified end-to-end against a local fake OpenAI-compatible server: a 16-call run (8 prompts × base/guide) completed without error and produced a report with distinct Base/Guide sections; confirmed separately (by reading chatInternal/harvestMessagesToAnyllm in anyllm_client.go) that a Message{Role: "system"} is forwarded through the exact same, already-in-production code path Harvey’s normal chat turns use for Config.SystemPrompt — not new, unverified SDK territory. AssayHelpText (helptext.go) and the derived assay.1.md updated and regenerated via ./bin/assay -help > assay.1.md, matching the Makefile’s own generation recipe. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in every entry above. go vet ./... clean.

No real first experiment was run — deferred per the confirmed scope. A plausible future subject, noted but not built: HARVEY.md’s “Documentation conventions” section paired with a new doc-comment-presence sensor, neither of which exists yet.


2026-07-13 — Shared foldBackTurn helper (Direction D, Phase D) — Direction D complete

Context. subagent-dispatch-design.md / subagent-dispatch-plan.md Phase D, the last of four. cmdReadChunks (commands.go) and @mention route dispatch (terminal.go) both duplicated the same two-call pattern — a.AddMessage("user", ...) followed by a.AddMessage("assistant", ...) — to summarize a bounded sub-dispatch back into the main conversation. /plan next deliberately does not fold back into history at all (a plan step is a side-effecting action, not a chat turn) and was correctly left untouched, per the design doc’s original scope decision.

Decision. Added Agent.foldBackTurn(userContent, assistantContent string) (harvey.go, next to AddMessage) — two AddMessage calls. cmdReadChunks and the @mention route-dispatch branch both call it instead of inlining the pair independently.

Rejected. Nothing rejected — this phase was a pure mechanical extraction with no behavioral judgment calls, per the plan.

Consequences. No new tests needed, per the plan — this is a pure refactor of already-tested call sites. Confirmed every existing test that inspects a.History after a /read-chunks or @mention dispatch (TestCmdReadChunks_AddsResultToHistory, TestAtMentionDispatch_landsInHistory, plus the full read_chunks_cmd_test.go/routing_test.go suites) passes unmodified. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in every entry above. go vet ./... clean.

This completes all four phases of subagent-dispatch-design.md/subagent-dispatch-plan.md (Direction D). Summary across the full direction: fixed two real, previously-uncaught bugs found by auditing before designing (Bug 1: @mention in chunk-analysis instructions was cosmetic-only, contradicting documented design intent; Bug 2: /plan next’s post-step model restore read a config field the switch itself had already overwritten, so it never actually restored anything) via one shared, correctly-tested resolveDispatchTarget primitive (Phases A–B); then removed two further real duplications, each verified behavior-preserving by re-running every pre-existing affected test unmodified rather than assumed (Phases C–D). @mention’s local-model-switch fallthrough and chunk analysis’s tool-free design were both explicitly audited and correctly left out of scope throughout, rather than folded in for the sake of a larger-looking consolidation.

Directions E (guide-vs-sensor token-cost comparison via assay --guide-compare) and F (harness-template skills) from harness-engineering-exploration.md remain open.


2026-07-13 — Shared runBoundedTurn tool-vs-plain-chat helper (Direction D, Phase C)

Context. subagent-dispatch-design.md / subagent-dispatch-plan.md Phase C. cmdPlanNext (plan_cmd.go) and DispatchToEndpoint (routing.go) each independently implemented the same branch: build a ToolExecutor and call RunToolLoop when tools are enabled and a registry exists, otherwise call client.Chat directly. Chunk analysis (chunk_analyzer.go) does not have this branch at all — confirmed during Direction D’s initial audit that it’s deliberately tool-free (each chunk is read-only text synthesis; no side-effecting tools wanted), so it’s correctly excluded from this helper, not an oversight.

Decision. Added bounded_turn.go: runBoundedTurn(ctx, client, registry, cfg, useTools, messages, dbg, w) (updatedHistory []Message, stats ChatStats, err error). Takes the ToolExecutor path when useTools && registry != nil (returning the tool-extended history, or the unchanged input messages if RunToolLoop itself falls back because the client isn’t ToolCapable); otherwise a direct client.Chat call, returning nil for updatedHistory — the nil-vs-non-nil distinction is what cmdPlanNext’s planStepHadErrors(updatedHistory) depends on, and ranging over a nil slice is already a no-op matching the prior else-branch behavior exactly. Both call sites now delegate to it: cmdPlanNext passes a.Tools/a.Config.ToolsEnabled (identical gate to before); DispatchToEndpoint passes registry/ep.Tools (identical gate to before, dbg is nil since that function never had a DebugLog parameter to begin with).

Rejected. Extending chunk_analyzer.go’s RunChunkedAnalysis to also use runBoundedTurn — would add tool-calling to chunk analysis, a scope expansion the design doc explicitly ruled out, not a duplication this phase is meant to remove.

Consequences. TDD-first: TestRunBoundedTurn_UsesToolLoopWhenEnabled, TestRunBoundedTurn_PlainChatWhenToolsDisabled, TestRunBoundedTurn_PlainChatReturnsNilHistory (registry nil even with useTools=true still takes the plain-chat path) — confirmed red then green; no ToolCapable mock exists anywhere in the test suite (RunToolLoop‘s actual tool-calling branch has never been unit-tested, only its non-ToolCapable fallback), so these tests exercise the fallback path, which is still sufficient to prove runBoundedTurn picks the right branch and returns the right nil-vs-non-nil history — introducing a full ToolCapable test double was judged out of scope for this extraction. Both call sites’ full existing test suites (TestCmdPlanNext_modelAnnotationSwitches plus all plan_test.go tests; all routing_test.go tests including TestDispatchToEndpoint_toolsDisabledFallsBackToChat/_toolsNilRegistryFallsBackToChat) pass unmodified after the swap — direct evidence the extraction is behavior-preserving. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in every entry above. go vet ./... clean.

Phase D (shared fold-back helper) is next, per subagent-dispatch-plan.md.


2026-07-13 — @mention in chunk-analysis instructions actually dispatches now (Direction D, Phase B)

Context. subagent-dispatch-design.md / subagent-dispatch-plan.md Phase B — fixing Bug 1, confirmed during Direction D’s audit: cmdReadChunks (commands.go) and injectOrChunk’s auto-chunk path (file_inject.go) both parsed an @mention out of the chunk instruction, but only used it to relabel ChunkAnalysisParams.Model for recording — the actual RunChunkedAnalysis call always ran against a.Client unconditionally, contradicting the documented design intent (chunked-analysis-design.md, DECISIONS.md 2026-07-05: “Harvey’s existing @mention routing infrastructure routes each chunk analysis call to the named model”).

Decision. Both call sites now call resolveDispatchTarget(a, mentionName, out) (added in Phase A) after parsing the mention out of the instruction, and pass target.Client — not a.Client — to RunChunkedAnalysis. cmdReadChunks (one file, one resolution) uses defer target.Restore(). injectOrChunk loops over potentially multiple path-like tokens in one prompt, each independently mentionable — using defer there would queue every iteration’s restore until the whole function returns, leaving an earlier iteration’s switched-in model still active while a later iteration’s chunk analysis runs. Instead each iteration calls restore() explicitly right after its own RunChunkedAnalysis call (on both the success and error paths), keeping each token’s resolve/dispatch/restore cycle fully self-contained within its own loop iteration. An unresolvable @name in either path prints the same “not found — using current model” warning style already used elsewhere in the codebase (/plan next, @mention routing) rather than silently falling back.

Rejected. Using defer inside injectOrChunk’s loop for symmetry with cmdReadChunks — rejected once the multi-file-per-prompt case was considered: it would incorrectly leave a still-switched model active across unrelated later iterations instead of restoring immediately after each one’s own use.

Consequences. TDD-first: TestCmdReadChunks_MentionDispatchesToNamedModel and TestInjectOrChunk_LargeFileMentionDispatchesToNamedModel, both using attemptModelSwitchOverride (from Phase A) to make the resolved client’s replies distinguishable from a.Client’s without spawning a real process. Both confirmed red against the pre-fix code (temporarily reverted, confirmed the exact “default reply” output the bug predicts, then restored) before confirming green against the fix — direct proof this was a real, previously-uncaught bug, not a hypothetical. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in every entry above. go vet ./... clean.

Phases C (shared tool-vs-plain execution helper) and D (shared fold-back helper) are next, per subagent-dispatch-plan.md.


2026-07-13 — resolveDispatchTarget: one correct model-resolution primitive, fixing a real /plan next restore bug (Direction D, Phase A)

Context. subagent-dispatch-design.md / subagent-dispatch-plan.md Phase A. Auditing /plan next, /read-chunks/injectOrChunk’s auto-chunk path, and @mention registry dispatch in full (per harness-engineering-exploration.md Direction D) found three independent ways of resolving “which model should run this bounded turn” — and confirmed two of them are actually broken, not just duplicated. Bug 2 (this phase’s target): cmdPlanNext (plan_cmd.go) captured defaultModel := activeModelLabel(a) before a step-scoped model switch, but its restore call ignored that captured value entirely and instead read a.Config.Llamafile.Active live, at restore time — a field switchLlamafileModel (llamafile.go:220) had already overwritten to the step’s own model as part of performing the switch. Traced the exact mutation to confirm this wasn’t a hypothetical: the restore call was, by construction, always trying to “switch to” the model that was already active, never the one that was active before the step. Confirmed with the user (2026-07-13) that this is in scope to fix as part of Direction D rather than filed separately, since the fix is exactly the shared model-resolution primitive this direction already needs.

A second, deeper finding while designing the fix: a cheap reference-swap restore (saving a.Backend/a.Client and reassigning them back) would not have worked either — switchLlamafileModel stops the previous backend process before starting the new one, so by restore time the pre-step process is already dead. A correct restore must capture the pre-step identifying name before switching and genuinely relaunch through the same mechanism, paying a second real cold-start cost — not something this fix can make cheaper, only correct (consistent with the Hardware Reality Check’s framing that a model swap costs minutes each way).

Decision. Added dispatch_target.go: DispatchTarget{Client LLMClient, Restore func()} and resolveDispatchTarget(a *Agent, name string, out io.Writer) (DispatchTarget, bool, error), resolving in order: (1) a registered route endpoint (a.Routes.Lookup) — an independent LLMClient via clientForEndpoint, never touching a.Client/a.Backend, Restore a no-op; (2) name already matches the active local model (case-insensitive, added as genuine no-op avoidance — auditing found the original code never actually had this despite the misleading defaultModel capture) — Restore a no-op; (3) a real local switch via attemptModelSwitch, capturing prevLlamafileActive/prevOllamaModel before switching so Restore relaunches the correct name afterward, not a post-switch-clobbered one. Added Agent.attemptModelSwitchOverride func(name string, out io.Writer) (bool, error) (nil-checked, same pattern as the existing toolsReliableOverride) so tests can simulate a successful local switch without spawning a real llamafile/llama.cpp process. cmdPlanNext now calls resolveDispatchTarget instead of its own broken switch/restore block, using the resolved client for the step’s Chat/RunToolLoop call and calling restore() unconditionally afterward — including on the step-failure path, which the original code never restored on at all (a deliberate, disclosed small improvement: leaving the agent stuck on a temporary step model after a failed step is a footgun, not a behavior worth preserving).

A third finding, while writing the regression test: TestCmdPlanNext_modelAnnotationSwitches originally constructed its test PlanStep with Model: "phi-mini" set directly on the struct, title text left unannotated. Tracing why the rewritten test initially failed with zero switch calls (not merely “wrong restore name”) found that formatPlan (called by SavePlan) only ever writes s.Title verbatim and never re-emits Model separately — so a Model field set without a [model: NAME] marker embedded in Title is silently dropped on the SavePlan → LoadPlan round trip cmdPlanNext performs. This means the original (pre-existing) test never exercised the switch path at all, for a completely different reason than its own stated one (“switch will fail, server not running”) — it never even reached a real switch attempt. Fixed by embedding the marker in Title directly, matching the actual file format. Not treated as a bug to fix elsewhere (plan-file serialization is outside Direction D’s scope) — noted here as a real gap found, not silently worked around.

Rejected. Filing the two dispatch bugs as separate, unrelated fixes — rejected per the confirmed decision; both live in the exact code this direction already consolidates. Skipping restore on the step-failure path (matching the original’s silence there) — rejected as encoding an accident (never restoring on error) as if it were a deliberate choice, when no positive reason for it was found.

Consequences. TDD-first: TestResolveDispatchTarget_RouteEndpoint_NoAgentMutation, TestResolveDispatchTarget_AlreadyActive_SkipsSwitch, TestResolveDispatchTarget_LocalSwitch_RestoreReturnsToOriginal (the direct regression test for Bug 2 — simulates switchLlamafileModel’s exact mutation via the override and asserts Restore() switches back to the pre-step name, not the step’s own), TestResolveDispatchTarget_UnknownName_ReturnsNotFound — all confirmed red then green. TestCmdPlanNext_modelAnnotationSwitches was rewritten to actually assert on switch/restore behavior instead of only “doesn’t panic”; verified it fails against the pre-Phase-A cmdPlanNext code (temporarily reverted, confirmed red, restored) before confirming it passes against the fix. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in every entry above (untouched by this change). go vet ./... clean.

Phase B (wiring resolveDispatchTarget into /read-chunks/injectOrChunk, fixing Bug 1) is next, per subagent-dispatch-plan.md.


2026-07-13 — Cause-neutral gofmt Check() message (Direction B, Phase B)

Context. feedforward-budget-design.md / feedforward-budget-plan.md Phase B. The original plan proposed sharpening PipeExternalFormatter.Check’s generic "content differs from formatted output" message to name what the design doc assumed was the realistic residual cause: a syntax error gofmt can’t parse, on the reasoning that Check() only fires after applyAutoFormat already ran and presumably fixed everything else.

Correction found before implementing, by grepping every .go file for the exact string being replaced (not just the formatter’s own test file). Two existing Direction-A tests, TestWriteFile_FormatCheckSensor_reportsWhenAutoFormatOff and TestWriteFile_FormatCheckSensor_injectsWhenConfigured (builtin_tools_test.go:277-324), deliberately set Config.AutoFormat = false and write syntactically-valid-but-unformatted Go (extra whitespace, no syntax error) — a real, tested, supported configuration. runPostWriteSensors calls Check() unconditionally regardless of AutoFormat, so in that config the finding is genuinely “auto-format is disabled,” not a parse failure. Check() has no way to distinguish the two causes internally — it only ever compares raw content against the formatter’s canonical output. The original plan’s proposed message (“likely a syntax error”) would have been actively wrong in the AutoFormat=false case.

Decision. Confirmed with the user: use a cause-neutral message that names both real causes with actionable guidance for each, rather than asserting one as dominant. code_formatters.go:152 (PipeExternalFormatter.Check) now returns: "gofmt: content is not in canonical format — if auto-format is disabled, run gofmt or write already-formatted content; if it was enabled, a syntax error may have prevented it (check the file compiles)".

Rejected. The original “likely a syntax error” wording (shown wrong by the AutoFormat=false test path). Branching the message on Config.AutoFormat at the call site (passing the flag into runPostWriteSensors/Check() so each config gets a precise, single-cause message) — considered, but adds a parameter to thread through for a message-wording nicety; the cause-neutral single message covers both cases accurately without a signature change. Reverting to the fully generic original message — rejected since the cause-neutral version is still strictly more actionable (names the two remedies) even without pinpointing which applies.

Consequences. Updated the two builtin_tools_test.go assertions that pinned the old substring ("differs from formatted" → "not in canonical format"). Added TestPipeExtFormatter_Check_ReportsCauseNeutralMessage (code_formatters_test.go, using tr a-z A-Z as a formatter guaranteed to differ from lowercase input) — the first direct test of PipeExternalFormatter.Check’s “differs” message text; none existed before this change. FileExternalFormatter.Check (returns no message at all — file-mode check unsupported) and BuiltinFormatter.Check’s message are confirmed byte-identical to before, per scope. go vet ./... clean. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in every entry above (untouched by this change).

This completes both phases of feedforward-budget-design.md/feedforward-budget-plan.md (Direction B). Item 1 (content-aware guide selection) remains explicitly out of scope, per that document’s own audit — no path-scoped skill exists yet in Harvey’s own catalog to gate.


2026-07-13 — Shared BudgetTracker across ragAugment and injectOrChunk (Direction B, Phase A)

Context. feedforward-budget-design.md / feedforward-budget-plan.md Phase A. Confirmed real via code audit before designing: runChatTurn ran ragAugment (always requesting top-5 chunks, no budget check on the survivors) then injectOrChunk (each small file checked only against a flat 16KB maxInjectFileBytes cap, no aggregate check across multiple files in one prompt) — three independent, uncoordinated budget mechanisms (RAG, direct-file-inject, the pre-existing oversized-file chunking path), exactly as small-model-budget-design.md Direction 2 diagnosed. Three decisions were confirmed with the user before implementing: omitted-content notes are model-visible (not just a SensorEvent) since “some retrieved/requested content was withheld” is directly relevant to how the model should answer; BudgetTracker.Total = remainingContext(a) as-is, no additional response-headroom reservation this increment; and the overall Phase A/B scope as drafted.

Decision. Added budget_tracker.go: BudgetTracker{Total, Used int}, NewBudgetTracker(total int) *BudgetTracker, (*BudgetTracker) Reserve(tokens int) bool, (*BudgetTracker) Remaining() int. ragAugment(prompt string, tracker *BudgetTracker) gained a second parameter — chunks are kept in score-descending order until one fails tracker.Reserve(estimateTokens(c.Content)); that chunk and all lower-scored ones after it are omitted, and a "[Note: %d lower-relevance chunk(s) omitted — context budget]" line is appended to the context block. injectOrChunk(ctx, prompt, out, tracker *BudgetTracker) gained a fourth parameter — its two direct-inject branches (≤16KB, and oversized-but-fits-remainingContext) now Reserve before appending; a file that doesn’t fit is skipped with the existing dim-style human note plus a "[Note: %d file(s) exceeded context budget and were not injected: ...]" line appended after the loop. runChatTurn constructs one BudgetTracker per turn, before the ragAugment call, and threads the same instance into injectOrChunk — this is what makes it a shared pool rather than two independent trackers. A nil tracker is treated as “unconstrained” by both consumers, preserving every pre-existing call site’s behavior unchanged.

A real regression found and fixed during verification, not just a test artifact. remainingContext(a) returns 0 both when the context is genuinely full and when the limit is simply unknown (effectiveContextLimit() <= 0) — its own doc comment says so. Naively passing that straight into NewBudgetTracker meant any session with an unset/unprobed context length (e.g. newTestAgent’s default config, and plausibly some real llamafile-entry-less configs) got Total = 0, silently blocking all file/RAG injection, even a one-line file — caught by TestRunChatTurn_CannotReadRetry_SkipsWhenAlreadyInjected turning red after Phase A landed. Fixed by reusing the exact convention injectOrChunk’s pre-existing oversized-file branch already applies for the same ambiguity: fall back to a conservative 4096-token default only when the limit is positively unknown (a.effectiveContextLimit() <= 0), not when it’s known and genuinely exhausted.

Rejected. Reserving additional response headroom on top of remainingContext(a) in this increment (would close small-model-budget-design.md’s “response headroom” priority-table gap, but was confirmed out of scope — a separate, adjacent change). Wiring injectMemoryContext’s session-start memory budget into the same shared tracker (fires once per session, not per turn — not the same coordination gap). Wiring the interactive chunked-analysis branch of injectOrChunk into the shared tracker (already budget-aware via its own remainingContext(a) call and interactive/self-limiting).

Consequences. TDD-first for budget_tracker.go (TestBudgetTracker_ReserveFitsWithinTotal, TestBudgetTracker_ReserveRejectsWhenExceeded, TestBudgetTracker_ReserveExactlyAtTotal, TestBudgetTracker_RemainingReflectsUsed, confirmed red then green) and for ragAugment’s trimming logic (TestRAGAugment_BudgetTracker_TrimsLowerScoredChunks, confirmed red — first for an unrelated reason, a same-source Ingest call overwriting the prior batch, fixed in the test, then red for the right reason, then green). One honest deviation: injectOrChunk’s budget-skip logic and its test (TestInjectOrChunk_BudgetTracker_SkipsFileThatDoesNotFit) were written in the same pass rather than strict red-then-green; verified after the fact by temporarily disabling the new Reserve checks and confirming the test fails for the expected reason, then restoring — a real check, but not the same order as the rest of this session’s TDD-first work, noted rather than silently presented as identical. A new TestBudgetTracker_SharedAcrossRAGAndFileInjection proves the pool is genuinely shared (RAG consuming ~200 of a 220-token tracker leaves too little for a file that would otherwise fit) rather than two independent budgets that happen to run in sequence. Five existing ragAugment call sites and five existing injectOrChunk call sites were updated to pass nil (unconstrained), confirmed behavior-unchanged before the new trimming/skip logic was added. go vet ./... clean. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in every 2026-07-12 entry above (untouched by this change).

Phase B (sharpen gofmt Check()’s generic message) is next, per feedforward-budget-plan.md.


2026-07-12 — Add go vet as the first genuinely new sensor (Direction A, Phase B)

Context. computational-sensors-design.md / computational-sensors-plan.md Phase B, following Phase A’s Check() wiring. go vet was chosen over staticcheck/gocyclo/unused-param/hunspell as the first genuinely new sensor because it only reports near-certain bugs by design — no style-nit noise floor to tune, unlike a general linter — making it the lowest-risk addition to introduce first (per the exploration doc’s own warning against activating a wave of rules at once). The two judgment calls flagged as open in the design doc were resolved in the plan doc before implementing: go vet only runs when the workspace has a go.mod; a genuine go vet finding is distinguished from go vet itself failing to run (mid-edit compile error) via a "# "-prefixed package-header line in its output, which never appears before a real per-analyzer finding.

Decision. Added runGoVet(a *Agent, absPath string) []SensorEvent (builtin_tools.go), invoked from runPostWriteSensors for .go files. Findings are always both human-visible (SensorEvent) and appended to the tool-result string unconditionally — no config gate, unlike Phase A’s Check() findings — since go vet has no low-severity tier to withhold tokens for. A "# "-prefixed line in go vet’s output causes the entire result to be suppressed (nil, no events, no appendix), treating a build failure as Harvey’s own sensor failing to run rather than a code-quality finding, matching applyAutoFormat’s existing “errors are silently suppressed” policy. Timeout is a dedicated 15s constant (goVetTimeout), not Config.Security.RunTimeout (5 minutes, meant for user-driven run_command/git_command — inappropriate for something that runs synchronously on every .go write on Pi-class CPU).

Rejected. Reusing Config.Security.RunTimeout for go vet’s timeout, as the plan doc originally suggested (“reuse whatever timeout convention run_command already uses”) — on reflection during implementation, the two have different risk profiles (an occasional user-invoked shell command vs. a sensor running on every single file write) and reusing a 5-minute default would let a hung/slow go vet invocation block every write for far too long.

Consequences. TDD-first: TestWriteFile_GoVetSensor_reportsRealFinding (a Printf format/argument mismatch — both SensorEvent and tool-result appendix), TestWriteFile_GoVetSensor_suppressesCompileError (an undefined-symbol compile error — neither), TestWriteFile_GoVetSensor_skipsWithoutGoModule (no go.mod → go vet never invoked). The two suppression-path tests were vacuously green before implementation (nothing ran at all yet) and were re-confirmed as meaningfully green afterward — real subprocess timings (1.32s / 0.07s) confirm go vet actually ran and the suppression logic was genuinely exercised, not just trivially passing. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in the earlier 2026-07-12 entries.

This completes both phases of computational-sensors-design.md/computational-sensors-plan.md (Direction A’s first increment). staticcheck/gocyclo/unused-param/hunspell-style checks remain deliberately deferred to a follow-on increment, per that document’s own scope decision.


2026-07-12 — Wire dormant CodeFormatter.Check() into a post-write sensor (Direction A, Phase A)

Context. computational-sensors-design.md / computational-sensors-plan.md Phase A, the first harness-engineering direction resumed after the prerequisite refactor cycle. Scoping found that CodeFormatter.Check(content, filePath) (bool, []FormatIssue) (language_registry.go) already exists, is implemented for Go via gofmt (PipeExternalFormatter), and is unit-tested — but nothing outside its own tests ever calls it. Separately, applyAutoFormat (builtin_tools.go) already runs the formatter automatically after every write_file, with its result note appended into the tool’s own returned string (model-visible, costs tokens), gated by Config.AutoFormat (default true). An earlier draft of the design doc assumed a separate edit_file tool existed and needed the same wiring — checked, and Harvey has no edit_file tool at all, only whole-file write_file; corrected before implementing.

Decision. Added runPostWriteSensors(a *Agent, relPath string) string (builtin_tools.go), called from write_file after applyAutoFormat, running Check() against whatever content is currently on disk (i.e. after any auto-format rewrite already happened). Confirmed with the user beforehand: findings are human-visible by default (always a SensorEvent via a new Agent.ActiveStatus StatusReporter field, free, no token cost) and model-visible (appended to the tool-result string) only when Config.SensorInjectFormatFindings (default false, surfaced in harvey.yaml as sensor_inject_format_findings) is set. Rationale for the default: after a successful auto-format, Check() on a Go file should almost never find anything left to report; the residual case (a syntax error gofmt can’t parse) is rare enough not to warrant spending tokens on by default, but still cheap and useful to surface to a human.

Agent.ActiveStatus is new plumbing needed because tool handlers (closures created in RegisterBuiltinTools(r, a), capturing *Agent) had no path to the turn’s live StatusReporter — that lived only on ToolExecutor.Status, which handler closures never see, and ToolExecutor itself has no *Agent reference. Rather than changing ToolHandler’s signature (which would touch every registered tool), ActiveStatus is set alongside the two existing ex.Status = sp / ex.Status = retrySp assignments in terminal.go and cleared to nil once each spinner stops.

Rejected. Changing ToolHandler’s signature to thread a reporter through explicitly — correct in principle but a much larger, more invasive change for this increment; Agent.ActiveStatus achieves the same reporting path with two call-site changes instead of touching every tool.

Consequences. TDD-first: TestWriteFile_FormatCheckSensor_reportsWhenAutoFormatOff, TestWriteFile_FormatCheckSensor_injectsWhenConfigured, TestWriteFile_FormatCheckSensor_silentWhenAlreadyFormatted (using a new captureStatus StatusReporter test double), confirmed red, then green. Full suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in the earlier 2026-07-12 entries. Phase B (go vet, a genuinely new sensor) is next.


2026-07-12 — Unify sensor/status reporting into one SensorEvent shape

Context. harness-prerequisite-refactor-design.md / harness-prerequisite-refactor-plan.md Phase C (Item 3). Three disconnected mechanisms reported what are all, conceptually, sensor signals: tool_executor.go’s e.Status.UpdateStatus(...) calls (transient, rendered on the spinner’s status line), groundingCheck’s result (printed directly to out after the spinner had already stopped), and the prose-tool-call “only tool-call syntax” warning (also printed directly, independently). This was flagged in harness-engineering-exploration.md as the direct prerequisite for that document’s Direction A (new computational sensors need somewhere to report) and Direction C (the sensor-sidecar pattern).

Decision. Added sensor_event.go: SensorClass (Computational/Inferential, per the harness-engineering framework’s computational/inferential axis) and SensorEvent{Kind, Message, Class}, plus reportSensorEvent(out io.Writer, ev SensorEvent) — the one place the “print a warning line” formatting now exists, used by both groundingCheck’s call site and the prose-tool-call-syntax warning in terminal.go. StatusReporter (tool_executor.go) gained ReportSensor(ev SensorEvent) alongside the existing UpdateStatus(msg string); *Spinner implements it as a thin delegate (s.UpdateStatus(ev.Message)) — no rendering change, since the spinner’s live status line doesn’t yet do anything with Kind/Class. All three original signals now construct a SensorEvent: the three ExecuteToolCalls call sites (tool_executor.go) via ReportSensor, and the two terminal.go call sites via reportSensorEvent. Every event constructed today is Computational — each is a deterministic check over already-known content (a string match, a tool-dispatch result), not an LLM judgment; Inferential exists for a future sensor (e.g. an LLM-based review) with nothing emitting it yet.

Scope was deliberately limited to this plumbing, per the plan: no UI redesign (the sensor-sidecar two-view pattern from harness-engineering-exploration.md Direction C remains unbuilt follow-on design work), and the spinner’s rendering is unchanged.

Rejected. Rendering Class/Kind differently now (e.g. distinct symbols per kind) — premature without a second, non-Computational sensor to actually distinguish from; deferred to whenever Direction A’s tooling introduces a real second sensor kind.

Consequences. Neither groundingCheck’s nor the prose-tool-call-syntax warning’s printed output had prior test coverage (only groundingCheck’s own return value was tested, in grounding_test.go) — new tests were written first: TestReportSensorEvent_writesMessage, TestReportSensorEvent_matchesPreRefactorGroundingFormat (exact-byte characterization of the grounding call site’s pre-refactor format), TestSpinner_ReportSensor_delegatesToUpdateStatus. One honest caveat: the grounding call site’s output is byte-for-byte unchanged (reportSensorEvent’s format was matched to it exactly), but the prose-tool-call-syntax warning’s output is only visually identical, not byte-identical — its pre-refactor code had the space after “⚠” outside the ANSI color wrap (yellow(" ⚠")+" "+text) while reportSensorEvent puts it inside (yellow(" ⚠ ")+text); both render as the same visible text in a terminal, since ANSI color codes don’t affect a plain space’s appearance, but the two original call sites already disagreed with each other at the byte level before this change, so a single shared formatter could not be byte-identical to both simultaneously — matched to the grounding style since that had the more precise characterization test. Full package suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in the other 2026-07-12 entries.

This completes all three items of harness-prerequisite-refactor-design.md/harness-prerequisite-refactor-plan.md (Item 4 remains owned entirely by small-model-budget-design.md). Harvey is now ready to resume the harness-engineering-exploration.md directions this cycle was a prerequisite for.


2026-07-12 — Unify token-budget/context-percentage reporting

Context. harness-prerequisite-refactor-design.md / harness-prerequisite-refactor-plan.md Phase B (Item 1). The same “estimate tokens used, compute %, pick a warning tier” logic was written three times: the turn-time warning’s Ollama branch (terminal.go, exact count via CountTokens), its llamafile/llama.cpp branch (estimated), and /status’s cmdStatus (a third, near-identical copy branching on provider again). Auditing all three side by side surfaced three real discrepancies, not just duplicated code:

  1. The turn-time Ollama branch read limit directly from a.Config.Ollama.ContextLength, while /status and the llamafile branch both used effectiveContextLimit() — which additionally falls back to a probed llamafile-entry/ModelCache value when Ollama.ContextLength is unset in harvey.yaml. This meant the live overflow warning could silently never fire for a session where /status would correctly report high usage.
  2. The ≥100%-full message text differed by backend for no documented reason: Ollama said “reply may be truncated”; llamafile said “try /clear or switch to a model with larger context.”
  3. The estimate methodology itself differed: the turn-time llamafile branch summed estimateTokens per message (for _, m := range a.History { used += estimateTokens(m.Content) }), while /status’s estimate branch called estimateTokens once on the whole concatenated history (estimateTokens(HistoryText(a.History))). Since estimateTokens floors at a minimum of 1 token per call, per-message summation over-counts whenever history has many short/compacted messages.

Decision. All three, confirmed with the user before implementing:

  1. Use effectiveContextLimit() as the limit source everywhere, including the Ollama turn-time path — fixes the silent-gap case.
  2. Unify the ≥100% message to one combined line: "Context full: %s%d / %d tokens (%d%%) — reply may be truncated; try /clear or switch to a model with larger context".
  3. Use the whole-history-string estimate (estimateTokens(HistoryText(a.History))) everywhere, matching /status’s pre-existing (more accurate) approach rather than per-message summation.

Implemented as Agent.contextUsage() (used, limit int, exact bool) and formatContextUsage(used, limit int, exact bool) (contextTier, string) in context_estimator.go. runChatTurn (terminal.go) and cmdStatus (commands.go) both call contextUsage(); runChatTurn additionally calls formatContextUsage() and gates printing on tier (≥80%/≥100%), while cmdStatus keeps its own always-shown “Tokens: …” line format using just the raw used/limit/exact values.

Rejected. Preserving today’s three-way divergence behind explicit per-caller parameters — considered for each of the three discrepancies, but none had a positive rationale on inspection; all three read as unintentional drift from being implemented separately at different times rather than deliberate design choices.

Consequences. None of contextUsage()/formatContextUsage() had direct unit tests before this change (the logic was only reachable indirectly through runChatTurn/cmdStatus); characterization/new-behavior tests were written first, confirmed red, then made green by the implementation: TestContextUsage_estimatedPath, TestContextUsage_ollamaExactPath, TestContextUsage_ollamaUsesEffectiveContextLimit (regression test for discrepancy 1), TestFormatContextUsage_belowWarnThreshold, TestFormatContextUsage_warnTierExact, TestFormatContextUsage_warnTierEstimated, TestFormatContextUsage_fullTier, TestFormatContextUsage_unknownLimit. Full package suite green except the same pre-existing, unrelated TestCmdModelList_ShowsLlamafileEntries failure noted in the 2026-07-12 tool-call-dispatch entry above (untouched by this change either).


2026-07-12 — Unify tool-call dispatch-and-report (tryExecuteApertusToolCalls / tryExecuteProseToolCalls)

Context. harness-prerequisite-refactor-design.md Item 2. tryExecuteApertusToolCalls (Apertus-native <SPECIAL_71>...<SPECIAL_72> syntax) and tryExecuteProseToolCalls (fenced JSON blocks, used by small models like qwen2.5/llama3.2) were near-byte-identical: same NewToolExecutor setup, same ExecuteToolCalls call, same per-result reporting loop — differing only in which parser produced calls. Auditing them while scoping the pre-harness-engineering refactor cycle surfaced a real behavioral inconsistency, not just duplicated code: only the prose path printed an immediate “⚠ Unknown tool(s): … Available tools: …” terminal warning (tool_executor.go:316-324 in the pre-refactor code); the Apertus path silently omitted it, even though both paths fed their unknownNames into the same downstream self-correction message injected into history (terminal.go:1367-1378). No code comment or design doc justified the asymmetry — it reads as Apertus support having been added after prose support without carrying the same reporting over.

Decision. Extracted the shared execute-and-report step into executeAndReportToolCalls(a *Agent, calls []anyllm.ToolCall, out io.Writer) (dispatched bool, unknownNames []string) (tool_executor.go). Both tryExecuteApertusToolCalls and tryExecuteProseToolCalls are now thin wrappers: parse, bail on len(calls) == 0, delegate. Resolved the inconsistency by making both paths print the “Unknown tool(s)” warning — user confirmed (2026-07-12) this should be the fix rather than preserving the old asymmetric behavior. Also extracted availableToolNames(a *Agent) []string (the GetToolSchemas() → name-slice loop), which was duplicated a third time in terminal.go’s history-injection correction message; both call sites now share it.

Rejected. Preserving the Apertus path’s silence via a boolean flag/branch in the merged function — considered, but there was no positive reason found for the asymmetry, so carrying it forward as an explicit option would have encoded an accident as a decision.

Consequences. Neither original function had direct unit test coverage before this change (only their parsers, ParseProseToolCalls/ParseApertusToolCalls, were tested in codeblock_test.go) — characterization/new-behavior tests were written first per this repo’s TDD-first convention, confirmed red against the pre-refactor code, then the refactor made them green: TestExecuteAndReportToolCalls_dispatchesKnownTool, TestExecuteAndReportToolCalls_unknownToolWarnsWithAvailableList, TestTryExecuteProseToolCalls_toolsDisabled, TestTryExecuteProseToolCalls_unknownToolWarns, TestTryExecuteApertusToolCalls_dispatchesKnownTool, TestTryExecuteApertusToolCalls_unknownToolWarns (the last of these locks in the new, consistent warning behavior). Full package test run is otherwise green; the one unrelated pre-existing failure (TestCmdModelList_ShowsLlamafileEntries, tied to already-in-progress, uncommitted llamafile-discovery work in llamafile.go/backend_startup.go predating this session) is untouched by this change. go test -race cannot run in this sandbox (ThreadSanitizer: unsupported VMA range) — pre-existing environment limitation, not caused by this change.

2026-07-08 — IBM generative computing / Granite Switch / Mellea research review

Context. TODO.md carried two research items: review IBM’s aLoRA, Mellea, and Granite Libraries/Project Switch blog posts, and review github.com/generative-computing/granite-switch for implications across Harvey, Henry, and Mable. Reviewed all three IBM Research blog posts (inference-friendly aLoRAs, generative computing/Mellea, Granite libraries/Project Switch) and the granite-switch repo.

Findings. - aLoRA (activated LoRA) adapts weights only for tokens after an in-prompt invocation string, so the base model’s KV cache computed before that point is reusable — no full reprocessing on adapter switch. IBM claims 20–30x faster per-task-switch vs. standard LoRA. - Granite Switch composes multiple task-specific aLoRA “adapter functions” (RAG query-rewrite, hallucination detection, citation gen, requirement-checking, Guardian safety checks) onto one base Granite model via a control-token dispatcher, sharing one KV cache across switches. Apache-2.0, Python/PyTorch, served via vLLM or HF Transformers. Only trained for Granite-family models (3.2/4.1 lineage) — of models currently on Harvey’s benchmark list, only granite-4.1-8b-source-Q4_K_M would qualify. - Mellea is a Python orchestration layer on top: converts type hints to schemas, auto-inserts control tokens, enforces output constraints at decode time — “generative computing” as typed function calls with verifiers instead of free-text prompting. - Ecosystem check (the load-bearing finding): aLoRA is not vLLM/HF-exclusive. llama.cpp merged native aLoRA support upstream at release b6396 (ggml-org/llama.cpp#15212, discussion #15213) — GGUF format plus llama-server support, not just static LoRA merge/scale. mozilla-ai/llamafile (what Henry produces) has generic --lora/--lora-scaled support (PR #786) aligned with llama.cpp’s API, but it is unconfirmed whether that predates or includes the b6396 aLoRA-specific commit. Ollama’s ADAPTER support is the older static-adapter path with no confirmed aLoRA invocation-string/KV-reuse mechanics. - Harvey’s own ~/Laboratory/hullicination.txt (garbled Gemma4-E4B chunked-read output) is a live example of the failure mode Granite Switch’s hallucination-detection adapter targets — concrete motivation if the runtime-support question resolves favorably.

Decision. No immediate adoption. Do not pull in vLLM/Mellea as a Python orchestration layer for Harvey — that doubles the runtime stack Harvey deliberately avoids (Go + Ollama/llama.cpp). Henry’s existing Python/HF/torch pipeline is the more natural integration point if the tokenizer/control-token propagation through GGUF conversion checks out. Concrete next checks captured in ~/Laboratory/henry/continue_next_time.txt rather than acted on now.

Rejected. Adopting Mellea directly (Python-only, no Go bridge). Treating this as applicable to Mable today (Mable is from-scratch corpus training, not Granite adapter composition) — noted as a design pattern worth remembering when Mable reaches its training-architecture decision, not an action item.

Consequences. TODO.md research items checked off, pointing here. Henry gets a follow-up list in continue_next_time.txt covering the llama.cpp/llamafile version check and GGUF tokenizer-metadata question. Separately under discussion: whether a Mellea-equivalent (typed instructions + verifiers) belongs in Harvey itself as a Go module, and whether KV-cache reuse for such a scheme is llama.cpp’s responsibility or something Harvey would need to manage at the orchestration layer.


2026-07-05 — /read-chunks command: explicit chunked analysis, independent of the overflow trigger

Context. The only way to exercise RunChunkedAnalysis (map-reduce chunking) was to hit the automatic context-overflow guard — either by feeding a genuinely huge file, or by artificially shrinking effectiveContextLimit() (as the 2026-07-05 live retests did). This made comparing chunking strategies, or comparing chunk-quality across models with different context windows, needlessly fragile: results were confounded by whatever the current model’s context budget happened to be.

Decision. Add /read-chunks PATH [--chunk-size N] [--max-chunks N] [--overlap paragraph|sentence|none] [INSTRUCTION...] (cmdReadChunks, read_chunks_cmd_test.go). It calls ChunkDocument + RunChunkedAnalysis directly — the identical functions the automatic path uses — with no threshold check at all; invoking the command is the confirmation. --chunk-size/--max-chunks/--overlap override a.Config.Chunking for that invocation only (not persisted), so the same file/model can be swept across chunking strategies in one session. INSTRUCTION falls back to the last user message when omitted, matching the implicit path’s pre-fill convenience; @model mentions are parsed the same way. Unlike the tool-call path (where the synthesis is returned as a tool result and re-composed by another model turn) or the pre-inject path (where it’s prepended as context for the next model turn), /read-chunks prints the full synthesis directly to the terminal — the point is to see the chunking pipeline’s raw output for evaluation, not have it filtered through an additional model pass — and also appends it to history as a normal user/assistant exchange so follow-up questions can reference it.

Rejected. Gating the command behind the same overflow check it’s meant to bypass (defeats the purpose). Silently ignoring a.Config.Chunking.Enabled: false — that flag only gates the automatic trigger; an explicit command isn’t automatic, so it’s not checked here at all (only the per-invocation --chunk-size/--max-chunks/--overlap flags override the loaded config, leaving other fields like Enabled irrelevant to this path).

Consequences. Naming follows the existing hyphenated convention (/read-pdf, /read-dir), not the /read_chunks placeholder. Command registered alongside /read in commands.go. Tests: TestCmdReadChunks_NoArgs, TestCmdReadChunks_NoClient, TestCmdReadChunks_BypassesThreshold, TestCmdReadChunks_InstructionFallsBackToLastUserMessage, TestCmdReadChunks_NoInstructionNoHistory, TestCmdReadChunks_PermissionDenied, TestCmdReadChunks_InvalidChunkSizeFlag, TestCmdReadChunks_AddsResultToHistory.


2026-07-05 — /resume slash command as a thin alias for /session use

Context. Live-testing the chunking fix (see below) via piped/tmux-driven input was fragile in part because the interactive startup flow’s prompt sequence is hard to predict from outside (resume-session prompt, model picker, possible external-server-adopt prompt). Investigating a general “invoke CLI flags from inside the REPL” mechanism, most of it turned out to already exist: /model use, /session continue, /session replay, and /record start already delegate to the same underlying functions the equivalent CLI flags call (established by the 2026-06-20 unified-/model-command decision). /session use with no arguments already shows the identical interactive picker the startup “Resume a prior session?” prompt uses, and loads the chosen file via ContinueFromFountain — functionally identical to what a /resume command would do.

Decision. Add /resume [FILE] as a thin alias: cmdResume simply calls cmdSession(a, append([]string{"use"}, args...), out). No new generic flag-to-command mechanism was built — the existing per-command delegation pattern already covers this need, and /resume is the discoverable name matching the --resume CLI flag.

Rejected. Building a generic mechanism to invoke arbitrary startup flags mid-session. Most flags either already have a natural slash-command equivalent (model selection, session load/replay, recording) or don’t make sense mid-session (--workdir, --llamafile-dir). A generic passthrough would duplicate the existing per-command pattern without covering meaningfully more ground.

Consequences. TestCmdResume_aliasForSessionUse, TestCmdResume_noArgsShowsPicker added. The startup-time interactive “Resume a prior session? [y/N]” prompt itself is unchanged — dropping it in favor of --resume//resume was considered but deferred, see TODO.md.


2026-07-05 — Llamafile GPULayers defaults to 0 (CPU-only), not 99

Context. A live chunking retest against bonsai-8b (Q1_0 quantization) on Raspberry Pi hardware appeared to hang for 20+ minutes. Investigation found the underlying llama-server process had been running for over 2 hours of CPU time with no output, launched with -ngl 99 (maximise GPU offload) — Harvey’s default for every llamafile model (config.go, LlamafileConfig.GPULayers: 99). Raspberry Pi hardware has no usable GPU-compute backend; forcing maximum GPU-layer offload on such hardware is a plausible cause of severe degradation or an effective hang, independent of the quantization type. LlamaCppConfig.GPULayers (the sibling backend’s config) already defaulted to 0 for this exact reason — the llamafile default was an inconsistency, not a deliberate choice.

Decision. Change LlamafileConfig.GPULayers’s default from 99 to 0. buildLlamafileArgs (llamafile_service.go) already treats 0 as “explicitly pass -ngl 0” (forces CPU-only), distinct from a negative value (omits the flag, defers to the binary’s own default) — so 0 is the correct, unambiguous safe default, not just an arbitrary placeholder. Users with real GPU-compute hardware opt in via gpu_layers: in harvey.yaml. The “only persist when overriding the default” check in SaveLlamafileConfig was updated from != 99 to != 0 to match.

Consequences. TestDefaultConfig_LlamafileGPULayersDefaultsToZero, TestSaveLlamafileConfig_DoesNotPersistDefaultGPULayers, TestSaveLlamafileConfig_PersistsCustomGPULayers added. Existing harvey.yaml files that don’t already set gpu_layers explicitly silently pick up the new safer default on next load — no migration needed. Users on capable GPU hardware who were relying on the implicit 99-default will need to add gpu_layers: 99 explicitly.


2026-07-05 — Chunking guard fix: unknown context limit must not bypass overflow detection

Context. TODO.md reported garbled output from Gemma4-E4B when asked to review a document for topic drift, with the note “Never got the chunk prompt entry option.” Root-cause investigation traced this to remainingContext() (context_estimator.go) returning 0 both when the model’s context limit is genuinely unknown and when it is known but exhausted. read_file’s chunking pre-read guard in builtin_tools.go used if rem := remainingContext(a); rem > 0 as its sole gate — so an unknown limit silently skipped the entire overflow check and fell through to a full raw read, rather than triggering the chunk-prompt UX. file_inject.go’s injectOrChunk already handled this correctly (falling back to a conservative 4096-token budget when rem <= 0 and the limit truly is unknown), but builtin_tools.go’s tool-call path did not share that logic.

A second, deeper cause was found for the llamafile backend specifically: effectiveContextLimit() (harvey.go) only resolves a context window for llamafile models from the LlamafileEntry.ContextLength field in harvey.yaml — the ModelCache fallback never carries a value for llamafile models, because probeLlamaCppAndCache (used by useLlamafileEntry on every llamafile connect, including adoption) only writes SupportsTools/ToolMode into the cache, never ContextLength. switchLlamafileModel and addAndStartLlamafile both call ProbeLlamafileContextLength to populate LlamafileEntry.ContextLength, but adoptExternalServer (llamafile.go) — used when Harvey detects an already-running llamafile server at startup and offers to adopt it — did not. Any model adopted this way has ContextLength permanently stuck at 0 for the session.

Decision. Two fixes: (1) builtin_tools.go’s read_file chunking guard now uses the same fallback pattern as injectOrChunk: rem <= 0 sets rem = 4096 rather than skipping the guard outright. (2) adoptExternalServer now calls ProbeLlamafileContextLength and stores the result on the registered LlamafileEntry, matching the other two llamafile-registration call sites.

Known remaining gap. startAndUseLlamafile (backend_startup.go) has a similar hole: when it detects a server already running under a different model name than the configured active entry, it adopts the detected name via useLlamafileEntry without registering (or probing) a matching LlamafileEntry. Deferred — this is a narrower edge case (requires an already-running server serving an unexpected model) than the primary adopt-on-first-connect path just fixed.

Consequences. TestReadFile_ChunkingEnabledContextLimitUnknown (builtin_tools_test.go) and TestAdoptExternalServer_probesContextLength (llamafile_test.go) cover the two fixes. Live retest against bonsai-8b (Q1_0 quantization) was inconclusive on chunk-quality: the test document (~12.7KB) fell just under the 4096-token fallback budget so chunking did not trigger, and the single-shot response did not complete within ~20 minutes, suggesting Q1_0 quantization has poor CPU dequantization throughput on this hardware independent of the chunking fix. Re-testing chunk-quality on a document large enough to force chunking, and/or against a non-Q1_0 4B–8B model, is still open — see TODO.md.


2026-06-30 — Assay switches from callOllama to harvey.LLMClient for all backends

Context. bin/assay had a private callOllama function that spoke Ollama’s proprietary /api/chat endpoint directly. Llamafile happened to also expose this Ollama-compatible API, so the single function covered two backends. Adding llama.cpp support requires the OpenAI-compatible /v1/chat/completions path, which Ollama does not expose at /api/chat. Writing a parallel callOpenAI function would create two diverging code paths that must be kept in sync.

Decision. Replace callOllama (and the private ollamaRequest / ollamaResponse structs) with harvey.LLMClient — the same interface used throughout the harvey interactive agent. The concrete implementation (AnyLLMClient backed by mozilla-ai/any-llm-go) already supports Ollama, llamafile, and llama.cpp via the OpenAI-compatible API. All three backends follow the same client.Chat(ctx, messages, &buf) call path. Token stats come from harvey.ChatStats instead of the proprietary Ollama response shape.

Rejected. Adding a parallel callLlamaCpp function that speaks /v1/chat/completions directly. This avoids touching the existing Ollama path but creates maintenance burden: two implementations of the same call, diverging error handling, different stat fields. Token stat normalisation would have to be done twice.

Consequences. The ollamaRequest, ollamaMessage, and ollamaResponse types are removed from cmd/assay/main.go. The callOllama function is removed. Token stats now come from ChatStats.PromptTokens, ChatStats.ReplyTokens, and ChatStats.TokensPerSec. The Ollama model-listing path (listOllamaModels) is retained as-is since it calls /api/tags directly and is Ollama-specific by nature. A parallel listOpenAIModels helper is added for llama.cpp’s /v1/models endpoint.


2026-06-30 — Assay does not manage the llama-server process lifecycle

Context. For llamafile, assay starts the binary itself (StartLlamafileService), finds a free port, and defers cleanup. This was necessary because llamafile is a single self-contained executable with no prior setup. llama.cpp (llama-server) requires separate configuration: model path, context size, GPU layers, quantisation, threading. These are server-administrator decisions that assay should not make on behalf of the user.

Decision. --llamacpp URL connects to a running llama-server at the given URL. Assay does not start or stop it. The user starts the server before running assay and stops it afterward. This matches the --ollama pattern exactly.

Rejected. A --llamacpp-path PATH flag that mirrors --llamafile PATH. The reason it was rejected: llama.cpp needs too many server-tuning flags (ctx, threads, GPU layers) to make a one-flag launch practical without exposing all of them in assay’s flag set — which would duplicate harvey’s own LlamaCpp configuration. Requiring the user to start the server explicitly keeps assay’s surface area small.

Consequences. Users must start llama-server before running assay. Error messages guide them: if the URL is unreachable, assay exits immediately with a clear message rather than timing out.


2026-06-30 — Shared probeLlamaCppAndCache helper for capability detection

Context. ProbeLlamafileProps was already implemented and wired for llamafile (via useLlamafileEntry in backend_startup.go), but the llama.cpp startup path (startLlamaCppModelPath in backend_llamacpp.go) never called it. As a result, toolsReliable() always found no ModelCache entry for llama.cpp models and returned false — but tool definitions were still sent, causing the model to hallucinate rather than call them.

Decision. Extract the probe-then-cache-write logic into a probeLlamaCppAndCache(a *Agent, modelName, baseURL string) helper in backend_llamacpp.go. Wire it into both paths: - useLlamafileEntry in backend_startup.go (replaced inline block with one call) - startLlamaCppModelPath in backend_llamacpp.go (new, called after backend is fully wired)

The function is a no-op when a.ModelCache == nil or when an existing entry has ProbeLevel != "none", preserving the skip-on-re-probe behaviour.

Rejected. Duplicating the inline block in startLlamaCppModelPath would have caused the two paths to drift. A method on Agent was also considered but adds no real benefit over a package-level helper that takes *Agent.

Consequences. Both llamafile and llama.cpp models now populate model_cache.db immediately after startup. toolsReliable() sees the capability entry on the first turn rather than defaulting to CapNo.


2026-06-30 — /ollama command removed; Ollama management delegated to the Ollama CLI

Context. Harvey had a large /ollama command with subcommands covering start/stop/status, server lifecycle, model listing, pull/push/rm, probe, logs, env, ps, and alias management. Most of these duplicate functionality already covered by the ollama CLI itself. The surface was redundant, brittle (Harvey had to maintain parity with the Ollama API), and added cognitive load for users who switch between Harvey and the shell. The unified /model command (introduced 2026-06-20) already provided a backend-agnostic entry point for model switching. The alias subcommand was already shared.

Decision. Remove /ollama entirely. Ollama model management (pull, push, rm, list, show, run) is delegated entirely to the ollama CLI. Harvey’s responsibilities for Ollama reduce to:

  1. Model discovery — aggregateModels queries the live Ollama /api/tags endpoint when Ollama is reachable; result appears in /model list.
  2. Model switching — /model use resolves across all backends including Ollama.
  3. Alias management — /model alias unchanged; aliases now carry an Engine field (“ollama”, “llamafile”, “llamacpp”, or “” for legacy).
  4. Auto-probe on alias creation — when /model use creates a new Ollama alias, FastProbeModel is called immediately so capability data (tool support, embed support, context length) is cached without a separate /ollama probe command.
  5. Stale alias cleanup — /model clean replaces /ollama clean, pruning aliases for all engines (not just Ollama) using pruneStaleModelRefs. Legacy aliases with no engine field are preserved.
  6. Service lifecycle — Harvey starts Ollama only when a model needs it and Harvey was configured to manage it. /model stop and /model status remain backend-agnostic. Detailed service management (logs, env, ps) is the ollama CLI’s job.

The cmdOllama, ollamaProbe, pruneStaleOllamaRefs, ollamaModelTable, and removeModelFromConfig functions are deleted.

Rejected alternatives.

Consequences.


2026-06-30 — Blank-slate active model: no persistence, no auto-start from config

Context. Harvey previously persisted the last-used Llamafile model to harvey.yaml as llamafile.active and auto-started it at next session. This caused surprising behavior: starting Harvey would silently launch the last Llamafile process without prompting, the “active” model name was meaningless across session restarts (the user may have added or removed models), and the pattern did not generalise to Ollama or llama.cpp. The concept of “sticky active model” proved idiosyncratic across all three backends.

Decision. Drop the active-model persistence concept for sessions not being resumed. Specifically:

The intent is that at startup Harvey always shows the full model picker, giving the user explicit control every time, rather than guessing which model they want.

Rejected alternatives.

Consequences.


2026-06-28 — /plan IVR support deferred — design incomplete

Context. Harvey’s /plan feature provides bounded-context task execution but lacks output validation and automatic repair. The Instruct-Validate-Repair (IVR) pattern (from the Mellea project) was evaluated as a candidate extension. Two integration options were considered: (A) extend /plan with opt-in inline validation annotations; or (B) add a new /ivr command.

Decision. Deferred to a future phase. Design review revealed too many open questions to proceed safely in the current release cycle:

Preferred path when ready. Option A (extend /plan with annotations) remains the correct integration point. Option B (separate /ivr command) is rejected — IVR is fundamentally about making /plan more reliable, not a separate workflow.

Consequences.


2026-06-28 — Local model backend design deferred — unified abstraction under exploration

Context. Users want llama.cpp as a Harvey backend for better performance on ARM devices. A server-based integration design was drafted in llamacpp-support.design.md. Design review identified a broader problem: Ollama and Llamafile management both have reliability issues in their current form, and adding a third ad-hoc backend without resolving the underlying inconsistency would make the situation worse. All three backends (Ollama, Llamafile, llama.cpp) share the same lifecycle concerns — start, stop, status, model listing, client wiring — but are currently implemented independently with different data structures and command patterns.

Decision. Implementation deferred. The design phase continues toward a unified model backend abstraction covering all three local inference backends. The existing llamacpp-support.design.md is a reference, not a finalized design.

The unified design will address: - A common ManagedBackend interface for lifecycle management (start, stop, status, list models, active model, base URL, new LLM client) - Consistent client wiring in Agent when the active backend changes — replacing the current split between OllamaStartedByHarvey bool and llamafileProc *os.Process - A unified or parallel command surface that users can discover consistently across backends - Server detection and adoption (currently only Llamafile has adoptExternalServer; Ollama and llama.cpp need equivalent) - PID or process persistence across Harvey sessions for backends Harvey started

Rejected alternatives.

Consequences.


2026-06-27 — Chunked document analysis uses paragraph/block boundaries, not fixed-size tokens

Context. The chunked analysis feature (see chunked-analysis-design.md) must split a document into chunks before the map phase. The three practical options are: fixed-size token windows, semantic embedding-based splits, and structure-aware splits on natural document boundaries (paragraphs for prose, function/block boundaries for source code).

Decision. Use structure-aware splitting: paragraph boundaries (double newline) for prose document types (.md, .txt, .rst, .tex, .html), and function/block boundaries (blank-line-then-signature heuristic) for source code types (.go, .ts, .py, .js, .c, .h, others). Each chunk includes the last paragraph or signature of the preceding chunk as overlap. Document type is detected by file extension; unknown extensions default to paragraph splitting.

Structure-aware chunking is supported by multiple independent studies. The Bioengineering evaluation (Gomez-Cabello et al., 2025) found adaptive boundary alignment achieved 87% accuracy vs 50% for fixed-size chunking across identical RAG pipelines. AutoChunker (Jain et al., ACL 2025) demonstrated that preserving document hierarchy at boundaries reduces noise and improves chunk coherence. The KES 2026 systematic comparison (Śmigielski et al.) confirmed structure-aware outperforms fixed-size across diverse document and query types.

Rejected alternatives.

Consequences.


2026-06-27 — Chunked analysis uses map-reduce; sliding window and ephemeral RAG deferred

Context. When a document is split into N chunks, Harvey must process each chunk and combine the results. Three patterns were considered: map-reduce (process chunks independently, synthesize once), sliding-window summarization (process chunks sequentially, carry a rolling summary), and ephemeral RAG (ingest the file into a temporary vector store and query it).

Decision. Use map-reduce. Each chunk is processed independently with the user’s chunk instruction as the prompt (map phase). After all chunks complete, a single synthesis pass combines the partial results into a final answer that is injected into the main conversation history (reduce phase). The synthesis model defaults to the same model used for the map phase.

DocETL (Shankar et al., 2410.12189) showed that decomposing single-pass LLM document operations into map→reduce sequences improved accuracy 21–80% over single-pass approaches across four complex document analysis tasks. NexusSum (Kim & Kim, ACL 2025) demonstrated a 30% improvement in BERTScore F1 using a hierarchical multi-LLM pipeline with controlled per-chunk output, the same two-phase structure Harvey adopts here.

Rejected alternatives.

Consequences.


2026-06-27 — Chunked analysis is user-directed; overflow triggers an alert, not silent chunking

Context. When Harvey detects that reading a file would overflow the model’s remaining context, it must decide whether to chunk silently and automatically or to pause and involve the user. The trigger for this decision uses two signals: an os.Stat byte-size estimate before the file is read, and a remaining-context estimate that accounts for current history, system prompt, and injected memories (not the raw context window).

Decision. Harvey alerts the user rather than chunking silently. The alert shows the file name, estimated size, and estimated remaining context, then presents the user’s most recent message as a pre-filled chunk prompt with the instruction: Enter instructions to process each chunk in turn, or “no” to return to the conversation. The user may edit the prompt, accept it as-is, or cancel. The chunk prompt may include an @model directive, which Harvey’s existing @mention routing infrastructure uses to route each chunk analysis call to the named model.

QASC (Rastogi, 2605.22834) provides direct empirical support: treating the user query as a first-class input to segmentation improves relevance 18–27% over fixed or automatically derived chunking prompts. Chunking quality is tied to query specificity; only the user knows what they want from each chunk.

The two-signal trigger (byte estimate before read, remaining-context estimate accounting for history) addresses the underlying accounting bug: Harvey currently compares file size against the raw context window rather than the context that remains after history and injected content are accounted for. ContextWeaver (Wu et al., 2604.23069) identifies this as the primary cause of unexpected context overflow in agentic systems.

Rejected alternatives.

Consequences.


2026-06-25 — Source registry lives in knowledge.db; not a separate database

Context. The scholarly provenance design (see scholarly-provenance-design.md) requires a sources authority table and an observation_sources join table. Two placement options were considered: a new provenance.db alongside knowledge.db, or new tables inside the existing knowledge.db.

Decision. Add sources and observation_sources directly to knowledge.db. The sources table needs to join against observations, concepts, and kb_fts, all of which live in knowledge.db. SQLite cross-database joins via ATTACH DATABASE cannot use foreign keys and require every query to name the attached database alias, making all query code more fragile. A single database with multiple tables is the correct SQLite idiom.

Rejected alternatives.

Consequences.


2026-06-25 — Scholarly provenance: inference-time only; training-time attribution deferred

Context. Two Scholarly Kitchen articles (2026-06-17 and 2026-06-25) and the Cambridge Scholarly AI Workshop identified that AI systems interact with scholarly content at two points: training time (content absorbed into model weights) and inference time (content retrieved and injected via RAG at query time). The workshop explicitly classified training-time attribution as technically intractable at current model scales and recommended focusing practical interventions on inference-time retrieval.

Harvey’s architecture makes inference-time provenance fully tractable: the RAG pipeline (ragAugment, RagStore.Query) has complete observability of what was retrieved and from where. Training-time attribution for Ollama or Llamafile models is not accessible to Harvey and would require coordination with model providers.

Decision. The scholarly provenance work (v0.0.15) focuses entirely on inference-time provenance: 1. A minimum provenance payload on RAG chunks (source, DOI, title, version, rights, content hash, retraction flag). 2. A source registry in knowledge.db as the authority for source metadata, linked to observations via observation_sources. 3. Per-source [[rag-source: ...]] Fountain notes so session files serve as citable records of what evidence informed each response. 4. HARVEY.md system-prompt guidance to retrieve before generating and to attribute content at the point of use, not post-hoc.

Training-time attribution is explicitly deferred and recorded as out of scope, not a gap in the design.

Rejected alternatives.

Consequences.


2026-06-24 — INT./EXT. scene prefix redefined as local/remote computation

Context. The original Fountain format spec (v1.0–1.1) defined INT. as “Harvey is involved as orchestrator” and EXT. as “direct model-human conversation without Harvey.” This made EXT. scenes effectively hypothetical — the recorder never wrote one, because Harvey is always involved. Remote Ollama route dispatches (e.g. @pi2) and cloud API calls were both recorded as INT. despite running on remote machines. The distinction was meaningless in practice.

Decision. Redefine the prefix semantically as location of computation: INT. = runs on the local machine where Harvey is running; EXT. = runs on a remote system. This maps naturally to the theatrical meaning (interior/exterior), gives EXT. scenes real-world frequency, and encodes practically important information (network latency, data exposure, cost). Remote Ollama routes and cloud API routes are now EXT. HARVEY still appears in EXT. scene dialogue as the forwarding character when Harvey initiated the route dispatch; HARVEY is absent only in truly direct conversations (no Harvey involvement). The RecordExteriorTurn recorder method writes EXT. scenes; RecordTurnWithStats continues to write INT. scenes.

Rejected alternatives.

Consequences.


2026-06-24 — Fountain sessions become full audit trails (v0.0.15)

Context. Corin Wagen’s article “Tool Use and AI Scientists” argues that tool calls are the primary mechanism for AI interpretability — the decision trace of what an agent chose to do and why. Harvey’s Fountain session files record dialogue, file writes, and shell commands, but tool calls appear only as unstructured prose (“Harvey calls read_file: {args}”), tool results are not recorded, RAG context retrieval leaves no trace, and memory injection at session start is invisible. See audit-trail-design.md.

Decision. Extend the Fountain format to v1.2 with four new audit elements. A Harvey session file is a sequence of many scenes — one per discrete interaction (chat turn, shell command, file write, skill activation). The placement of new elements respects this: notes go inside existing scenes; only one new scene type is added.

Rejected alternatives.

Consequences.


2026-06-20 — Command vocabulary standardised across all resource-management commands

Context. Harvey’s command families share a common resource-management pattern but use inconsistent verbs: /llamafile drop, /rag drop, /route rm, and /model alias delete all mean the same thing; /skill info and /skill-set info duplicate /memory profile show’s pattern under a different name; /session has no list or show; /route has no use. Users must learn each command family independently rather than applying a single vocabulary pattern. See llamafile-primary-design.md and TODO.md.

Decision. Standardise on eight core verbs for all resource-management commands: list, add (register external resource), new (create internal item), use (activate), show (display content/details), edit (open in $EDITOR), remove (delete/unregister), rename. Backend service commands additionally support start, stop, and status (health/connection — distinct from show). The add vs new distinction is preserved: add registers something that already exists externally (a file path, a URL); new creates something Harvey owns (a database, a skill, a plan). Existing non-standard verbs (drop, rm, info, create, set) are kept as backward-compatible aliases; the canonical verb is the one documented and tab-completed.

Rejected alternatives.

Consequences.


2026-06-20 — Llamafile becomes the primary model backend; Ollama is secondary

Context. Harvey has supported both Llamafile and Ollama since v0.0.11, but startup logic, documentation, and default prompts all treat Ollama as the assumed backend. New users who want a fully local, no-server-required setup must discover Llamafile through man pages rather than finding it naturally in the startup flow. See llamafile-primary-design.md.

Decision. Reverse the priority: at startup Harvey probes for an active Llamafile first, registered Llamafiles second, Ollama third. The model picker (shown when no session is being continued) lists Llamafile models above Ollama models. getting-started.md and INSTALL.md lead with the Llamafile path; Ollama is documented as an advanced alternative. Ollama support is fully retained — no existing config or commands change.

Rejected alternatives.

Consequences.


2026-06-20 — At-mention (@model) switches the active model while preserving history

Context. Switching models mid-session requires /llamafile use NAME or /ollama use NAME, which breaks conversational flow. Users who want a different model for the next question should be able to express that inline. The theatrical framing — a model switch is a new character entering the scene — also clarifies how downstream systems (memory miner, replay, plan executor) should handle boundaries. See llamafile-primary-design.md.

Decision. If the REPL input begins with @name where name matches a registered Llamafile or Ollama model, Harvey switches to that model and forwards the remainder as the prompt. Conversation history is preserved unchanged. If @name is not recognised, the whole input is forwarded to the current model without warning (false positives on natural @ mentions are rare enough that silent pass-through is less surprising than an error). Mid-session switches are recorded in the session file as [[model switch: NAME (BACKEND) at TIMESTAMP]] Fountain notes rather than starting a new session file — continuing in the same file preserves pre-switch context for memory mining and replay. The memory miner, session replay, and plan executor each gain logic to track model attribution across switch boundaries.

Rejected alternatives.

Consequences.


2026-06-20 — Unified /model command as a backend-agnostic delegating facade

Context. Users who switch between Llamafile and Ollama must remember which backend is active to choose the right command. As more backends are added (remote routes, encoderfiles), per-backend command proliferation increases cognitive load for users who just want to switch models. See llamafile-primary-design.md.

Decision. Add /model [list|use NAME|show NAME|status] as a backend-agnostic facade. /model use NAME resolves the name by checking Llamafile models first, then Ollama models, then named routes, and delegates to the appropriate backend command. /model list merges all backends into one sorted table. The backend-specific commands (/llamafile, /ollama) are unchanged and remain the authoritative interfaces for backend-specific operations (/llamafile start, /ollama pull, etc.).

Rejected alternatives.

Consequences.


2026-06-20 — Context utilization reads n_ctx from /v1/models; config override available

Context. A [ctx: N%] indicator requires knowing both the current token count (available from ChatStats.PromptTokens) and the model’s maximum context window. Context window size is model-specific and not always available at runtime. See llamafile-primary-design.md.

Decision. Priority order for context length: (1) context_length field on LlamafileEntry in harvey.yaml — explicit user override; (2) data[0].meta.n_ctx from the /v1/models API response — tested on Qwen3.5-2B, Qwen3.5-4B, and Apertus-8B, consistently present across all three model families; (3) OllamaContextLength on Config, already populated by ShowModel; (4) unknown — suppress the indicator entirely. The n_ctx value is the runtime context window (what llamafile loaded), not n_ctx_train (training context). When the probe succeeds and no user config is present, the result is stored in memory only — not written back to harvey.yaml — to avoid config churn on every startup.

Rejected alternatives.

Consequences.


2026-06-19 — Tab completion: two-layer design with shared SelectFrom helper

Context. Harvey’s buildCompleter() only completes top-level command names, @route references, Ollama model names, and file paths. Users must remember subcommand names by heart and must know exact RAG store/model names to use use and drop subcommands. Several commands already show numbered pickers when no name is given, but each reimplements the pattern differently. See tab-completion-design.md.

Decision. Extend completion in two layers: (1) second-token subcommand names using a new Subcommands []string field on Command; (2) third-token argument values using a new ArgCompletion map[string]func(*Agent) []string field that maps each subcommand to a candidate-list function. Additionally, introduce a shared SelectFrom / SelectItem / SelectFromStrings API in a new ui.go file. Commands whose first positional argument comes from a finite, enumerable list (/rag use, /memory show, /llamafile use, etc.) display the picker when no argument is given. ui.go lives in the harvey package; promotion to termlib is deferred until a clean generalisation is proven.

Rejected alternatives.

Consequences.


2026-06-18 — MinIO replaced with aws-sdk-go-v2 S3 client

Context. remote_s3.go uses github.com/minio/minio-go/v7 as the S3 protocol client. MinIO’s Go client has moved to a closed-source license, making it unsuitable for Harvey’s AGPL-3.0 codebase. The affected surface is small: Stat, Get, and List operations on S3-compatible stores (AWS S3, MinIO server, Cloudflare R2). See s3-replacement-design.md.

Decision. Replace the MinIO client with github.com/aws/aws-sdk-go-v2 (Apache-2.0 licensed). The AWS SDK v2 supports all S3-compatible endpoints via the BaseEndpoint override option. The call sites in remote_s3.go map cleanly: StatObject → HeadObject, GetObject → GetObject, ListObjects → ListObjectsV2. Credentials continue to come from environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) and the SDK’s default credential chain.

Rejected alternatives.

Consequences.


2026-06-18 — Spinner gains dynamic status message channel

Context. Harvey’s spinner currently shows rotating Edward Lear quotes and a timer while waiting for the LLM. Users have no way to tell whether Harvey is embedding a query, calling a tool, waiting for Ollama, or doing something else. Claude Code and similar tools display live status messages that update as work progresses. See spinner-ux-design.md.

Decision. Add a StatusCh chan string field to the Spinner struct and an UpdateStatus(msg string) method. The spinner’s message line shows the most-recent status update instead of the next Lear quote whenever a message is pending; Lear quotes resume when no status is pending. The caller sends non-blocking updates via UpdateStatus; the spinner goroutine reads them on the fast tick. This preserves the existing Lear personality while surfacing actionable progress at key moments: tool call start/end, RAG embedding, context injection, model switching. Tab completion is out of scope for this work item; it is a separate, larger effort.

Rejected alternatives.

Consequences.


2026-06-18 — Assay evaluation output moves to workspace-level directory

Context. bin/assay writes evaluation results to testout/ inside the harvey/ source repository. This directory is gitignored, but the JSON and Markdown artifacts look like test output to language models that read the source tree, causing models to misinterpret stale evaluation results as current test failures. See assay-llamafile-design.md.

Decision. Change the default output directory for bin/assay from testout/ to $WORKSPACE/assay-results/<timestamp>/ where $WORKSPACE is resolved the same way Harvey resolves its workspace (walk up from cwd to the directory containing agents/harvey.yaml). If no workspace is found, fall back to a assay-results/ directory in the current working directory. The --output flag overrides the default as before.

Rejected alternatives.

Consequences.


2026-06-18 — Assay adds Llamafile backend via --llamafile flag

Context. bin/assay currently only supports Ollama as a model backend, but Harvey supports both Ollama and Llamafile. Users evaluating a Llamafile model must run it manually and point assay at it with a custom URL, which is error-prone and undocumented. See assay-llamafile-design.md.

Decision. Add a --llamafile PATH flag to bin/assay. When provided, assay starts the llamafile process on an ephemeral port (same startLlamafile logic as in llamafile_service.go), runs the evaluation suite against that endpoint, then terminates the process on exit. The --model flag is still respected (it sets the model name in the report) but --ollama is ignored when --llamafile is given. Embeddings continue to use the Ollama embedder unless --rag-db is also given and the store’s recorded embedding model differs, in which case the operation fails fast with a clear error.

Rejected alternatives.

Consequences.


2026-06-18 — Web developer template added to built-in profile set

Context. The five templates shipped in v1 (backend-developer, frontend-developer, dataset-developer, data-scientist, technical-writer) do not have a template that covers the full polyglot web development stack used in this workspace: Go backends, uv-managed Python scripts, SQL (SQLite3 and Postgres), Deno+TypeScript frontends, and vanilla JavaScript/CSS/HTML5. A backend developer using Deno or a frontend developer writing Go API clients currently reaches for an incomplete template. See web-developer-template-design.md.

Decision. Add a web-developer.spmd template to templates/profiles/. It covers: Go (net/http, database/sql), uv+Python (scripting, data processing), SQL (SQLite3 dialect and Postgres), Deno+TypeScript (runtime, standard library, no bundler by default), JavaScript (ES modules, no framework by default), CSS (custom properties, no utility framework by default), HTML5 (semantic markup). The template’s NOTE: recommends qwen2.5-coder:7b or granite3.3:2b and suggests ingesting both Go source and the deno.json/package.json for context.

Rejected alternatives.

Consequences.


2026-06-18 — /memory profile subcommand set expanded and naming standardized

Context. The current /memory profile command has three subcommands — show, update, use — but their semantics do not match Harvey’s established command vocabulary. show lists active profiles (like list does elsewhere) rather than showing the content of the active profile. use creates a new profile from a template (like new does elsewhere) rather than selecting an existing saved profile. update opens the current profile in $EDITOR. There is no way to rename a workspace. See memory-profile-ux-design.md.

Decision. Standardize the subcommand set:

Subcommand New behaviour Was
list List all profiles (active + archived) (show partial)
show Print the content of the current active profile (missing)
edit Open the active profile in $EDITOR (rename of update) update
use [NAME] Switch to a named template or picker unchanged
rename NAME Rename the workspace display name in the active profile (missing)

update is kept as a deprecated alias for edit with a one-line deprecation notice, to avoid breaking existing workflows. The /profile top-level alias continues to delegate to all subcommands. The help text for /memory is updated to list all five subcommands.

Rejected alternatives.

Consequences.


2026-06-18 — PDF capability disclosed in HARVEY.md system prompt

Context. Harvey’s read_file built-in tool description states that PDF files are extracted automatically via poppler. But when tools are disabled — or when a small model uses prose tool calls and does not consistently read all tool descriptions — the model has no knowledge of this capability and asks the user to manually convert PDFs to text. HARVEY.md is always injected as the system prompt, making it the correct place to disclose capabilities that should be known regardless of tool-call mode. See quick-fixes-design.md.

Decision. Add a File reading capabilities section to HARVEY.md that enumerates what Harvey can read without conversion: plain text, Markdown, Go/TypeScript/Python source, and PDF (extracted via poppler automatically). This mirrors the pattern of the existing “Tagged code blocks” section — documenting Harvey’s automatic behaviors so the model can confidently use them rather than guessing.

Rejected alternatives.

Consequences.


2026-06-18 — Llamafile model discovery includes Windows .exe extensions

Context. scanLlamafileModels() in llamafile.go uses strings.HasSuffix(e.Name(), ".llamafile") to identify llamafile binaries. On Windows, llamafile binaries end in .exe (plain) or .llamafile.exe (when distributed with the double extension). Users on Windows who place binaries in ~/Models see an empty picker even with valid models present. The same bug affects llamafileModelName, which only strips the .llamafile suffix and leaves .exe on Windows paths. See quick-fixes-design.md.

Decision. Extend scanLlamafileModels to match three patterns: .llamafile, .llamafile.exe, and (on Windows only) any .exe file in the models directory. llamafileModelName is updated to strip suffixes in the correct order: strip .exe first (if present), then .llamafile (if present). The llamafileDefaultModelsDir() platform function already returns the correct OS-appropriate path; no change needed there.

Rejected alternatives.

Consequences.


2026-06-18 — --resume flag auto-selects the most recent session

Context. Harvey’s --continue PATH flag resumes from a specific session file. When the user simply wants to pick up where they left off (the most common case), they must find and type the session path, or navigate the interactive picker. Both are unnecessary friction when the intent is always “resume my last session.” See quick-fixes-design.md.

Decision. Add a --resume flag (no argument) that resolves to the most recently modified .spmd file in agents/sessions/ and sets cfg.ContinuePath to that path before Run. If no sessions exist, Harvey prints a one-line notice and starts fresh. The implementation delegates entirely to the existing ContinueFromFountain path — no new session-loading logic is needed.

Rejected alternatives.

Consequences.


2026-06-09 — Programming language support uses a central LanguageRegistry with pluggable handlers

Context. Harvey’s RAG system already supports ingesting 17 programming language file extensions (commands.go:4975-4979), but the looksLikePath function (commands.go:3463-3467) was missing extensions for C, C++, Pascal, Oberon, Lisp, and Basic. Additionally, all languages used generic paragraph-based chunking which breaks code structures (functions, procedures) across chunk boundaries, reducing RAG retrieval quality for programming queries. Users working with source code need language-aware features: code-aware chunking, documentation extraction, syntax highlighting, and auto-formatting.

Decision. Create a comprehensive language support system with the following architecture:

  1. Central LanguageRegistry (language_registry.go) — Maps language identifiers to handlers (detectors, chunkers, extractors, formatters, highlighters). Each language has a LanguageInfo struct with metadata (name, extensions, comment markers, block delimiters, capabilities).

  2. Pluggable Interfaces — Define Go interfaces for each capability:

    • LanguageDetector — Identifies language from file path and/or content
    • CodeChunker — Splits source into meaningful units (functions, classes, procedures)
    • DocExtractor — Extracts comments, docstrings, and symbol documentation
    • CodeFormatter — Formats source code according to language conventions
    • SyntaxHighlighter — Adds ANSI color to code blocks for terminal display
  3. Code-Aware Chunking — Language-specific chunkers that respect code structure:

    • C/C++: Split at function boundaries, preserve preprocessor directives and structs
    • Pascal: Split at PROCEDURE/FUNCTION boundaries, preserve TYPE/RECORD definitions
    • Oberon: Split at MODULE/PROCEDURE boundaries
    • Lisp: Split at top-level forms (balanced parentheses), keep DEFUN/DEFMACRO together
    • Basic: Split at SUB/FUNCTION boundaries
  4. Progressive Enhancement — All features are opt-in. Basic file I/O works for all languages. If a language-specific handler fails, fall back to generic behavior.

  5. Immediate Fix — Add missing extensions (.c, .cpp, .h, .hpp, .pas, .Mod, .obn, .lisp, .bas) to looksLikePath function for tagged code block detection.

Rejected alternatives.

Consequences.


2026-06-09 — Code block path detection (looksLikePath) extended to support all RAG-ingestible languages

Context. The looksLikePath function in commands.go (lines 3463-3467) determines whether a string looks like a file path rather than a language identifier. This is used by fencePathToken when parsing tagged code blocks (e.g., ```c:program.c). The function had a hardcoded list of known extensions that was missing: .c, .cpp, .h, .hpp, .pas, .Mod, .obn, .lisp, .bas. This meant that tagged code blocks for these languages were not recognized as file paths, preventing the auto-write feature from working.

Decision. Extend the knownExts slice in looksLikePath to include all extensions supported by RAG ingestion (from ragIngestableExts in commands.go:4975-4979). Additionally, add a comment noting that these are programming languages supported by RAG ingestion for future maintainability.

Rejected alternatives.

Consequences.


2026-06-08 — /loop chat iterations use a shared runChatTurn helper that skips skill auto-trigger and autoExecuteReply

Context. The REPL’s plain-chat path does more than call the model: it checks whether the input matches a skill trigger pattern (auto-dispatching to a different flow entirely), and after the reply, offers to write fenced code blocks to disk via an interactive Y/n prompt (autoExecuteReply). Both make sense for a human typing one message at a time; both are problematic when the same prompt is sent N times unattended — a skill could fire on iteration 3 but not iteration 1, and a Y/n prompt would block forever waiting on stdin that nothing will type.

Decision. Factor the REPL’s inline chat block (terminal.go, roughly lines 635-820) into a shared (a *Agent) runChatTurn(ctx, input, out) (reply string, stats ChatStats, err error). It keeps everything that defines “how Harvey answers a prompt” — RAG augmentation, the tool-loop-or-plain-chat branch, token/context warnings, stats, Fountain recording — and excludes skill auto-trigger matching and autoExecuteReply, both of which belong to “how the REPL reacts to a typed line.” /loop calls this helper directly for its chat-mode iterations; the REPL becomes a thin wrapper around the same helper plus its own skill-trigger/autoExecuteReply handling.

Rejected alternatives.

Consequences.


2026-06-08 — /loop caps iterations at 100 and defaults to 10

Context. /loop is the first Harvey command that can run LLM calls — and, with tools enabled, write files or execute shell commands — repeatedly and unattended. Harvey’s existing security posture (safe mode, permission system, audit log) is built around bounding and surfacing risky actions rather than trusting the user to always type the right thing.

Decision. /loop takes an optional --count N (following the --depth N convention already established by /read-dir), defaulting to 10 and capped at 100. There is no “run forever” option.

Rejected alternatives.

Consequences.


2026-06-08 — /loop requires an explicit interval; no self-pacing mode

Context. Claude Code’s /loop can omit the interval and let the agent self-pace via a wake-scheduling primitive. Harvey has no equivalent — it is a synchronous CLI process with no persistent scheduler or “wake me up later” mechanism.

Decision. INTERVAL is a required first argument to /loop, parsed with the existing parseDurationString helper (config.go:650, already used for run_timeout/ollama_timeout in harvey.yaml). There is no self-pacing mode.

Rejected alternatives.

Consequences.


2026-06-08 — /loop runs as a blocking foreground command, not a background goroutine

Context. Harvey’s REPL (terminal.go:Run) is a single-threaded loop that blocks on each turn, mutating a.History, a.Recorder, and the shared output writer with no locking — because nothing has ever run concurrently with it. Adding a command that repeats a prompt on an interval raises the question of whether it should run in the background while the user keeps typing, or take over the REPL until it finishes.

Decision. /loop runs in the foreground inside its own command handler, reusing the SIGINT-cancellation pattern already used three times in terminal.go (chat, ! commands, @mention dispatch): one cancellable context for the whole run, a goroutine watching os.Signal, and a wasCancelled check. Any Ctrl+C — mid-iteration or during the inter-iteration sleep — stops the whole loop and returns to the prompt.

Rejected alternatives.

Consequences.


2026-06-05 — Profile templates and help guides ship embedded in the binary

Context. Harvey installs by copying a single executable to $HOME/bin. Users on three OS / two CPU architectures should not need to install a separate asset package. Templates and help guides must therefore travel with the binary.

Decision. Use Go’s //go:embed directive (standard library since Go 1.16) to compile a templates/ directory tree into the binary at build time. A single EmbeddedTemplates embed.FS variable in templates.go gives the rest of Harvey read access to template and help guide content at runtime. Workspace-local templates in agents/templates/profiles/ are checked at runtime and merged with the built-in list, allowing organisations to add shared templates without patching Harvey.

Rejected alternatives.

Consequences.


2026-06-05 — Initial developer/writer template set; library templates deferred

Context. Harvey needs a useful starting set of profile templates but the full range of library staff roles requires domain expertise and UX review that is not yet available.

Decision. Ship five developer/writer templates for v1:

Template Role
backend-developer Go, Python, TypeScript+Deno, SQL for application work
frontend-developer HTML, CSS, TypeScript/JavaScript, Deno bundling
dataset-developer Front end plus SQL, dataset CLI, datasetd web service
data-scientist Data analysis, SQL for exploration, Python data tooling
technical-writer Documentation, man pages, tutorials, Markdown and Fountain

Library role templates (subject specialist, systems/digital, instruction/data literacy, support staff) are deferred until library staff and a UX colleague can define the categories and content correctly. Placeholder files are named in the plan but not authored.

Rejected alternatives.

Consequences.


2026-06-05 — /profile use verb; /profile top-level alias

Context. The profile switching command needed a name consistent with Harvey’s existing command vocabulary. Two candidates were considered: switch and use.

Decision. Use use as the subcommand verb because it matches the established pattern in Harvey: /ollama use, /rag use, and /kb use all select the active item from a list. Register /profile as a top-level alias delegating to /memory profile, following the same one-line handler pattern as /recall → /memory recall.

Rejected alternatives.

Consequences.


2026-06-05 — Profile switching writes a Fountain handoff document

Context. When a user switches profiles mid-session with /profile use, the in-progress conversation context would be lost after ClearHistory(). The user may need to resume the previous context in a future session.

Decision. Before clearing history, /profile use writes a .spmd summary file to agents/hand-off/<timestamp>.spmd. The handoff captures the last N assistant messages as bullet points and lists file paths and open questions from recent turns. No LLM call is required — the handoff is structural, not summarised. Because it is a .spmd file, the memory miner can extract facts from it in a later session, migrating context from the old role into the new session’s experience memories over time.

The previous workspace_profile document is archived (status set to archived) rather than deleted, preserving the history of who this workspace has been used as.

Rejected alternatives.

Consequences.


2026-06-05 — Help guides for Ollama and PDF tools embedded in binary

Context. New users frequently fail to install Ollama or PDF extraction tools before running Harvey. The error messages Harvey currently produces do not explain what is missing or how to fix it. Users on three operating systems need platform-specific install instructions.

Decision. Embed short Markdown help guides (templates/help/ollama.md, templates/help/pdf-tools.md) in the binary using the same //go:embed infrastructure as profile templates. Surface them via /help ollama and /help pdf-tools. Print a one-line pointer to the relevant guide when a detection failure occurs at startup (Ollama unreachable) or during a command (PDF extraction fails). Guides are deliberately short: what it is, how to install on each platform, one troubleshooting line.

Rejected alternatives.

Consequences.


2026-06-02 — Persistent command history across sessions

Context. Harvey’s termlib.LineEditor supports Up/Down arrow history navigation within a session, but the history is in-memory only and lost on exit. Users must retype slash commands, ! shell commands, and prompts from prior sessions, which breaks flow — especially for repeated workflows like /rag ingest, /memory mine, or iterating on a prompt.

Decision. Persist the input history to agents/harvey_history inside the workspace (one entry per line, plain text). On startup Harvey loads this file and seeds the LineEditor before entering the REPL. On clean exit the in-memory history is written back, capped at 1000 entries (most recent kept). Consecutive duplicate suppression is already handled by AppendHistory; no further deduplication is applied at write time.

The implementation requires two changes:

  1. termlib (lineeditor.go) — add two methods to LineEditor:
    • SetHistory(lines []string) — replaces the in-memory history slice wholesale (used at startup).
    • History() []string — returns a copy of the current history slice (used at exit to write back).
  2. Harvey (terminal.go) — add loadCmdHistory(ws, le) called after le is created (line ~225), and saveCmdHistory(ws, le) called in the REPL exit path. Both functions resolve the path as ws.AbsPath("agents/harvey_history"). saveCmdHistory truncates to the last 1000 entries before writing.

The history file path is not configurable in this iteration; agents/ is Harvey’s conventional home for all runtime state (harvey.yaml, sessions/, memories/, rag/, knowledge.db).

Rejected alternatives.

Consequences.


2026-06-02 — UX nudge system for memory discoverability

Context. Users who understand the three storage silos (RAG / Memory / Knowledge Base) can get significantly better results, but the ingestion decision (“where does this go?”) breaks flow. No built-in mechanism surfaced actionable hints about pending mining, empty RAG stores, or RAG being disabled.

Decision. Implement a four-part nudge system:

  1. Session-start digest — a sessionMemoryDigest() function called after the ready line that prints dim hints only when a condition is actionable:

    • Unmined sessions pending → suggest /memory mine
    • Active RAG store is empty → suggest /rag ingest
    • RAG off but chunks exist → suggest /rag on No output is printed when everything looks healthy.
  2. Enhanced /status — extend cmdStatus with a Memory/RAG summary block (active memories, unmined sessions, active store, chunk count, RAG on/off). Keeps the one-stop status view complete.

  3. New /hint command — on-demand improvement suggestions that aggregate all three silos and explain the decision rule. Verbose version of the session digest with context about why each suggestion matters.

  4. /help learn topic — a unified “How Harvey learns” help page with a three-column table (what to ingest → which command → where it goes) and the single decision rule:

    • Have a text file or document? → /rag ingest
    • Something useful happened in a session? → /memory mine
    • Making an observation about an experiment? → /kb observe
  5. /recall alias — routes to /memory recall to make the unified retrieval interface the obvious entry point.

Rejected alternatives. - Single storage silo — would reduce configuration but lose retrieval precision for small models. Topic-scoped RAG stores (e.g., deno_typescript, go) give better recall than one large mixed store. - Always-on verbose status — printing all memory info on every startup is too noisy. Only surface hints when actionable. - Merging /rag on + /memory recall into a single toggle — the per-prompt RAG injection (ragAugment) and session-start injection (UnifiedMemory.Recall) are different channels. A single toggle would require auditing whether UnifiedMemory already includes RAG chunks. Deferred to a future audit.

Consequences. - terminal.go gains a sessionMemoryDigest() call after the ready line. - commands.go gains cmdHint, enhanced cmdStatus, and a /recall registration. - helptext.go gains LearnHelpText. - cmdHelp dispatches "learn" and "memory-overview" to LearnHelpText. - help topic list is updated to include learn.


2026-06-02 — model_map in RAG stores (deferred simplification)

Context. Each RAG store entry in harvey.yaml has a model_map field that maps generation models to embedding models. In practice every store uses nomic-embed-text for all generation models, making the map redundant.

Decision. Deferred. Do not remove model_map now. The code is already correct and operational. Remove it when there is a concrete reason to simplify the config schema (e.g., adding a new embedder type that makes the override meaningful).

Consequences. model_map remains in the config and ragAugment continues to honour it. No user-visible change.


2026-06-02 — Dual RAG injection audit (deferred)

Context. Harvey has two RAG injection paths that run independently: 1. Per-prompt via ragAugment() in terminal.go (when a.RagOn) 2. Session-start via UnifiedMemory.Recall() which also queries the RAG store

A user with both memory.enabled and rag.enabled may receive RAG content twice per turn — once in the system prompt injection and once prepended to each prompt. This wastes context tokens and may confuse small models.

Decision. Deferred. Audit and fix when a user observes noticeably degraded context efficiency. The fix would be to either: (a) skip RAG chunks in UnifiedMemory.Recall() when a.RagOn is true, or (b) make ragAugment a no-op when UnifiedMemory already injected from the same store.

Consequences. Known overlap. No immediate action required.


2026-07-25 — Dual RAG injection audit: status update, folded into provenance proposal

Context. Since the 2026-06-02 entry above, per_prompt: false (SkipPerPrompt, config.go:68) shipped 2026-06-28 as part of the M6 memory milestone. When set on a RAG store entry, ragAugment (rag_support.go:621) skips per-prompt injection for that store entirely. This is a related but distinct fix from either option the 2026-06-02 entry proposed: it’s a static, per-store, manually-opted-in YAML flag, not an automatic check of whether a.RagOn is set (option a) or whether UnifiedMemory.Recall() already injected from the same store this session (option b). A user who isn’t aware of the overlap, and leaves per_prompt unset, still gets double injection today — the default behavior described in the 2026-06-02 entry is unchanged.

Decision. Do not close this as a standalone item. Fold the remaining automatic-dedup work into the design phase of improved_provenance_reasoning_proposal.md, informed by Doc Searls’ “personal AI” writing (digested in ../doc_searchs_personal_ai_summary.md): - The proposal’s Problem 1 (grounding checks don’t see RAG-injected content) and Problem 2 (retrieval confidence isn’t visible) are both currently scoped only to ragAugment‘s injection. UnifiedMemory.Recall()’s session-start RAG injection (harvey.go:358, injectMemoryContext) is a second, currently-unaddressed injection path into the same conversation — a hallucinated misreading of Recall()-injected content is exactly as ungrounded as one from ragAugment-injected content (Searls’ “logohybris”: confident, fluent wrongness indistinguishable from a real answer). - Designing the grounding fix (A) and the confidence-display fix (B) against only one of the two injection paths would leave the other path’s chunks silently ungrounded and confidence-invisible even after the proposal ships — reproducing, at the injection-plumbing level, the exact problem the proposal exists to close. - Closing the automatic-dedup gap and closing the grounding/confidence gap are best done as one pass over everything RAG injects into the model’s context, not two separate patches to the same call sites later.

Consequences. No code change from this entry alone. improved_provenance_reasoning_proposal.md’s “Constraints for the design phase” section gains a bullet naming this fact so a future design pass doesn’t have to rediscover it. The 2026-06-02 entry above is left as historical record.


2026-05-31 — prose tool call correction injection

Context. Small models emit tool calls as JSON fenced blocks rather than structured API responses. The original tryExecuteProseToolCalls returned bool and could not distinguish “dispatched successfully” from “dispatched but every call errored”. When models hallucinated tool names the warning was suppressed because len(results) > 0 was always true.

Decision. Change tryExecuteProseToolCalls to return (dispatched bool, unknownNames []string). Track a succeeded counter internally; set dispatched = true only when ≥1 call succeeded. When unknownNames is non-empty, inject a correction message into history after a.AddMessage("assistant", ...) so history ordering is: user → assistant → correction-user. This gives the model a chance to retry with the correct tool names.

Consequences. The noToolCalls guard also gates autoExecuteReply to prevent directory-tree code blocks from being offered as files to write after successful tool-call turns.


2026-05-31 — histLenBeforeChat pattern for noToolCalls guard

Context. Harvey needs to know whether a chat turn resulted in structured tool calls (via RunToolLoop) so it can skip autoExecuteReply when tool calls already handled file writing. The check len(a.History) == histLenBeforeChat correctly detects no tool calls only when captured before a.AddMessage.

Decision. Capture histLenBeforeChat := len(a.History) before the Chat/RunToolLoop call. Compute noToolCalls := len(a.History) == histLenBeforeChat before a.AddMessage. This invariant must be preserved: any refactor that moves a.AddMessage before the noToolCalls check will silently break the guard.

Consequences. Documented as a key invariant in CLAUDE.md.


2026-05-28 — Three-silo memory architecture

Context. Harvey needs to accumulate knowledge across sessions without polluting the LLM context window on every turn. Three distinct content types require different ingestion and retrieval strategies: (1) external documents, (2) session experience, (3) research observations.

Decision. Three independent silos unified at retrieval time by UnifiedMemory.Recall():

Silo Ingestion Retrieval
RAG store /rag ingest (explicit) Per-prompt via ragAugment()
Memory store /memory mine or auto-mine on exit Session-start via UnifiedMemory
Knowledge base /kb observe (explicit) On-demand via UnifiedMemory

Consequences. Each silo has its own command namespace (/rag, /memory, /kb). The unified retrieval via /memory recall is the recommended entry point. All three silos share a token budget enforced at injection time.


2026-06-30 — Skill suggestions from session transcripts

Context. Sessions accumulate reusable multi-step workflows that would benefit from being captured as skills. A mechanism is needed to propose skill candidates automatically rather than requiring users to author SKILL.md files by hand.

Decision 1 — Output goes directly to the live agents/skills/ directory, not a staging area. Each accepted candidate immediately becomes loadable via /skill load. The generated SKILL.md is clearly marked as auto-generated and the user is expected to review and refine it before committing. This avoids a two-step accept-then-move workflow that adds friction without safety benefit.

Decision 2 — Command lives on /skill, not /memory, as suggest. /skill suggest [SESSION] fits the skill management namespace naturally; using /memory suggest would imply the output is a memory record. The subcommand reads from a session file (defaulting to the most recent .spmd) and is otherwise independent of the memory mining pipeline.

Decision 3 — Separate LLM prompt from memory mine. skillSuggestorPrompt in skill_suggestor.go is a distinct constant from the memory miner’s extraction prompt. The output schemas differ (skills need steps[] and variables[]; memories need kind and confidence), and mixing them into one prompt would degrade extraction quality for both.

Consequences. Suggestor in skill_suggestor.go owns the full pipeline. cmdSkill wires the suggest subcommand. SkillCandidate reuses SkillVariable (updated to add Type field and JSON tags). No staging directory is needed.