agentleFS
Sign inSign up

siclaw

scitix/siclaw/CLAUDE.md

Auto-loaded at session start. Keep concise — deep reference lives in docs/design/. Siclaw is an AI-powered SRE copilot that runs Kubernetes diagnostics via natural language. Three runtime modes share one agent core: Full spec: docs/design/invariants.md LocalSpawner runs ALL AgentBox instances in-process, sharing one filesystem. - skillsHandler.materialize() must NOT be called in local mode — wipes all users' skills - Local skills sync writes only to skills/user/{userId}/ scoped paths buildSkillBundle() packages only global + skillset (dev only) + personal skills selected…

CLAUDE.md236 starsChanged 4 months ago
  • Reads credentials
  • Installs packages
# Siclaw — Operating Manual for Claude

> Auto-loaded at session start. Keep concise — deep reference lives in `docs/design/`.

---

## What This Project Is

**Siclaw** is an AI-powered SRE copilot that runs Kubernetes diagnostics via natural language.

**Three runtime modes share one agent core:**
```
Headless CLI (single diagnostic invocation; optionally paired with local Portal — see invariants.md §1.4)
Gateway + LocalSpawner (multi-user, local dev — all users share one process + filesystem)
Gateway + K8sSpawner  (production — one isolated pod per user)
```

---

## Critical Architecture Invariants

> Full spec: `docs/design/invariants.md`

### 🔴 Local Mode: Shared Filesystem

`LocalSpawner` runs ALL AgentBox instances **in-process**, sharing one filesystem.
- `skillsHandler.materialize()` **must NOT be called in local mode** — wipes all users' skills
- Local skills sync writes only to `skills/user/{userId}/` scoped paths

### 🔴 Skill Bundle Contract

`buildSkillBundle()` packages **only global + skillset (dev only) + personal skills** selected for the current workspace. Core skills are baked into the Docker image. `materialize()` does NOT restore core skills.

### 🔴 Headless CLI + Local Portal: Read-Only Snapshot Contract

When `siclaw --prompt "..."` starts in a cwd that has a reachable local Portal (`.siclaw/local-secrets.json` present AND `/api/health` responds), Portal becomes the **read-only source of truth** for skills, knowledge, credentials, agents, MCP servers, and LLM providers. Do not add code paths that let the headless CLI write back to Portal — writes belong in Portal Web UI.

- Snapshot contract: `GET /api/v1/cli-snapshot?agent=<name>` — see `src/portal/cli-snapshot-api.ts`. Agent-scoped joins via `agent_skills`, `agent_hosts`, `agent_clusters`, `agent_mcp_servers`, `agent_knowledge_repos`.
- Materialization: payload unpacked to `.siclaw/.portal-snapshot/run-<random>/{skills,knowledge,credentials}/`. Each invocation owns its root and removes only that root on exit or cancellation. Empty snapshots remain authoritative; never substitute ambient resources. These materializers (`src/lib/portal-*-materializer.ts`) are **NOT** the same as `skillsHandler.materialize()` (§1.2) — different code path, scoped ephemeral output, safe in headless CLI cwd.
- CLI requires `--prompt` and exits on missing provider configuration. Interactive setup and terminal slash commands have been removed; configuration belongs in Portal Web UI or a pre-provisioned standalone settings file.
- Standalone headless CLI (no Portal, no selected agent): legacy `settings.json` flow unchanged. A selected Portal agent must load successfully; never silently execute against local configuration when its snapshot fails.

### 🔴 Shell Security: Defense-in-Depth

> Full spec: `docs/design/security.md`; output sanitization: `docs/design/sanitization.md`

Primary defense: **OS-level user isolation** — child processes run as `sandbox` user; `kubectl` has setgid `kubecred` (ADR-010). Secondary: **whitelist-only command validation** (`src/tools/infra/command-sets.ts`). `sed`, `awk`, `nc`, `wget` intentionally excluded. kubectl read-only (13 safe subcommands).

### 🔴 Two Separate Databases

| Database | Engine | Purpose |
|----------|--------|---------|
| Portal/Gateway DB | **MySQL (prod) / node:sqlite (local)** via `DATABASE_URL` scheme | Users, sessions, skills, MCP config, chat history. One unified DDL in `src/portal/migrate.ts`; driver chosen by `src/gateway/db.ts` factory. |
| Memory DB | node:sqlite | Embeddings, chunks, investigations. pi-agent only. Separate file from Portal DB. |

Local mode (`siclaw local`) auto-creates SQLite at `.siclaw/data/portal.db` unless `DATABASE_URL` is overridden. Production K8s uses MySQL via `DATABASE_URL=mysql://...`.

### 🟡 mTLS Scope

mTLS is **K8s mode only**. Do not add mTLS dependencies to local mode code paths.

---

## Change Impact Matrix

> Before modifying any file, find it here. Read the required docs and verify cross-cutting concerns **before writing code**.

| If you change... | Must read | Must verify | Cross-cutting concerns |
|---|---|---|---|
| `src/tools/infra/command-sets.ts` | security.md §4, tools.md §6 | `npm test` | Skill scripts still work; sanitization rules still align |
| `src/tools/infra/extra-commands.ts`, `docker/extra-commands.json` | 2026-06-10-extra-command-whitelist.md, security.md §4 | `npm test` (`extra-commands.test.ts`) | Additive-only merge via `setExtraCommands` (built-ins win; cache invalidated); `FORBIDDEN_EXTRA_COMMANDS` denylist must stay aligned with security.md exclusions; loaded once in `createSiclawSession` |
| `src/tools/infra/exit-classification.ts` | tools.md §6.1b | `npm test` (`exit-classification.test.ts`) | SINGLE answer to "whose failure was it" for all four exec tools. Two non-derivable contracts: the class must be in the TEXT (`details` is stripped before the model sees a result) AND `details.error` must stay accurate (it drives the Trace outcome) — which is why `no_match` sets neither `error` nor a red trace. `appendAnnotation` runs AFTER postExecSecurity: folding the annotation into the body feeds it to a structural (JSON) sanitizer, which replaces anything it cannot parse with a suppression notice, and a failed command's output is exactly that. Channel-vs-command needs UNFILTERED stderr (`filterPodNoise` strips the kubectl lines a transport failure announces itself in); markers are line-anchored and only consulted when stdout is empty, and `command terminated with exit code N` is NOT one (it means the command ran). `channelLeg` is a SEPARATE field, not another class: "was this the target's answer" and "where did it break" are independent questions, and folding the second into the first needs a class per combination. It distinguishes the transport (kubectl exec / the SSH dial, named by kubectl's own diagnostics) from the namespace-entry leg (`nsenter`, `ip netns exec`) — the latter had NO markers, so a vanished `pod=` netns and an `nsenter` permission failure were both reported as `target_reported_failure`, i.e. as the target's own answer to a command it never saw. Both markers are unambiguous only because neither wrapper is reachable as a user command (`nsenter` is in no whitelist, `ip netns exec` is refused by the validator); if either becomes reachable the marker starts misattributing a user command's failure. Every tool calling `classifyExit` must forward the leg into `details` — enforced by a test, since the field is useless if it stops there. Four classes were added after reading the review traces and each answers a question the exit code cannot: `output_truncated` (the command RAN and its output is a PREFIX — a search over it proves nothing; covers both the maxBuffer kill and a 141 on the final pipeline stage), `pipeline_upstream_failed` (an earlier stage failed while the last exited 0, so an empty result means the QUERY failed, not that nothing matched), `invalid_arguments` (the CLIENT refused the flag or the printer, so the request never reached the cluster and retrying it unchanged fails identically — read from the client's own answer rather than a curated flag blocklist, which would start refusing working commands the moment a kubectl is upgraded), and NotFound-as-`no_match` in the `local` context only (an existence answer; a trace shows the same check running three times because it read as a failure — but in pod/node/host context the same string means the exec target is gone, so it stays a channel failure there). `Error from server` is matched by REASON, not as a prefix: Timeout / InternalError / etcd / dial tcp are the transport, everything else is the server's answer. Changing a class, a leg, or its wording is an agent-visible contract change — say so in the PR |
| `src/tools/infra/output-sanitizer.ts` | sanitization.md, tools.md §6.2 | `npm test` | Pipeline fallback in restricted-bash; any tool that emits captured command output |
| `src/tools/infra/json-projection.ts` | tools.md §6.1c | `npm test` (`json-projection.test.ts`) | `json_path` for node/pod/host exec, evaluated in the AgentBox because the targets usually lack `jq`. NOT jq and NOT a subprocess on purpose: jq would run agent-authored program text as `agentbox`, the credential OWNER, and its `$ENV` reads the environment — this evaluator has no expression language, so there is nothing to sandbox. Position in `PostExecOptions.project` is a contract: AFTER sanitization (projecting first strips the shape a structural sanitizer matches on and could surface a value it would have redacted) and BEFORE truncation (truncated JSON does not parse, so it would fail on exactly the documents it exists for). A structural sanitizer's redaction notice makes its own output un-parseable, so the projector parses the JSON SPAN and re-appends the notice — dropping it would present edited content as verbatim. A filter expression is REJECTED, never ignored |
| `src/tools/infra/tail-truncation.ts` | tools.md §6.1b | `npm test` (`tail-truncation.test.ts`) | Fires for `kubectl logs --tail=N` and nothing else, so it is REACHABLE only from a tool whose `preExecSecurity` passes `extraAllowed: kubectl` — restricted_bash alone. It was wired into node_exec, pod_exec and host_exec, where the command is refused before it can run and the note was dead code in all three; removing it failed no test, which is why the reachability is now pinned by an invariant rather than left to reasoning. Deliberately silent in a pipeline (the line count belongs to the last stage), without an explicit positive `--tail` (`--tail=-1` means everything), and when the window is not full (that is itself the answer) — at exactly N lines a full window and a truncated one are indistinguishable, and the note says so rather than asserting truncation |
| `src/tools/infra/pipeline-status.ts` | tools.md §6.1b | `npm test` (`pipeline-status.test.ts`, runs REAL bash) | Per-stage exit status via `PIPESTATUS`, so a pipeline's exit code stops being the only evidence — `kubectl get x | jq .` with kubectl at 1 and jq at 0 reported success on an empty result. **restricted_bash ONLY**, and the boundary is measured: `/bin/sh` in the image is dash, where `${PIPESTATUS[@]}` is a hard `Bad substitution`, so instrumenting node_exec (`setsid sh -c`) or host_exec (a remote `sh -c` on a shell we do not choose) would break working commands; pod_exec forbids pipelines. `;`/`&&` chains are NOT covered — PIPESTATUS describes the last pipeline only. Three contracts: the sentinel is stripped BEFORE anything reads the output (a structural sanitizer parses the whole payload, so a trailing marker makes every instrumented `-o json` pipeline "not JSON"); the caller's exit code is PRESERVED, not replaced by the last stage's (a command that set `pipefail` asked for 141 and gets it — the classification explains it instead of the wrapper overriding a shell option someone chose); and SIGPIPE is benign only UPSTREAM of a stage that stops reading on purpose — a 141 on the FINAL stage is `output_truncated`, because nothing downstream of it could have closed the pipe. That last rule was written the other way first and a real trace refuted it: `kubectl logs --tail=-1 | grep -c` returned 141 with NO output after 83s, while the same shape that completed took 11s and printed `0` |
| `src/tools/infra/command-validator.ts` | security.md §4, tools.md §6.2 | `npm test` | All tools calling `validateCommand()`. A sensitive-path refusal is MACHINE-READABLE (`rejected_by: "sensitive_path"`, `matched`, `hint`) — assert on `rejected_by`, never on the prose: the old bare denial named neither the argument nor an alternative, so the agent retried a different command and was refused again, and ~46 tests pinned the exact sentence |
| `src/tools/cmd-exec/restricted-bash.ts` | security.md, tools.md §5, sanitization.md | `npm test` | kubectl validation; skill bypass (`isSkillScript`); 3-layer sanitization |
| `src/tools/cmd-exec/*.ts` | tools.md §3 | `npm test` | Security pipeline via `preExecSecurity` / `postExecSecurity` |
| `src/tools/script-exec/*.ts` | tools.md §4, skills.md | `npm test` | Script transmission; skill resolution |
| `skills/core/node-logs/scripts/*.sh` | `skills/core/node-logs/SKILL.md` ("what an empty result means") | `npm test` (`src/tools/script-exec/node-logs-skill-scripts.test.ts`) | A log-retrieval script's OUTPUT CONTRACT is the honesty of its empty case, and the tool layer already forwards a non-zero exit plus stderr to the model (`node-script.ts` / `host-script.ts`), so a script that swallows failures is throwing away a signal that reaches the model for free — never wrap the fetch in `|| true`, and never merge the source's stderr into stdout where the filter would then drop it. Exactly ONE `status:` line, printed LAST by the shell: whether an empty stream is `no_match` or `source_error` is unknowable until the source exits, which is after the reporting stage has already written its output, so the stage passes "nothing matched" back through its exit code (10) instead of printing a verdict. `--grep` is ERE (an agent passing `a\|b` expecting alternation is the default case, and BRE silently returned nothing); an invalid pattern is `filter_error`, never "nothing found". Time inputs accept RFC3339 and are converted — journalctl rejects the `T…Z` form every alert is written in. A default `--since` must never survive alongside `--boot` or an explicit `--until`, or it silently clips the window the caller asked for. Tier 3 (`get-node-logs-via-api.sh`, `local_script`) exists because tiers 1 and 2 can BOTH be unavailable on a NotReady node; its hard constraint is that the kubelet answers an unsupported `?query=` with the `/var/log` HTML index and HTTP 200, so every response is type-checked before it is counted. **Test convention**: these are shell scripts exercised for real by a vitest file that builds a private PATH (only the utilities they may use, plus fake `journalctl`/`systemctl`/`kubectl`) — the test lives under `src/`, NOT beside the scripts, because everything inside a skill directory is packaged and shipped to agents (`collectSkillDirectoryFiles`) |
| `src/tools/infra/ssh-dial.ts` | ssh-jump-host.md | `npm test` (`ssh-dial.test.ts`) | Broker-free; shared by `ssh-client.ts` (broker path) AND Portal `host-api.ts` `/test`. ProxyJump `forwardOut`+`sock` chaining; reverse teardown; shared TOFU verifier. Keep it broker-free (ssh2 + node only) — no Portal-layer inversion |
| `src/tools/infra/ssh-client.ts` | ssh-jump-host.md | `npm test` (`ssh-client.test.ts`) | `acquireSshTarget` recurses on `HostMeta.jump_host` (depth ≤3, cycle guard); single-hop behavior unchanged; reads materialized key/password/passphrase files into inline `DialHop`s |
| `src/portal/adapter.ts` (host credential.get/list, both HTTP + WS mirrors) | ssh-jump-host.md, invariants.md §1.4 | `npm test` (`adapter*.test.ts`) | Resolve `jump_host_id`→name at the boundary (neutral wire ref); transitive jump authorization (`isJumpOfBoundHost`) — binding a target grants transit through its bastion chain; keep the two mirrors in sync via `buildHostSshCredential` / `isJumpOfBoundHost` |
| `src/tools/infra/security-pipeline.ts` | tools.md §8.2, security.md | `npm test` | Facade for all cmd-exec tools; changes affect all 3 tools |
| `src/gateway/skills/` | skills.md, invariants.md §1-2 | `npm test` | Bundle contract; `materialize()` NOT safe in local mode |
| `src/core/model-compat.ts` | — | `npm test` (`model-compat.test.ts`, `model-api-invariants.test.ts`) | SINGLE resolver for a model's wire protocol — FOR PORTAL-MANAGED MODELS ONLY. A control plane that answers `config.getModelBinding` itself (control-plane does) supplies its own `compat`, and the runtime forwards it verbatim (`agent-model-binding.ts` types it `Record<string, unknown>`), so none of this file runs on that path: a rule fixed here is NOT fixed there, and both sides had to fix `maxTokensField` separately. Protocol is a **per-model** attribute — one endpoint serves several (a model gateway can run Claude-protocol and OpenAI-protocol models side by side), so `model_entries.api_type` is NOT NULL and `model_providers.api_type` survives only as the value pre-filled when adding a model. Two contracts: (a) the descriptor ALWAYS emits `api` — pi would fall back to the provider if the key were absent, and stating it explicitly is what stops the two layers disagreeing; never emit `""`, pi's `parseModels` does `if (!api) continue` and drops the model from the registry outright ("model not found", not a protocol error); (b) `compat` derives from that same api, since it describes the wire protocol. `provider.api` remains a read-time floor only for legacy SQLite rows the NOT NULL tightening couldn't reach. Every `FROM model_entries` SELECT feeding `buildProviderModelDescriptor` must name `api_type` (7 sites: adapter ×4 HTTP+WS mirrors, chat-gateway, cli-snapshot-api, model-routing-config) — enforced by `model-api-invariants.test.ts`, which also pins the call-site count. Wire SHAPE is per-model for the same reason the protocol is: one gateway serves gpt-5 (which rejects `max_tokens`) beside DeepSeek (which requires it), so `defaultProviderModelCompat(provider, model)` takes the model as a REQUIRED arg — a new call site that hasn't thought about it must be a compile error, which is exactly what was missing while `maxTokensField` sat hardcoded for the repo's whole history. `maxTokensField` follows the same always-emit rule as `api`, but its validation is a strict WHITELIST rather than a passthrough (pi types it as a two-member union, so an unknown value drops the model from the registry); `looksLikeOpenAiReasoningModel` (mirroring control-plane's `isReasoningModel`) is a naming convention and only the fallback — the persisted per-model value always wins, which is what covers renamed ids and families shipping after this code. Never infer a max-tokens switch on an anthropic provider: the messages API fixes the name and pi ignores the setting there. `model-compat-invariants.test.ts` is the twin of `model-api-invariants.test.ts` — it requires every descriptor-feeding SELECT to name EVERY descriptor column, not just `api_type` |
| `src/portal/provider-model-listing.ts`, `src/portal/siclaw-api.ts` (`fetch-models` / `models/batch`) | security.md §4 | `npm test` (`provider-model-listing.test.ts`) | The FIRST Portal endpoint that dials an operator-supplied URL and streams the response back to the browser. Guard is `isBlockedIpLiteral` (`tracing-exporters.ts`) with `allowLoopback: true` — deliberately laxer than the tracing probe because `http://localhost:11434/v1` (Ollama) is a legitimate provider and the runtime already dials it; do NOT tighten without checking that. It is literal-only: a hostname resolving to metadata still passes (needs resolve-then-pinned-connect; nothing here does that yet). Body read through `readBodyWithCap` — `res.text()` buffers first, so a post-hoc slice bounds nothing. `api_type` writes go through `isValidApiType` on all three paths (POST / PUT / batch): passthrough not enum (new pi api ids must work without a release) but bounded, or a typo reaches pi's registry and kills every turn. Batch uses `insertIgnorePrefix` — a pre-read dedup set races a concurrent add and rolls back the whole import |
| `src/core/brain-session.ts` (`BrainModelInfo`, `modelNeedsRebind`) | — | `npm test` (`brain-session.test.ts`, `model-routing.test.ts`, `http-server.test.ts`) | `modelNeedsRebind` is the SINGLE rule deciding whether a session must be re-bound to a changed model. It once existed as two copies — the routing runner's and the agentbox prompt path's — and both were missing the same field; the prompt-path copy is gone now that the binding is resolved inside `runAttempt` (see the binding row below), leaving `runAttempt` the only caller. It must compare every attribute that changes how a turn is issued — `api` and `maxTokensField` included: a per-model `api_type` override leaves id/provider/reasoning/contextWindow/maxTokens identical, so omitting it means `setModel` is skipped, the session keeps the old model object, and every turn goes out on the old wire protocol (`unsupported_protocol` 400) or the old max-tokens field (`please use MaxCompletionTokens` 400). `maxTokensField` reads off pi's compat union via `readMaxTokensField` — the key exists only on the chat-completions variant. Any field added to `BrainModelInfo` that affects request shape must be propagated in BOTH `getModel()` and `findModel()` (pi-agent-brain.ts) — they narrow pi's Model and silently drop whatever they don't list. **DEPLOY: this file is in the AGENTBOX image** — a provider-config contract change needs an agentbox rebuild + `SICLAW_AGENTBOX_IMAGE` bump + pod recycle, not just runtime/portal |
| The prompt path's model binding: `bindingCandidateForRun` in `src/agentbox/http-server.ts` + `resolveEffectivePolicy` in `src/core/model-routing.ts` | 2026-06-22-unified-model-routing-entry.md | `npm test` (`http-server.test.ts`, `model-routing.test.ts`) | **Provider registration and model lookup belong to `runAttempt`, per candidate — never upstream of the runner.** A caller placed before it can only register the PRIMARY's one config, while a fallback candidate may live on another provider; and having registered it, that caller must then decide what a refusal or a missing model MEANS — which is the judgement the policy already encodes (`model_not_found` is in `DEFAULT_FALLBACK_ON`). Resolving the binding early therefore breaks ordered fallback for exactly the case it exists for: a bad primary with a healthy secondary. The request's binding is instead passed to `resolveEffectivePolicy` as the single candidate (carrying its `modelConfig`), so a refusal becomes that candidate's classified setup failure with pi's own message attached, and a missing model an ordinary `model_not_found`. A config refusal classifies as `unknown`, which is in `DEFAULT_NO_FALLBACK_ON` — deliberate: a misconfiguration is not transient, and an operator who wants it to fall back adds the kind to `fallbackOn` rather than having the answer hardcoded above the policy. Do NOT read pi's `parseModels` as validating `api`: it skips a model only when `api`/`baseUrl` is FALSY (`if (!api) continue`), and the `registerProvider` path (`applyProviderConfig`, the one every Portal-managed provider takes) pushes every model unconditionally — so `find()` RETURNS a model whose api is misspelled and the rejection lands later, in pi-ai's `resolveApiProvider`. A test that stubs `findModel` to return nothing therefore pins an id/config mismatch and proves nothing about an unrecognised api. Preserved tolerance: a binding with NO `modelConfig` naming a model absent from the registry keeps the session on its current model (nothing to register ⇒ the caller means an already-existing model) — a stub returning `undefined` for EVERY id breaks the fallback model's resolution too and silently makes that test vacuous. **`candidate.modelConfig` is OPTIONAL while the top-level `modelConfig` is the turn's documented registration config**, so a policy carrying only provider/model identities is valid at this boundary and the binding's config is merged into the candidate that names it (`withBindingConfig`) — a candidate's OWN config always wins, and it is never lent to a candidate on another provider. Both control planes in tree hydrate every candidate, which is exactly why dropping it was invisible from their side. **Runtime tunables (`modelConfig.params`) are applied by `runAttempt` AFTER that candidate's `setModel`, never before** — pi's `setThinkingLevel` CLAMPS to the CURRENT model and a clamp to `off` on a non-reasoning model is not persisted, so `setModel` then restores the old default and an effort applied early is silently dropped on the first turn of a non-reasoning → reasoning binding. Per candidate, with the turn's top-level `params` as the fallback: hydrated candidates carry provider config with no `params`, so reading only the candidate drops the caller's effort on every routed turn. `http-server.test.ts`'s fake brain MODELS these pi semantics (clamp + non-persistence + restore-on-switch) because a fake that only records the last call cannot see an ordering bug at all |
| Anything about a model's WIRE COMPAT: `src/core/model-compat.ts` (`resolveAnthropicCompat`, `withResolvedModelCompat`) + `model_entries.compat_overrides` | — | `npm test` (`model-compat.test.ts`, `brain-session.test.ts`, `siclaw-api.misc.test.ts`, `model-compat-invariants.test.ts`) | **A compat rule must be applied on BOTH paths or it is not applied.** `buildProviderModelDescriptor` covers configs assembled here from `model_entries`; a control plane that answers `config.getModelBinding` supplies the WHOLE `modelConfig` — compat included — and the runtime forwards it verbatim (`agent-model-binding.ts` returns `data.binding` unchanged), so that function never runs on the path most production traffic takes. `maxTokensField` had to be fixed twice for this reason, and the adaptive-thinking 400 survived a "fix" that touched only the descriptor. The second application is `withResolvedModelCompat`, called in the prompt handler on the turn's `modelConfig` AND on every routing candidate's own config (each candidate is registered from its own, so fixing one moves the failure down the chain). It fills ONLY keys the caller left unstated — an explicit value including `false` is the caller's decision (pi documents `false` as the opt-out) — and copies rather than mutates the control plane's object. Resolution order per model: `compat_overrides` → pi's own `ANTHROPIC_MODELS` table → for a CLAUDE id only, a GENERATION rule → the latest generation. **`anthropic-messages` is a protocol other vendors implement** (MiniMax, Z.ai, any Anthropic-compatible gateway), so an id that is not recognisably Claude resolves to NOTHING and keeps pi's defaults — handing it the newest Claude shape is a guess about a different product, and adaptive thinking is exactly what such an endpoint rejects. The cost is stated: a Claude model renamed past recognition is indistinguishable from a MiniMax id and needs the explicit override. That last default is deliberately the INVERSE of pi's (`forceAdaptiveThinking` false): pi's default protects an old config file, while this boundary receives a live id, pi's table is a release snapshot that lags exactly during a launch (`claude-opus-5` is not among its 14 claude ids), and the set requiring adaptive is open and growing while the legacy set is closed. The generation rule is a RULE, not a mirror of pi's table — nothing is added when a model ships — and a missing minor means `.0`, never "unknown": `claude-opus-4` is real, pre-adaptive and absent from pi's table, so reading it as current would 400 it the other way. `forceAdaptiveThinking` is in `modelNeedsRebind` for the same reason as `api`/`maxTokensField`, and absent must stay `undefined` (folding it to `false` would rebind every OpenAI-protocol model every turn). `supportsTemperature` rides along because pi reads it from the same object, but pi's agent path never sets `options.temperature` today — defensive, not a fix for an observed failure. **This spans the AGENTBOX image**: rebuild it, repoint `SICLAW_AGENTBOX_IMAGE`, and note that `imagePullPolicy: Always` only pulls on pod CREATE — a live pod keeps the old code, so verifying against an existing session proves nothing |
| `DEFAULT_MAX_TOKENS` / `DEFAULT_CONTEXT_WINDOW` in `src/core/model-compat.ts` | — | `npm test` (`model-compat.test.ts`, `migrate-sqlite.test.ts`); `cd portal-web && npx vitest run` (`Models.test.ts`) | Take pi's own numbers (`maxTokens ?? 16384`, `contextWindow ?? 128000` on its config path) rather than inventing a per-protocol table: pi's Anthropic table puts claude-haiku-4-5 / opus-4-5 / sonnet-4-5 at **64000**, so the previous 65536 default was ABOVE a live model's ceiling, and the Claude protocol rejects that rather than clamping. Accepted cost: a 128000-capable model held to 16384 until someone fills the field in — a truncated answer, not a failed turn. **A default is only reachable where nobody supplies a value, so changing it here fixes nothing on its own** — this bit once because the three API write paths took the constant while `portal-web` pre-filled 65536 in the create form and always sent it (the API keeps any positive value), so the primary user path never reached the default at all. Every layer that can name the number must agree: the constants, `model_entries`' column defaults in `migrate.ts` (INTERPOLATED from the constants — a hand-copied number is a second answer to one question), and the Portal form, which now sends the field ONLY when the operator filled it in. Column defaults need BOTH halves: fresh installs get it from `CREATE TABLE`, while an existing table is only reachable through `setColumnDefault` (MySQL — guarded on COLUMN_DEFAULT, since `widenColumn` compares COLUMN_TYPE and reads INT→INT as "no change", and `safeAlterTable` only ADDs; SQLite would need a table rebuild, so an existing file keeps its old default and the value must stay a backstop no writer reads). Existing ROWS are never migrated — a stored value can be a deliberate choice for a model whose ceiling really is that high — so an affected model needs its `max_tokens` corrected, not a redeploy. Tests must cover the WRITE PATHS, not just the constants and the DDL: the fix was correct in three writers for a release while the value reaching the DB came from the frontend |
| `src/portal/migrate.ts` | invariants.md §5 | `npm test` (runs `migrate-sqlite.test.ts` + `schema-invariants.test.ts`) | Single DDL must stay MySQL + SQLite compatible; index names must match legacy; no `TIMESTAMP(3)` / `ON UPDATE` / `JSON` columns. `chat_{sessions,messages}.delegation_id` is `VARCHAR(64)` (was `CHAR(36)`; the group `#reduce` suffix overflowed) — new installs via the CREATE + `safeAlterTable` defs; EXISTING MySQL widened via the idempotent MySQL-only `widenColumn` MODIFY (`safeAlterTable` only ADDs missing columns, never widens) |
| `src/shared/message-kinds.ts` (`CHAT_MESSAGE_KINDS`, `SYNTHETIC_USER_KINDS`, `ChatMessageMetadata`) + `src/portal/human-prompt.ts` | prompt-basis.md | `npm test` (`message-kinds.test.ts`, `human-prompt.test.ts`, `message-kind-invariants.test.ts`) | SINGLE registry of which `role='user'` rows are a person asking something. The ORTHOGONAL twin of `session-origin.ts` and BOTH are needed: synthetic rows land in ordinary user sessions, so the origin filter never sees them. It repeats that file's history — four prompt-count call sites each spelled the filter out by hand, all four naming `delegation_event` alone, so `task_event` (19,502 rows against 7,512 real questions over 30 days) was counted as a question everywhere. The rule that a new kind is added HERE is a COMPILE ERROR, not a comment: `ChatMessageMetadata.kind` narrows to `CHAT_MESSAGE_KINDS` and the six write-path payloads carry that type — a comment is exactly what `session-origin.ts` had when `subagent` shipped past it, and narrowing this type surfaced three kinds no author had enumerated. **The check has one hole and it is not where you would guess**: TS rejects an unregistered kind in an inline LITERAL, but a value arriving as `Record<string, unknown>` is assignable with NO error (a source index signature need not satisfy a target's declared optional property), so a helper that builds metadata and returns an open record slips past — which is how `model_route_notice` ran a full release unregistered with the type in place and the suite green. No shape of the type fixes it (dropping the index signature rejects every literal carrying a payload key, i.e. all of them), so ROOT metadata BUILDERS are typed at their own declaration and pinned by name in `message-kind-invariants.test.ts` — a builder whose object lands NESTED (`modelRouteSuccessMetadata`, under `model_route`) is deliberately not on that list. **There is NO sicore counterpart to `SYNTHETIC_USER_KINDS`** and three successive revisions of this claim named a file (`chatfields/message_kind.go`), a symbol, and a reciprocal comment that all do not exist: sicore counts prompts with no kind filter at all (`metrics/handler.go`, `adapter/rpc.go`), so its figure is inflated by MORE than this one was, and its `metrics/trace_kinds.go` answers an ADJACENT question (which user row may title a trace) — which is why `steer` belongs in that set and must stay OUT of this one. Do not copy either list into the other. **`steer` is deliberately NOT synthetic** (a person typing mid-turn; the tag says WHEN it was sent, not who wrote it) and `error_response` rides an assistant row — which is why the all-kinds list is broader than the synthetic subset. **The SQL is dialect-aware or it is broken**: MySQL's `JSON_EXTRACT` returns a QUOTED JSON string so `JSON_UNQUOTE` is what makes the comparison mean anything, while SQLite has no `JSON_UNQUOTE` at all — the first version hardcoded MySQL and every metrics endpoint 500'd under `siclaw local`. It goes through `jsonScalarOrNull` (`dialect-helpers.ts`, the 4th dialect difference) for that reason, and the `JSON_VALID` guard is load-bearing on BOTH sides (MySQL raises ER_INVALID_JSON_TEXT, SQLite `malformed JSON` — neither returns NULL, so one bad row fails the whole query). **A string assertion is not a test here**: the shipped SQLite break passed a suite that only checked the SQL's shape, so `human-prompt.test.ts` EXECUTES the predicate against real `node:sqlite`. Assert against the predicate, never a pinned SQL string. Vocabulary in `shared/` (writers in agentbox/gateway/tools depend on it; it is bundled into the AgentBox image and must not reach into the gateway), SQL in `portal/` (needs `db.driver`). Prompt-basis prose lives in TWO places besides the card hint — `KpiCards.tsx` and the daily `TrendChart` subtitle in `pages/Metrics.tsx`; the second was missed on the first pass |
| `src/portal/session-origin.ts` (`TRACE_ORIGINS`, `PARENT_ATTRIBUTED_ORIGINS`) | 2026-09-12-coordinator-retirement-assessment.md | `npm test` (`metrics-entry.test.ts`, `siclaw-api.misc.test.ts`) | SINGLE registry of which `chat_sessions.origin` values are execution traces rather than conversations. A new trace origin is added HERE — never at a call site: `'subagent'` shipped 2026-05 and all EIGHT hardcoded `NOT IN ('task','delegation')` predicates missed it, so sub-agent children showed up in the user's Chat list, inflated every session/prompt count, and `task.prune` could never reach them (unbounded growth — the first prune after the fix DELETES that backlog). Historical `delegation` (retired coordinator → peer, peer's own config) and `subagent` (an agent's own context isolation, parent's config + narrowed whitelist) are DIFFERENT mechanisms that happen to share the `delegation_id` wire field — a query may never treat one as the other. `PARENT_ATTRIBUTED_ORIGINS` excludes `task` on purpose (a scheduled run IS its own entry bucket, with no parent turn to inherit from). Assert against the registry, not against a pinned SQL string — a pinned string is what let this rot for a month. `adapter.ts` has HTTP + WS mirrors of the stats queries: change both. **A `role='user'` count takes `entryPromptPredicate`, NEVER `entryMessagePredicate`**: parent attribution is right for tool/assistant telemetry (the parent's `spawn_subagent` call and the child's tool calls are all real work) and wrong for prompts (a trace child's opening user row is the task text its PARENT wrote, so inheriting the parent's entry counts one human request twice). Getting this backwards is invisible in tests that only assert the alias is in scope — it surfaced as `siclaw-api.ts` totalPrompts disagreeing with the `adapter.ts` summary about one number. The parent branch MUST also test `parent_s.id IS NOT NULL`: an unmatched LEFT JOIN NULLs every parent column, and a NULL origin SATISFIES the two buckets that test for one (`web` IS `origin IS NULL`; `all` opens with the same disjunct), so an orphan trace row lands in Web/Overview — the one place a trace may never appear. Orphans are by design, not corruption: the retired peer-delegation writer persisted a NULL parent rather than an unverified ref, and deletion/retention leaves children behind. A trace row with an absent parent belongs to NO bucket |
| `src/gateway/sse-consumer.ts` (`stream_error` / error rows) | **2026-08-02-error-surfacing-contract.md** | `npm test` (`sse-consumer.test.ts`) | Two events describe one failure and they are NOT interchangeable: the raw assistant `message_end` (`stopReason:"error"`) is the FACT stream (metrics, a2a tracker), `stream_error` is the RENDER signal. Consumers render from `stream_error` ALONE — it is deduped (pi retries in-turn), REVOCABLE (a recovered retry suppresses it, while the `message_end` is already gone and would paint an error over a successful answer), and carries the LAST error of the turn rather than the first. A consumer that buffers the `message_end` as a lost-signal fallback must ALSO drop it when a later message in the turn carries real output — half-implementing revocation resurrects the withdrawn error at the fallback step, and leaves the live view disagreeing with the reload, since no row was written for a turn that recovered. It is **NOT terminal**: flushed at every TURN boundary since a steer adds turns to a request in flight, so termination is `done`/`prompt_done` and tearing the stream down on an error loses every later turn. Error ROWS follow the same turn scoping, so a reload agrees with what was on screen. Changing WHEN it is emitted is a consumer-visible contract change even with no type change — say so in the PR |
| `src/gateway/db.ts`, `db-mysql.ts`, `db-sqlite.ts` | invariants.md §5 | `npm test` — db.test.ts covers both drivers | DML return shape must match mysql2 (`[OkPacket, undefined]`); SQLite transactions serialised by mutex |
| `src/gateway/dialect-helpers.ts` | invariants.md §5 | `dialect-helpers.test.ts` | All 4 dialect differences (upsert, INSERT IGNORE, JSON ops, `safeParseJson`) flow through here; every caller chooses via `db.driver` |
| `src/lib/bootstrap-portal.ts`, `bootstrap-runtime.ts`, `src/cli-local.ts` | invariants.md §1 | manual `siclaw local` smoke test | Portal must `waitForListen` before Runtime boots; secrets persist in `.siclaw/local-secrets.json` |
| `src/portal/cli-snapshot-api.ts` | invariants.md §1.4 | `npm test` — `cli-snapshot-api.test.ts` | Three auth gates in order: `enableCliSnapshot` flag, loopback-origin check, dedicated `cliSnapshotSecret` header (not `jwtSecret`); endpoint MUST stay read-only |
| `src/lib/portal-snapshot-client.ts` | invariants.md §1.4 | `npm test` — `portal-snapshot-client.test.ts` | Cwd-scoped `.siclaw/local-secrets.json` read; sends `X-Siclaw-Cli-Snapshot-Secret` header (never `Authorization: Bearer`); silent fallback on 401 / 403 / missing secret (do not crash the headless CLI) |
| `src/lib/portal-skill-materializer.ts`, `portal-knowledge-materializer.ts`, `portal-credential-materializer.ts` | invariants.md §1.4, skills.md "Skill Discovery by Agent" | `npm test` — each has a `.test.ts` | Ephemeral write to `.siclaw/.portal-snapshot/run-<random>/{skills,knowledge,credentials}/`; each invocation removes only its own root on exit or cancellation; **distinct from** `skillsHandler.materialize()` — do not merge the two |
| `src/cli-agents.ts` | invariants.md §1.4 | manual smoke: `siclaw agents` with/without Portal | Non-interactive agent lister; must exit non-zero + friendly stderr when Portal unreachable |
| `src/core/agent-factory.ts` | tools.md §7, guards.md §4, invariants.md §10 | `npm test` | Tool registration; guard pipeline installation; brain type compatibility; `portalSkillsDir` / `portalKnowledgeDir` / `portalCredentialsDir` opts override `config.paths.*` when a Portal snapshot is active |
| `src/core/tool-capabilities.ts`, capability migrations and runtime projections | 2026-09-12-coordinator-retirement-assessment.md | `npm test` (`tool-capabilities.test.ts`, `migrate-sqlite.test.ts`, `internal-api.test.ts`, `all-entries.test.ts`) | Capability GROUP keys and concrete TOOL whitelists are different layers: null/[] group selections resolve to null (unrestricted), while the non-empty ["no_tools"] selection resolves to [] concrete tools. A cleanup that drops the final key must use no_tools. Test through parsing, type resolution, capability resolution, the production registry and appended file tools; asserting only stored JSON once let a permission expansion pass as a restriction. Preserve null/[] compatibility and independent handoff/task transport rules |
| `src/tools/all-entries.ts` | 2026-09-12-coordinator-retirement-assessment.md | `npm test` (`all-entries.test.ts`) | Check the complete production registry when removing tools: grouped import/registration lines can also contain surviving tools. A passing factory test did not detect missing handoff, request_input and channel_update entries; the registry test must resolve those tools through the real capability filter |
| `src/core/guard-pipeline.ts` | guards.md | `npm test` | Guard registry, pipeline installation; all guard stages affected |
| `src/core/guard-log.ts` | guards.md §7 | `npm test` | Structured logging for all guards |
| `src/core/session-tool-result-guard.ts` | guards.md §5 | `npm test` | Persist guard; session history write validation |
| `src/core/tool-result-context-guard.ts` | guards.md §5 | `npm test` | Context guard; context budget enforcement |
| `src/core/stream-wrappers.ts` | guards.md §5 | `npm test` | Output guards; stream event repair |
| `src/core/tool-call-repair.ts` | guards.md §5 | `npm test` | Input guard; malformed tool call sanitization |
| `src/core/prompt.ts`, `src/core/agent-types.ts` | agent-prompt-lifecycle.md; **⚠️ REQUIRES HUMAN APPROVAL** | `npm test` (`prompt.test.ts`, `agent-types.test.ts`) | Describe intent and wait for OK before editing; persisted Agent prompts replace only the identity/behaviour layer, retain template-variable/mode-block rendering, and never replace platform safety/mode/context assembly |
| `src/core/agent-types.ts`, `src/shared/agent-retirement.ts` | 2026-09-12-coordinator-retirement-assessment.md | `npm test` (`agent-types.test.ts`, `agent-api.test.ts`, `error-envelope.test.ts`) | Retired types fail closed with `AGENT_RETIRED`, HTTP 410 and `retriable: false`; never normalize them into Custom or depend on the migration having disabled them. Every Agent PUT checks the stored type, including name-only updates. The Portal type catalog mirrors supported capabilities and descriptions; retired instances have a separate read-only display and never enter the picker |
| `src/agentbox/http-server.ts` + `session.ts` (`turnId`, pending-abort latch) and every `abortSession` / `chat.abort` call site | 2026-09-12-coordinator-retirement-assessment.md | `npm test` (`http-server.test.ts`, `server-chat-abort.test.ts`) | A session id names a CONVERSATION reused across turns, so an abort addressed by session alone lands on a successor once its turn ends (a lease expiry or an abort retry is enough). A prompt carries `turnId`, the box records it, an abort naming another turn answers already-stopped, and the pre-spawn latch is consumed ONLY by that turn's prompt — which is what makes an orphaned latch harmless and let the old `listSessions` probe and the skipped cold-start Stop both be deleted. `chat.abort`'s optional `turnId` ignores a stale one; the Stop BUTTON sends none and must keep meaning "stop what is running" — and MORE THAN ONE turn is live then (a second send registers its turn before it can take the session lock), so name them ALL, snapshotted BEFORE breaking the consumer: a settling turn removes itself from that set, so reading it after would miss the turn being stopped. A task supervisor supplies `turnId` at dispatch rather than reading it from the ack — the lost-ack case is exactly the one that would otherwise have nothing to name. The pre-spawn latch is armed UNCONDITIONALLY when named (the on-disk-history guard only ever protected against a session-wide latch), is held PER (session, turn) — one slot per session let two turn latches overwrite each other — and an abort naming a NOT-CURRENTLY-RUNNING turn arms one instead of answering already-stopped, because that turn's prompt may still be in flight or its session rebuilding. Cancellation is per-turn on the Runtime side too (`turnAborts`): aborting the session's controllers for a request that named a QUEUED turn breaks the RUNNING turn's consumer while the box is told only about the queued one. `endTurns` aborts EVERY turn it reports (a queued turn declared interrupted must not then start) and suppresses each turn's own terminal per-turn. Both fields optional ⇒ additive rollout, but this spans the **agentbox** image: build it, repoint `SICLAW_AGENTBOX_IMAGE`, recycle pods. An abort the box never confirmed must FAIL, not report a successful Stop — success tells the management plane to stop retrying and tear down supervision |
| `src/gateway/frontend-ws-client.ts` (Portal → Runtime reverse delivery) | invariants.md §1 | `npm test` (`frontend-ws-client.test.ts`, `error-envelope.test.ts`) | Commands use authenticated reverse WebSocket delivery. Handler failures cross the JSON boundary through `wrapRpcError`, preserving code/status/retriable fields. Retired peer relay control and terminal acknowledgements no longer exist; do not reintroduce them for handoff |
| `src/memory/` | invariants.md §7, decisions.md ADR-005 | `npm test` | Requires embedding config; pi-agent only |
| `Dockerfile.agentbox` | security.md §3-5 | `docker build` | Dual-user model; capability set; setgid kubectl |
| `kbc/platform/pod/*` (KB compile box, Python) | kbc/platform/pod/README.md | `cd kbc/platform/pod && python test_compile_box.py` (needs `pip install claude-agent-sdk aiohttp`; CI: kbc-ci.yml, path-filtered) | Box↔runtime HTTP+SSE contract is shared with `src/gateway/capability/session-driver.ts` + `server.ts` (`/session` body, event vocabulary) and `agentbox/box-profile.ts` (allowedTools names, ANTHROPIC env forward) — change both sides together. Behavior changes need a `siclaw-kbc-box` image rebuild + redeploy (runtime env `SICLAW_COMPILE_BOX_IMAGE`); a rolled-out runtime does NOT pick up a new box image for existing live sessions |
| `k8s/` or `helm/` | security.md §5, invariants.md §11 | `helm template` | mTLS K8s-only; container hardening |
| `src/agentbox/resource-handlers.ts` | invariants.md §1,6 | `npm test` | `materialize()` safe in K8s, destructive in local mode |
| Anything about an AgentBox mTLS certificate's LIFETIME: `src/shared/cert-validity.ts`, `issueAgentBoxCertificate` in `src/gateway/security/cert-manager.ts`, `ensureCertSecret` + the pod-reuse branch in `src/gateway/agentbox/k8s-spawner.ts`, `isCertFresh`/`isCertUsable` in `manager.ts` | security.md, invariants.md §4 | `npm test` (`cert-validity.test.ts`, `k8s-spawner.test.ts`, `manager.test.ts`) | A certificate goes bad TWO ways and a fingerprint comparison sees only one. The leaf lives `AGENTBOX_CERT_VALIDITY_DAYS`; a fresh one is minted on every spawn, but the per-agent Secret's 409 branch judged staleness by CA fingerprint ALONE and discarded it — so the Secret was written once and never again, and past day 30 every box of the agent failed mTLS in BOTH directions (Runtime → box `CERT_HAS_EXPIRED`; box → Runtime a bare `socket hang up`, because `rejectUnauthorized` rejects during the handshake and NOTHING logged it on either side until `tlsClientError` was wired up). Recreating the pod did not help: the new pod mounted the same dead Secret. Three contracts. (a) **UNKNOWN IS NOT EXPIRED, everywhere** — `readCertificateNotAfter` returns null on a parse failure, `certificateNeedsRenewal(null)` is false, a pod with no `cert-exp` label reads as fresh. Reading a missing label as stale is exactly how the CA-fingerprint version once drained every freshly created box on sight, and the same trap is one edit away here. An unreadable SECRET is still stale (pre-existing contract), but an unparseable CERTIFICATE inside a readable Secret is NOT: the Secret is per-agent, so replacing on a parse failure would make any blind spot in the parser delete and recreate, on every spawn, a certificate the agent's siblings are mounting. (b) **`isCertUsable` vs `isCertFresh` are different questions and must stay wired to different call sites.** Usable (rotated CA / already dead) ⇒ worthless, drop immediately and serve nothing from it. Fresh (nearing expiry) ⇒ still works, so ROLL it one at a time — every box of a pool mounts the SAME Secret and therefore comes due at the same instant, so treating "due" as urgent empties the pool in one tick. Acquisition paths ask `isCertUsable` (a user's turn must not wait out a cold start for a box that works); the drain path asks both. (c) The pod is stamped with the expiry of the certificate it ACTUALLY mounts — which is the reused sibling's, not the one just minted. **Spans the agentbox image** (the box reads its cert off disk once at startup, so re-issuing the Secret under a running pod does nothing — replacement is the only repair, which is why the renewal window must be long enough for drain + respawn + drain budget). **The window is bounded from BOTH sides and the upper bound is measured, not reasoned**: `SICLAW_AGENTBOX_CERT_RENEW_BEFORE_DAYS` adjusts the lead time but is refused above `MAX_RENEW_BEFORE_DAYS` (half the lifetime), because a window at or above the LIFETIME makes every certificate due the instant it is signed — a box is drained for "nearing expiry", its replacement is signed with a full lifetime, and the replacement is judged identically. Verified live: at 40 days against a 30-day lifetime the pool drained 8 boxes in minutes and stopped itself on the drain budget (`that is a loop, not a deploy`). Half rather than merely below, because `< lifetime` bounds the immediate churn but not the steady state (29 days on a 30-day cert = one quiet day, then a full-pool roll daily). Corollary: **the env can never be used to force renewal on demand** — any window big enough to make a fresh certificate due is big enough to make its replacement due too, so the replace path is exercised by tests plus the log line, never by widening the window |
| The AgentBox startup budget: `src/agentbox/startup-budget.ts` + the `withStartupDeadline` steps in `src/agentbox-main.ts` | — | `npm test` (`startup-budget.test.ts`) | **The allowance is a TOTAL across every pre-listen step, never per step.** It was per step — three sequential steps at 30s each — so a slow Runtime meant up to 90s of silence before `listen()`. `startupProbe` allows periodSeconds × failureThreshold = 60s from CONTAINER start and the pod is `restartPolicy: Never`, so past 60s kubelet does not retry the container: it kills the pod into phase `Failed`, the Runtime collects it as a crashed box, refills the slot, and the replacement queues behind the same slow gateway. Adding a step, or giving one its own timer, silently re-opens that. A spent budget SKIPS the remaining steps rather than running them with a short timeout — every step's fallback is "come up without it, take it on the next push", and the tool whitelist stays fail-closed meanwhile. "Unlimited" must mean RUN, not skip, and must arm NO timer: `setTimeout` coerces anything past 2^31-1 ms into a near-immediate fire, so an Infinity deadline would abort the work instantly. Budget ≪ probe window is the invariant; node startup and module loading sit on top of it, uncounted. **agentbox image** |
| Pool spawning: `waitForPodReady` in `src/gateway/agentbox/k8s-spawner.ts`, `spawnInstances`/`mayFillInstance` in `manager.ts` | — | `npm test` (`k8s-spawner.test.ts`, `manager.test.ts`) | **`waitForPodReady`'s deadline and the pod's `startupProbe` window do not measure the same span and must never carry the same number.** The probe budget starts when the CONTAINER starts; the deadline starts at pod CREATION and also covers scheduling, image pull and volume attach. Both were 60s, so a pod that spent 30s being scheduled and then passed its probe in 40s was healthy at t=70s and already declared a spawn failure at t=60s. **De-duplication in `spawnInstances` is load-bearing, not an optimisation**: pool fill is triggered from `getOrCreatePooled`, i.e. once per SESSION REQUEST, and runs in the background — so while a pool sat short every arriving request started its own spawn for the same indices (observed: `pool short by 4 … spawning instances 1,2,3,4` four times in one second, four pods under one name, four readiness waits on the one pod, a raw 404 ApiException when one recycled a pod another was waiting on), all contending for the resources the boxes needed to start. De-dup alone is not enough — an attempt leaves the in-flight map the moment it settles, so `SPAWN_RETRY_COOLDOWN_MS` bounds retries of a slot that keeps failing. It counts spawns that NEVER STARTED, distinct from `CRASH_RESPAWN_COOLDOWN_MS` (boxes that ran and died); a slot can be under both, and both must agree. The awaited spawn in the `accepting.length === 0` branch deliberately IGNORES the cooldown — there is nothing to serve the turn from. `exitedUnexpectedly` is pod phase `Failed`, NOT a readiness timeout: a timed-out spawn leaves a running pod that is never collected, so do not "fix" the timeout by treating it as a crash |
| `src/core/job-registry.ts` | tools.md §9 | `npm test` (`job-registry.test.ts`) | `claimNotification` is the single-fire dedup; a completion notice fires exactly once across the process-exit vs `job_stop` race |
| `src/core/background-bash-runner.ts`, `src/tools/cmd-exec/disk-output.ts` | tools.md §9, sanitization.md §6b | `npm test` (`background-bash-runner.test.ts`) | Sanitize-on-write per LINE (line-safe actions only); output file under `userDataDir`, `O_NOFOLLOW`, written only by node main process; model never reads unsanitized output |
| `src/tools/cmd-exec/restricted-bash.ts` (`run_in_background`) | tools.md §5/§9, sanitization.md §6b | `npm test` | Reject background for non-line-safe (JSON) sanitizers; foreground path unchanged when param/executor absent |
| `src/tools/infra/output-sanitizer.ts` (`OutputAction.lineSafe`) | sanitization.md §6b | `npm test` | Every `OutputAction` MUST set `lineSafe`; structural (JSON) sanitizers are `false` |
| ANY new reader that accumulates a stream into a string — HTTP request/response bodies, child stdout/stderr | — | `npm test` (`http-server.test.ts` + `gateway-client.test.ts` each pin a mid-character split) | **Decode on the STREAM, never per chunk.** `body += chunk` / `chunk.toString()` inside a `data` handler decodes each fragment independently, so a multibyte character straddling a chunk boundary becomes **two U+FFFD** — silently, and only when the split happens to land inside a character, which is why it survived for a release: an em dash inside a synced SKILL.md reached the agent corrupted while the DB row was byte-exact. Call `req.setEncoding("utf8")` / `res.setEncoding("utf8")` / `child.stdout.setEncoding("utf8")` (Node then holds the partial bytes in its own StringDecoder), or push Buffers and `Buffer.concat(...).toString("utf8")` at the end — which is what `rest-router.ts`, `internal-api.ts` and `siclaw-api.ts` already do. A byte-based size cap must then use `Buffer.byteLength(chunk)`, since `.length` on a string counts UTF-16 units and lets CJK output run past the limit. A hand-rolled `IncomingMessage` test double needs a `setEncoding` that actually switches its chunks to strings, or it silently exercises a path production never takes |
| `src/agentbox/session.ts` (`notifyParent`/`runSyntheticPrompt`/`JobRegistry`) | tools.md §9.2, agent-prompt-lifecycle.md | `npm test` (`notify-parent.test.ts`, `session.test.ts`) | Synthetic prompt acquires the SAME `_promptDone`/`_promptInflight` mutex `/prompt` uses, synchronously — no two concurrent `brain.prompt()`; `_backgroundWorkCount` defers release but does not block ordinary prompts; invalidated sessions force the deferred release to 0ms once detached ownership reaches zero; after persists settle it emits `background_turn_done` so an idle WebUI refetches (keep the trigger AFTER `Promise.allSettled(pendingPersists)`) |
| `src/agentbox/sync-handlers.ts` immutable-session handlers (`mcp`, `prompt`, `tools`) | mcp-session-lifecycle.md, agent-prompt-lifecycle.md | `npm test` (`sync-handlers.test.ts`, `session.test.ts`) | All immutable-session handlers share one invalidation path; in-flight turns finish, detached work may keep the old brain usable, and the next quiescent release is immediate |
| `src/portal/chat-gateway.ts` (persistent session SSE) + `portal-web/src/hooks/usePilotChat.ts` (EventSource) | tools.md §9.2 | `npm test` (`chat-gateway-events.test.ts`); portal-web `vitest run` | `GET …/chat/sessions/:sessionId/events` is read-only, query-token auth, MUST NOT close on `prompt_done`; frontend renders the synthetic turn body from a DB **refetch** on `background_turn_done` (NOT from live events — `message_update` deltas aren't emitted on this channel); raw `<task_notification>` bubble hidden via `metadata.kind` in `toPilotMessage` |
| `src/core/cli-background-host.ts`, `src/cli-main.ts` | tools.md §9.2 | `npm test` (`cli-background-host.test.ts`) | Idle → `sendCustomMessage(triggerTurn)`; streaming → `followUp`; `sessionRef` updated on every session swap |
| `src/core/model-routing.ts` (routing runner) + routed prompt paths in `src/agentbox/http-server.ts` / `session.ts` + `src/gateway/sse-consumer.ts` (deferred persistence) | — | `npm test` (`model-routing.test.ts`, `http-server.test.ts`, `sse-consumer.test.ts`) | Routed brain events bypass `_eventBuffer` and reach SSE via the extra channel. The PRIMARY candidate streams LIVE (`optimisticPrimaryStream`, default on; interactive HTTP path) so the happy path equals no-routing; fallback candidates buffer until their own first tool call. A live primary that fails emits `model_route_rollback` (so consumers drop what it rendered) before `model_route_switch`. Synthetic/background turns (`session.ts`) pass `optimisticPrimaryStream:false` — they persist from collected events, so a live failed attempt would leak in. Persistence is COMMIT-GATED in `sse-consumer.ts`: on a routed turn the assistant + error rows are deferred and only written at a commit point (tool_start / `model_route_success` / `model_route_exhausted`), and dropped on `model_route_rollback` — the live frontend already rendered them via SSE, so this only governs what survives a reload (tool rows are NOT deferred — a tool row is already committed). setup-failure exhaustion MUST still synthesize an assistant `message_end` with `stopReason:"error"`; exhausted runs RESOLVE (only `prompt_error` rejects) — callers must not log/score them as success. **SINGLE ENTRY (2026-06, see `docs/design/2026-06-22-unified-model-routing-entry.md`):** EVERY prompt now flows through `runPromptWithModelRouting` via `resolveEffectivePolicy` — real multi-candidate routing when a fallback target exists, else a single-candidate run built from the current model (the `routeEnabled ? runner : brain.prompt` ternary is gone). Consequences: (a) EVERY turn emits `model_route_start` + `model_route_success` (single candidate → `isFallback:false`, no switch), so commit-gating in `sse-consumer.ts` now applies to EVERY turn — a normal no-tool turn defers its assistant row until `model_route_success`; (b) `_routeBrainEventsThroughExtra` now means "a policy resolved" (`effectivePolicy !== undefined`), false ONLY for the no-current-model edge where the runner does a bare `brain.prompt` (events then flow through the live `_eventBuffer` + SSE subscription, persisted inline since no `model_route_start` fires); (c) every turn now runs preflight (`ensureContextForModelPrompt`) — an over-budget turn compacts proactively (compaction on) or fails cleanly in preflight (compaction off) instead of mid-stream; (d) DEPLOY-ORDERING: agentbox emits `model_route_*` on every turn, so the paired gateway `sse-consumer.ts` MUST be a version that commit-gates routed turns — do not run a new agentbox against a pre-routing-commit-gating gateway. **(e) The diversion is owned by the RUNNER, via `onEventCaptureChange`, and must be lifted in the same synchronous block as the attempt's own unsubscribe.** A caller cannot do it off the promise: `runAttempt` drops its subscription the instant `brain.prompt()` returns, while the caller regains control a microtask later in `.then`, and an event landing in between reaches nobody. The hook is PAIRED (capture on at attempt start, off before unsubscribe) rather than one-shot, or a buffering fallback candidate would stream live. Ordering is pinned by `model-routing.test.ts` ("gives delivery back before unsubscribing"), because the loss window is not reproducible from a prompt-scoped mock — an event queued inside `brain.prompt` still travels the runner's channel, and a macrotask lands after `.then`. **What the diversion protects against, concretely:** It exists only while a fallback could still discard an attempt; after that nothing can be discarded, and `runAttempt` has already unsubscribed in its `finally` — so leaving it on drops every event in the window `onPromptFinish` explicitly waits for (`agent_end` / `auto_compaction_end` / `auto_retry_end` while `isAgentActive`/`isCompacting`/`isRetrying`). `auto_compaction_end` is the sharp one: `setIsCompacting(false)` in `usePilotChat` has no other caller, so losing it pins the UI in "compacting" permanently. The same flag gates `_eventBuffer`, so lifting it also restores replay for a late SSE reconnect in that window |
| `src/core/subagent-registry.ts` (`RUN_IN_BACKGROUND_ENABLED`/`BACKGROUND_BASH_ENABLED` consts; `isSubagentGroupEnabled()` env) | tools.md §9, 2026-07-subagent-group.md | `npm test` (`subagent-registry.test.ts`) | Two compile-time master switches (`RUN_IN_BACKGROUND_ENABLED`/`BACKGROUND_BASH_ENABLED`) hide their params/tools; a THIRD, `isSubagentGroupEnabled()`, is an **env read** (`SICLAW_SUBAGENT_GROUP_ENABLED`, default ON) whose OFF does NOT hide the tool — it clamps the item cap to 1, rejects `reduce_prompt`, and gates `buildDescription()`. Every count/duration knob parses through the shared `parsePositiveIntEnv(raw, fallback, {unitMs?})` |
| `src/core/background-bash-runner.ts` (argv mode) | tools.md §9.4 | `npm test` | Generic background exec: `command` (shell, bash) OR `file`+`args` (argv, node/pod); `onComplete` fires once in settle (node_exec unpins debug pod) |
| `src/tools/cmd-exec/node-exec.ts` / `pod-exec.ts` (`run_in_background`) | tools.md §9.4, security.md | `npm test` | node_exec: ensure+PIN debug pod, `timeout`-wrap the host command (leak guard), 600s ceiling; pod_exec: no pin; both reject non-line-safe action; pass full `refs` |
| `src/tools/infra/debug-pod.ts` (refcount/pin + `ensureDebugPodReady`) | tools.md §9.4 | `npm test` (`debug-pod.test.ts`) | `acquire`/`release` refcount; `evict()` skips+re-arms while pinned; `ensureDebugPodReady` extracted from `runInDebugPod` — foreground path must stay identical |
| `src/gateway/channels/lark-card.ts` (feedback buttons) / `lark.ts` (`card.action.trigger`) / `src/portal/adapter.ts` (`chat.recordFeedback`) | — | `npm test` (`lark-card.test.ts`, `lark.test.ts`, `adapter-rpc.test.ts`, `migrate-sqlite.test.ts`) | 👍/👎 chain is best-effort end to end — losing buttons or the echo must never affect the reply. Button `action.value` is SELF-CONTAINED (session_id + card_id + channel_id + locale); do not introduce a Feishu-message-id→session mapping. `message_feedback` upsert key is `(message_ref, sender_external_id)` — a re-vote flips the rating. Button-state echo depends on in-memory card sequence (gateway restart ⇒ toast-only confirmation), by design. Unlike `im.message.receive_v1`, the card handler's RETURN VALUE is the callback response (toast) — it must stay awaited, not detached |
| `src/gateway/channels/lark.ts` (personal/group access denial, `deliverSingleUseLink`) / `src/gateway/channel-manager.ts` (`PersonalAccessDenied`, `isOpenAccessTier`) / `src/gateway/channels/lark-card.ts` (`buildLinkActionCard`) | 2026-07-29-personal-access-denial.md | `npm test` (`lark.test.ts`, `channel-manager.test.ts`) | A SINGLE-USE link reaches a user ONLY through `deliverSingleUseLink` (card with an `open_url` button, text fallback) and NEVER through a group renderer — `PersonalAccessDenied.actionUrl` (one-time) and `ChannelAccessDenied.authorizeUrl` (shareable console) are separate types precisely so the group field cannot carry a token anyone in the room could redeem. `isOpenAccessTier` is exported here and used by BOTH the gateway and `portal/adapter.ts`; two copies drifting apart means the runtime treats a tier as open while the adapter refuses to bind and the sender gets SILENCE. Every `denied` field is untrusted: `reason` indexes a Map (an object lookup walks the prototype chain — `reason:"toString"` rendered a Function / serialized to `{}` and was dropped), only PROSE is length-capped (truncating a URL guarantees a dead link), `expiresAtMs` is rejected when non-numeric/`<=0`/out-of-Date-range (`Intl` THROWS past ±8.64e15 while `Number.isFinite` passes), durations FLOOR (rounding up overstates a one-shot link by 30s), and an expired link is WITHHELD with a resend hint. `rendersActionLink` (url is http(s) AND the reason is not KNOWN to be link-less — a DENY-list, so an unknown reason keeps its link) gates every render site; `offersSelfService` is the allow-list and only picks the button LABEL — a reason with no self-service step must say nothing about links at all. An explicit refusal we cannot render is STILL a refusal: the silent branch is gated on `!denied`, not on the tier |
| `src/gateway/channels/lark.ts` (`/apikey`) / `src/gateway/channel-manager.ts` (`issuePersonalApiKey`/`getPersonalApiKeyStatus`) / `src/portal/adapter.ts` (`channel.issueApiKey`/`channel.apiKeyStatus` stubs) | 2026-07-28-feishu-apikey-command.md | `npm test` (`lark.test.ts`, `channel-manager.test.ts`, `adapter-rpc.test.ts`) | Upstream-mode-only RPC pair — the runtime calls them UNCONDITIONALLY, so each one MUST have a graceful `success:false` stub in the Portal `adapter.ts` (mirrors `channel.pairPersonal`); without it a Standalone deployment hits method-not-found and the rejection is swallowed, leaving the user with no reply. Personal (p2p) chat ONLY; the group path drops `/apikey` silently BEFORE the @-gate (a group reply would post someone's credential pickup link to everyone). Parsing is deterministic and returns early — never expose this as a model-callable tool (prompt injection ⇒ issuing primitive). Both dispatch sites share `parseApiKeyCommand` (regex has NO trailing `\b` on purpose — with it `/apikeys` escapes to the model) and only a BARE `/apikey` may rotate; any other subcommand → usage help with ZERO RPC (a typo must not kill the key being inspected). Issuing is SINGLE-FLIGHT per `(channel, sender)` — it bypasses the per-binding queue, so a double-tap would otherwise mint two keys and leave the first pickup link dead. Timestamps render defensively (`<=0`/out-of-Date-range → omit the line): `Intl.format` THROWS past ±8.64e15, and that throw would be reported as "unavailable" AFTER the key was already rotated. Do NOT copy PAIR's authorized-mode gate — `open` mode is the primary use case and admission is the frontend's call. Reply carries `pickupUrl` only (never plaintext); the URL must be absolute HTTP(S), and an expired success is withheld with an explicit `/apikey` retry. `rotated:true` MUST warn the old key died — and that survives EVERY reject path, not just the success body: the two paths that replace the body wholesale (unrenderable URL, expired at the send boundary) compose their notice through `withRotationWarning` at the CALL SITE, because only it knows a rotation committed behind the failure (`deliverSingleUseLink` is generic and must stay rotation-unaware). Both hardening fixes dropped it once and the tests ASSERTED the degraded copy, so a `rotated` fixture belongs in every new reject-path test — and a non-rotated twin, or the warning starts lying to first-time requesters. The unrenderable-URL notice is its OWN string: sharing `API_KEY_UNAVAILABLE_NOTICE` with the no-frontend-client path (where no RPC ran) made a destroyed credential read as "the service is busy". Frontend `error` is length-capped then surfaced on ALL THREE paths — issue, denial, AND `status` (the last was missed for a release; `truncateDenialProse` also rejects a non-string, which used to interpolate as `[object Object]`). A thrown RPC or missing frontend client still replies (unlike PAIR, silence here reads as a broken bot) |
| `src/gateway/channels/lark.ts` (`/webchat`) / `src/gateway/channel-manager.ts` (`issuePersonalWebChatLink`) / `src/portal/adapter.ts` (`channel.issueWebChatLink` stub) | 2026-08-06-feishu-webchat-command.md | `npm test` (`lark.test.ts`, `channel-manager.test.ts`, `adapter-rpc.test.ts`) | Upstream-mode-only RPC called UNCONDITIONALLY, so every Portal needs a registered handler and Standalone needs a graceful `success:false` stub. Personal chat ONLY; drop `@bot /webchat` in groups BEFORE the @-gate. Both paths share `parseWebChatCommand`; unlike `/apikey`, its token boundary is deliberate, so `/webchats` remains ordinary input while whitespace suffixes get usage with ZERO RPC. Minting is SINGLE-FLIGHT per `(channel, sender)` and forwards Feishu `message_id` as `request_id`; durable replay is the frontend's responsibility. Success and denial links are untrusted: only absolute HTTP(S), card-button first, text fallback, expired URL withheld. A success expiry must tell the sender to rerun `/webchat`, not “send another message”. Free-form `message`/`error` prose is length-capped; action URLs are never logged or truncated. Denial rendering logic is shared with `/apikey`, but copy tables stay separate so resume instructions name the right command. What the minted link CONFERS is stated exactly once, in the design doc's "What the link confers" — the Runtime assumes bearer authority over a chat session because it cannot verify otherwise, and every delivery rule derives from that assumption. Code comments DEFER to it and must not restate the semantics: three comments once asserted three different answers, and since `/webchat` is dispatched before `resolvePersonalBinding` (no Runtime binding gate — admission is wholly the frontend's), which answer is right is what sets the blast radius of a wrongly-approved mint |
| `src/tools/workflow/spawn-subagent.ts`, `src/agentbox/subagent-group.ts` (`validateAndRenderGroupPlan`/`buildReduceInput`/`GroupCircuitBreaker`) | 2026-07-subagent-group.md | `npm test` (`subagent-group.test.ts`, `spawn-subagent.test.ts`) | ONE tool (v3 single-tool merge — no `spawn_subagent_group`): items-only schema, plan validated + rendered at the CALL layer (fail-fast, zero children on a bad plan); pure logic (no session/clock/env) lives in `subagent-group.ts`, tool is a thin schema+bridge; uniform model-visible `{status, item_results[], reduce_summary?}` return (collapse path wraps the per-child report); conditional default (1 item → foreground, >1 → background); `available` gated on the executor only (hidden from children → no recursion); `isSubagentGroupEnabled()` (env `SICLAW_SUBAGENT_GROUP_ENABLED`, default ON) OFF → item cap forced to 1 + `reduce_prompt` rejected + `buildDescription()` gated to single-task text (ops rollback, not tool registration); circuit breaker judged by COMPLETION order (first 5 all-failed, zero success → trip; any success releases); `truncateReduceSummary`/`truncateBody` reuse the shared boundary-clipping `truncateAtBoundary` (agentbox `delegation-summary.ts`); model-visible adds `group_summary` (breaker reason / reduce-failure / cancel note) when there is no `reduce_summary` — no separate `reduce_error` key |
| `src/agentbox/session.ts` (`createSpawnSubagentExecutor`/`runSubagentGroup`/`startBackgroundSubagentGroup`/`makeGroupProgressEmitter`) | 2026-07-subagent-group.md, tools.md §9.2 | `npm test` (`session.test.ts` batch + collapse cases) | ONE `createSpawnSubagentExecutor` dispatches on the batch plan: `renderedTasks.length===1 && no reducePrompt` → COLLAPSE to `runSpawnedSubagent`/`startBackgroundSubagent` with the BARE spawnId (legacy events/delegation_id/notification unchanged), else the batch orchestration; **`runSpawnedSubagent` body unchanged** (only new call sites; batch children tagged `{groupId}#{index}`); orchestrator holds NO `subagentLimiter` slot; worker pool ≤ `getGroupWorkerShare()` submits children INTO the global limiter, lazily; ALL group children (map + reduce) additionally pass the manager-wide `groupChildLimiter` (collective cap `concurrency-1` ACROSS groups, acquired BEFORE the global slot — ordered, no cycle) so N concurrent batches can't starve an interactive single spawn; two abort scopes (external → map+reduce; timeout/circuit-break → map only); reduce summary uses `fullSummary ?? summary` then `truncateReduceSummary(6000)` (the 1800 capsule made the 6000 cap a no-op); batch job reuses `type:"subagent"`+`isGroup` and participates in `_backgroundWorkCount`; background live progress via the throttled **live-only `group_progress`** `emit_chat_event` (correctness rebuilds from persisted per-child + terminal events); `subagent_done` carries additive `is_group`; worker pool is the shared `ConcurrencyLimiter` (not a hand-rolled counter); **reduce gate = `doneCount>0`** — every map-child `partial` is a cancellation stub (`runSpawnedSubagent` reports `partial` only when stopped, by mapAbort OR the parent's `_aborted` setup-window check — never for real partial output), so counting it would run a reduce over cancellation notices; an in-flight `partial` is NOT reclassified (keeps its honest status, matching its own persisted terminal event → reload agrees with live, `session.test.ts:949`); a FAILED reduce sets a local `reduceError` (NOT `reduceSummary`, so per-item summaries survive) and downgrades an all-done batch to `partial`; status ladder checks `doneCount===total` BEFORE `userAbort`/`timedOut` (a post-completion Stop stays `done`; a failed/cancel-skipped reduce → `partial`); `persistGroupTerminalEvent` carries an `itemStatuses` snapshot (skipped items render on reload) and the group stop-latch persists its OWN terminal event (mirrors the single-spawn latch — else the card sticks 'Running' on reload); foreground `onProgress` is trailing-throttled + cancelled on completion (return value is authoritative) |
| `portal-web/src/hooks/usePilotChat.ts` (`isGroupForm`/`annotateGroupCompletions`/`group_progress`), `components/chat/PilotArea.tsx` (`isGroupFormMessage`/`SubagentGroupCard`), `components/plan/foldJobs.ts` | 2026-07-subagent-group.md | `cd portal-web && npx vitest run` (`usePilotChat.group.test.ts`, `foldJobs.test.ts`) | Frontend dispatches by FORM, not tool name: a `spawn_subagent` whose items list has >1 entry OR a `reduce_prompt` → batch card; a single-item no-reduce call → the legacy AgentWorkCard (collapse events are legacy-shaped, path unchanged); legacy `spawn_subagent_group` tool name still recognised as batch so history renders; batch card rebuilds from persisted `{groupId}#…` + bare-id terminal events on reload and folds the live `group_progress` frame in place; `subagent_done` with `is_group` → authoritative refetch; a user Stop suppresses BOTH live signals (job_stop claims the notification; no synthetic turn), so the bg-subagent POLLER is the only fold path then — it covers the group form, MERGES (`mergePage1IntoHistory`) when paged back instead of skipping, and judges its stop condition only on an APPLIED page (unapplied-fetch judgment races the post-abort suppression window → card stuck Running); every wholesale page-1 replace goes through `reconcileRefetchedPage1` (carry live-only `groupProgress` = the queued-vs-running knowledge + restore row identity for unchanged rows so memoized cards skip re-render); `foldJobs` surfaces the batch as ONE job and excludes `#`-tagged children; unknown `group_progress` degrades safely on an old build |

---

## Development Protocol

**Before modifying code**: find the files in the Change Impact Matrix above, read the required docs, and note cross-cutting concerns. After changes: run `npm test`, update docs if behavior changed. If something broke that the matrix didn't predict, add a new row or cross-cutting concern entry — every surprise improves the harness.

**Documentation rule**: Design docs record **contracts and rationale** (what must hold, why it was decided), not implementation steps (which functions are called in which order). Implementation details belong in code comments. This keeps docs stable across refactors — a renamed function shouldn't require a doc update, but a changed security contract must.

---

## Tech Stack

```
Runtime:    Node.js ≥22.19.0  (ESM-only)     Tests:      vitest (npm test)
Language:   TypeScript 5.9    (strict, .js)   Type check: npx tsc --noEmit
Frontend:   React + Vite + Tailwind           Agent:      pi-coding-agent
DB (GW):    mysql2 / node:sqlite (raw SQL)    DB (mem):   node:sqlite + FTS5 + sqlite-vec
```

**Conventions**: ESM-only, named exports, no default exports. `CONTRIBUTING.md` for PR format. `gh` CLI for PR comments.

Discussion

Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.

Posts are public.Sign in to post

No one has posted yet. Be the first.