Release: the behavioral agent harness — faculties, one wire, guards-as-threads - #345
Merged
Merged
Conversation
Change input from single { old_text, new_text, replace_all } to
edits: [{ old_text, new_text }, ...]. Multiple disjoint edits are
matched against the original file, not incrementally. Overlapping or
nested edits are rejected with a clear isError. Non-unique old_text
per edit returns an error.
Drop replace_all — each edit must be unique. Accept legacy top-level
old_text/new_text and migrate to edits[0] (pi's prepareArguments
pattern).
Stop returning full file content — return { patch, replacements,
notice } where notice is "Successfully replaced N block(s) in PATH."
Full content was context bloat.
Keep CRLF normalize/restore and unified-patch generation (buildPatch
already handles multiple ranges). MINIMAL: no fuzzy/whitespace-tolerant
retry. Upgrade path: port pi's fuzzyFindText.
Tests: multi-edit disjoint, overlap rejected, non-unique error,
full-content no longer returned, patch across multiple ranges is
truthful (apply patch to original → equals new content).
Change bytesWritten from content.length (UTF-16 code units) to
Buffer.byteLength(content, 'utf-8') — the actual number of bytes
written to disk. Test with multibyte content ('✓x✓x✓' = 11 bytes,
not 5 code units).
…esult
Nit 1: drop the non-null assertion `truncatedBy!` in the truncation
branch. The full TruncationResult is now passed straight through from
truncateHead, so the type flows without manual assertion.
Nit 2: stop hand-maintaining a reduced TruncationDetail shape. Delete
the local TruncationDetail type and the per-branch construction. Use
the full TruncationResult from ./truncate.ts as the output field type,
passing the truncation result directly (no re-computation drift).
Extend ReadOutputSchema to describe all 11 TruncationResult fields.
The schema is cast through `unknown` at the top level because
TruncationResult.truncatedBy ('lines' | 'bytes' | null) is a nullable
enum that AJV's JSONSchemaType cannot statically verify — same
limitation as the existing inputContentPartJsonSchema cast. AJV
validates the shape at runtime.
Tests: add pass-through assertion pinning truncation.outputBytes ===
Buffer.byteLength(content-without-notice) on a truncated read.
Delete src/tools/skill-client.ts (untracked, never committed) and src/tools/tests/markdown.spec.ts. The useTool sweep deleted use-mcp-server.ts but left skill-client.ts importing it and markdown.spec.ts importing skill-client.ts — the one failing test and the source of all remaining tsc errors in src/tools/. Resolution: deletion (option 1). The plaited markdown CLI (src/cli/markdown.ts, registered in bin/plaited.ts) supersedes skill-client.ts with the same three modes (extract-links, validate-links, frontmatter). The markdown skill (.agents/skills/markdown/SKILL.md) documents the CLI as the operator surface. No live imports of skill-client remain outside its own spec. use-mcp-server.ts is not restored — the plan's Decision Log explicitly dropped the MCP-serving layer. Gate: tsc zero errors in all of src/tools/; 158 pass, 0 fail; no use-mcp-server/useMCPServer references remain.
Removes use-mcp-server.ts and the MCP-backed git-context, markdown, and typescript-lsp tools plus their specs. The frontier-tooling decision dropped the MCP SDK in favor of the AJV/JSONSchemaType useTool pattern (src/tools/use-tool.ts). package.json drops the "." root export in favor of "./tools"; plan.md records the resolved wiring decision.
Wraps replayToFrontier as the replay-frontier tool — JSON in/out through
useTool. Output serializes the pending Set to { stateKey, pendingCount }
and passes the frontier through verbatim; a disabled selection (the raw
fn's throw) is caught into { isError, message } so it never crosses the
model channel.
TDD: red-green on the public tool boundary (5 tests), not the raw fn.
Raw algorithm fns renamed to a `Raw` suffix to free the clean camelCase
tool const names; still exported pending the privatize refactor.
Convention divergence noted: repo tools are single-word; these are
multi-word dash-case names (replay-frontier) with camelCase consts.
Wraps exploreFrontiers as the explore-frontiers tool. The internal Map<string, StateNode> is serialized to a plain object keyed by stateKey (Object.fromEntries, root-first); traces, findings, and report cross verbatim (all JSON-safe). maxDepth is required (int ≥1) per the schema, naming the non-termination hazard for unbounded-state programs. TDD: red-green on the public tool boundary (9 tests). The tests assert on the serialized plain-object stateGraph (Object.values(graph)[0]) rather than the raw Map.
Wraps verifyFrontiers as the verify-frontiers tool. The raw result —
{ status, findings, report, livelocks } — is already verdict-shaped and
JSON-safe, so the tool constructs the output object explicitly (no as
cast) and passes it through. maxDepth is required (int ≥1); the progress
spec drives livelock detection (a cycle that never selects a progress
event is failed).
TDD: red-green on the public tool boundary (8 tests covering verified,
failed, truncated, livelock-with/without-progress, empty-progress,
deadlock-still-fails, omit-progress).
The interface is the tool, full stop. Removes export from the raw algorithm functions (replayToFrontierRaw, exploreFrontiersRaw, verifyFrontiersRaw) and all graph internals (frontierStateKey, findStronglyConnectedComponents, findLivelocks, isCycle, StateNode, TraceRecord, DeadlockFinding, LivelockFinding, ExploreFrontiersArgs, ExploreFrontiersResult, VerifyFrontiersArgs, VerifyFrontiersResult). They stay module-private — the three useTool wrappers call them internally. Only the three tools + their Input/Output/Schema types (and FrontierStateNode/FrontierReport output-helper types) are exported. Rewrites frontier-analysis.liveness.spec.ts per the TDD "public interface, not private helpers" rule: the fake-graph builder helpers (fakeNode, graph, labeledGraph) and tests that pinned internal algorithm identities are deleted. SCC/livelock/stateKey behavior is now driven through explore-frontiers and verify-frontiers on real thread programs: a 60-step ring terminates (large-cycle SCC), a self-loop ticker with no progress is failed (livelock), a two-state cycle selecting progress internally is verified, escape edges don't redeem a livelock (cycle exit to a sink is not in-cycle progress), and two structurally-equal programs yield the same serialized state graph (order/generator-identity invariance without fake PendingBids). Registers the six schemas in src/tools.ts. Convention divergence: repo tools are single-word; these three are multi-word dash-case (replay-frontier, explore-frontiers, verify-frontiers) with camelCase consts.
Renames the three frontier tools to domain-first names so the group sorts together in tool listings and schema discovery: - replay-frontier -> frontier-replay - explore-frontiers -> frontier-explore - verify-frontiers -> frontier-verify Tool consts (replayFrontier/exploreFrontiers/verifyFrontiers), the exported *Input/*Output/*InputSchema/*OutputSchema types, and TSDoc self-references are updated; test imports and describe labels updated. Module file frontier-analysis.ts -> frontier.ts (git mv, history preserved). Private raw fns and the *Args/*Result internal types keep their names -- not exported, no churn.
The @packageDocumentation header listed the private raw fns
(replayToFrontierRaw, exploreFrontiersRaw, verifyFrontiersRaw) as the
module's entry points, inverting the public/private surface. List the
three tools (frontierReplay, frontierExplore, frontierVerify) as entry
points and relegate the raw fns to an "internal" note. The args/result
doc {@link}s that genuinely reference the raw fns are unchanged.
The ## Trace kind filters section linked isSelectionTrace, isFrontierTrace, and isDeadlockTrace -- symbols that exist nowhere in src/ (the names implied guard functions that were never written). Replace the dead links with the real discrimination surface: the Trace union discriminated by kind (values from TRACE_MESSAGE_KINDS), linking SelectionTrace, FrontierTrace, and DeadlockTrace -- all exported from behavioral.types.ts and resolvable cross-module.
Finish the html refactor: five useTool tools (html-validate-and-escape,
html-validate-attribute-value, html-render, html-update-attributes,
html-scale-check) with real JSON schemas, registered in src/tools.ts.
Violations are returned as data, never thrown. HtmlViolation/CssViolation
bags replace the deleted ValidationError class. Module-private
validateAndEscapeHtmlRaw and validateAttributeValueRaw return { ok }
unions; the wrappers map them to the tool output shape. Mutating tools
return the original input html unchanged on validation failure.
Flatten src/tools/html/ into src/tools/{css,html}.schemas.ts plus html.ts;
delete the old module and its Renderer/ValidationError classes. Add
src/tools/tests/html.spec.ts (TDD from the dev-branch specs: 65 tests
covering all six swap modes, all four match operators, on* security,
quote-breakout neutralization, CSS validation, attribute rules, and
scale-check boundaries). Update the css-schemas generator/workflow paths.
Convert src/tools/mcp-client.ts from a makeCli command to the useTool
shape ({ name, description, inputSchema, outputSchema, run }) with
hand-written AJV JSON schemas — a 7-branch oneOf on `mode` per branch
(call-tool/list-tools/list-prompts/get-prompt/list-resources/
read-resource/discover), cast through `unknown` as JSONSchemaType since
discriminated unions exceed its static power (read.ts/frontier.ts
precedent). All seven modes survive; src/cli/mcp-client.ts untouched.
Auth is a permissive object in the model-facing schema and validated at
the trust boundary with an AJV-compiled discriminated-union schema
(single source, no Zod, no parallel schema source).
Connections now route through src/kernel/use-plugin-adapter.ts (renamed
from the empty use-plugin-adaptert.ts): a Map<serverUrl, PoolEntry>
lazily connected and reused across calls, evicted on connect failure,
closed on teardown — mirroring the pi-extension getSharedClient +
session_shutdown -> closeSharedClient pattern. The adapter owns no
discovery data; the tool never closes a client itself.
Tests use a real in-process MCP server fixture (McpServer +
WebStandardStreamableHTTPServerTransport over loopback HTTP) rather
than mocking the client SDK. 12 tests cover the schema contract and a
round-trip of all seven modes through the pool, plus pool reuse and
multi-url separation.
Records the 2026-09-07 Decision Log entry this implements.
Replace the in-memory createOAuthProvider + ~/.plaited/mcp/tokens file persistence with a BunKeychainOAuthProvider that implements the v2 OAuthClientProvider shape from @modelcontextprotocol/client: - issuer-keyed clientInformation(ctx)/tokens(ctx)/saveTokens(tokens,ctx)/ saveClientInformation(ci,ctx) — credentials are bound to the authorization server that issued them (the SDK's discardIfIssuerMismatch enforces the stamp); ctx === undefined returns the most-recently-saved set for the resource-server read. - state(), saveDiscoveryState()/discoveryState() — persist RFC 9728 discovery state to skip re-discovery. - validateResourceURL(serverUrl, resource) — RFC 8707 origin binding; rejects a resource on a different origin with IssuerMismatchError. - invalidateCredentials(scope) — per-scope keychain eviction. - prepareTokenRequest/addClientAuthentication — drive the v2 SDK's fetchToken for the client_credentials and refresh_token grants. Tokens and client info persist to the OS keychain via Bun.secrets (BunKeychain) — one provider per server-url, reused across process restarts (keychain persists; the connection doesn't, but reconnect reads tokens back). The v2 SDK's auth() orchestrator (invoked by the transport on 401) does RFC 9728 discovery and the token exchange; the provider supplies grant params + credentials and persists the issuer-stamped results, so the hand-rolled buildOAuthRequest/ exchangeOAuthTokens/file persistence are deleted. Migrate the shared adapter pool (use-plugin-adapter.ts) to the v2 @modelcontextprotocol/client Client + StreamableHTTPClientTransport, whose authProvider option accepts the v2 OAuthClientProvider directly. The keychain is abstracted behind a Keychain interface (BunKeychain default; InMemoryKeychain test double) — the only boundary mocked. 9 provider tests cover token round-trip across a reconnect, the refresh_token rotation after reconnect, issuer-binding on clientInformation, validateResourceURL mismatch rejection, and scoped invalidateCredentials. The 12 mcp-client round-trip tests remain green against the v2-migrated adapter.
Add a stateless skill-client useTool implementing the agentskills.io
three-tier progressive-disclosure pattern for local skills, with
search-on-demand replacing the spec's static catalog (the deliberate
2026-09-07 decision — marked MINIMAL so the deviation is greppable).
Three modes (discriminated union on mode, hand-written AJV oneOf,
cast through unknown as JSONSchemaType — same pattern as mcp-client/
read/frontier; no Zod):
- discover (tier 1): scan .agents/skills/ at project (relative to the
provisioned cwd) and user (~/.agents/skills/) level, parse YAML
frontmatter into { name, description, location, ...frontmatter }
records. Lenient validation per spec — warn but load when the name
doesn't match the parent dir or exceeds 64 chars; skip (with a
warning) on unparseable YAML or a missing/empty description.
Project-level overrides user-level on name collision (deterministic
precedence, first-found within a scope).
- read-skill (tier 2): load the SKILL.md body with frontmatter stripped;
returns isError when the file is absent or the frontmatter is
unparseable.
- list-resources (tier 3): enumerate bundled files in the skill
directory (parent of the SKILL.md location) as relative paths,
without reading them; SKILL.md itself is excluded (it is the
instructions, not a bundled resource).
Own frontmatter parsing (does not import from src/cli/markdown.ts);
src/cli/markdown.ts stays a read-only reference. Returns data only —
never writes. cwd is provisioner-supplied (same trust-boundary
treatment as read/ls/write).
15 tests cover the schema contract and a fixture skill dir exercising
discovery, lenient-validation skips/warnings, project-over-user
precedence, body stripping, and the bundled-resource walk.
Add a stateless discovery useTool backed by .plaited/discovery.sqlite (bun:sqlite) that holds a unified catalog of remote MCP tools and local skills for the search-mediated progressive-disclosure loop. This is the only tool that touches the store file. Five modes (discriminated union on mode, hand-written AJV oneOf, cast through unknown as JSONSchemaType — same pattern as mcp-client/ skill-client/read/frontier; no Zod): - create: insert a row (kind 'mcp-tool' | 'skill', name, description, handle = server-url for mcp-tool / SKILL.md path for skill, metadata = inputSchema for mcp-tool / frontmatter for skill). - read: fetch one row by id (null when absent). - update: patch name/description/handle/metadata (null row + isError when the id is absent). - delete: remove by id (deleted: true/false). - search: case-insensitive LIKE over name + description, optional kind filter, limit (default 100); an empty query matches all rows (tier-1 catalog). dbPath is provisioner-injected, not model-facing: createDiscoveryTool takes a resolved dbPath from the provisioner; the input schema omits dbPath entirely, so a model-supplied dbPath is rejected by additionalProperties: false at the boundary. This is the deliberate deviation from the other file tools (which take a path relative to a provisioned cwd, not an absolute store path). The SQLite connection is lazily opened on first use so tool construction is side-effect-free. The store is not git-backed — local SQLite, regenerable (re-scan the filesystem, re-discover servers). Population/refresh/search are kernel-thread policy via this tool, not adapter provisioning. 19 tests cover the schema contract (including the model-supplied dbPath rejection), CRUD round-trips for both kinds, missing-row error shapes, and search across name/description with kind filter, limit, and the empty-query catalog case.
Add a Phase 3.5 section recording the search-mediated progressive- disclosure tool primitives delivered in Slices A–E (mcp-client useTool conversion + adapter pool, v2 keychain OAuth provider, skill-client useTool, discovery useTool), and record Slice F (provisioning via provision-defaults.ts + the kernel progressive- disclosure behavioral thread) as a separate, separately-tackled body of work — not folded into this phase. Update the two resolved Open Questions: the discovery-tool schema contracts are landed (hand-written AJV oneOf, no Zod); the phase placement is confirmed as new Phase 3.5, not folded into Phase 3.
The adapter pool carried a write-only McpDiscovery cache on each PoolEntry
(getPoolDiscovery/setPoolDiscovery). getPoolDiscovery had zero readers across
src/; setPoolDiscovery was called exactly once in mcp-client discover mode,
behind a MINIMAL: comment admitting the cache was "write-only here". It was a
parallel, transient, non-authoritative discovery store duplicating the
discovery tool's responsibility (.plaited/discovery.sqlite).
Per the design rule, the discovery tool is the sole canonical discovery store;
the adapter pool owns connection lifecycle only. A refresh/TTL optimization, if
ever wanted, belongs in kernel-thread policy (when to call discovery search vs
re-discover), not a hidden adapter field.
src/kernel/use-plugin-adapter.ts:
- Remove the McpDiscovery type (the discover-mode output is typed by
McpServerCapabilities in mcp-client.ts; McpDiscovery was a looser parallel
used only as a pool-cache field).
- Remove discovery?: McpDiscovery from PoolEntry.
- Remove getPoolDiscovery/setPoolDiscovery exports.
- Rewrite the @packageDocumentation header: the pool owns connection
lifecycle only; discovery data is the discovery tool's store, populated by
kernel-thread policy.
src/tools/mcp-client.ts:
- In the discover case, drop the setPoolDiscovery(url, result satisfies
McpDiscovery) call and the MINIMAL: comment above it. Just return
{ mode: 'discover', result }; the caller (kernel thread) decides whether
to persist via discovery create/update.
- Drop the now-unused setPoolDiscovery / McpDiscovery imports.
Gates: tsc --noEmit clean; rg "getPoolDiscovery|setPoolDiscovery|poolDiscovery|
pool.*discovery" src/ empty; rg "McpDiscovery" src/ empty. mcp-client.spec.ts
12 pass / 0 fail (43 expect() calls) — the discover result shape is covered by
the existing round-trip test, which never pinned the cache.
Replace Zod with AJV (draft 2020-12) compiled validators, preserving the
public .parse()/.safeParse() API so existing call sites are unchanged.
Schemas are plain JSON Schema objects exposed via .schema (embeddable in
other tool schemas) backed by a shared Ajv2020 instance mirroring
html.schemas.ts and css.schemas.ts.
Discriminated unions use oneOf + const; the lax stream fallback uses
anyOf + not:{enum} so unknown types pass through while malformed known
frames still throw — the same semantics as the prior Zod .refine.
Relocate the module from src/tools/responses/ to src/tools/open-responses.ts
and its tests to src/tools/tests/, dropping the superseded Zod files to
avoid a parallel schema source. read.ts now imports the JSON Schema
directly via .schema instead of z.toJSONSchema.
Convert mcp-client from a module singleton importing getSharedClient to a
factory `createMcpClientTool({ getClient })`. The tool is now stateless; the
connection pool is injected at provisioning, matching the createDiscoveryTool
precedent. Drop the dead `mcpClient` singleton export (nothing imported it).
Expose `createConnectionPool()` in use-plugin-adapter as a closure factory
returning { getClient, closeClient, closeAll, size } — no module-level Map.
The legacy singleton exports (getSharedClient, closeAllClients,
pooledClientCount) now delegate to a default pool instance so pre-refactor
imports keep working during the collapse.
mcp-client.spec.ts constructs the tool via the factory against a test-owned
pool instance so teardown (pool.closeAll / pool.size) is isolated per suite.
All 12 mode tests still pass against the real loopback mcp-server-fixture.
`rg "import { getSharedClient }" src/tools/` is now empty — the tool no longer
imports a global pool. AdapterSessionOptions stays in the adapter for this
slice (re-exported from mcp-client); it moves to kernel.ts in Slice 2.
Move the connection pool contract out of use-plugin-adapter into kernel.ts:
createConnectionPool (closure factory), AdapterSessionOptions, PoolEntry,
ConnectionPool, and GetClientFn now live on the kernel floor. kernel.ts
instantiates the pool once via createKernel(), wires the MCP client tool
(createMcpClientTool({ getClient: pool.getClient })) at provisioning, and
owns the pool's lifecycle — shutdown() drains every pooled connection and is
registered on process beforeExit so no client leaks across an agent run.
mcp-client now imports GetClientFn + AdapterSessionOptions (type-only) from
kernel.ts instead of the adapter; it re-exports them for tool consumers. The
mcp-client test pulls createConnectionPool from kernel.ts. The kernel
teardown test asserts shutdown() drains the pool (size → 0) and is
idempotent.
use-plugin-adapter is reduced to a thin re-export shim of the pool surface
from kernel.ts so any lingering import keeps resolving mid-refactor; it is
deleted in Slice 4. The legacy singleton exports (getSharedClient,
closeAllClients, pooledClientCount) are gone — nothing imported them after
Slice 1.
Gates: tsc clean; 57 tests pass across kernel + mcp-client + discovery +
skill-client + oauth. `rg "import { getSharedClient }" src/tools/` is empty.
A useTool unit that parses a plugin.json manifest at a path resolved against
the provisioned cwd and returns the structurally-validated declarations
{ mcps, skills, models, threads }. Stateless — no writes, no provisioning;
loading only. A kernel thread reacts to plugin.loaded later (threads.ts,
deferred).
PluginManifestSchema is the contract threads consume, compiled once with
AJV. Strict object composition (additionalProperties: false) at every level:
mcps[].url required; models[] require provider/modelId/endpointUrl with
optional apiKeyRef (a keychain key name) and locality; skills/threads are
arrays of non-empty path strings. The schema rejects a raw `apiKey` field on a
model so secrets stay in the keychain — only `apiKeyRef` (a key name) is
allowed.
Errors (missing file, invalid JSON, schema violation) → { isError, message }.
The output schema is a oneOf of the manifest shape and the error branch; the
success branch reuses the manifest's component schemas so there is no parallel
schema source.
Tests (18): schema contract per field group, apiKey rejection, and run-time
behavior against real temp plugin.json files (valid parse, missing file,
invalid JSON, invalid manifest, raw apiKey at the boundary).
The connection pool contract now lives entirely in kernel.ts (Slice 2);
nothing imports use-plugin-adapter. Delete the shim. `rg "use-plugin-adapter"
src/` is now empty.
Final src/kernel/ layout: kernel.ts (engine floor + pool + future endpoint
registry), oauth/ (keychain + provider), threads.ts (empty placeholder — the
mutable surface for initial system threads, explicitly last; do not populate
in this task). Also drop two empty, unreferenced dead files left in dev
(use-server.ts, use-trace-router.ts) so the kernel layout matches the target.
No tools.ts barrel change: mcp-client and discovery (the kernel-provisioned
factory tools) are not re-exported from src/tools.ts, and plugin-loader
matches that convention — it is a provisioned manifest parser, not a
primitive file tool.
Gates: tsc clean; 375 tests pass across src/kernel + src/tools/tests;
`rg "use-plugin-adapter" src/` empty; `rg "import { getSharedClient }"
src/tools/` empty.
…emas The UseResponse/Adapter/useResponse/CompactionResult seam lost its only caller in 0b7baac and the upcoming model tools fetch inline with provisioner-injected endpoint config, so the seam has no future consumer. - delete the seam from open-responses.ts; keep all *Schema vocabulary - rename open-responses.ts to open-responses.schemas.ts per the schema-only module convention (matches html.schemas.ts/css.schemas.ts) - update the 3 import sites: read.ts, open-responses.spec.ts, open-responses-input-content.spec.ts - drop the scripted-adapter test double and the two useResponse factory describes; the failed-stream schema test now iterates events directly read/open-responses specs still pass (92 tests).
Treat talking to an Open Responses endpoint as two stateless tools, same
shape as mcp-client/discovery — the model is one tool in the fixed set,
threads orchestrate the agentic loop later. No router object, no adapter
layer; multi-model routing is threads choosing a provider label.
- createModelTools({ endpoints }) — provisioner-injected provider→endpoint
map (url, keychain-resolved apiKey, extra headers); model-facing input
carries provider/modelId only, never a URL or key
- model-respond: builds the spec wire request (model is a string on the
wire), POSTs /v1/responses; non-streaming validates the ResponseResource
against a schema composed from OutputItemSchema/UsageSchema/ErrorSchema;
streaming buffers SSE frames (data: [DONE] terminates), validates each
with KnownStreamEventSchema/lax passthrough, assembles items from
response.output_item.done, passes response.failed through as
status:'failed' + error
- model-compact: POSTs /v1/responses/compact, returns the compaction
item's encrypted_content + usage
- errors are data, never throws: unknown provider names the label,
non-2xx structured error bodies surface as { isError, message },
function_call items come back as untouched data (never dispatched)
- MINIMAL: streaming buffers whole-body before returning; upgrade path is
a provisioner-injected onEvent for mid-stream triggers
- fixture: startOpenResponsesServer (Bun.serve loopback) built from the
openresponses compliance-suite contract — basic JSON response, SSE
happy path, function_call, response.failed stream, compact resource,
400/401 structured error bodies; records every request for routing and
auth assertions
- tests cover non-streaming, streaming, tool-call passthrough, provider
routing across two fixture servers, unknown provider, HTTP 400,
streaming failure, bearer key injection, compact round-trip and errors
…LI seam
The thinnest end-to-end agent: one turn runs from a JSON prompt to a JSON
result. Tracer-bullet that makes the kernel runnable; the CLI seam Harbor
will drive later.
- createScriptedModelTools({ script }): a deterministic canned model-respond
with no fetch, same shape and schemas as the live createModelTools.
DEFAULT_SCRIPTED_RESPONSE keeps createKernel() deterministic with no
endpoint configured; a live createKernel swaps in createModelTools.
- createDispatchBridge({ tools }): the action channel. Maps a function_call
item to a tool invocation and back to a spec-valid function_call_output
(fresh id via ueid(), call_id correlation). Unknown tool, malformed args,
a tool isError result, and unexpected throws all become failed output items
carried as data — never thrown into the space.
- TURN_LOOP_THREAD: a minimal, looping five-rule thread (MINIMAL: scaffolding
turn-loop — to be replaced by autoresearch-evolved threads, Phase 5.5). The
dynamic decisions live in the bridge (useTrace action channel, per plan.md
Decision 2024-09-03); the thread is the static coordination skeleton.
- createKernel() owns the pool, the (scripted) model tools, the dispatch
registry, and runTurn({ space, prompt }) -> TurnResult JSON. runTurn composes
a fresh behavioral() per turn; the bridge bounds the loop with a
max-iteration guard (no infinite loop).
- plaited turn '<json>': the --no-serve CLI slice. input { space, prompt } ->
runTurn -> JSON out. No daemon, no --seed, no permission flow (Phase 6).
Gates: bun --bun tsc --noEmit clean; 57 pass across src/kernel/ +
model.spec.ts + cli/turn.spec.ts (0 fail). End-to-end determinism:
plaited turn run twice -> byte-identical JSON. A scripted function_call
dispatches and the output correlates by call_id; the turn completes.
Two Harbor tasks (build-git-context-skill, build-typescript-lsp-skill) where a coding agent must author and install an AgentSkills-compliant skill — SKILL.md plus a JSON-in/JSON-out CLI script — at a conventional discovery location (.agents/skills/<name>/). Each task is self-contained: a Bun-based no-network environment bakes in SPEC.md and deterministic fixtures, and the verifier recomputes truth from those fixtures at grading time (no golden repo, no oracle). Verifier design (tests/test.sh): - Layer 1 gates the reward: skill located at project-level <workdir>/.agents/skills/<name>/ with user-level ~/.agents/skills/<name>/ fallback (project wins on collision); frontmatter name/description/body validated; CLI script present and executable. - Layer 2 grades CLI behavior against recomputed expectations: git-context 12 checks (status staged/unstaged/untracked, merge-base history, worktrees, context includeWorktrees edge case, input validation); typescript-lsp 9 checks (discover capabilities, documentSymbol names/kinds/offsets recomputed via the TypeScript 7 API, hover, inline unsupported-method errors, input validation). Validated with harbor run (docker oracle; daytona -k 2 -n 2): reward.json 1.0 on a reference solve, 0.0 when the skill is missing, 0.25 fractional gradient on a partial solve. biome.json: extend the test/CLI noConsole override to tasks/**/tests.
Rename the controller island attributes to the b-* namespace: P_TARGET/P_TRIGGER/P_SCALE/P_FORM -> B_TARGET/B_TRIGGER/B_SCALE/B_FORM (attribute values p-target/p-trigger/p-scale/p-form -> b-*). Updates constants, controller runtime, html tools + schemas, behavioral/controller test fixtures, README scaleCheck section, and framework skill references. Behavioral-domain vocabulary (behavioral(), b-threads, the engine) is untouched. One stale TSDoc p-target reference in src/tools/html.ts remains and is fixed in a follow-up phase. Verified: bun --bun tsc --noEmit clean; 19 controller Playwright failures are pre-existing browser-launch env issues (evalJs returns no result), unchanged by this refactor.
Phase A — package identity + CLI rename. Package: name plaited -> @behavioral/sh, version 7.2.0 -> 0.0.1 (greenfield). Updated description, repository/homepage/bugs URLs to github.com/behavioral-sh/behavioral + behavioral.sh, keywords, and the bin entry (plaited -> behavioral). CLI: bin/plaited.ts -> bin/behavioral.ts; the router name/description become `behavioral`. Updated example/usage strings in turn, git-context, and typescript-lsp CLI help text, plus all subprocess bin paths and temp-dir/email/name fixtures in the CLI test suites. The Harbor task identity in tasks/*.toml (name namespace, author name/email) is renamed. Runtime dir: .plaited/ -> .behavioral/. Covers the connect route (CONNECT_PLAITED_ROUTE -> CONNECT_BEHAVIORAL_ROUTE, the .behavioral/connect URL the controller island script tag uses), the discovery.sqlite path docs, the keychain service label (plaited.mcp -> behavioral.mcp), and the OAuth token cache (~/.behavioral/mcp/tokens/). No migration shim — v0.0.1 greenfield, nothing to migrate. Identity strings: CLIENT_INFO name, OAuth client_name, and the test-server name become `behavioral`. The dangling @see PlaitedTrigger TSDoc ref (no such type existed) is removed; PlaitedAttributesSchema -> BehavioralAttributesSchema (the b-* attribute schema). Verified: bun --bun tsc --noEmit clean; src/cli + src/tools tests pass (202 tests, 0 fail); `behavioral turn '{"space":"s","prompt":"x"}'` runs and prints JSON. The 19 controller Playwright failures are pre-existing browser-launch env issues (evalJs returns no result) and are unchanged by the connect-path rename — the browser never launches, so the .script[src*=...] selector is never even evaluated. Behavioral-domain vocabulary (behavioral(), b-threads, the engine) is untouched. docs/AGENTS.md/README/skills rename is Phase C.
The README described the pre-harness product: a src/kernel/ coordination floor, a src/tools/ useTool fleet, the dispatch-bridge turn loop — all dissolved or deleted. As the landing page of the branch about to become main, it must describe what ships. The rewrite follows the tree: engine in-process and families as processes behind one wire; the composition and its guard threads; the system families as endpoint-carrying overrides; the repository map per the per-family folders; the public API over the four package exports; and a composing example that imports defineConfig from the root and the system config helpers from ./behaviors, matching src/main.ts and src/behaviors.ts.
The units were named two ways: the public/config surface said behavior everywhere (src/behaviors/, the Behavior union, the behaviors allow-list key, useBehavior, BEHAVIORAL_HOME, behavior_error), while prose and internal identifiers said family — a config author read behaviors: ['shell'] and was then told shell is a family. Every naming decision to date had already chosen behavior; family was the borrowed word. This standardizes on behavior and retires family (git history keeps it): - identifiers: familyAddThreads -> behaviorAddThreads, FamilyPort -> BehaviorPort, FamilyResult -> BehaviorResult, spawnFamily -> spawnBehavior, frontierFamily -> frontierBehavior, entryByFamily -> entryByBehavior; the family-harness test helper becomes behavior-harness - the routing map in b-program.ts was renamed off "behaviors" to lanes — it collided with the allow-list param of the same name - prose across every module doc, AGENTS.md, and the README (including the mermaid subgraph), with the two blind-rename artifacts hand-fixed: the stale wire list shell/store/responses/mcp in use-behavior.ts, and the sentence-case Each BEHAVIOR in AGENTS.md The css-schemas drift contract caught its header change and was regenerated. Full suite 595 pass; check clean.
"Behaviors" was the borrowed word: the paradigm owns behavior (behavioral programming, the engine, the threads), and the capability units borrowed it for infrastructure that has no canonical name. The cognitive-science term fits them exactly — faculty, an inherent capacity of a system: shell the motor faculty, store memory, mcp tool use, systemOne intuition, systemTwo reasoning, frontier metacognition — and it extends the Kahneman naming the system faculties already used. It also untangles the src/behavioral/ vs src/behaviors/ one-letter collision. The split, now two layers: behavioral = the paradigm (engine, threads, config type, BEHAVIORAL_HOME, @behavioral/sh — unchanged everywhere); faculty = a capability unit. - src/behaviors/ -> src/faculties/, src/behaviors.ts -> src/faculties.ts, package export ./behaviors -> ./faculties - Behavior -> Faculty (union), useBehavior -> useFaculty (file use-faculty.ts), the config key behaviors: -> faculties:, the wire registry BEHAVIOR_MESSAGE_KINDS -> FACULTY_MESSAGE_KINDS with behavior_error -> faculty_error - each family folder's process entry behavior.ts -> faculty.ts and its spec; shared wire files faculties.types/constants/threads; the harness test helper and resolve-faculty-entry - AGENTS.md, the readme, and the skills follow; the engine and controller are untouched by scope and by grep (the law held: zero references) Full suite 595 pass; check clean; css-schemas drift contract unchanged.
…works The review's first blocker: a config generated by init could not load from a host process. serve resolves the config's `@behavioral/sh` imports by walking node_modules from the CONFIG FILE's directory; a bare home has nothing on that walk, and Bun's global node_modules fallback covers only entry execution, not dynamic imports — so `behavioral init && behavioral serve` failed with "Cannot find module '@behavioral/sh'". Two parts: - bun.lock regenerated. Its root name said "plaited" (a rename casualty), which made `bun add -g <path>` fail while reconciling global deps — the mechanism for putting the CLI on PATH. The stale "plaited": "link:plaited" entry in the user's global manifest was the same drift on the other side. - init now links the running package under <home>/node_modules/@behavioral/sh (resolved from init's own location, so it points at whatever install is running). The generated config resolves from any host; the link appears in init's file output and is idempotent. MINIMAL: symlinkSync assumes POSIX/Bun; on Windows without symlink privileges, the documented fallback is a global install. The missing load-test lands with the fix (the gap that let this ship): runInit into a temp home → loadConfig the generated file → bProgram with it — the generated config must load and compose, not merely look right. Verified by hand from a clean home: init → serve boots the full composition (thread_added traces, zero errors) with no manual symlinks.
…cution
The review's second blocker: shell_request/shell_cancel routed
UNCONDITIONALLY while store and mcp sat behind has(). A host could compose
bProgram({ faculties: ['store'] }) and a client-injected shell_request still
spawned the shell process and was answered — contradicting the param's
contract, the Faculty union's allow-list meaning, and the serve spec's own
claim that an empty allow-list means "no spawns". Shell is the
arbitrary-execution faculty; a pruning boundary that does not bound it is
a violated design law.
The route now sits behind has('shell'), matching store and mcp: a pruned
shell has no route, spawns nothing, and mounts no pack. The allow-list
spec proves the boundary end-to-end: with faculties: ['store'], a
triggered shell_request is never answered.
Re-verified by hand: bProgram({ faculties: ['store'] }) + a shell_request
trigger produces no shell process and no result.
The notification branch awaited its handler without a guard — one bad notification rejected the read loop, serve exited 1, and runtime.terminate never ran. A notification has no response channel (no id), so the failure is logged to stderr and the loop keeps dispatching; requests keep their -32603 error response. Spec: a throwing notification is survived and the message after it still answers.
…bundle
Three follow-up findings in the init command, one coherent bundle:
- follow-up 3 — the provider template was systemOne-shaped for both
faculties: systemTwo's stub destructured `{ endpoint, signal }` (its
context is the endpoint MAP) and returned `{ model, answers }` (the
Decisions output; systemTwo's is the Open Responses `{ items, status }`),
so a scaffolded systemTwo provider failed its own typecheck. Each stub
now matches its faculty's respond contract; specs assert both shapes
and the negative, and the generated file typechecks against the surface.
- follow-up 6 — ts() escaped quotes and backslashes but not control
characters: a newline in a URL/header wrote an unterminated literal
(init exits 0, the config dies at load). The escape funnel now covers
the whole control range (\n/\r/\t named, others \uXXXX).
- follow-up 7 — the bare-file-name pattern that blocks path traversal
lived only in the input schema; the interactive collector's free-text
file answer flowed into runInit raw. The gate is enforced inside
runInit, the one funnel both paths share; the spec proves it via a
direct bad input and the collected tour input.
The follow-up 4 finding: two readers of the same schema disagreed on failure. eventGuardEntries threw when properties.type.const was absent; useFaculty's pump silently computed an undefined lane seal and dropped EVERY inbound result — a wiring defect surfacing as "requests are answered by nothing, no error, no trace". The extraction now has one home — eventTypeOf, exported from faculties.threads — and both readers use it: the guard generator and useFaculty's seal, which now throws at wiring time (the outer call, where schemas are handed over), same check and message. Specs pin both throws; eventGuardEntries' throw was previously untested.
Follow-up 2 (plus item 5's ruling and the two nits), one sweep:
- README (item 5, per the pilot's ruling): the faculties-are-processes
paragraph claimed the faculty guard blocks every parsed-but-invalid
result — true only for the systemOne/systemTwo overrides. The sentence
now states both lanes honestly and names guarding the default lanes a
recorded follow-up (the pilot reversed the doc's mount-them ruling).
- frontier-analysis reference: described the pre-refactor Worker world and
cited `frontier.worker.ts`/`workers.types.ts` (paths that never existed
post-rename); now describes the in-process embed, bindEmit, and the
wire's real one home (faculties.types.ts).
- mcp-client reference: the result envelope was the retired
{id, status, durationMs}; now the uniform {id, ok, result|error} with
the real codes (authorization_required/timeout/canceled/error).
- three links to the untracked prompts/ dir (gone in 789b21e) replaced
with the tracked artifact (css.schemas.ts as classifier context).
- rename-prose corruption: "runtime/tool faculty" back to behavior (the
conduct sense) in AGENTS.md and mcp types; "mcp-client worker" is now
the mcp faculty process.
- retired-Worker headers: shell/mcp/system-two types, faculties.types,
frontier faculty (postMessage/zod/"worker faculty" → the process/embed
world); init/cli/b-program header debris (the cut --interactive, the
stale "shared tools AJV", the duplicated host-constructed fragment).
- nits: the duplicated postResult doc block in shell merged; the SSE
first-data-line ceiling gets a MINIMAL comment naming the upgrade path.
The diagram spread horizontally: every rank child of COMPOSE (frontier embed, faculties, serve) sat side by side because they all link upward to the composition. Now the subgraph members stack (direction TB in HOST and FACULTIES), and invisible rank hints (~~~, layout-only) chain embed -> faculties -> serve so the whole diagram reads top to bottom: host, composition, in-process embed, spawned faculties, the serve edge. No visible edge or label changed.
The rename sweep fixed AGENTS.md:61's 'runtime/tool faculty' corruption but left its neighbor one paragraph down: 'when they do not change faculty' was the conduct-sense 'behavior' before the rename. Restored.
Two flowchart attempts sprawled horizontally for a structural reason: the embed, the faculties, and serve are all rank-children of the composition, so every flowchart layout fans them sideways. block-beta is mermaid's layered-diagram type — a single column of stacked layer blocks with the fan-out as connectors, which is the shape this architecture actually is. All content is preserved: the host layer (config/ingress/observation), the composition's guards-packs-engine-pump stack, the frontier embed, the five capability faculties, and serve. The wire annotation rides the faculties block title (block diagrams do not label edges).
…d block The SERVE layer was declared with the block: prefix, which opens a nested block that requires an end. With no body and no end, the parser consumed the connector lines as its children and died at EOF wanting 'end'. A bare top-level block takes no prefix — the four true layers (HOST, COMPOSITION, EMBED, FACULTIES) each pair a block: with an end; SERVE is now a plain block. All content unchanged.
The block diagram read as stacked rows: columns 1 forces one box-wide column, so the architecture became a list with borders. Back to flowchart TD, but with the structural cause of the old horizontal sprawl removed: frontier (the in-process embed) now sits INSIDE the composition block and serve (the host's CLI face) INSIDE the host block — both are architecturally true — so no loose rank-siblings remain and the rank math yields exactly three vertical bands: host, composition, faculties. The faculties spread side by side within their band (a process bus), composition stacks its guard/pack/engine/pump/frontier internals, and all wire labels are back on the edges (block diagrams cannot label edges).
…ts job One diagram was carrying three jobs — the layered assembly, the faculty catalog, and the request lifecycle — which is why no layout type felt right. Now: - the assembly (flowchart TD): terse three bands — host, composition, faculties — with one-line labels; the prose paragraphs carry the detail the old node labels were folding in, and the faculties render as a five-slot process bus. - the life of a request (sequenceDiagram): the lane contract as an actual message flow — request line in, result re-enters, valid selects, malformed is blocked by the guard visibly, cancel aborts with the first stop reason winning, crash synthesizes exactly one faculty_error with respawn on demand. The type is built for exactly this story; it sits after the 'faculties are processes' paragraph it illustrates.
The collections of threads a faculty ships were called packs (thread pack, root guard pack, the ICL pack) — a second word for a thing the repo already names: the faculty's threads. The files are threads.ts, the exports are shellThreads/mcpThreads/facultiesThreads; "pack" was the odd term out against the settled faculty vocabulary. Every occurrence is now "threads": the composition's root guard threads, each faculty's default threads, thread mounting in useFaculty and bProgram, spec names and comments, AGENTS.md's thread section, and the readme diagram node. No identifier changed — this was prose and comments only. Also fixes the sequence diagram parse error (semicolons are statement separators in mermaid sequence diagrams — the note now uses a comma).
…o AGENTS.md law The template keeps four headings (Context, Summary, Changed Files, Known Failures/Drift, Review Notes). The dropped sections duplicated the working law: targeted tests and tsc are the repo's quality gate, and the agent checklist is behavioral guidance that belongs in AGENTS.md, not a form.
CI floated `bun-version: latest`, which is how the bun 1.4.x worker regression reached the PR unannounced. Pin `bun-version: 1.4.2` in ci and css-schema-drift, raise the engines floor to `>= v1.4.2`, and pin `oven-sh/setup-bun` to its v2.2.0 commit (the CodeQL unpinned-action findings).
…m pool bun 1.4.x drops a worker's first postMessage when `self.onmessage` is assigned after a top-level await, so every transform timed out as jq_timeout and the shell/mcp/skill threads cascaded. Register the handler synchronously and queue until the wasm compile resolves. The worker is now a persistent pool of one: the host resets the status slot and reuses the worker across evaluations, paying worker boot + jq.wasm compile once per process instead of per eval. It is `unref`'d so it never holds the host process open (the runtime is `bun run`, which blocks on a live worker), and a timeout terminates and lazily respawns it. The single wait splits into a 30s startup budget (boot + compile) and the 1s eval budget, so a slow runner no longer reads as a jq timeout. The handler rejects any non-empty origin (CodeQL js/missing-origin-check).
Swap the hand-rolled `deepEqual` for the native `Bun.deepEquals` in `eventMatchesCandidate`. Event details are JSON-shaped, so the semantics match and the native call is faster.
…gistry The default CodeQL setup has no config support, so the four high-severity regex alerts in the auto-generated `src/cli/credential-patterns.ts` could not be suppressed. Switch to an advanced workflow with a `paths-ignore` for that file (betterleaks-generated, drift-checked, over-redaction by design) while keeping the `security-extended` suite the default setup ran.
Contributor
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
The advanced workflow uploaded under `.github/workflows/codeql.yml:analyze/...` while the already-open alerts live in the default setup's `/language:<lang>` category. A zero-result analysis only closes alerts in its own category, so the stale alerts stayed open. Pin `category: /language:<lang>` so the paths-ignored analysis supersedes and closes them.
`category` is an input to `codeql-action/analyze`, not `init` — the previous commit set it on `init`, where it was silently ignored and the analyses kept the workflow-path category. Setting it on `analyze` makes the zero-result, paths-ignored analysis land in the default setup's `/language:<lang>` category so it closes the orphaned alerts there.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
The repo's default line of work (
dev) has grown a complete behavioralagent harness since
mainlast took a release cut. A deep review of theharness (findings, remediation, and final re-review) completed with a
SHIP verdict at
a300135e; everything after that tip is docs/vocabularypolish. This PR carries the reviewed work to
mainso the default branchrepresents the harness and feature work can branch from it.
Summary
interpreter (
src/behavioral/) driven directly by the composition (bProgram,src/cli/b-program.ts), with capability faculties (shell, store, mcp,systemOne, systemTwo, frontier) as
Bun.spawnprocesses speaking onebehavior event wire over stdio lines.
src/faculties/,useFaculty, thefaculties:allow-list,faculty_error); theirdefault thread sets are threads (no "packs");
behavioralstays theparadigm layer.
their schema and the rejects are visible in the frontier/pending-bids/
deadlock traces; the pump re-enters parsed-but-invalid results (never
silently discards) and the lane seal blocks cross-faculty injection.
OpenRouter Decisions and Open Responses providers bundled;
behavioral initgenerates
<home>/config.ts(interactive at a TTY, agent JSON otherwise,env-name secrets that fail fast, optional provider scaffolding, and a
home-resolving package link so init → serve boots from a clean home).
behavioral serve) — line-framed JSON-RPC over stdio,ingress → triggers,
ui_*selections → client notifications, redactedtraces out — with a hardened codec (a throwing notification no longer
kills the loop).
the allow-list now gates the shell faculty), follow-ups 1-8, and the
docs sweep.
Changed Files
git diff main...dev: 504 files, +29,138 / −78,804. The count reflectshistory (the pre-harness kernel/tools surface is deleted) — the arc is
visible in
git log main...dev --oneline(1,422 commits). Headlinesurfaces:
src/behavioral/— the pure language layer (engine, traces, jq worker)src/faculties/— one folder per faculty + shared top (wire,useFaculty,process lane, home, entry resolution); colocated
tests/per facultysrc/cli/—b-program.ts(the composition),init,serve+json-rpc,load-config/defineConfig,trace-consumersrc/controller/— the dumb-relay Controller,ui_*vocabulary, AJVdetail schemas (never in the browser bundle)
src/faculties.ts/src/main.ts/package.json— the public surface(
././faculties/./controller/./utils)README.md— rewritten for the harness (two diagrams: the assembly;the life of a request),
AGENTS.md— the working law,skills/synced.gitignore/ untracked:plan.md,.prompts/,.research/arelocal-only working docs by design;
.github/pull_request_template.mdpruned to its four required headings
Known Failures / Drift
bun run checkclean;bun test603 pass / 0 fail.deferrals): default-lane guards (shell/store/mcp results surface as
selected-but-unmatched rather than guard-blocked — the README states
this honestly); the empty store threads file (populate in later work).
authorship (open questions for the
ui_*producers), the keychainsecret resolver, mid-stream delta publishing, concurrency caps.
Review Notes / Residual Risks
agent/agent-execute-*branches; the final verdict verified everyremediation claim independently (both blocker reproductions re-run by
hand) before SHIP. The response summary is
.prompts/harness-review-response.md(local-only by the working-docspolicy).
is fine either way.
action, not part of this PR.