Purpose: Living record of every significant technical, architectural, and process decision made on the cfcf project. Consulted before making new decisions to avoid re-litigating settled questions. Updated every session.
Format: Reverse chronological. Each entry has:
- a date and short title,
- the context that prompted the decision,
- the options considered (where multiple were on the table),
- what was decided,
- the outcome or lessons (where applicable).
Entries describe why we picked the path we did, not what shipped when — the latter belongs in CHANGELOG.md and git history.
2026-05-08 (later same day) — Reverted both --verbose AND --output-format stream-json on the claude-code adapters; back to plain claude -p. Plus: fix the architect-signals validator's false-positive "untouched template" rejection of clean READY/SCOPE_COMPLETE verdicts.
Context. Two follow-ups from the morning's "switch to stream-json" decision (entry below):
-
stream-json's UX didn't pan out. The web UI's log panel showed only the first JSONL line (the
systeminit event) — subsequent assistant / tool_use / tool_result events were either not flowing into the panel's polling cadence or were rendered as long unparseable single lines that didn't fit the panel.tail -fon disk worked, but the panel that 99% of users actually look at didn't. The structured noise was a strict loss vs. the silent buffering we replaced. -
A second bug surfaced when the SA ran with qwen3-coder. A complete calc workspace re-review legitimately produced a clean
readiness: "READY"with emptygaps/suggestions/risksarrays +recommended_approach: null— qwen's terse-but-correct output style. cfcf's signals-validator rejected this exact shape as "untouched template" because the template ships with all-empty fields. Result: cfcf paused with "signal file missing or malformed" even though the signals file was both present AND valid.
Decisions.
(a) Revert the stream-json switch + the original --verbose flag. Both adapters now run plain claude -p "<prompt>" (with --dangerously-skip-permissions [--model X]). Trade-off: silent log file during the run; final response dumped at exit. We accept this trade-off for now — readable text logs > unreadable JSONL — and revisit if/when we add a JSONL→text formatter to the web UI's log viewer (iter-7 territory). Entry below documents the original switch + the lessons we still keep about flag-vs-protocol-surface naming + ps-output-as-ground-truth; what we DON'T keep is the conclusion "always use stream-json". Explicit follow-up: a proper log-viewer formatter for JSONL would let us re-enable stream-json without the UX cost.
(b) Fix the architect-signals validator to distinguish "agent never edited the file" from "agent legitimately found nothing to add". The rule is now: {empty gaps + suggestions + risks + null recommended_approach} is treated as untouched-template only when readiness is NEEDS_REFINEMENT or BLOCKED (those values demand explanation). With READY or SCOPE_COMPLETE, empty supporting fields are accepted as the agent's actual verdict. 4 new regression tests in architect-runner.test.ts pin both halves of the contract.
Lessons.
-
Streaming protocol changes are not free even when they're "obviously better." stream-json IS strictly more capable than print mode, but the consuming surface (web UI log panel) was unprepared for it. Always pair a streaming-output change with the consuming-side formatter, OR accept the readability regression.
-
Validator-as-template-detector is a fragile pattern. "If everything's empty, the agent didn't fill it in" is intuitive but conflates two semantically distinct cases. The model that surfaced the bug (qwen3-coder) wasn't doing anything wrong — it was being terser than Claude's typical output style. The fix recognises that the load-bearing field is
readiness, not the supporting fields. Whenever a validator rejects on absence-of-content, ask "could the agent legitimately have nothing to add here?" -
Same-day reverts are healthy. The morning's stream-json switch shipped; the afternoon's dogfood revealed the UX cost; the same-day revert with a clear note in the decisions-log preserves the experiment + its cost without leaving the bad state in main. Better than insisting the morning's decision was "right" because it was logged.
Cross-refs. packages/core/src/adapters/claude-code.ts, packages/core/src/adapters/claude-code-ollama.ts — both back to plain -p. packages/core/src/architect-runner.ts parseArchitectSignals — readiness-conditional template-detection. Tests pinning both invariants. Future work: JSONL→text formatter for the web UI log viewer (iter-7 candidate).
2026-05-08 — claude -p --verbose buffers stdout despite the Apr-17 commit's "live progress" claim; switch to --output-format stream-json for live JSONL events (SUPERSEDED same day — see entry above)
Context. Commit bb92921 (2026-04-17, "fix: add --verbose to Claude Code invocation for live log progress") added --verbose to the claude-code adapter's buildCommand. The commit message claimed: "--verbose shows turn-by-turn text output so users can watch progress, matching Codex's verbose-by-default behavior." That claim was untested against a long-running prompt; subsequent quick prompts completed in <1s, so the silence was masked.
Dogfooding 2026-05-08 against a 30B local model (claude-code-ollama driving qwen3-coder:latest through ollama launch claude) revealed the truth: claude -p --verbose still buffers stdout to the end. The dev iteration log file stayed at 0 bytes for ~3 minutes while ollama's own log showed dozens of /v1/messages?beta=true POSTs flowing through (each a real turn). Claude was making real progress; the harness saw nothing until the agent exited.
Confirmed via process inspection: 3 claude PIDs alive, ollama runner at 0% CPU between turns, log file unchanged. After agent exit, the buffered output dumped all at once.
Why the Apr-17 claim turned out to be wrong. Without seeing the source, the most plausible explanation is that claude's -p mode has always been a "single-completion" contract — it returns one final text response when the agent finishes its work, regardless of --verbose. --verbose adds metadata + warnings to that final response; it doesn't change the streaming contract. The streaming output mode is --output-format stream-json (a separate flag, structured JSONL events for each turn). The Claude Code desktop app uses --output-format stream-json --verbose --input-format stream-json (visible in ps output of an interactive session) — that's the proper streaming invocation.
Decided — switch the claude-code and claude-code-ollama adapters to --dangerously-skip-permissions --verbose --output-format stream-json -p "<prompt>". Trade-off:
- Win: Live turn-by-turn progress in the log file (one JSONL event per assistant text chunk, per tool call, per tool result). Useful for tailing during long iterations on local models.
- Cost: The log file is now JSONL instead of plain text. Casual
tail -fshows structured noise (one JSON object per line) rather than narrative. A future log-viewer formatter can render the stream as readable text on demand; for nowcat/tailwork but require a JSONL eye to read. - No change to signal-file output: Judge / architect / reflection consume
cfcf-iteration-signals.jsonfrom disk, which the agent writes viaEdit/Writetool calls. Stdout format change doesn't affect signal-file parsing.
Lessons.
-
A "fix: …live progress…" commit deserves a long-running test before claiming success. The Apr-17 fix was likely tested against a quick "say hello" prompt that completed in <1s — too fast to tell streaming from buffering apart. Future "live log" claims need a multi-minute agent run as the verification path.
-
Read the upstream protocol surface, not the surface flag.
--verbosesounds like it should make output verbose-and-streamy.--output-format stream-jsonis the actual streaming contract per Anthropic's CLI design. Generic-name flags (--verbose) are usually about quantity of output, not streaming semantics. -
ps-output of a known-streaming peer is a good ground truth. Once the user's interactive Claude Code session was visible viapsrunning--output-format stream-json --verbose --input-format stream-json, the right answer was obvious. Whenever an upstream tool's flag behaviour is murky, look at how its first-party UI invokes it.
Cross-refs. packages/core/src/adapters/claude-code.ts, packages/core/src/adapters/claude-code-ollama.ts — both pass --output-format stream-json --verbose. Tests in packages/core/src/adapters/adapters.test.ts pin the flag.
2026-05-08 — cfcf clio docs ingest/edit --project X was silently absorbed by parent option (commander.js shadowing)
Context. A Product Architect session (calc workspace, 2026-05-08) reported: PA passed --project cf-system-pa-memory on cfcf clio docs ingest, but both ingested docs (pa-workspace-memory + pa-session-pa-...) ended up in cf-system-default. PA then ran cfcf clio docs edit <id> --project cf-system-pa-memory to migrate them and reported it "silently no-op-ed" the project move while still applying any --set-meta change.
Reproduction confirmed against the user's actual Clio DB:
cfcf clio docs edit <id> --project cf-system-pa-memory→ "Nothing to edit. Pass at least one of --title, --author, --project, --set-meta, --unset-meta." (action's payload-empty guard fired even though--projectWAS passed)cfcf clio docs edit <id> --project cf-system-pa-memory --set-meta debug=test→ "Updated document …" but the doc remained incf-system-default; the audit-log diff showed onlymetadatachanged, noprojectId.cfcf clio docs ingest /tmp/x.md --project cf-system-pa-memory→ ingest's child-defaultcf-system-defaultwon.
Root cause. commander.js's option-resolution rule when the same long-name option exists on both a parent and a child command: the parent's option absorbs the value, leaving the child's opts field undefined. The CLI code at packages/cli/src/commands/clio.ts had defined -p, --project directly on the docsCmd parent (for its default-list action), AND each child subcommand (ingest, edit, etc.) had its own --project. When the user typed --project X, commander attached X to the parent's hidden opts; the child's action handler saw opts.project === undefined.
For ingest, the child had a default of "cf-system-default", so the un-set value silently fell back — no error, just the wrong project. For edit, the child had no default, so the action's "is anything to edit?" guard fired with the misleading "Nothing to edit" message. When the user added --set-meta to bypass the guard, the metadata change went through but the project change still didn't.
Options considered.
- Rename one of the
--projectflags (e.g. parent →--scope-project, child →--target-project). Functional but breaks the symmetric CLI naming + bloats the user-facing surface. Rejected. - Read
opts.projectfrom both child and parent in each action handler (opts.project ?? cmd.parent?.opts().project). Hacky; every command that nests underdocswould need this fallback. Rejected. - Remove the parent-level options on
docsCmdso the children's--projectreach their action handlers cleanly. The cost:cfcf clio docs(no subcommand) used to act as a defaultlistwith--project/--limitoptions; after the fix, it prints help and users invokecfcf clio docs list ...explicitly. Selected.
Decided — option 3. docsCmd's .option(...).action(listDocs) block was removed; the explicit docsCmd.command("list") (which had the same options redundantly) is now the only way to list. Default-action behaviour for the cfcf clio docs parent now falls through to commander's help.
The fix is verified end-to-end against the user's real Clio DB:
$ cfcf clio docs edit <id> --project cf-system-pa-memory
Updated document <id>
project: dbb42f29-820b-4f59-9514-0045fee2081f ← cf-system-pa-memory uuid
$ sqlite3 ~/.cfcf/clio.db "SELECT (SELECT name FROM clio_projects WHERE id=d.project_id) FROM clio_documents d WHERE d.id='<id>';"
cf-system-pa-memory ← moved correctly
Audit log now shows the projectId diff:
edit-metadata|agent|{"diff":{"projectId":{"before":"<old-uuid>","after":"<new-uuid>"}}}
Lessons.
-
commander.js parent-vs-child option shadowing is a real footgun. The library matches a long-name option to a command in its lookup chain; if the parent matches first, the parent gets the value. There's no warning, no error — the child's action handler just sees
undefined. When you add an option to a child and a same-named option already exists on a parent, you've silently changed semantics. Rule we're adopting: treat parent-level options as exclusive to commands that have no children with overlapping names. Either move the option to the children explicitly, or rename it to avoid the clash. -
Default-action subcommand patterns interact badly with shadowing. The
parent.option(...).action(listAction)pattern (parent doubles as a default-list action) made the parent define options that any subcommand would also need, creating clashes by construction. The cleaner pattern is: parent has NO action (commander prints help), explicit.command("list")carries the list options. Default actions are convenient but expensive to keep correct as the subcommand tree grows. -
"Silent no-op" reports deserve hard reproduction before believing the symptom. PA's report was correct on the failure mode but identified the wrong root cause (claimed it was a "workspace auto-route" overriding the explicit flag). Running the actual command sequence in a debug shell, watching the audit log, and checking commander's parsed-opts shape pinpointed the real issue in ~10 minutes. Don't accept "this command silently no-ops" without seeing what payload actually reaches the server.
Cross-refs. Commit ships in iter-6 6.28's debug pass. Regression test at packages/cli/src/commands/clio-option-parsing.test.ts pins the option-parsing invariants (4 tests covering edit/ingest with and without --project).
2026-05-07 — Anthropic's third-party-harness policy → adapter expansion via ollama launch (item 6.28)
Context. Anthropic's Jan→Apr 2026 clarification on subscription OAuth tokens (Consumer Terms §3.7 + the February 2026 written clarification: "Using OAuth tokens obtained through Claude Free, Pro, or Max accounts in any other product, tool, or service — including the Agent SDK — is not permitted" + Boris Cherny's 2026-04-04 X post tying subscription limits exclusively to interactive use) made cfcf's unattended iteration loop a textbook violation pattern when running under a Pro/Max subscription. The rule targets the credential (subscription OAuth token in any third-party tool / harness / Agent SDK), not the headless-vs-interactive pattern per se — but in practice the unattended claude -p path used by cfcf's dev / judge / reflection / documenter roles maps onto exactly that violation. Carve-outs — interactive claude CLI, Anthropic's first-party Routines, CI on the user's own repo with CLAUDE_CODE_OAUTH_TOKEN, Agent SDK with API-key auth — don't cover the cfcf use case.
OpenAI / Codex's policy is materially different. The Codex pricing page explicitly endorses the API-key path as "great for automation in shared environments like CI", and the CLI is documented as scriptable for non-interactive pipelines. No published prohibition on third-party harnesses. The existing codex adapter is therefore unaffected; the response is scoped to Claude Code.
cfcf needs (a) a non-Claude-subscription path for unattended roles and (b) clear UX guidance steering users toward that path while leaving the interactive Product Architect + Help Assistant roles on Claude Code (where the subscription is unambiguously fine because the agent's TUI takes over the user's shell directly).
Options considered.
- Stay on Claude Code for all roles (status quo). Policy violation by the rule as written. Rejected.
- Env-vars + local proxy. Plumb
customBaseUrl+customAuthTokenper role on the existingclaude-codeadapter; users run a local proxy (claude-bridge/LiteLLM/claude-code-router) that translates Anthropic-shape requests to ollama. Drafted in the 2026-05-07 design pass and rejected after a correction from the user: the working setup isollama launch claude --model gemma4:31b— a single exec command, no proxy stack, no env-var dance. The proxy approach would have shifted maintenance responsibility onto cfcf docs (which proxy to recommend, version pinning, debugging steps when the proxy desyncs from upstream API shape). - Single config flag on the existing
claude-codeadapter ("via ollama? yes/no") that swapsbuildCommandbetween directclaudeandollama launch claude. Functional but fights the existing adapter contract: the model picker has to source from two different lists depending on the flag, availability has to consider both binaries, the display name is ambiguous. Rejected. ollama launch <agent>exec wrapper as separate adapter entries in the registry. Three new adapters:opencode(direct),claude-code-ollama,opencode-ollama. Each has a distinct availability check, model picker source, and display name. Existingclaude-codeandcodexadapters unchanged. Selected.
Decided — option 4. Specifically:
- Three new adapters in
packages/core/src/adapters/, registered inadapters/index.ts:opencode(direct) — availability viaopencode --version; models picker shows just the "Custom model name…" sentinel from 6.26 because cfcf doesn't see opencode's internal provider config (the user owns it viaopencode auth login).claude-code-ollama—buildCommandreturns{ command: "ollama", args: ["launch", "claude", "--model", <ollama-model>, ...claude-flags] }; availability requires BOTHollamaandclaudedetected; models fromavailableOllamaModels; instruction filename staysCLAUDE.md.opencode-ollama— same pattern withopencodeas the wrapped agent; availability requires bothollamaandopencode.
- Ollama detection as a standalone helper alongside
detectAvailableAgents()—ollama --versionto confirm installation,ollama listto enumerate locally-pulled models. Result cached asavailableOllamaModels: string[]onCfcfGlobalConfig; refreshed bycfcf init/cfcf config editand on web Settings tab mount. - Role mapping (recommended
cfcf initdefaults — user can override):- Interactive — stay on Claude Code by default: Product Architect (
cfcf spec), Help Assistant (cfcf help assistant), Solution Architect when invoked manually viacfcf review. The agent's TUI takes over the user's shell; cfcf is acting as a launcher with curated prompts. Within Anthropic's allowed-interactive scope. - Unattended — default off Claude Code: dev, judge, reflection, documenter, and architect when
autoReviewSpecs=true. Pick fromclaude-code-ollama,opencode-ollama,opencode(direct, with user-configured provider auth), or the existingcodex.
- Interactive — stay on Claude Code by default: Product Architect (
- Warning UI when
claude-codeis selected for an unattended role — CLI one-liner at config save, web yellow callout below the adapter dropdown. Suppressed for PA / HA / manual SA. Wording: "For limited testing only — do not use for production use as Anthropic prohibits unattended harness use of Claude Code subscriptions. Seedocs/guides/anthropic-policy.md." - Documentation sweep — README + the relevant guides (
manual.md,cli-usage.md,installing.md,workflow.md,troubleshooting.md) get a section on the policy + the role-by-role recommendation. New dedicated guidedocs/guides/anthropic-policy.mdcarries the long-form explanation.
Out of scope (deferred): Claude API metering as a separate role-target lane (a pure "API-key Claude" path that doesn't go through claude-code CLI at all); opencode with non-ollama providers exposed in the model picker (Anthropic API direct, OpenAI, OpenRouter dropdowns — adapter still works, just no first-class picker for those); auto-installation / management of ollama or local models.
Lessons.
- The policy is about the credential, not the pattern — but maps to the pattern in practice. Reading "third-party harness" as "any binary you run" loses the actual rule (subscription OAuth token in an unauthorised tool). cfcf's
claude -pin the iteration loop is a violation because it consumes a subscription OAuth token from a non-Anthropic-managed harness, not because it's headless. The credential framing matters: it explains why an env-var-with-API-key path would be policy-clean (no subscription OAuth involved) while the same binary running headless under subscription is not. - Don't assume cross-vendor symmetry. Anthropic's framing ("subscription OAuth token in third-party tool = no") is materially different from OpenAI's ("API key path is great for automation, ChatGPT-sign-in is for personal use, no third-party-harness prohibition"). The mitigation has to be vendor-specific; a generic "harness compatibility" abstraction would have over-fit one and under-served the other.
- Trust the user's working command shape over the design's first-pass. The original draft used env-vars + proxy because that's what the literature on "Claude Code with custom backend" surfaces first. The user's actual setup is
ollama launch claude --model gemma4:31b— a much simpler exec wrapper that side-steps the entire proxy-stack-maintenance question. When the user's running command differs from the design's first instinct, the user's command usually wins because it's already known to work. - Separate adapter entries beat config flags when the adapter's "shape" really differs.
claude-code-ollamahas a different command, different model picker source, and different availability check thanclaude-code. Cramming both into one adapter via a flag would have cost clarity at every picker site to save ~30 lines of adapter boilerplate. TheAgentAdapterinterface is cheap; use it. - Document the rejected option, not just the chosen one. The env-vars-and-proxy approach is a plausible reading of "support ollama in claude-code" and a future contributor might re-propose it without context. Captured here so the rejection rationale is durable.
Cross-refs. docs/plan.md row 6.28 (full implementation scope across seven sub-bullets a–g); F.1 in the Backlog (Third agent adapter — trigger fired; superseded by 6.28 for the OpenCode candidate, residual Aider + Goose candidates remain); docs/guides/anthropic-policy.md (planned, will carry the long-form user-facing explanation when 6.28 ships).
Context. Item 6.18 round-3 surfaced a recurring question: when an agent (or the iteration loop, or the user via the CLI / web UI) writes to Clio, who is doing the write — and how do we make the audit log + future analytics able to tell? Pre-6.18-round-3 the answer was inconsistent: the iteration loop's auto-ingest paths sometimes set author: "agent" (a meaningless catch-all), the PA + HA prompt examples didn't pin a stamp at all, and the user-facing CLI defaulted to "mcp-agent" or whatever the caller passed. Result: the audit log records mutations, but you can't filter to "all writes by the dev role under claude-code" because the dimension wasn't being captured.
This isn't an access-control problem — Clio is shared memory across all agents and the user in the cf² ecosystem, by design. Anyone can write to anything. The question is identity attribution, not authorisation.
Options considered.
- Free-text author per call. Status quo. Each call site picks its own string. Worst case the audit log has dozens of variants of "the dev agent" —
"dev","dev-agent","claude","claude-code dev"— none of which are filterable. - A single canonical role string per call site (
"dev","judge", etc.). Better than free text, but loses the "which adapter under which model" axis. When two workspaces both ran the dev role but one used Claude Code and the other used Codex, the audit log can't distinguish them. - Structured three-part stamp:
<role>|<agent>|<model>(e.g.dev|claude-code|sonnet,product-architect|codex|gpt-5,user|cli|default). Mirrors Cerefox's existing actor convention exactly so a future swap-in to a Cerefox-backed Clio doesn't break audit-log filters or analytics. Selected.
Decided — option 3. Specifically:
- Format:
<role>|<agent>|<model>.role∈{dev, judge, architect, reflection, documenter, product-architect, help-assistant, user}.agentis the AgentAdapter name (claude-code/codex).modelis the resolved model alias or"default"when no override was set. Pipe separator chosen to match Cerefox's existing convention; not parsed (it's an opaque identifier from the audit log's perspective), but legible enough that humans reading audit rows can decode roles + adapters at a glance. - Single source of truth:
formatClioActor(role, agent, model)inpackages/core/src/clio/actor.ts+ROLE_*constants. Every call site imports the helper rather than templating the stamp inline. - Threaded through every write site: (a)
loop-ingest.tshas anactorForRole(workspace, role)helper that maps role → workspace's per-role AgentConfig and passesauthor: <stamp>to all 5 auto-ingest sites (reflection / architect / decision-log / iteration-summary / raw artifacts). (b) PA + HA prompt-assemblers render a "Clio actor stamp" subsection inside Memory + pre-fill--author "<stamp>"/--actor "<stamp>"on everycfcf clio docs ingest/delete/restore/editexample so the agent uses the right stamp on direct writes. (c)cfcf spec+cfcf help assistantlaunchers compute the actor at launch from the resolved AgentConfig and pass it to the assembler. (d) Iteration-roleclio-guide.mdtemplate gained a brief author-convention section so dev / judge / architect / reflection / documenter follow the same convention when asked to write directly. - The stamp is metadata, not access control. Documented explicitly in the actor.ts module docstring + CLAUDE.md + the system-projects entry below. Any role can write to any doc; missing or inconsistent stamps just make writes invisible to audit-log filters, they don't refuse the write.
Read-audit gap (deferred to 6.9). Cerefox additionally maintains a separate usage_log for read operations (each search / docs get records the requestor + query). cfcf's audit log captures mutations only today. Likely we want parity for the same actor-convention reason (so analytics can answer "which roles search Clio most often?", "which queries return zero hits?"), but it requires (a) new event type(s) + a new SQL table or column, (b) a CLI surface (cfcf clio audit --reads), and (c) a privacy review (does logging every search query risk capturing prompt-leakage if the agent quotes problem.md content into a query?). Captured in docs/plan.md row 6.9.
Lessons.
- Identity is a metadata problem in shared memory; access control is a different problem. Don't conflate them. Once you accept that "anyone can write to anything", the audit-log dimension matters more than per-write authorisation. Cerefox got this right and it scales nicely.
- A single source of truth for an identifier format pays off the moment you have three+ call sites. The
formatClioActorhelper costs ~10 lines and lets every PR that touches a write site stay consistent without each author re-deriving the convention from prose docs. - Mirror upstream conventions before inventing your own. Cerefox's actor stamp pre-existed; copying it (rather than inventing a parallel cfcf-flavoured one) means any future CerefoxRemote backend swap-in keeps audit-log queries working unchanged. This is the same principle as the wrapper-layer-invariants lesson from 2026-04-25 — port the convention, not just the engine.
Cross-refs. packages/core/src/clio/actor.ts; packages/core/src/clio/loop-ingest.ts (actorForRole helper + 5 auto-ingest sites); packages/core/src/{product-architect,help-assistant}/prompt-assembler.ts (actor-stamp subsection); packages/core/src/templates/clio-guide.md (iteration-role guidance); CLAUDE.md "Clio author / actor convention" entry.
2026-05-03 — System-managed Clio Projects: cf-system-* convention, lock at backend, boot-time ensure (NOT migrations)
Context. Across iter-5 + iter-6 a small handful of Clio Projects accreted that were owned by cfcf code, not the user — agent prompts hardcoded the project names, the iteration loop auto-routed to them, the PA/HA paths read + wrote them. Names varied historically (default, cfcf-memory-pa, cfcf-memory-global) without a unifying convention. Two related risks:
- User renames or deletes a system project via the web UI, the next agent run silently auto-creates a replacement under the original name (via
resolveProject({createIfMissing: true})), and the user's edits are orphaned — looks like cfcf "lost" their data. - Without pre-creation, system projects only appear in the web UI's project picker after their first auto-ingest. New users see an empty Projects tab and reach for "Create project" before realising the system bucket they want already has a canonical name.
Decisions.
- Naming convention:
cf-system-*prefix. Four projects today (cf-system-default,cf-system-memory-global,cf-system-pa-memory,cf-system-ha-memory). Future per-role memory projects (cf-system-reflection-memory, etc.) join the set as their roles wire up. Single mechanical rule; no judgement calls about what's "system" vs "user". - Single source of truth in TypeScript (
packages/core/src/clio/system-projects.ts): per-name constants + aSYSTEM_PROJECTSSet +isSystemProject(name)helper. Every cfcf module that writes to a system project imports the constant rather than hardcoding the string. - Locked at the backend layer, not the UI.
LocalClio.editProject+deleteProjectthrow if the target is inSYSTEM_PROJECTS. The lock therefore holds across web UI + CLI + any future surface (a third-party tool that hits the API directly couldn't bypass it). The web UI's hide-the-button behaviour is layered defence, not the primary mechanism. - Doc ingest into a system project IS allowed. Only the project itself (rename / delete / re-describe) is locked. This keeps Clio's "shared memory across all agents and the user" model intact — users + agents can still pin notes into any system bucket — while protecting the canonical project names that agent prompts hardcode.
- Pre-creation at server boot via
ensureSystemProjects()called fromstart.ts. Idempotent (callsgetProjectfirst, no-ops if present) + best-effort (failures don't block boot, theresolveProject({createIfMissing: true})auto-create path is still in place as defence-in-depth). - NOT seeded via SQL migrations. Tempting alternative: a
0003_seed_system_projects.sqlthat INSERTs the four system rows. Rejected — migrations are immutable (you can't edit a shipped one), so adding a new system project later would mean a fresh migration, and the code-sideSYSTEM_PROJECTSconstants would have to stay in lockstep. Putting the seeding inensureSystemProjects()means a single edit to the TS file is enough; the function runs every boot and stays in sync with the constants by construction.
Lessons.
- Pre-existing data under old names becomes a back-compat tax the moment you rename. When we renamed
cfcf-memory-pa→cf-system-pa-memorywe couldn't drop the old project (users had docs in it). It's reachable via the web UI's Projects tab; users migrate manually via per-doc project reassignment. Lesson for next time: lock the convention before docs accumulate under the legacy name. - Boot-time invariants belong in code, not migrations, when they need to evolve. Migrations are great for schema changes (immutable, deterministic, version-tagged). They're a poor fit for "make sure these N rows exist" when N grows over time and the rows are tied to TypeScript constants. The boot-time ensure pattern is mechanically simpler and lets the source-of-truth live where it's read from.
- Lock at the layer that all surfaces share. Hiding the Edit/Delete buttons in the web UI is a UX nicety; throwing in
editProject/deleteProjectis the actual lock. If we'd only done the UI hide, a CLI command (cfcf clio projects delete cf-system-pa-memory) or a direct API call would still let it through.
Cross-refs. packages/core/src/clio/system-projects.ts; packages/server/src/start.ts (boot-time ensure); packages/web/src/pages/memory/ProjectsTab.tsx (UI badge in Actions column); CLAUDE.md "System-managed Clio Projects" entry.
Context. The 2026-04-25 entry above committed cfcf-Clio to parity with Cerefox at the MemoryBackend boundary. Iter-6's web Clio Memory page (item 6.18) added a richer surface — project edit / delete, document edit / restore, content-unchanged short-circuit on re-ingest, the (system) badge — which raised the question for every PR: what counts as a "parity-required" surface, and what's "stack-driven divergence we should document"? Without a sharper rule, every Clio change risks either over-conservatism (refusing useful local-only features) or drift (silently adding behaviours that won't survive a future CerefoxRemote swap-in).
Decided — promote the parity rule to a load-bearing constraint and codify the question.
- What MUST stay compatible (the "could a future Cerefox-backed swap-in still serve this same call?" test):
- Endpoint shapes (
/api/clio/*). - Query parameter names + semantics (search modes,
?by=doc/?by=chunk, project filter, metadata filter, match count, small-to-big knobs). - Response field names + shapes (
DocumentSearchHit,ClioAuditEntry, etc.). - CLI verb structure (
cfcf clio search/ingest/docs/projects/audit/...).
- Endpoint shapes (
- What's allowed to diverge (stack-driven implementation choices, not surface contracts):
- SQLite/FTS5 vs Postgres/tsvector + pgvector internals.
- Per-column
bm25(...)weights vssetweighttsvector composition. - FTS5's lack of a built-in stop-word list vs Postgres's English stop-word default.
- SQLite's
bm25()returning negative values requiring per-pool min-max renormalisation vs Postgrests_rank_cdalready being roughly[0, 1].
- When stack constraints force a divergence, document it in the design doc + decisions log so the integration plan can address it (e.g. "Clio uses 3-column FTS5 with weights
(2.5, 2.5, 1.0)to match Cerefox's effective A:B = 2.5× fromts_rank_cd; the underlying ranker differs but the title-boost ratio is the user-visible knob both sides expose").
The question to ask, codified for every Clio surface change: "could a future Cerefox-backed swap-in still serve this same call?"
Why this matters now. Iter-6's Memory page added several new surfaces that touched the boundary: PATCH/DELETE /api/clio/projects/:idOrName (new endpoint shapes), workspace-dependency check returning 409 + {dependentWorkspaces, docCount} (new response shape), content-unchanged short-circuit splitting updateDocument into 3 branches (full skip / metadata-only / full re-chunk) with corresponding audit event types. Each of these passed the parity test — Cerefox's RPCs already exposed equivalent endpoints + response shapes, including the metadata-only-edit branch (Cerefox's cerefox_update_document_metadata was the prior art). Without the rule, we might've made up new endpoint shapes and locked ourselves out of the swap-in path.
Lessons.
- A meta-rule that fits in one sentence is more useful than a list of dos-and-don'ts. "Could a future Cerefox-backed swap-in still serve this same call?" is the actual question on every PR. Maintaining a long compatibility checklist would atrophy; a single question becomes muscle memory.
- Internal stack divergence is fine and expected. SQLite isn't Postgres. FTS5 isn't tsvector. Trying to make them identical at the implementation layer is a fool's errand — what matters is that the user-visible knobs (search modes, parameter names, response shapes) compose the same way. The 2.5× title-boost ratio is the user-visible knob; the per-column
bm25()weights are the implementation. Both sides expose the same knob; the implementation underneath is each stack's business. - Capture each documented divergence in the decisions log when it happens. "We diverge on X because Y" is a durable artifact; a year from now when a CerefoxRemote PR is being designed, the integration team can read this log + the per-divergence design-doc notes and know what they signed up for. No surprises.
- The content-unchanged short-circuit is a worked example of the rule paying off. Round-2 of 6.18 added a 3-branch logic to
updateDocument(full skip / metadata-only / full re-chunk) with corresponding audit event types. Cerefox already had the metadata-only path (cerefox_update_document_metadata); cfcf's mirror keeps the swap-in compatible while solving a real correctness bug (stale-version proliferation when round-tripping unchanged docs). Parity + correctness, not parity vs correctness.
Cross-refs. CLAUDE.md "Cerefox interface parity is a load-bearing constraint" entry; docs/design/clio-memory-web-ui.md; the 2026-04-25 entry on MemoryBackend boundary parity (the precursor to this one).
2026-05-02 — Post-6.25 UX iteration: history-row rendering, pause message wording, paused-state control-surface deduplication
Context. Three small UX gaps surfaced during the first round of dogfooding the structured pause actions (item 6.25, shipped via PR #27 a few hours earlier). Each was a discrete defect with its own root cause; together they're a useful study of "what kinds of things go wrong when you ship a feature that introduces a new event type + a new control surface." Captured as one entry because the lessons compound.
The three defects (briefly, as context for the lessons).
-
loop-stoppedhistory rows rendered with empty Type / Agent / Result columns + a broken "log" button. Root cause: the newloop-stoppedevent type was added to core'sHistoryEventType, but the web package keeps its own parallel mirror of that type. Adding to one doesn't auto-update the other; the type system can't enforce sync between two parallel definitions. Compounded by the rootbun run typechecknot covering the web package's strictertsc -b && vite build— the gap surfaced only whenlocal-install.shtriggered the web build. -
The pre-loop pause message read as jargon. Original phrasing described gate-arithmetic ("readiness=missing does not satisfy gate=needs_refinement_or_blocked") rather than the user's situation + what to do. Engineer-readable, user-hostile.
-
Top-level Resume / Stop / Document buttons competed with the new structured FeedbackForm action panel during pause state. Not just visual clutter: the legacy
Resumebutton calledapi.resumeLoop(workspaceId)without the newactionargument — defaulting server-side to"continue"and bypassing the user's structured choice from the FeedbackForm. Wrong-routing footgun on top of the duplicated control surfaces.
Lessons.
-
A new event type or signal touches every consumer. When
loop-stoppedwas added toHistoryEventType, the natural assumption was "I added it to the type definition; everywhere using the type is auto-covered." Wrong: the web package has its own mirror of the type — not a shared import — so the addition was silently incomplete. For any new enum value or event type, grep for the existing values across all packages and update each consumer. The doc says they should be in sync; the type system can't enforce sync between two parallel definitions. -
The root
bun run typecheckdoesn't cover the web package. Caught this whenlocal-install.shfailed — the web package'stsc -b && vite buildis stricter than root'stsc --noEmitand runs only when explicitly invoked (or transitively by the install script). Future improvement (already filed as a follow-up after the 0.16.5 SSOT version work): root typecheck should walk all packages, so the dev loop catches web type errors before manual builds. Until then: any change to shared types should runcd packages/web && bun run buildas part of pre-commit smoke. -
Empty cells in a UI table = forgotten code path. The
loop-stoppedrow's empty Type / Agent / Result columns weren't a styling bug; they were "the switch fell through, the conditional render had no branch." Empty cells should be visually loud (they were silent here) — or, better, every renderable union member should have an exhaustiveswitchthat TypeScript's never-check enforces. The row's broken "log" button is the same lesson: action buttons should check their data exists before rendering, not assume the dispatcher will handle a missing target gracefully. -
A new control surface needs an audit of existing surfaces it competes with. The structured FeedbackForm was added without checking whether the LoopControls (top-level buttons) were going to render simultaneously. Both rendered correctly in isolation; together they created a wrong-routing path because the legacy Resume call didn't pass the new
actionparameter. When you add a new control surface, audit every existing surface that's reachable in the same app state for overlap. Hide or update the old surface; don't leave both visible. -
"Pause message wording" is a recurring failure mode. The earlier "fix" had used gate-arithmetic phrasing ("readiness=missing does not satisfy gate=..."). That phrasing worked for an engineer reading the code; it didn't work for a user reading a UI prompt. Pause messages should describe the user's situation + what to do, not the harness's internal state machine. Future auto-generated pause messages should be reviewed against this lens.
Cross-refs. Commits c30cf1d, d1017ee on fix/loop-stopped-history-render.
Context. Two days into dogfooding the structured pause actions (item 6.25), a follow-up gap surfaced when the user re-ran the loop on the calc workspace (whose 4 iterations had completed days earlier). The Solution Architect correctly observed "No new product-scope gaps: the current problem pack's power operation is already implemented and tested in source" — but had to express this through readiness: NEEDS_REFINEMENT because the existing 3-value enum (READY / NEEDS_REFINEMENT / BLOCKED) had no way to say "spec is fine but there's no work to do." The user's "fix the spec" mental model from the misleading verdict competed with the architect's actual analysis ("the scope is done").
This is the same vocabulary-conflation pattern as the 2026-05-02 structured-pause-actions entry above: when a single signal is overloaded across orthogonal axes (here: spec-quality vs work-remaining), the harness silently misroutes the agent's actual intent. Different enum, same anti-pattern.
Decided. Add SCOPE_COMPLETE to ArchitectReadiness:
- Always blocks the loop regardless of
readinessGatesetting (no semantic where "proceed despite no work" makes sense; the gate's "tolerate spec issues and run anyway" semantic doesn't apply when there's no work). - Pauses with a dedicated
pauseReason: "scope_complete"(not"anomaly"— this is a positive outcome, not a problem state). - Available actions narrow to
finish_loop/stop_loop_now/refine_plan(hidescontinue— nothing to build — andconsult_reflection— no iterations to reflect on). - Distinct UI rendering:
readinessColor: var(--color-info)(neutral), dedicatedarchitect-reviewguidance text,pauseReasonTitle: "Loop paused: scope already complete".
Naming: SCOPE_COMPLETE chosen over NOTHING_TO_DO / ALREADY_DONE / IMPLEMENTATION_COMPLETE. Mirrors the architect's own language ("no new product-scope gaps"); reads naturally in the UI ("Architect rated this SCOPE_COMPLETE"); distinguishes clearly from judge.determination === "SUCCESS" (which means "the loop ran successfully") — SCOPE_COMPLETE means "the loop never started because there was nothing to do."
Holistic agent-signal audit. Triggered by the same vocabulary-conflation lesson, audited every agent's signal vocabulary against real-world cases to identify other gaps. Outcome: only the architect needed extending. Other roles either compose existing signals naturally or have orthogonal coverage:
| Role | Signal vocabulary | Verdict |
|---|---|---|
| Architect | readiness: 3 values |
Gap fixed: added SCOPE_COMPLETE |
| Dev | status: completed / partial / blocked + tests + self_assessment + blockers |
Sufficient. The "nothing to do mid-loop" case (e.g. iter-N over-delivers and consumes iter-N+1's plan items) is caught downstream by judge's anomaly_type: "no_changes" — semantically accurate enough; no new dev signal warranted by current usage. |
| Judge | 4 determinations + 5 anomaly_types + reflection_needed/reason | Sufficient. The 5 anomaly types (token_exhaustion / user_input_needed / circling / no_changes / regression) cover the long tail of iteration outcomes. SCOPE_COMPLETE-equivalent at iteration time = anomaly_type: "no_changes" + should_continue: false + key_concern explanation. |
| Reflection | 5 iteration_health values + recommend_stop + harness_action_recommendation |
Sufficient. The earlier 2026-05-01 fix already added iteration_health-based disambiguation; SCOPE_COMPLETE-equivalent at reflection time = recommend_stop: true + iteration_health: "converging". |
| PA | Free-form outcomeSummary + decisionsCount + clioWorkspaceMemoryDocId |
N/A. PA is conversational by design — no structured-determination enum. Free text expresses any outcome. |
Lessons.
-
The vocabulary-conflation anti-pattern recurs naturally. First instance:
recommend_stop=trueoverloaded across stuck/done/disagree (2026-05-02 entry above). Second instance:readinessoverloaded across spec-quality and work-remaining (this entry). Both fixes were "split the conflated axis." Worth watching for in any future signal additions: if one signal field has to express two orthogonal axes, the consumer will silently misroute one of them. -
Audit before assuming symmetry. When the user asked "now that we've added a new state to the architect, should we add it to other roles too for consistency?" — the natural temptation was to mechanically add SCOPE_COMPLETE everywhere. The right answer was an honest analysis: only the architect was missing it; other roles already have sufficient expressivity through different mechanisms. Symmetry-for-symmetry's-sake is the failure mode I previously called out in the structured-pause-actions discussion (PA + HA per-workspace overrides). Same lesson applies to signal vocabulary.
-
The "always blocks regardless of gate" semantic is the right shape for terminal-positive states. SCOPE_COMPLETE is functionally a terminal state (loop has nowhere to go), but it's a positive terminal — not a failure. The gate's tolerance semantic ("yes, run anyway") only makes sense when there's something to run; for "no work" it doesn't apply at all. Future agent-signal extensions in the "loop already done somehow" space should follow this pattern: hard-block, dedicated pauseReason, dedicated narrowed action set.
-
A new signal value forces a checklist of touchpoints. For
SCOPE_COMPLETE: type extension, gate logic, pause-reason, allowed-actions matrix, UI color, UI guidance text, agent prompt, tests across all of those, decisions-log entry. ~9 surfaces touched. Captured here so the next maintainer adding a signal value has a checklist to follow rather than hunting for affected sites by grep.
Conclusion. Per-role audit findings captured above confirm no further signal additions are warranted at this time. Future signal expansions must justify against the conflation-anti-pattern lens (one field, one axis) before being added.
Cross-refs.
- Architect prompt extension:
packages/core/src/templates/cfcf-architect-instructions.md - Type:
packages/core/src/types.ts(ArchitectReadinessenum) - Harness logic:
packages/core/src/architect-runner.ts:readinessGateBlocks+packages/core/src/iteration-loop.ts:buildPreLoopBlockReason+pauseReasonAllowedActions - UI:
packages/web/src/components/ArchitectReview.tsx(readinessMeta) +packages/web/src/components/WorkspaceHistory.tsx(readinessColor) +packages/web/src/components/FeedbackForm.tsx(action matrix) - Tests:
packages/core/src/iteration-loop.test.ts(12 new tests coveringreadinessGateBlocks,buildPreLoopBlockReason,pauseReasonAllowedActionsfor SCOPE_COMPLETE)
2026-05-02 — Structured pause actions: ResumeAction enum + iteration_health discrimination + reflection consult mode
Context. Two related bugs surfaced during dogfooding the tracker workspace on 2026-05-01:
-
Reflection-on-SUCCESS misroute: iter-5 ended with judge SUCCESS (9/10, 20/20 tests, plan 100% complete). The
reflectSafeguardAfter=3ceiling forced reflection even thoughreflection_needed: false. Reflection honestly observed "loop has nothing left to do" and setrecommend_stop: true. The harness sawrecommend_stop=trueand surfaced a "Questions needing your input" popup — a false alarm, because reflection AGREED with SUCCESS rather than flagging a problem. -
Free-text feedback misroute: user typed "Judge and Reflection agree, proceed to the Documentation phase and close the loop" into the popup. The harness's resume contract had one input channel (textarea) and one output behavior (continue with the text injected into the next dev agent's prompt as "guidance"). The user's clear control intent was silently rerouted as agent context — fired iter-6, dev agent got confused user feedback that didn't match the iteration it was running.
Both bugs traced to the same root cause: the harness's pause-resume contract had insufficient signal vocabulary to express user intent. recommend_stop=true was overloaded across three meanings ("stuck" / "done, agree" / "supposedly done, disagree"); the feedback string was overloaded across "context for next agent" / "instructions for harness" / "audit note." When semantics are conflated into one channel, the harness has no choice but to pick one default routing — silently misrouting all the others.
Options considered.
-
Just dismiss the false-alarm popup; accept the noise. Cheapest. Rejected: the popup creates anxiety ("did I do something wrong?") at exactly the moment of celebration (loop succeeded). Triggers any time
reflectSafeguardAftercycles complete on a SUCCESS iteration — non-rare in practice. -
Skip reflection entirely on SUCCESS iterations. Eliminates the problem class but loses real audit/safeguard value: reflection's cross-iteration view occasionally catches nuance the single-iteration judge misses. The user explicitly pushed back on this option; correctly.
-
Disambiguate
recommend_stop=trueviaiteration_health— already-existing reflection signal field with semantically distinct values (converging/stable= agreement;stalled/diverging/inconclusive= disagreement). Shipped first as a small fix (PR #26, 889127a, 2026-05-01). -
Generalize: introduce a structured
ResumeActionenum so all 9 pause cases get the same routing precision, not just reflection-on-SUCCESS. Free text stays optional + routes per action to the right destination. Shipped second as the bigger feature (PR #29, item 6.25, 2026-05-02). Full design indocs/research/structured-pause-actions-design.md.
Decided — ship both fixes (3 + 4). They're complementary:
- The
iteration_healthdiscrimination is the minimal fix for the specific tracker case; required regardless of the bigger framework. - The
ResumeActionenum is the general solution for the pause-resume contract across all cases. Without it, every pause case has the same "single textarea silently routes one way" failure mode.
Specific design choices for ResumeAction:
- Five actions, no more:
continue,finish_loop,stop_loop_now,refine_plan,consult_reflection. Empirically covers the universe per the matrix in the design doc. Adding a sixth would mean the matrix grew a column; we've staffed it for one human-decision step at a time. finish_loopvsstop_loop_nowchosen overstop_and_documentvsstop(initial naming). Clearer framing: "honour the configured loop end-state" vs "explicit immediate termination."finish_looprespectsautoDocumenter=false;stop_loop_nowalways skips the documenter.consult_reflectionas the escape hatch. When the user has nuanced free-text intent that doesn't map to a button, route through reflection — it has the cross-iteration view + already outputs structured signals. New fieldharness_action_recommendationlets reflection tell the harness what to do. Cheaper + cleaner than a separate "interpreter agent."- No bare "Resume" button. User must explicitly pick an action. Forces clarity; eliminates silent defaults. Mirrored in CLI:
cfcf resume --action <name>(defaults tocontinuefor back-compat with pre-6.25 scripts only). - Free text always optional + always routed. Per the routing table, text goes to the right destination per action: dev prompt for
continue, documenter prompt forfinish_loop, history audit forstop_loop_now, architect prompt forrefine_plan, reflection prompt forconsult_reflection. Never silently lost; never silently misrouted. - Per-pause-case applicability matrix computed by
pauseReasonAllowedActions(), single source of truth for both UI button visibility AND CLI argument validation. The two surfaces stay in sync because they consume the same helper. refine_planflow: synchronous architect spawn → automatic continue. No mid-flow re-pause (avoids pause-loops). Architect's existingplan-validation.tsguardrails catch destructive rewrites.stop_loop_nowhistory note: dual-write — structuredloop-stoppedevent inhistory.json(machine-readable) + human-readable narrative paragraph initeration-history.md. Two surfaces; one truth.consult_reflectionafter A6 (reflection just ran withrecommend_stop=true): allowed. Re-spawn reflection with the user's new feedback as fresh input. Full flexibility; cost is one extra agent run.
Lessons.
-
When semantics are conflated into one channel, the harness silently misroutes. This was the meta-pattern across both bugs.
recommend_stopwas a single boolean expressing three things;feedbackwas a single string expressing three things. Once we explicitly separated control-intent (action) from context (free text), every pause case routed correctly without per-case heuristics. The fix is in the protocol vocabulary, not in the harness logic. -
Defense-in-depth: signals-as-contract is the right design even when agents misbehave. The reflection-on-SUCCESS bug only mattered because the agent did its job correctly (wrote good signals + a thorough analysis) but the harness misinterpreted. Both fixes preserve the "files are the contract; agents are producers" invariant established in 5.6 (reflection PR1). Any future signal vocabulary expansion (this entry) reinforces rather than weakens it.
-
Symmetry is a misleading argument. Reflection-PR-discussion gave us "reflection runs every iteration → per-workspace override useful" → therefore by symmetry PA + HA should also be per-workspace overridable. Wrong: the workload structures differ, and symmetry is a UI-tidiness argument, not a design argument. Same lesson applies in reverse: just because reflection-on-PROGRESS pauses make sense doesn't mean reflection-on-SUCCESS pauses do (different scenarios despite similar signals). Look at the actual workload, not the surface symmetry.
-
The user's free-text feedback is precious — never silently drop it. Even when no agent runs (e.g.
stop_loop_now), capture the text to history as audit. Future debugging + retrospectives benefit from "what was the user thinking when they did this?" Free text without a destination is data leak; free text routed to somewhere is always useful. -
The escape hatch is a feature, not a fallback.
consult_reflectiondoesn't exist because we couldn't enumerate the long-tail; it exists because the LLM-interpreter pattern is genuinely the right tool for "the user's intent is nuanced and I have an agent that's good at interpretation." Building it as a first-class action (rather than as a "Layer 2 fallback when buttons are too restrictive") makes the design honest about what each component is for.
Outcome. Item 6.25 ships with full per-pause-case action routing, contextual UI button matrix, CLI --action flag with matrix validation, reflection consult mode, and stop_loop_now audit history. Both fixes (small iteration_health discrimination + general ResumeAction enum) cover the bug class end-to-end. ~580 tests passing.
Cross-refs.
docs/research/structured-pause-actions-design.md— full design + matrixdocs/plan.mdrow 6.25- 2026-05-01 reflection-on-SUCCESS fix (commit 889127a, PR #26):
iteration_healthdiscrimination via the pre-existing signal field
2026-05-01 — npm publish auth: OIDC trusted publishing + sigstore provenance (bootstrapped via a one-shot token)
Context. v0.16.2 was cfcf's first publish to npmjs.com, after the 2026-04-30 public-flip. The publish needed to work the first time with whatever auth path was simplest to bootstrap; subsequent publishes wanted the strongest supply-chain posture npm offers. Two distinct decisions, one workflow.
Decided — bootstrap path (v0.16.2 only). Manual npm publish from CI using a granular npm access token scoped to @cerefox/codefactory*, stored as NPM_TOKEN in the repo's GitHub Secrets. The token was created with the bypass-2FA option enabled — required because npm publish from a non-interactive CI runner can't satisfy a 2FA-via-authenticator-app check. Token was used for v0.16.2's publish and revoked the same day.
Decided — durable path (v0.16.3 onwards). Switch to OIDC trusted publishing via npm's GitHub Actions integration:
- Each of the 4
@cerefox/codefactory*packages has a Trusted Publisher entry on npmjs.com pointing at this exact repo (fstamatelopoulos/cfcf) + workflow (release.yml). - The
npm-publishjob declarespermissions: id-token: write, mints a short-lived OIDC token per workflow run, andnpm publishvalidates against the registered trust before publishing. No long-lived secret anywhere in the repo. - All 4 packages have publishing access tightened to "Require two-factor authentication and disallow tokens (recommended)" — the most restrictive supply-chain posture npm offers when combined with OIDC.
- Workflow uses Node 22 +
npm install -g npm@latestto ensure npm CLI ≥ 11.5.1 (the minimum Trusted Publishing requirement; Node 22 ships with npm 10.x). A pre-flight version check fails fast with a clear error if the install drops below 11.5. --provenanceflag added to allnpm publishcalls. Each tarball ships with a sigstore-signed attestation linking it to the exact GitHub Actions run that built it, surfaced as a "Provenance" badge on npmjs.com.
Why two-step instead of OIDC from day one. OIDC trusted publishers can only be registered against an existing package + a real workflow run that's already published it. You can't register a Trusted Publisher for a package that doesn't exist yet — npm has nothing to bind the trust against. A bootstrap publish using something else is required. The bypass-2FA token + same-day revocation is the recognised pattern (npm's docs walk through it explicitly). Once v0.16.2 was on npm, registering OIDC against the workflow + future-proofing every subsequent publish was a 5-minute UI step per package.
Outcome. Only release.yml on fstamatelopoulos/cfcf, manually triggered via workflow_dispatch with publish_to_npm=true, can publish new versions of these 4 packages. The repo holds no long-lived publish secret. All v0.16.3+ tarballs carry a sigstore provenance attestation visible on the package's npmjs.com page.
Lessons.
-
Plan auth in two phases when starting a new package. OIDC's chicken-and-egg means day 1 needs a different path. Designing for the durable end state from day 1 saves you from leaving a long-lived token in CI "just in case".
-
Revoke bootstrap credentials the same day, even if you're "definitely going to switch tomorrow". Tokens that linger past their stated lifetime are how supply-chain incidents start.
-
Provenance attestation is free with OIDC, costs one CLI flag, and gives users a visible signal that the artefact came from the repo it claims to. Worth turning on for every package that supports it.
Context. Prerequisite for npm OIDC trusted publishing (which requires a public repo on the free tier) and for the broader 5.5b "publish to npmjs.com" milestone. The repo had been private since inception (~351 commits, 4.43 MB of history including design notes, dogfood logs, and a Cerefox-internal phase). Flipping to public is one-way; needed a clean security/privacy sweep first.
Decided — pre-flight sweep before flipping the visibility switch.
- Secrets scan:
gitleaks detectagainst the full git history. Returned 0 findings. .env*audit: confirmed no.env*files anywhere in history.- Personal-info audit: no private keys, API tokens, phone numbers, physical addresses, or Cerefox-internal URLs in any commit, doc, or config.
- Author-metadata acceptance: two emails on git author metadata + author name on 4 design docs — these are intentional and accepted (the project is publicly attributed to its author).
SECURITY.md+CONTRIBUTING.mdadded at repo root before flipping (vuln reporting flow + contribution guide).LICENSEat repo root: switched the published packages' license fromUNLICENSEDto Apache-2.0 to match the rest of the Cerefox ecosystem.
Decided — npm package naming for 5.5b.
- npm package name =
@cerefox/codefactory(reads cleanly in install instructions; future-proofs the@cerefoxscope for sibling packages). Was@cerefox/cfcf-clipre-publish. - Per-platform native packages =
@cerefox/codefactory-native-<platform>(was@cerefox/cfcf-native-*). - CLI binary stays
cfcf— typing-friendly, users invoke 100x/day. No multi-bin aliases (cause cross-platform headaches without enough payoff). - Legacy
@cerefox/cfcf-*resolution fallback removed fromconstants.ts/clio/db.ts/doctor.ts— a hard cut so the legacy name can never silently take effect at runtime (security: prevents a hypothetical attacker from squatting the legacy name).
Decided — os/cpu declared on the main package itself, not just the per-platform native ones ("os": ["darwin", "linux"] + "cpu": ["arm64", "x64"]). So a Windows-native or FreeBSD user running npm install -g @cerefox/codefactory gets a fast, loud EBADPLATFORM at install time instead of silent success without the matching native package + a runtime crash on first run. cfcf doctor's libsqlite3 check stays as second line of defense.
Decided — cross-compile darwin-x64 on macos-14, not on macos-13. The macos-13 (Intel) runner pool became unviable for free-tier public repos (multi-hour queues during the public-flip window). clang -arch x86_64 on the macos-14 (arm64) runner produces a working darwin-x64 binary; validated by reverse-direction testing on a real Intel Mac.
Outcome. Repo public on 2026-04-30. v0.16.2 published to npmjs.com same day with the bypass-2FA bootstrap token (see 2026-05-01 decision-log entry on publish auth).
Lessons.
-
The cross-compile direction matters. macos-14 → darwin-x64 cross-compile works; macos-13 → darwin-arm64 is the historical baseline but the runner pool is constrained. Test the direction you can actually run in CI before you commit to it.
-
Hard-cut legacy package-name fallbacks before going public. A legacy resolution path is a foot-gun the moment your package-name is on a public registry where anyone can squat the old name.
-
Loud install-time failure beats silent runtime crash.
os/cpuon the parent package is the difference between "this OS isn't supported" atnpm installtime versus a SQLite-not-found stack trace 20 minutes into a user's first session.
Context. v0.16.2 shipped cfcf to npmjs.com. The next obvious task: a curl-bash one-liner so a random user lands on the GitHub repo, pastes one line, and gets a working cfcf. Multiple iterations between 2026-04-30 and 2026-05-01 tested four distinct install-tool strategies on two real Macs (Intel + Apple Silicon, both with prior cfcf in various states). Each design that "looked right on paper" surfaced a real-world UX problem that ruled it out. Capturing the full journey here so we don't re-litigate.
The hard constraint. install.sh runs as a child process of the user's shell (curl ... | bash spawns bash as a child). A child process cannot modify its parent's environment — Unix process model. Any export PATH=... in install.sh affects only install.sh's subshell; when install.sh exits, the parent shell's PATH is unchanged. Any rc-file edit only takes effect on next shell startup or source ~/.zshrc. Every modern shell-based installer hits this wall (Bun's official installer, rustup, nvm, Volta, Homebrew); they all print "open a new terminal" when they have to add new PATH entries.
The corollary: the BEST install.sh can do is install cfcf into a directory that's already on the user's PATH, so no new PATH entry is needed. If we have to add a new entry, we MUST tell the user to source-rc / open new terminal once.
Options considered (chronological, with what each tested + ruled out).
-
bun install -g @cerefox/codefactory(the v0.16.3 baseline). Single tool. ~/.bun/bin always user-writable; PATH already set up by Bun's installer. Worked structurally but Bun blocks postinstall scripts of transitive deps by default (oven-sh/bun#4959). cfcf depends ononnxruntime-node(downloads platform-specific .node binaries) +protobufjs(codegen) — without those running, Clio's embedder breaks at runtime. cfcf's publishedpackage.jsondeclarestrustedDependencies: ["onnxruntime-node", "protobufjs"], but bun's transitive-trust handling for-ginstalls is incomplete. User-facing fix: manualbun pm -g trust @cerefox/codefactory onnxruntime-node protobufjs. Ruled out for new users: the trust-prompt UX is exactly the "click yes to a security alert" pattern that erodes first-impression trust on an unknown CLI. -
bun install -g+ scripted auto-trust in install.sh. install.sh runs the trust grant for the user, named (not--all), with verbose output. Implemented + tested. Worked end-to-end. Ruled out: even with named packages + auditable output, the visiblebun pm -g trustline in the install output raised "what is this approving?" concerns. "I want to avoid asking a user that is a miracle that they showed interest to an unknown app to just 'trust' something." Engineering simplicity (single tool, ~120 line install.sh) didn't justify the new-user-UX cost. -
npm install -g @cerefox/codefactory(npm runs postinstalls by default — no trust step needed). Implemented + tested on the Apple Silicon Mac. Hit the EACCES gotcha: stock-installer Node (the official.pkgfrom nodejs.org) sets npm's global prefix to/usr/local/, root-owned.npm install -gfails without sudo. The npm-documented fix isnpm config set prefix ~/.npm-global+export PATH="$HOME/.npm-global/bin:$PATH"in shell rc — whatnvm/fnm/asdfdo automatically. Worked but added complexity: install.sh needs to detect the EACCES condition, set up~/.npm-global, write to shell rc; ~280 lines instead of ~120; user gets "IMPORTANT: open new terminal" friction every fresh install (parent shell can't see the new ~/.npm-global/bin until rc is re-sourced). -
npm install -g --prefix ~/.bun @cerefox/codefactory— the winning design. cfcf installs to~/.bun/bin/cfcf. Since cfcf REQUIRES Bun (usesbun:sqlite,Bun.spawn, etc. directly at runtime), every cfcf user has Bun. Bun's installer adds~/.bun/binto the shell rc when bun is installed. So~/.bun/binis on the user's PATH, and cfcf is reachable immediately — no new PATH entry, no rc edit by cfcf, no source-rc step.
Decided — option 4. Specifically:
scripts/install.sh: bootstrap Bun if missing; bootstrap npm if missing (bun install -g npm); runnpm install -g --prefix ~/.bun @cerefox/codefactory; print "Installation complete!" banner.scripts/uninstall.sh: detect cfcf at any of the historical install locations (~/.bun/lib/node_modules, ~/.npm-global/lib/node_modules, npm system prefix /lib/node_modules, ~/.bun/install/global/node_modules) and clean each up via the right tool.packages/cli/src/commands/self-update.ts: samenpm install -g --prefix ~/.bunfor upgrades.- The
--prefix ~/.bunflag is per-command; doesn't touch user's npm config (their othernpm install -goperations keep using their default prefix). - Bun is cfcf's RUNTIME (via the runtime APIs); npm is cfcf's INSTALL TOOL (because of bun#4959). Two tools, clean separation. install.sh handles both bootstraps transparently.
Friction cases under option 4:
| User profile | Friction |
|---|---|
| Already has Bun installed (the realistic cfcf user) | None. cfcf at ~/.bun/bin/cfcf, immediately on PATH. cfcf init works in same terminal. |
| Doesn't have Bun yet | One-time "open new terminal" or source ~/.zshrc after install.sh — same step curl bun.sh/install | bash requires regardless of cfcf. install.sh detects this state via ORIGINAL_PATH and prints an "IMPORTANT" banner block. |
Trade-offs accepted:
- cfcf lives in
~/.bun/lib/node_modules/alongside Bun's own globals. Cosmetic; functionally fine. cfcf is the only binary so no~/.bun/bin/namespace conflict. --prefix ~/.bunis a non-standard npm invocation pattern. Documented clearly + consistent across install.sh, self-update, and the docs' "direct install" examples.- uninstall.sh has to know to use
--prefix ~/.buntoo. Done; also detects 3 other historical install locations for cleanliness.
What a "fully zero-friction-always" install would require (and why we can't have it):
A generic-user one-liner that works perfectly on EVERY machine WITHOUT ever needing the user to do anything afterwards is not achievable under the Unix process model. install.sh's subshell can't reach into the parent shell. For users brand-new to Bun, Bun's installer must add ~/.bun/bin to their rc, and their current shell must source it once. This is a one-time step Bun's official installer also requires; cfcf doesn't add new friction beyond what Bun itself imposes. The bun-prefix design collapses cfcf-specific friction to zero for the realistic cfcf user (someone with Bun already).
Lessons.
-
Test on real machines, not on the dev machine. Every iteration's "works on my machine" failed on a fresh machine in a different way. Three distinct end-to-end failures (postinstall blocking, EACCES, parent-shell-PATH not picked up) were all surfaced by clean-machine tests, not by the dev environment.
-
Lean on the user's existing tooling instead of adding new toolchain. The breakthrough (option 4) was realizing cfcf already requires Bun, so Bun's PATH entry is "free" for us to use. We were trying to install into a NEW directory and add a NEW PATH entry, then fight the parent-shell-can't-be-modified problem. Installing into the EXISTING bun directory uses the EXISTING PATH and side-steps the constraint entirely.
-
The "(but I hear you about the existing solution)" turn is worth pursuing. The user explicitly accepted option 2 (bun-only with auto-trust) but flagged residual concern. Re-examining the priorities — "standard JS-ecosystem path", "no allow commands in output" — surfaced npm as the better fit, leading to options 3 then 4. Following the user's reaffirmed priorities even after agreement is more valuable than treating "agreement" as the end of the discussion.
-
Write up failed designs in the decision log too, so the next maintainer (or LLM agent) doesn't try them again. The four-option rationale here is the durable artifact; the v0.16.4 CHANGELOG entry only describes what shipped.
Outcome. v0.16.4 ships option 4. Both fresh-Mac tests pass on first install with no follow-up steps. Updated install.sh + uninstall.sh + self-update.ts + the README + installing.md + troubleshooting.md + the per-release INSTALL.md asset all use --prefix ~/.bun.
2026-04-29 — Codex failed to record rollout items: thread ... not found is benign telemetry noise; we don't filter it
Context. Dogfood on the cfcf-calc workspace surfaced an entry in the documenter's stderr log:
failed to record rollout items: thread <UUID> not found
The documenter completed successfully, committed the docs, and the project's tests + typecheck passed. But the warning is loud + alarming-sounding — Product Architect flagged it during a post-loop review, and a reasonable user would too. Question: is this a cfcf bug we should fix, a codex bug we should report, or noise we should filter?
Investigation.
The message comes from codex's own internal rollout/session ledger — the system codex uses to track agent threads + tool-use turns for its own observability + history features. Specifically:
- Codex tags every interactive session with a
threadUUID at startup. - During a session, codex periodically writes "rollout items" (tool calls, agent turns, etc.) to its rollout ledger, keyed by
thread. - If the registration of the thread itself failed silently (or the thread ID got out of sync with the ledger's expectations), subsequent rollout writes can't find it → the warning fires.
The warning appears specifically under our cfcf launchers because:
- Both PA + iteration-time agents run codex with non-default
-c sandbox_mode=...and-c approval_policy=...overrides - The combination occasionally trips codex's thread-registration path (the precise interaction is internal to codex; we have no API to inspect it)
- The agent's actual work — bash tool calls, file edits, conversation turns — completes normally regardless. Only codex's internal session-history bookkeeping is affected.
Decided — leave it alone. We don't filter, we don't redact, we don't report upstream until/unless something concrete changes:
- It's a codex-internal warning, not an error our agents emit. Filtering it from the captured log would amount to silently hiding output codex itself produced — that's the wrong default. Users who can read the log can read it accurately; future codex updates may make the warning useful diagnostic info (or remove it entirely).
- It's not a functional failure. The agent's outputs (commits, signals, files) are unaffected. Filtering would risk masking a future REAL codex error of the same shape.
- It's not consistent enough to fingerprint reliably — the exact wording could shift across codex versions, and we don't want to maintain a regex-based denylist.
- We don't have a clean upstream report to file. "Codex's rollout ledger drops thread registrations under non-default sandbox modes sometimes" is hard to reproduce on demand + the agent works correctly anyway. We'd need a deterministic repro before filing.
What WOULD change our mind:
- If the warning ever co-occurs with actual cfcf-visible breakage (agent failures, signal-file corruption, missing commits, etc.) → we investigate as a real bug and likely file upstream.
- If codex documents an opt-out config (e.g.
-c rollout_telemetry=off) → we flip it for cfcf launches to keep the log clean. - If the warning's wording/shape stabilises across codex versions and we could reliably filter it without false-positive risk → we revisit the filter decision.
Lessons.
- "This warning is loud, but harmless" is a real category. Don't assume every alarming string in a captured log is a bug. Trace where it came from + what it's about before acting. Codex's stderr ≠ cfcf's stderr.
- Distinguish "agent's own logs" from "harness's diagnostics". cfcf captures everything the agent CLI emits; some of it is the agent's internal noise, not domain output. Filtering should be opt-in by the user (a future
--quietor per-line filter), not unilateral. - PR descriptions are a good place to record "we considered X and dismissed it". When a reviewer or future-you re-encounters the warning, the bug-fix PR's body explains why bug 2 wasn't addressed alongside bug 1. This decisions-log entry promotes that explanation to a more durable home.
Outcome. No code change in cfcf. Future readers who hit this warning + grep the docs will land here.
Context. cfcf has two interactive roles: Help Assistant (HA, cfcf help assistant) and Product Architect (PA, cfcf spec). Both run the user's configured agent CLI (claude-code or codex) in interactive mode and ask the agent to behave as a domain expert in cfcf — answering "how does the loop work?" / drafting Problem Pack files / explaining Clio retrieval / etc. The conventional approaches for "make an agent expert in a domain" are: fine-tuning, RAG, or a custom agent built from scratch. None of those are appropriate for cfcf — fine-tuning needs training data + ongoing maintenance, RAG needs a retrieval index + chunking pipeline, custom agent abandons our adapter-agnostic promise.
The "breakthrough" we landed on. Just embed the full cfcf documentation bundle (~160 KB) directly into the agent's system prompt at launch. No fine-tuning. No RAG. No custom agent. cfcf assembles the prompt as: role-specific framing + state injection + memory inventory + every help topic from docs/guides/*.md and docs/api/server-api.md + closing instructions. Total prompt: ~170-180 KB. The agent CLI (claude-code: --append-system-prompt; codex: model_instructions_file tempfile) accepts the full string as the agent's system context.
Modern context windows make this trivial:
- Sonnet 4.6 / Opus 4.7: 200K tokens (~800 KB plain text)
- GPT-5 / o3: similarly generous
- Even at 180 KB system prompt, the user's conversation has 600+ KB headroom
Why this works in practice (validated by dogfood, 2026-04-29):
- Modern LLMs read + cross-reference long contexts well. The agent doesn't need a retrieval step — it can answer "what does
cfcf reflectdo?" by paging through the bundledcli-usage.mdtopic in-context. We tested this with PA: user reports the agent "clearly understands the cfcf full context and is very helpful". - Markdown structure makes the bundle navigable. Every help topic has a
## Topic: <slug> — <title>separator. The agent uses the headings to find what it needs. - No build pipeline. Docs are read at launch from the embedded bundle (
packages/core/src/help-content.generated.ts, generated byscripts/embed-help-content.tsfrom the source.mdfiles). cfcf rebuilds the bundle whenever docs change; agent gets fresh content every launch. - Adapter-agnostic. Both claude-code and codex consume the prompt the same way (Pattern A: ephemeral system-prompt input). Future agent CLIs that follow the same convention drop in trivially.
Costs accepted.
- Tokens per turn are higher (the bundle is in the system message that's part of every turn's context). For HA/PA this is fine — they're interactive and short-lived. We do NOT use this pattern for the iteration-time agents (dev/judge/SA/reflection/documenter), whose runs are long + numerous — for those, signal-file-driven IO with smaller per-role prompts is correct.
- System prompt size scales with documentation volume. We monitor size + version it (current ≈170 KB; warns above 250 KB would be a soft cap if added).
- "Hallucination" risk: the agent can still confidently state non-facts. Mitigation: the bundle is the ground truth, and the role prompts say "treat the docs below as authoritative; cite topic + section when explaining cfcf concepts".
Where this pattern applies (and doesn't).
| Apply | Skip |
|---|---|
| Interactive roles where the user asks open-ended cfcf questions | Iteration-time agents (dev / judge / SA / reflection / documenter) |
| Roles whose value depends on understanding the SDLC, not just executing fixed steps | Tools that don't need cfcf-meta knowledge (e.g. a future "linter" role) |
| When the user is in the room to course-correct | Non-interactive agents (no human at the keyboard) |
Lessons.
- Big context windows changed the design space. Patterns that were impossible in 2023 (dump 160 KB of docs into a system prompt) are trivial in 2026. Re-evaluate "expensive" assumptions periodically — what was infeasible may be the simplest path now.
- Adapter-agnostic patterns are achievable when you respect the lowest-common-denominator interface. Pattern A (system-prompt-as-input) works across both supported agents. Pattern B (auto-load from cwd) was more clever but locked us into per-CLI quirks; we abandoned it for v2.
- Dogfood is the validator. "Will an agent really understand 160 KB of documentation?" was an open question until users said yes. Build the simplest thing, ship to dogfood, listen.
Outcome. Pattern is now the canonical approach for any future cfcf interactive role. Documented in docs/research/help-assistant.md (HA's design baseline) + docs/research/product-architect-design.md (PA's design baseline). Future role authors should reuse the launcher seam in packages/core/src/<role>/launcher.ts rather than re-inventing the prompt-injection mechanism.
Context. bun install -g <local-tarball> (and at least some non-registry URL forms) appends duplicate "<key>": <value> entries to ~/.bun/install/global/{package.json,bun.lock} instead of overwriting. Functionally harmless (last-occurrence wins on parse) but produces dozens of warn: Duplicate key lines on every subsequent install — looks like cf² is broken when it isn't. Workaround = dedup these files before/after each bun install -g we trigger.
The path to the right fix. Three iterations, each fixing a different wrong assumption:
-
First attempt:
JSON.parseround-trip. Naïve approach: parse the file (parser collapses dup keys to last occurrence), re-stringify. Worked onpackage.jsonbut failed silently onbun.lockbecausebun.lockis JSON5-shaped (trailing commas, JSON Parse error on strict parse). Our try/catch swallowed the error; the dedup was a no-op on the file that mattered most. Lesson: when "best-effort" code fails silently, you don't notice until empirical testing. -
Second attempt: dedup identical lines. Switched to text-based: drop any object-entry line we've seen before. Caught some cases but missed the same-key/different-value variant — bun creates dups like
"@x": "/path/foo.tgz"and"@x": "file:///path/foo.tgz"(same key, normalised value forms). Different lines, identical-line dedup misses them. Lesson: the dup pattern isn't "appended same line"; it's "appended same key with a different value form". Harder to detect via text comparison. -
Third attempt: keep-last-occurrence by line content. Tracked each key's last-seen line content; only emitted lines whose content matched their key's last-seen value. Worked for the path-vs-file:// case but broke when both lines were byte-identical (same key + same value): both lines matched the "last-seen content" check, both got printed, dedup did nothing. Lesson: when comparing "is this the canonical occurrence?", track an INDEX not the content — content equality is undecidable when content is identical.
Final fix (works empirically, both first and second installs are dup-free):
- Walk consecutive object-entry lines into a "run" array.
- For each entry, record
last_idx_for_key[k] = current_run_index. - When the run ends (non-entry line:
{,}, blank), emit only entries whose run index matches the recorded last index for their key. - Naturally scopes dedup to a single object literal (different sections of
bun.lockhave separate runs separated by structural lines, so legitimate same-key-in-different-section entries are preserved).
Implementation in scripts/install.sh's dedup_bun_global and the equivalent JS in packages/cli/src/commands/self-update.ts. Used both before AND after each bun install -g (bun creates the dup mid-install; pre-install dedup catches accumulated mess from earlier runs, post-install catches the freshly-created dup).
Lessons worth keeping.
- Best-effort code that fails silently is a debugging trap.
try { ... } catch { /* ignore */ }masks "I don't know why this isn't working". Add at least one failure-path log line — the user shouldn't have to drop into the source to discover the silent no-op. - Empirical loop > assumptions about file structure. I assumed
bun.lockwas JSON; it's JSON5. I assumed dups were consecutive; they were interleaved. I assumed "same content" meant "same line"; sometimes content was identical and the same content matched both members of a dup pair. Each assumption looked obvious until the empirical test broke it. - When deduping, track by stable identifier (index, line number) not by content. Content-based identity comparisons are brittle when content can be identical.
- Dump file state between operations. The
head bun.lock+grep -cpattern surfaced exactly which dup pattern bun was generating. Two minutes of empirical inspection saved an hour of speculation.
Outcome. Tagged in v0.15.0 (the current iter-5 PR4 work). Workaround can be removed if/when bun fixes the underlying append-vs-overwrite bug; until then, our dedup runs both sides of every bun install -g.
Context. The project has two interchangeable names: cfcf (Cerefox Code Factory) and cf² (pronounced "cf square"). Both have always been valid but their usage drifted: the v0.14.0 user manual + Help-tab work mixed both forms in user-facing prose (e.g. "cfcf is the harness" alongside "cf² doesn't fight users…"). Inconsistent, confusing for new users.
Decision. Codify the existing implicit principle:
- Use
cf²in user-facing documentation, UI labels, and prose mentions. This is the brand. It's what users say out loud and read in headings. - Use
cfcfin source code, CLI commands, file paths, package names, environment variables, and any technical identifier. This is the keystroke-friendly form. It's what users type and what tools recognise.
Specifically:
| Context | Form | Examples |
|---|---|---|
| H1 / H2 in user docs | cf² |
# cf² User Manual, ## What problem cf² solves |
| Prose ("cf² is", "cf² does") | cf² |
"cf² only modifies content between sentinels" |
| CLI command literals | cfcf |
cfcf help, cfcf init, cfcf clio search |
| File paths | cfcf |
~/.cfcf/clio.db, cfcf-docs/problem.md |
| Package names | cfcf (historical) |
@cfcf/core (workspace), @cerefox/codefactory since 5.5b |
| Sentinel comments | cfcf |
# >>> cfcf shell completion >>> |
| Source-code identifiers | cfcf |
cfcf-config-dir, CFCF_PORT |
| Conversation / headlines | cf² |
"cf² is a deterministic harness" |
Why both forms. cfcf is keystroke-friendly (4 letters, all lowercase, single hand), which matters every time a user types a command. cf² reads as a brand and looks distinctive. Different users encounter different tradeoffs at different times; one form would compromise both.
Why this convention is sustainable. The split is mechanical: anything a user types stays cfcf (otherwise we'd have to teach people to type a Unicode superscript). Anything a user reads uses cf² (because it's the brand name we want them to associate with the project). No judgement calls; the rule audits itself.
Outcome. Swept docs/guides/manual.md, troubleshooting.md, installing.md to apply the rule. Regenerated the embedded help bundle so cfcf help (CLI) and the web UI Help tab serve the corrected text. Tagged v0.14.2.
Lessons. When a project has two forms of its name, lock the convention before user-facing surfaces ship. The cost of a sweep grows linearly with how much content references both forms — cheap at v0.14.x; would have been a chore at v1.0.
Context. The Clio CLI accreted across three iterations (5.7 → 5.11 → 5.12 → 5.13) without revisiting the overall shape. Iter-5 dogfood produced repeated "where do I do X?" friction (rename a doc, move it between projects, edit metadata) — same root cause: doc operations were scattered between top-level (get, delete, restore, versions, ingest) and a docs namespace (docs list, docs edit). Plan item 5.8 promoted this to a normalisation pass before writing the user manual.
Options considered.
- Literal Cerefox CLI parity. Audit of
../cerefox/src/cerefox/cli.pyshowed Cerefox's CLI is internally inconsistent (list-docsplural vsdelete-docsingular;metadata-searchflips noun-verb ordering vslist-docs). Inheriting that mess would import the wrong shape. - Both
searchandingestunderdocs. Maximally rule-bound. Forcesmetadata search/metadata keysinto eitherdocs metadata search(3-deep nesting) ordocs metadata-search(hyphen rebirth). Rejected. - Mode flag (
search --content/search --metadata). Collapses two operations with different ranking semantics (BM25/cosine vsupdated_at desc) and parameter shapes (string vs JSON filter). Orphansmetadata keyswith no good home. Rejected. - Both
search+ingesttop-level (no namespacing for either). Returns to the pre-cleanup scattered state. Rejected. searchtop-level +ingestunderdocs(with correspondingmetadata.search/keys,projects.list/create/show,embedder.list/active/install/setnamespaces). Selected.
Decision. Option 5 — namespaced surface with three-clause rule:
- Collection-wide / Clio-wide / headline operations stay top-level (
search,audit,reindex,stats).- Verbs that operate on a specific noun-instance go under that noun's namespace (
docs ingest/get/edit/delete/restore/versions,projects create/show,embedder install/set).- A sub-concept with multiple operations of its own gets its own namespace (
metadata search/keys).
Search and ingest sit on opposite sides of the rule because they play different roles: search produces a ranking from the whole collection (clause 1), ingest creates a specific doc-instance (clause 2). The asymmetry has a real reason. Option 5 is also the only shape where metadata reads as a clean sibling namespace next to docs/projects/embedder — the killed alternatives all force metadata into awkward nesting or hyphen-rebirth.
No deprecation aliases. Single user, pre-OSS-launch. Rename cleanly, no legacy verbs.
Out of scope (this round): top-level cfcf verbs (workspace, run, review, reflect, document, server, config, init, doctor, self-update, status, resume, stop). Iter 6 will audit those with the same lens.
Why this matters for parity. Cerefox parity is at the abstraction layer — every Cerefox MCP tool maps to a MemoryBackend method, and every cfcf CLI verb maps to a MemoryBackend method. The CLI surface itself can be cleaner than Cerefox's CLI without breaking that mapping. The user noted that cfcf's clean shape can serve as a reference for a future Cerefox CLI cleanup at the Cerefox-OSS level.
Reference. Full audit, killed-alternatives analysis, and locked surface in docs/research/cli-verb-normalisation.md.
2026-04-27 — Embedder-recommended chunk size as a safety ceiling, plus pre-flight warnings on switch + reindex
Context. Each embedder in the catalogue declares a recommendedChunkMaxChars calibrated for its tokenizer's model_max_length minus a safety margin (e.g. 1800 chars ≈ 4 chars/token × 0.9 × 512 tokens for a bge-small-en-v1.5-class context). Two related risks emerged once clio.maxChunkChars became user-configurable:
- User sets
clio.maxChunkCharslarger than the active embedder'srecommendedChunkMaxChars. Inputs above that ceiling get silently truncated by transformers.js / ORT tomodel_max_length, degrading embedding quality with no error path. - User runs
cfcf clio embedder set <new>to switch to a model with a smaller ceiling. Existing chunks now exceed the new model's context window AND existing embeddings live in the old model's vector space.
Decisions.
- Treat
recommendedChunkMaxCharsas a ceiling, not a fixed override. Smaller user values are honoured (smaller-is-safe; sometimes better for retrieval precision). Larger user values get capped at the embedder's ceiling with one stderr warning per ingest call. Without an active embedder there's no ceiling. - Pre-flight warnings on
cfcf clio embedder set. NewGET /api/clio/embedders/:name/switch-impactreturns three counts:embeddedChunkCount(existing-embedding mismatch),chunksOverNewCeiling(truncation risk),configMaxOverCeiling(config setting will be capped). The CLI prompts y/N when any signal fires, requires--yesfor non-interactive use. The existing--force(recovery, no checks) and--reindex(switch + re-embed atomically) remain. - Pre-flight confirmation on
cfcf clio reindex. Reindex re-embeds many chunks under the active embedder; non-trivial cost. CLI prints active embedder + scope + cost hint, prompts y/N.--yesskips for non-interactive use;--forcealready skipped by historical contract. - Web UI Server Info page surfaces all of these. The new chunk-size input shows the cap warning inline when its value exceeds the active embedder's ceiling. The preferred-embedder line includes guidance on switching safely (
--reindexrecommended,--forcefor recovery only).
Why warning-and-prompt instead of auto-correct. Same principle as on-demand version retention (item 6.22): silent rewrites of corpus state surprise users in unattended-agent workflows. The user sees the impact and explicitly opts in.
Out of scope (tracked). The --rechunk capability (re-run the chunker per doc when switching to a model with a smaller ceiling) is plan item 6.23. Until then, the warning text recommends cfcf clio reindex --rechunk (planned).
Context. The Clio hybrid-search engine fuses an FTS (keyword) candidate set with a vector (semantic) candidate set into a single ranked list. The original implementation used Reciprocal Rank Fusion (RRF) with k=60; a side-by-side audit against Cerefox surfaced that Cerefox uses alpha-weighted score blending (α × cosine + (1−α) × ts_rank_cd, default α=0.7) and exposes α as a tunable per-call. cfcf had no equivalent knob.
Options considered.
- Keep RRF, add a weight knob:
α/(k+vec_rank) + (1−α)/(k+fts_rank)— same user-visible α, different math. - Switch to Cerefox-style score blending — same user-visible α and same underlying math.
Decision. Option 2: score blending. Tunable per-call (alpha query param / --alpha CLI flag) with clio.hybridAlpha global default of 0.7. FTS-bypass-on-threshold semantics preserved (FTS-matched chunks always pass the minScore floor regardless of cosine).
Implementation note — BM25 renormalisation. SQLite FTS5's bm25() returns values in [-∞, 0] where more-negative = more relevant; not directly comparable to cosine [0, 1]. The fix is min-max normalisation within the candidate pool: (maxRank − bm25) / (maxRank − minRank) produces a [0, 1] higher-better score. Trade-off: absolute blended scores depend on candidate-pool composition (different filter combos can produce different absolute scores) but relative ranking is preserved — which is what hybrid cares about. Cerefox doesn't need this step because Postgres' ts_rank_cd is already roughly [0, 1].
Lesson. Choose hybrid-fusion algorithm to match what the upstream system exposes — even when an alternative (RRF) is mathematically defensible, missing the user-visible knob is a real interop gap.
Context. cfcf clio search <query> was returning chunk-level results (one row per matching chunk). Same document with multiple matching chunks produced multiple visually-redundant rows; an unrelated document that mentioned the keyword in passing wedged itself between chunks of the right document. Bad UX for the "what docs match X?" question agents and humans actually ask.
Options considered.
- Keep chunk-level as default; add
--by-docflag for dedup. - Make doc-level the default; keep chunk-level as
--by-chunkfor raw inspection.
Decision. Option 2. Cerefox precedent: their primary cerefox_search MCP tool returns one row per matching document; the chunk-level engine cerefox_hybrid_search is internal-only.
Implementation. New LocalClio.searchDocuments fetches matchCount × 5 chunk candidates via the existing engine, dedups by document_id keeping best score, decorates each hit with versionCount + matchingChunks + the best chunk's content. HTTP GET /api/clio/search?by=doc (default) / ?by=chunk (raw).
Why surface versionCount + matchingChunks on hits. versionCount mirrors Cerefox's cerefox_search_docs.version_count — agents reasoning about doc maturity ("evolved" vs "fresh") avoid a follow-up cfcf clio docs versions call. matchingChunks is cfcf-specific — "matched 5 chunks" vs "matched 1 chunk" is a useful breadth signal, near-free given dedup already pools the candidates.
Context. The original Clio implementation expanded each search hit's content with a fixed-radius window of neighbour chunks (radius=1 if embedding dim>768, else 2). Cerefox's cerefox_search_docs makes a different per-document decision: documents whose total_chars is at most p_small_to_big_threshold (default 20000) return the full document content as the hit; larger documents return matched chunk + p_context_window (default 1) neighbours. Returns is_partial: bool so the caller knows which path was taken.
Decision. Adopt Cerefox's per-doc logic in the doc-level search path (searchDocuments). Configurable via clio.smallDocThreshold (default 20000) + clio.contextWindow (default 1) globally; per-call overrides via ?small_doc_threshold= / --small-doc-threshold and ?context_window= / --context-window. New result field DocumentSearchHit.isPartial.
Outcome. Small documents (typed-up notes, design briefs, cheat-sheets) come back in one piece — agents read them inline from the search hit instead of round-tripping to cfcf clio docs get. Large documents still return a focused window. Bypasses the chunk-level engine's expansion entirely (contextWindow=0 actually means "bare chunk", not "chunk + hardcoded radius").
Lesson. Retrieval features land at the layer the caller experiences. Per-chunk tricks are right for the chunk-level engine but wrong for the doc-level surface; mirror the right layer's behaviour.
Context. SQLite migrations that drop+rebuild a parent table (the canonical "alter constraint" pattern: CREATE new → INSERT FROM old → DROP old → RENAME new) trigger ON DELETE CASCADE referential actions on child tables for every row in the parent — silently destroying child data. PRAGMA defer_foreign_keys = ON postpones FK constraint checks but does NOT defer referential actions; CASCADE fires immediately. The only working solution is PRAGMA foreign_keys = OFF set outside the wrapping transaction (the pragma is a no-op inside an active transaction).
Decision. The migration runner scans each migration's first 4 lines for a -- @migration-flags: disable-foreign-keys marker. When present, it brackets the migration's BEGIN IMMEDIATE / COMMIT with PRAGMA foreign_keys = OFF / ON outside the transaction. Migrations that drop+rebuild a parent with CASCADE children must declare the flag.
Lesson. Read the SQLite docs for pragma semantics carefully: defer_foreign_keys and foreign_keys = OFF solve different problems and are not interchangeable. Migration tests must run against a populated database, not a fresh one — defer_foreign_keys happens to "work" on an empty schema (no cascade target → no observable bug) but fails on real data.
Context. Cerefox runs lazy version retention on every snapshot: deletes versions older than CEREFOX_VERSION_RETENTION_HOURS (default 48), always keeps the most-recent version, skips archived=true versions. cfcf's clio_document_versions accumulate forever; the schema has the archived column but nothing reads it.
Options considered.
- Match Cerefox: lazy auto-cleanup on every snapshot.
- Explicit on-demand:
cfcf clio cleanup-versions [--older-than 168h] [--keep-last 1] [--dry-run]. - Both: opt-in lazy via config + always-available on-demand.
Decision. Option 3, with on-demand as the default. Silent automatic deletion of historical content surprises users in an agent-driven workflow; the trust story for unattended runs is "nothing disappears unless I ask". Auto-cleanup is gated on clio.versionRetentionHours config so users who do want Cerefox's behaviour can opt in.
Status. Tracked as plan item 6.22; not implemented in iter-5. Default thresholds will match Cerefox's. Respects archived=true.
Context. Several small, related Clio schema choices baked into the implementation. Consolidated here so they're discoverable.
Decisions.
-
version_id IS NULL= live; FTS triggers gate on it. Theclio_chunks_fts_autrigger fires on UPDATE with predicateWHEN old.version_id IS NULL OR new.version_id IS NULL— so the snapshot path'sUPDATE clio_chunks SET version_id = <new>automatically removes prior chunks from the FTS index, and the subsequent INSERT of new chunks adds them back. Schema-level invariant; zero application code needed to keep search and versions in sync. -
authoris a typed first-class column onclio_documents. Cerefox keeps author only on the audit log. cfcf promotes it to a column because (a) search hits and listings render it inline without a JOIN, (b) future audit/retention queries filter heavily on author, (c) the storage cost is negligible vs. the query simplicity. Default'agent'so legacy records backfill cleanly. -
Version row's
sourcecarries the OUTGOING author, not the trigger label. Cerefox'scerefox_document_versions.sourcestores the snapshot trigger ("file"/"agent"/ etc.). cfcf's same column instead storestarget.authorat update time — i.e. who wrote the content being archived. Different read-model:cfcf clio docs versions <id>answers "who wrote v3?" withversions[0].sourcedirectly, no audit-log JOIN needed. -
Audit log is write-only. The
clio_audit_logschema (from initial schema) reservesevent_typevalues for'search'and'get'but the live writer skips them. Reasons: (a) volume — every preload-context read in the iteration loop would write a row, dwarfing actual mutation entries; (b) the trust story is "who changed what", not "who saw what"; (c) Cerefox'scerefox_audit_logis also write-only in practice. The columns stay (no migration churn) so a future "verbose mode" could turn read-logging back on without schema work. -
Audit writes are best-effort, outside the mutation transaction. A failure in
writeAuditwarns to stderr and returns; the mutation still succeeds. Wrapping audit + mutation in one transaction would mean a stuck audit could roll back successful ingests — wrong failure mode for an observability layer. Symmetric to Cerefox'scerefox_create_audit_entry(PERFORM-ed at the bottom of ingest RPCs, return value not checked). Idempotent no-op delete/restore (already-deleted / already-live) intentionally do NOT write audit rows. -
IngestResult.actionenum is the source of truth; legacycreatedboolean kept one release. With three outcomes ("created","updated","skipped") a boolean stops carrying signal.createdis preserved (trueiffaction === "created") so existing callers keep working; the field is@deprecatedand will be removed once dependent code (notablyiteration-loop's auto-ingest hooks) migrates. Same pattern as Cerefox'sIngestResult.action. -
--document-idupdates preserve title/author when not explicitly passed. Cerefox always rewrites title/author from RPC parameters because their MCP layer requires them. cfcf's CLI ingests files wheretitledefaults to the file basename — silently overwriting an existing doc's deliberately-named title was a footgun. Updates by ID now preserve the existing values when the caller omits them; explicit--title/--authorstill override. Documented divergence from Cerefox at the API layer.
Context. Initial plan: ship cfcf as a bun --compile self-contained native binary in a tarball with colocated node_modules/ for externalised heavy native deps (@huggingface/transformers, onnxruntime-node, sharp) plus pinned native libraries (custom-built libsqlite3 with loadExtension enabled, sqlite-vec). Curl-bash installs to ~/.cfcf/bin/. Phase-0 smoke passed. Then the first real cfcf init against the binary failed with Cannot find module '@huggingface/transformers' from '/$bunfs/root/cfcf'.
The wall: Bun --compile doesn't resolve heavy native deps from disk.
bun build --compile --external <pkg> is supposed to produce a binary where <pkg> resolves at runtime via standard Node module resolution. In practice, Bun's compile-mode runtime resolver searches only the embedded /$bunfs/root/ filesystem; it does not walk to disk for --external modules. Verified: cfcf-binary with deps colocated at ~/.cfcf/bin/node_modules/@huggingface/transformers/ — direct invocation errors Cannot find module '@huggingface/transformers' from '/$bunfs/root/cfcf'. Same with NODE_PATH=... set explicitly (Bun ignores it in compile mode).
Workarounds attempted, none of which worked:
- Static absolute path in
import()— Bun treats it as a bare specifier. file://URL inimport()— top-level loads, transitive imports re-enter the same broken resolver.- Static top-level
import * as Transformers from "@huggingface/transformers"to force compiler analysis — Bun's compile-mode bundler quietly externalises packages with native.nodeaddons or dynamicrequire()patterns regardless of the--externalflag. Binary stays the same size; no transformers-related strings embedded. createRequire(process.execPath)— transformers is ESM,createRequireis CJS-only.- Wrapper shell script that sets up env before exec — symptoms suggested env tweaks wouldn't help (the issue is internal to Bun's resolver path-walking).
Why this isn't fixable in our codebase. Bun's --compile is designed for the case "JS that I can fully bundle." Heavy native-addon deps with dynamic require() patterns (which onnxruntime-node does to load its .node binary based on process.platform/arch) sit outside that model. Hugging Face's own docs explicitly recommend serverComponentsExternalPackages: ['onnxruntime-node'] for Next.js — their idiomatic stance is "don't bundle this, expect the runtime to resolve it." Tools that DO ship self-contained AI inference (Ollama, LM Studio) are written in Go/Rust with C++ inference engines linked directly; they don't use transformers.js. No widely-used tool ships transformers.js inside a bun --compile / pkg / nexe-style binary.
Decision: pivot to npm-format distribution.
- Build:
bun build(without--compile) bundles the TypeScript source into a singledist/cfcf.js.bun pm packwraps it +package.jsoninto an npm-formatcfcf-X.Y.Z.tgz. - Distribution: GitHub Releases tarball asset (private repo) →
bun publishto npmjs.com when the project goes public. - User install:
bun install -g <tarball-URL>(ornpm install -g <tarball-URL>). A smallinstall.shwrapper provides curl-bash UX and bootstraps Bun if missing. - Runtime: Bun ≥ 1.3 is a hard requirement (declared in
package.json'sengines.bunand in the README). - Native deps: per-platform optional npm packages (
@cerefox/cfcf-native-darwin-arm64, etc.) declared in the CLI'soptionalDependencies. npm picks the right one based onos+cpufields.applyCustomSqlite()resolves the package path viarequire.resolve. Same pattern Claude Code, sharp, swc, and esbuild use.
Lessons.
- Validate the gnarliest path first. Smoke tests proved
cfcf --versionandcfcf clio embedder listworked from a compiled binary, but neither imports transformers. The actual transformers-loading path was not exercised until end-to-end install. A real smoke must invoke at least one command that hits every external dep. - Be skeptical of "just works at runtime" docs. Bun's
--compiledocs state externals resolve at runtime "as normal." That's not what happens for our shape. Where docs are vague, run a focused test before committing to a design. - Match the upstream stack's distribution model. transformers.js's docs, examples, and supported deployment targets all assume Node-ecosystem distribution. Trying to shoehorn it into a self-contained binary fights the entire ecosystem's design.
- Self-contained binaries fit a specific shape. Bun
--compile/pkg/nexe/ Deno--compilework great when deps are JS-only or have native deps that statically analyse. Heavy-native-addon deps with dynamic loading break this model. Default to npm-format unless there's a strong reason for self-contained. - Time-box workaround attempts. When a design hits an unexpected wall, time-box the workarounds (one hour, two attempts) before stepping back to question the design itself.
- Diagnostic signature.
Cannot find module 'X' from '/$bunfs/root/...'is a Bun-compile-mode-specific signature. If a future investigation surfaces it, jump straight to "is this resolver actually walking to disk?" rather than chasing path config.
Context. A "re-spawn self with env var" pattern (used to host the cfcf server in the same binary as the CLI: Bun.spawn([process.execPath], { env: { CFCF_INTERNAL_SERVE: "1" } })) worked under the old bun --compile binary because process.execPath was the cfcf entry. Under the new npm-format install, process.execPath is the bun runtime; re-spawning bare bun with no script argument launches a Bun REPL. The server never starts.
Decision. Derive the bundled JS path from import.meta.url and spawn bun run <bundle> with the env var set. The bundled JS is the entry; bun runs it.
Lesson. Any "re-spawn self" pattern needs to know whether process.execPath is the entry or the runtime. They're the same thing for a --compile binary and different for everything else. Generalising: be explicit about which artifact you're invoking when re-spawning.
Context. A single-document Clio corpus + an irrelevant query was returning that one document as a "match" — vector candidates with near-zero cosine were being fused into the result set rather than dropped. The fusion algorithm was correct; the missing piece was a noise-floor filter.
Cerefox precedent. Cerefox uses CEREFOX_MIN_SEARCH_SCORE = 0.50 as a cosine-similarity floor on the vector branch:
- In hybrid search: chunks that matched the FTS keyword operator pass through regardless. The threshold filters only vector-only candidates.
- In semantic search: every result is filtered by cosine ≥ threshold.
- In FTS search: threshold ignored entirely.
The 0.50 default was calibrated for OpenAI text-embedding-3-small. Cerefox's published guidance:
| Score | Meaning (text-embedding-3-small) |
|---|---|
| 0.0 – 0.20 | Noise floor — unrelated content |
| 0.20 – 0.45 | Weak / tangential — same domain, different topic |
| 0.45 – 0.70 | Genuine match — related concepts, paraphrases |
| 0.70 – 1.0 | Near-duplicate / direct answer |
Decision. Mirror the architecture exactly. Threshold sourced via cfcf's standard precedence chain: per-call (--min-score / ?min_score=) → per-config (clio.minSearchScore) → built-in default 0.5. searchSemantic filters all candidates by raw cosine; searchHybrid filters only the vector-only branch (FTS-matched chunks always pass).
Calibration caveat. Cerefox's 0.50 was tuned for text-embedding-3-small. cfcf ships with bge-small-en-v1.5 (Xenova, 384d) and the default nomic-embed-text-v1.5 (q8, 768d). Different models produce different cosine distributions. The 0.5 default may need recalibration with empirical data from a non-trivial corpus. The user-tunable knob means recalibration doesn't have to ship in code — users dial clio.minSearchScore per their corpus. A future Clio v2 item should add per-embedder default thresholds to the catalogue.
Lesson — port the wrapper-layer invariants too, not just the engine. When porting from a system that's been used in anger, tightening rules added later (filters, retries, rate limits, validation guards) are usually load-bearing in ways that don't show up in algorithm diagrams. cfcf's PR2 ported Cerefox's SQL/RPC layer 1:1 — BM25, vector cosine, RRF fusion, small-to-big — but missed this threshold because it lives in Cerefox's Python Searcher wrapper, not in the RPC. A literal port of the engine misses wrapper-layer invariants.
Context. Cerefox is the OSS shared-agent-memory system; cfcf-Clio is the local-only embedded variant. The current architecture has LocalClio as the default MemoryBackend implementation; a future CerefoxRemote adapter should swap in for cross-machine sharing without caller-side changes.
Decision (architectural commitment). Maintain parity at the MemoryBackend boundary:
- Search semantics must match (mode names, threshold semantics, FTS-bypass rule, small-to-big behaviour, hybrid blending). New retrieval features land in both surfaces with the same shape — or are explicitly scoped as local-only with documented rationale.
- Schema field names must map 1:1 where possible (e.g.
clio_documents↔cerefox_documents;metadatais JSONB on Cerefox / TEXT-JSON on Clio with the same well-known keys:workspace_id,role,artifact_type,tier). - Defaults must align unless intentionally different. When deviations exist (e.g. cfcf's per-embedder
recommendedChunkMaxCharsoverrides a flat global default), the deviation is a documented design choice, not drift. - Future Cerefox changes are evaluated for cfcf inclusion. Currently mirrored or planned: audit log, soft-delete + versioning, metadata-key discovery, retention config.
Trade-off. Every Cerefox change becomes a "should we mirror this?" decision. The cost of letting them drift is high: every divergence becomes a behaviour surprise when a future user swaps in CerefoxRemote. Document deviations explicitly in this log when they happen.
2026-04-25 — Embedder version pin: transformers@3.8.1 + onnxruntime-node@1.21.0 for Intel Mac support
Context. onnxruntime-node 1.24.x dropped darwin-x64 (Intel Mac) binaries. @huggingface/transformers 4.x pins ORT-node 1.24.x — meaning shipping transformers 4.x means dropping Intel Mac users.
Options considered.
- Drop Intel Mac. Use the latest transformers + ORT-node.
- Pin transformers + ORT-node to the last pair with darwin-x64 binaries.
- WASM-via-
onnxruntime-web(browser ORT-web) on Node. Avoids platform-specific native binaries entirely.
Decision. Option 2: pin @huggingface/transformers@3.8.1 + onnxruntime-node@1.21.0. cfcf only uses the long-stable pipeline("feature-extraction", ...) API, so being a few minor versions behind has no functional impact today.
Why not Option 3 (WASM). Probed and deferred. transformers.js bundles the browser ORT-web build, which loads models via fetch(URL); when Node passes it a local file path, it errors ERR_INVALID_URL. There's no built-in WASM-on-Node code path. Making it work requires either vendoring/forking transformers.js or rolling our own embedder using onnxruntime-web/node + a separate tokeniser (~1–2 weeks). Revisit if Microsoft permanently stays off darwin-x64 AND staying behind on transformers becomes a real cost.
Exit criteria for revisiting the pin. The pin is a load-bearing-but-soft commitment, not permanent. Reconsider when any of these becomes true:
- A cfcf feature requires a transformers.js 4.x-only API.
- A security advisory lands against ORT-node 1.21.0 or transformers.js 3.8.1 with no patch backport.
- The Intel-Mac user population shrinks below the threshold where continued darwin-x64 testing pays for itself.
- Microsoft restores Intel-Mac binaries in a future ORT-node release (in which case we just bump versions).
If we drop Intel Mac later, the path is: bump transformers + ORT-node to current; drop darwin-x64 from the installer's release matrix; document the change here. The graceful FTS-only fallback in LocalClio.getEmbedder() means existing Intel-Mac users wouldn't be hard-broken — they'd lose semantic search, with a clear on-screen explanation and the option to switch to the linux-x64 tarball under WSL / Docker / Lima.
Context. The Clio implementation initially defaulted to bge-small-en-v1.5 (~120 MB, 384d, ~512 token context). Long design documents and iteration logs ended up split into many small chunks because the embedder's context window forced the chunker to slice aggressively.
Decision. Default to nomic-embed-text-v1.5 (q8 quantised, ~130 MB, 768d, ~8k token context). Comfortably fits the chunker's 4k-token effective window so long docs embed as one coherent passage. DEFAULT_EMBEDDER_NAME in the catalogue is the single source of truth — cfcf init's ★ marker, the no-arg cfcf clio embedder install fallback, the FTS-only "next steps" hint all read from it.
Lessons (when adding a new embedder to the catalogue).
- Check upstream for ONNX dtype variants.
nomic-ai/nomic-embed-text-v1.5publishes bothmodel.onnx(522 MB fp32) andmodel_quantized.onnx(~130 MB q8). Without an explicit hint,transformers.pipeline()picks fp32, ballooning the install. The catalogue'sdtypefield forces the right variant. - Mirrors can go restricted. The
Xenova/nomic-embed-text-v1.5mirror went 401-gated at some point; the officialnomic-ai/nomic-embed-text-v1.5repo has the same ONNX layout andtransformers.jsloads it identically. Workaround when a future Xenova mirror also goes 401: find the upstream<org>/<model>repo on HF and verify it has theonnx/subdirectory +config.json+tokenizer*.json.
Context. First real agent run failed because Bun.serve has a max idleTimeout of 255 seconds and the CLI's fetch() has a 10-second default. Agent runs can take minutes or hours. The synchronous request/response model (CLI sends POST, server runs agent, returns result when done) fundamentally doesn't work for long-running operations.
Decision. Refactor to async. POST /api/projects/:id/iterate returns 202 immediately; the server runs the agent in the background via iteration-runner.ts. The CLI polls GET /api/projects/:id/iterations/:n/status every 2 seconds, showing real-time progress dots. SSE log streaming via /iterations/:n/logs. No timeout issues regardless of how long the agent runs.
Context. In-memory loop state is lost on server restart. This includes bun --watch restarts (file changes during development), crashes, and manual restarts. Without persistence, cfcf resume fails after any restart with "No active loop for this workspace".
Decision. Persist LoopState to <workspace-config-dir>/loop-state.json on every phase transition. Load from disk as fallback in getLoopState(). The on-disk file is the source of truth; in-memory state is a cache.
Context. When a judge agent fails (wrong CLI flags, crash, etc.), the loop correctly pauses with anomaly, but the original implementation gave no clue about the cause — anomaly displayed alone with no log path, no exit code, no hint.
Decision. Capture judgeError with exit code and log path; show in CLI pause output. Set a retryJudge flag so cfcf resume retries only the judge on the same branch rather than starting a full new iteration (and discarding completed dev work).
Principle. For unattended runs, every failure path needs a self-explaining hint. Bare states like anomaly without context shift the diagnostic burden onto the user.
Context. When an iteration fails and the user retries, the iteration branch may already exist from the failed attempt but point to a different base (e.g., off main instead of the current feature branch). Naive checkout of the stale branch loses the working directory contents.
Decision. Delete the existing branch and recreate it off current HEAD. Validate the problem-pack BEFORE switching branches so failures surface before any branch manipulation.
Context. The Codex adapter's first real judge run failed silently with exit code 2.
Decision (adapter contract). Codex CLI requires global flags (like -a never) BEFORE the subcommand (exec): codex -a never exec -s danger-full-access "prompt". NOT codex exec -s danger-full-access -a never "prompt". Recent Codex versions also removed --approval-mode full-auto; cfcf uses -s danger-full-access (equivalent to Claude Code's --dangerously-skip-permissions).
Lesson. Adapter integration tests must run the actual external CLI with the actual flag combinations. Reading docs is not enough — flag-ordering is the kind of thing only failing exit codes reveal.
Context. cfcf server start spawns a background bun process. Shell job control (kill %1) is unreliable across shell contexts (different terminal, restart, etc.).
Options considered.
- Store the PID in a file at startup; read at stop.
- Scan for the process on the configured port via
lsof -ti :<port>.
Decision. Option 1 (PID file). More robust; doesn't depend on platform-specific port-scanning tools.
Context. Process-manager tests that spawn + kill subprocesses (e.g., sleep 30 with kill()) cause bun test --recursive to hang.
Decision. Run packages sequentially in CI and the root package.json test script: bun test packages/core && bun test packages/server && bun test packages/cli. Reliable. Confirmed Bun-version-specific issue with --recursive and concurrent subprocess management in tests.