Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
347d5c9
docs(roadmap): correct two stale markings now that PR #81 is merged
cemililik Aug 9, 2026
9737605
docs(roadmap): add phase 2.6.5 — core reliability remediation
cemililik Aug 9, 2026
7cf7d8b
docs(roadmap): fold the plan review of phase 2.6.5 — boundary, exit r…
cemililik Aug 10, 2026
8d7ffcf
feat(shared,docs): add cost:attempt_settled + ADR-0077 correcting its…
cemililik Aug 10, 2026
abc8a6e
docs(decisions): fold the Opus review of ADR-0077 — the barrier had n…
cemililik Aug 10, 2026
762c06f
docs(reference): specify cost:attempt_settled in its canonical home
cemililik Aug 10, 2026
ff7617a
feat(db): fold cost:attempt_settled into run_costs, and fix the step-…
cemililik Aug 10, 2026
cd8c971
docs(shared,decisions): fold the Sonnet review of #W15-1 step 1
cemililik Aug 10, 2026
83f7990
feat(core,shared,docs): restore realized cost from the ledger — Math.…
cemililik Aug 10, 2026
351f3af
feat(core): wire the money-durability barrier — engine-owned, three j…
cemililik Aug 10, 2026
50c60bd
test(core): four ledger-barrier e2e tests — and what they do NOT pin
cemililik Aug 10, 2026
9308798
fix(core,shared,docs): fold the Opus review of #W15-1 — the barriers …
cemililik Aug 10, 2026
5b82da0
fix(core,shared,docs): fold the Sonnet review of #W15-1 — a fourth fa…
cemililik Aug 10, 2026
5f69ddc
fix(tooling): stop a repo-local checkout leaking into the root test run
cemililik Aug 10, 2026
3d8f237
fix(repo): fold the Opus review of CR-90 — the guard was blind to the…
cemililik Aug 10, 2026
03ac462
fix(repo): fold the Sonnet review of CR-90 — the primary assertion wa…
cemililik Aug 10, 2026
6a9eec9
feat(core): add the durable-truth oracle — four views of one run must…
cemililik Aug 10, 2026
cc58dba
fix(core): fold the Opus review of CR-91 — the instrument could not i…
cemililik Aug 10, 2026
f5750a3
fix(core,docs): fold the Sonnet review of CR-91 — the cycle guard cau…
cemililik Aug 10, 2026
08fef46
fix(cli,core,docs): close W0 — the three half-done items, and a fourt…
cemililik Aug 10, 2026
1f5d4ec
fix(cli,core,docs): fold the Opus review of W0 — a fifth path, and tw…
cemililik Aug 10, 2026
6430016
docs(repo,cli): fold the Sonnet review of W0 — rehome the rule, name …
cemililik Aug 10, 2026
7dd6488
docs(roadmap): add CR-64 from the YAML/git-native reviews, and record…
cemililik Aug 11, 2026
c6a0269
fix(core,cli,tools,docs): fold the PR #82 review — bind the oracle to…
cemililik Aug 11, 2026
29d502b
style(core,tools): fold two Sonar smells from the review fold itself
cemililik Aug 11, 2026
93948fd
fix(core,tools,docs): fold the second PR #82 review round
cemililik Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ jobs:
- name: Lint the tooling scripts
run: pnpm lint:tools

# A repo-local SECOND checkout must never be collected by a root run (CR-90). This is the one check
# whose defect CI could not have caught on its own: the leak lives in `.claude/worktrees/`, hidden by a
# LOCAL `.git/info/exclude` entry, so a clean CI checkout never has it. The vitest exclusions defend a
# developer's tree; this step is what stops them being weakened — without it the guard would be the only
# root-`ci` check missing here, which is the #312 divergence the two steps above exist to close.
- name: Test isolation (no repo-local checkout in the run)
run: pnpm lint:test-isolation

# RUN the artifact this job just built, through the SAME `pnpm smoke:cli` script the root `ci` script
# calls — a check that exists in only one of the two is exactly the #312 divergence this change closes.
# Until now nothing in the required gate executed
Expand Down
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ coverage-tmp/
# Claude Code transient state (skills/agents ARE committed; runtime locks are not)
.claude/scheduled_tasks.lock
.claude/**/*.lock
# A repo-local SECOND checkout (CR-90). `.claude/worktrees/` was only ever hidden by `.git/info/exclude`,
# which is local and untracked — so a fresh clone did not carry the rule and nobody could see the reason.
# Tracked here so it travels. The vitest side of this is `REPO_LOCAL_CHECKOUTS` in vitest.config.ts; the two
# lists are kept in step by tools/test-isolation, which fails on any collected file that turns out to live in
# a directory carrying its own pnpm-workspace.yaml.
.claude/worktrees/
.worktrees/
/worktrees/
# The guard's own scratch directories. It clears them explicitly on every exit path, including a failure —
# but `.claude/__test_isolation_fixture__/` is a SIBLING of `.claude/worktrees/`, so the entry above would not
# have covered it if a crash ever did leak one, and a leaked fixture is CR-90's defect self-inflicted.
__test_isolation_fixture__/
__test_isolation_detector_probe__/

# Private analysis — never commit, never reference from tracked files
docs/analysis/private/
311 changes: 183 additions & 128 deletions README.md

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions apps/cli/src/chat/persister.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ describe('createSessionPersister', () => {
});
afterEach(() => {
client.sqlite.close();
// Suite-level, not per-test. Several tests below spy `updateSession`/`recordSessionCost` into a throwing
// SQLITE_BUSY stub; a test-local `vi.restoreAllMocks()` at the END of the test is skipped the moment an
// assertion above it fails, and the stub then leaks into every later test in the file — turning one real
// failure into a cascade whose reported causes are all fictional.
vi.restoreAllMocks();
});

/**
Expand Down Expand Up @@ -250,6 +255,58 @@ describe('createSessionPersister', () => {
persister.close();
});

it('LATCHES a failed session:cancelled write, and still detaches the listener (CR-01)', async () => {
// The one arm that still called the store bare. `RunEventBus` isolates a listener throw from the
// producer, so a failing terminal write let the cancel path report success while the latch never set —
// and the latch is what refuses the next egress. A session whose terminal row never landed would resume
// believing it had ended cleanly.
const { built } = await setup(scriptedResolver([textTurn('hi')]));
const persister = createSessionPersister({
governor: undefined,
attachDurabilityProbe: built.attachDurabilityProbe,
store,
handle: built.handle,
sessionId: built.sessionId,
agent: built.agent,
context: built.context,
now: () => Date.parse('2026-06-25T00:00:00.000Z'),
uuid: () => 'msg-x',
});
// Observe the DETACH directly by wrapping the unsubscribe the handle hands back. Nothing else can see
// it: a second `cancel()` emits no event on an already-cancelled session, and `close()` calls the same
// idempotent unsubscribe, so both of those pass whether or not the listener leaked.
let unsubscribed = 0;
const realSubscribe = built.handle.subscribe.bind(built.handle);
vi.spyOn(built.handle, 'subscribe').mockImplementation((listener) => {
const off = realSubscribe(listener);
return () => {
unsubscribed += 1;
off();
};
});
built.session.start();
persister.start();
const updateSpy = vi.spyOn(store, 'updateSession').mockImplementation(() => {
throw Object.assign(new Error('database is locked'), { code: 'SQLITE_BUSY' });
});

built.session.cancel();

expect(persister.durabilityFailure?.message).toContain('database is locked');
// …and the user was still told: latching must not remove the listener-error notice.
expect(listenerNotes.join('\n')).toMatch(/database is locked/);

// **The half that actually matters, asserted directly.** The self-detach is in a `finally`, so a throwing
// write cannot jump over it and leave this persister attached to the bus. An earlier version of this test
// asserted `close()` does not throw — which is true whether or not the listener leaked, because `close()`
// calls the same idempotent unsubscribe. The real evidence is that a SUBSEQUENT event does not re-enter a
// persister that cannot write, so `updateSession` is never called again. Deliberately WITHOUT `close()`.
expect(updateSpy).toHaveBeenCalledTimes(1); // the failing write happened exactly once
// THE assertion: the throw did not jump over the unsubscribe. Without the `finally` this is 0, and the
// persister stays on the bus — so every later event re-enters one that cannot write.
expect(unsubscribed).toBe(1);
});

it('does not let a failed cost write advance the in-memory total (#W15-4)', async () => {
// `mutableSessionColumns` makes `recordSessionCost` the SINGLE writer of the durable total, so a total
// advanced past a write that did not land can never be repaired by a later flush — and a resume then
Expand Down
24 changes: 21 additions & 3 deletions apps/cli/src/chat/persister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,9 +412,27 @@ export function createSessionPersister(deps: SessionPersisterDeps): SessionPersi
case 'session:cancelled':
// The session's sole terminal — mark it ended (still resumable from the persisted transcript), then
// self-detach so the bus listener does not leak if the REPL's close() is skipped on an early exit.
deps.store.updateSession(record('ended'));
unsubscribe?.();
unsubscribe = undefined;
//
// Through `persistDurably`, like every other write here (`CR-01`) — but be precise about WHAT that
// buys, because the obvious reading is wrong. The latch half is inert on this arm: `session:cancelled`
// is emitted only by `AgentSession.cancel()`, which sets `#status = 'cancelled'`, and every later
// egress entry point is already refused by `#assertSendable()` with `not_active`. Nothing reads
// `durabilityFailure` again. It is wrapped for SYMMETRY with its siblings — one arm reaching the store
// bare is how the next reader concludes the wrapper is optional.
//
// **The `finally` is the load-bearing half.** Before it, a throwing write jumped straight over the two
// unsubscribe lines and left this persister attached to the bus — so every later event re-entered a
// persister that could not write. (The user was told either way: the throw always escaped `onEvent`
// into `deliver`'s listener-error sink.) Cleanup must happen on both paths, and a failed write is
// exactly when a leak is worst.
try {
persistDurably(() => {
deps.store.updateSession(record('ended'));
});
} finally {
unsubscribe?.();
unsubscribe = undefined;
}
return;
default:
return;
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/commands/agent-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { CliIo } from '../process/io.js';
import type { GlobalOptions } from '../process/options.js';
import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp-secret.js';
import { makePlainPrinter } from './chat.js';
import { stringifyJsonLine } from '../render/sanitize.js';

/**
* `relavium agent run <agent>` (2.Q) — invoke a single agent **one-shot** (non-interactive) on the same
Expand Down Expand Up @@ -158,7 +159,7 @@ async function runOneShotTurn(
): Promise<string | undefined> {
let turnErrorCode: string | undefined;
const renderer: (event: SessionStreamHandleEvent) => void = deps.global.json
? (event) => deps.io.writeOut(`${JSON.stringify(event)}\n`)
? (event) => deps.io.writeOut(`${stringifyJsonLine(event)}\n`)
: // A ONE-SHOT: the session is cancelled in `finally` right after, so suppress the session-continuity recovery
// hint (2.5.H) — "the session is still active / resend / `/compact`" would be false with no live REPL.
makePlainPrinter(deps.io, false);
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/commands/chat-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { openSessionStore, type OpenedSessionStore } from '../history/session-op
import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js';
import type { CliIo } from '../process/io.js';
import type { GlobalOptions } from '../process/options.js';
import { stringifyJsonLine } from '../render/sanitize.js';

/**
* `relavium chat-export <sessionId>` (2.P) — export a persisted session to a `.relavium.yaml` **scaffold** for
Expand Down Expand Up @@ -87,7 +88,7 @@ export function chatExportCommand(
sequenceNumber: result.sequenceNumber,
workflowPath: result.path,
});
deps.io.writeOut(`${JSON.stringify(event)}\n`);
deps.io.writeOut(`${stringifyJsonLine(event)}\n`);
} else {
deps.io.writeOut(`Exported session ${args.sessionId} to ${result.path}\n`);
}
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import {
import { createChatStore, type ChatStoreController } from '../render/tui/chat-store.js';
import { createMentionReader, type MentionReader } from '../render/tui/mention.js';
import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp-secret.js';
import { stringifyJsonLine } from '../render/sanitize.js';

/**
* `relavium chat` (2.M) — the agent-first interactive REPL over `@relavium/core`'s `AgentSession`. It binds
Expand Down Expand Up @@ -2389,7 +2390,7 @@ export async function drivePlain(ctx: ChatDriveContext): Promise<ChatDriveOutcom
*/
export async function driveJson(ctx: ChatDriveContext): Promise<ChatDriveOutcome> {
const unsubscribe = ctx.handle.subscribe((event) =>
ctx.io.writeOut(`${JSON.stringify(event)}\n`),
ctx.io.writeOut(`${stringifyJsonLine(event)}\n`),
);
const rl = createInterface({ input: ctx.io.stdin, terminal: false });
const onSigint = (): void => rl.close();
Expand Down
12 changes: 10 additions & 2 deletions apps/cli/src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js';
import type { CliIo } from '../process/io.js';
import type { GlobalOptions } from '../process/options.js';
import { stringifyJsonLine, stripTerminalControls } from '../render/sanitize.js';

export interface ExportCommandArgs {
/** The in-file `id` of the workflow/agent to export (resolved across both project catalogs). */
Expand Down Expand Up @@ -59,11 +60,18 @@ export function exportCommand(args: ExportCommandArgs, deps: ExportCommandDeps):
// Emit the cwd-relative path in both modes — the same contract `import` uses, so a script consuming either
// command's `--json` gets the same path shape (and no absolute filesystem tree leaks into the output).
if (deps.global.json) {
// `stringifyJsonLine`, not a bare `JSON.stringify` (CR-03). `targetDisplay` derives from the user-typed
// `--out`, so unlike `import --json`'s payload — whose `slug` is kebab-schema-validated and cannot carry
// one — it really can hold a C1 control or a bidi override, which `JSON.stringify` leaves raw.
deps.io.writeOut(
`${JSON.stringify({ id: parsed.slug, kind: parsed.kind, path: targetDisplay })}\n`,
`${stringifyJsonLine({ id: parsed.slug, kind: parsed.kind, path: targetDisplay })}\n`,
);
} else {
deps.io.writeOut(`Exported ${parsed.kind} '${parsed.slug}' to ${targetDisplay}\n`);
// The human line needs the terminal floor for the same reason: every dynamic string written to a terminal
// passes `stripTerminalControls` (security-review.md), and this one is user-supplied.
deps.io.writeOut(
`Exported ${parsed.kind} '${parsed.slug}' to ${stripTerminalControls(targetDisplay)}\n`,
);
}
return EXIT_CODES.success;
}
3 changes: 2 additions & 1 deletion apps/cli/src/commands/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js';
import type { CliIo } from '../process/io.js';
import type { GlobalOptions } from '../process/options.js';
import { stringifyJsonLine } from '../render/sanitize.js';

export interface ImportCommandArgs {
/** The external workflow/agent YAML to copy into the project (absolute, or relative to cwd). */
Expand Down Expand Up @@ -58,7 +59,7 @@ export function importCommand(args: ImportCommandArgs, deps: ImportCommandDeps):
writeAuthoredFile(target, rel, serializeAuthored(parsed), args.force);

if (deps.global.json) {
deps.io.writeOut(`${JSON.stringify({ id: parsed.slug, kind: parsed.kind, path: rel })}\n`);
deps.io.writeOut(`${stringifyJsonLine({ id: parsed.slug, kind: parsed.kind, path: rel })}\n`);
} else {
deps.io.writeOut(`Imported ${parsed.kind} '${parsed.slug}' to ${rel}\n`);
}
Expand Down
7 changes: 4 additions & 3 deletions apps/cli/src/commands/models-pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,10 @@ export function modelsPricingCommand(
? ''
: `\n Overrides the catalog price for this model: input $${microcentsToUsd(shipped.inputPerMtokMicrocents)}/Mtok, output $${microcentsToUsd(shipped.outputPerMtokMicrocents)}/Mtok. Yours wins. Run \`relavium models pricing ${stripTerminalControls(args.model)} --clear\` to go back to the catalog's.`;
// Strip any terminal-control byte from the (user-typed) model id before echo — parity with `renderModelList`'s
// FIX 2. `ModelListingSchema` only requires min(1), so an id can carry a control byte; the JSON path is safe on
// its own (JSON.stringify escapes them). The provider is a validated (kebab) ProviderId, and the prices are
// numbers — both already safe.
// FIX 2. `ModelListingSchema` only requires min(1), so an id can carry a control byte; the JSON path is safe
// because it goes through `stringifyJsonLine` — NOT because `JSON.stringify` escapes them, which it does not
// (see security-review.md). The provider is a validated (kebab) ProviderId, and the prices are numbers —
// both already safe.
deps.io.writeOut(
`Set user pricing for ${stripTerminalControls(args.model)} (${args.provider}): input $${args.inputUsdPerMtok}/Mtok, output $${args.outputUsdPerMtok}/Mtok${cachedNote}. It applies to your next run/chat and survives \`models refresh\`.${divergence}\n`,
);
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/commands/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ function toModelJson(m: ModelCatalogListing, providerSlug: (uuid: string) => str
return {
// The SLUG (e.g. `anthropic`), not the internal `llm_providers` UUID the catalog row carries — matching the
// `models refresh` report + the documented `{ provider }` contract. `--json` is unchanged otherwise
// (JSON.stringify escapes any control byte on its own, so the slug is not terminal-sanitized here).
// (the record goes out through `writeRecordLines` → `stringifyJsonLine`, which is what escapes the C1/bidi bytes `JSON.stringify` leaves raw — see security-review.md).
provider: providerSlug(m.providerId),
modelId: m.modelId,
displayName: m.displayName,
Expand Down
34 changes: 34 additions & 0 deletions apps/cli/src/render/json-line-surfaces.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* `CR-03` — the behavioural half of the machine-output floor.
*
* `#W15-10` built `stringifyJsonLine` and wired two call sites. `CR-03`'s finding named three more, and
* closing it routed FIVE — `commands/import.ts` and `commands/export.ts` write the same record shape and were
* not in the finding. `export.ts` was the last one found, and only because a source-scanning regex had missed
* it purely because prettier wrapped the argument onto its own line. A guard that only checks the places someone remembered
* catches nothing new, so the CALL-SITE half is now an ESLint `no-restricted-syntax` selector in
* `eslint.config.mjs` — it fires on the shape, anywhere in `apps/cli/src`, the first time a new surface is
* written. That is the mechanism; this file keeps the reason it exists visible as executable behaviour.
*/
import { describe, expect, it } from 'vitest';

import { stringifyJsonLine } from './sanitize.js';

describe('CR-03 — the --json machine-output floor', () => {
it('the shared serializer neutralizes what a bare JSON.stringify leaves raw', () => {
// Built from code points at RUNTIME, never typed into this file: a raw C1/bidi byte in source is a
// Trojan-Source hazard in its own right, and an editor or a tool in the chain can silently rewrite it.
const CSI_8BIT = String.fromCharCode(0x9b); // the 8-bit CSI — `JSON.stringify` leaves it raw
const DEL = String.fromCharCode(0x7f);
const RLO = String.fromCharCode(0x202e); // right-to-left override
const hostile = { id: 'n1', text: `ok${CSI_8BIT}2J${RLO}evil${DEL}` };
const safe = stringifyJsonLine(hostile);
const bare = JSON.stringify(hostile);

for (const raw of [CSI_8BIT, RLO, DEL]) {
expect(bare, 'the bare form is the hazard this item is about').toContain(raw);
expect(safe).not.toContain(raw);
}
// Lossless: `--json` promises to reproduce the data, which is why this escapes rather than strips.
expect(JSON.parse(safe)).toEqual(hostile);
});
});
Loading
Loading