Skip to content
Closed
Empty file added -
Empty file.
2 changes: 1 addition & 1 deletion .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ batched digest rather than per-wake injections.
3. **Do not separately arm `fm-watch.sh`.** The daemon manages the watcher as
its child; the singleton lock no-ops a stray arm harmlessly.

4. **Acknowledge** in `AGENTS.md` section 9 language: "Captain, away mode is active; I will batch routine updates and surface only decisions, failures, credentials, or review-ready work until you return."
4. **Acknowledge** in the home language using `AGENTS.md` section 9's outcome style; for English: "Captain, away mode is active; I will batch routine updates and surface only decisions, failures, credentials, or review-ready work until you return."

## How to exit afk

Expand Down
12 changes: 9 additions & 3 deletions .pi/extensions/fm-calm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// presentation adapters probe the exact API they patch and degrade independently with a
// diagnostic (see installCalmPresentationAdapter below) if a future Pi removes it; Pi
// still exposes no global renderer for arbitrary built-in or custom rows.
// docs/configuration.md owns the home-local Calm preference contract.
// docs/configuration.md owns the home-local Calm preference and language contracts.
//
// Pi has one first-registration-wins ToolDefinition per tool name, with no merge or
// unregister operation. Keep Calm-off registration empty; keep Calm-on load-time
Expand Down Expand Up @@ -54,6 +54,7 @@ import {
createCalmWorkingShipAnimation,
createCalmWorkingShipWidget,
} from "./lib/fm-calm-working-ship.ts";
import { formatHomeString } from "./lib/fm-language.ts";
import {
calmPresentationHides,
calmPresentationIsActive,
Expand Down Expand Up @@ -158,6 +159,7 @@ export default function (pi: ExtensionAPI) {

const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root;
const configDirectory = process.env.FM_CONFIG_OVERRIDE || resolve(fmHome, "config");
const language = { codeRoot: root, configDirectory };
const calmPreferencePath = resolve(configDirectory, "calm");
// "max" is the legacy value written by the removed third presentation level, whose
// behavior is now ordinary Calm; a home upgraded from it restores as on rather than
Expand Down Expand Up @@ -374,7 +376,11 @@ export default function (pi: ExtensionAPI) {
const names = contested.map((tool) => `"${tool.name}"`).join(", ");
const plural = contested.length > 1;
ui.notify(
`Firstmate Calm: the ${names} built-in tool${plural ? "s are" : " is"} already provided by another extension, so Calm may not fully function for ${plural ? "them" : "it"} this session.`,
formatHomeString(
language,
plural ? "calm.tool_collision.warning.other" : "calm.tool_collision.warning.one",
{ names },
),
"warning",
);
for (const tool of contested) {
Expand Down Expand Up @@ -474,7 +480,7 @@ export default function (pi: ExtensionAPI) {
});

pi.registerCommand("calm", {
description: "Toggle Firstmate's supported conversation-only transcript presentation.",
description: formatHomeString(language, "calm.command.description"),
handler: async (_args, ctx) => {
const active = !calmPresentationIsActive();
persistCalmPreference(active);
Expand Down
107 changes: 107 additions & 0 deletions .pi/extensions/lib/fm-language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Firstmate home language for visible product strings.
//
// docs/configuration.md owns the config/language contract, the optional tracked
// languages/<tag>.json packs, and the home-local config/languages/<tag>.json overlays.
// English is the source catalog and the fallback. Any other language is a pack or
// overlay; the resolver never prefers a specific non-English language.
// Background diagnostics stay in English and do not go through this module.

import { readFileSync } from "node:fs";
import { resolve } from "node:path";

export const DEFAULT_LANGUAGE = "en";

export type LanguageCatalog = Record<string, string>;

export type LanguageContext = {
codeRoot: string;
configDirectory: string;
};

const SOURCE_CATALOG: LanguageCatalog = {
"calm.command.description":
"Toggle Firstmate's supported conversation-only transcript presentation.",
"calm.tool_collision.warning.one":
"Firstmate Calm: the {names} built-in tool is already provided by another extension, so Calm may not fully function for it this session.",
"calm.tool_collision.warning.other":
"Firstmate Calm: the {names} built-in tools are already provided by another extension, so Calm may not fully function for them this session.",
};

const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[a-z0-9]+){0,3}$/;

function readOptionalCatalog(path: string): LanguageCatalog {
let raw: string;
try {
raw = readFileSync(path, "utf8");
} catch {
return {};
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
const catalog: LanguageCatalog = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "string") catalog[key] = value;
}
return catalog;
}

export function loadHomeLanguage(configDirectory: string): string {
let raw: string;
try {
raw = readFileSync(resolve(configDirectory, "language"), "utf8");
} catch {
return DEFAULT_LANGUAGE;
}
const line = raw
.split(/\r?\n/)
.map((entry) => entry.trim())
.find((entry) => entry.length > 0 && !entry.startsWith("#"));
if (!line) return DEFAULT_LANGUAGE;
const tag = line.toLowerCase();
return LANGUAGE_TAG.test(tag) ? tag : DEFAULT_LANGUAGE;
}

export function languageFallbackTags(tag: string): string[] {
const parts = tag.split("-");
const tags = [tag];
for (let i = parts.length - 1; i >= 1; i -= 1) {
tags.push(parts.slice(0, i).join("-"));
}
if (tags[tags.length - 1] !== DEFAULT_LANGUAGE) tags.push(DEFAULT_LANGUAGE);
return [...new Set(tags)];
}

export function resolveLanguageCatalog(ctx: LanguageContext): LanguageCatalog {
const catalog: LanguageCatalog = { ...SOURCE_CATALOG };
const tags = languageFallbackTags(loadHomeLanguage(ctx.configDirectory));
for (const tag of [...tags].reverse()) {
Object.assign(
catalog,
readOptionalCatalog(resolve(ctx.codeRoot, "languages", `${tag}.json`)),
);
Object.assign(
catalog,
readOptionalCatalog(resolve(ctx.configDirectory, "languages", `${tag}.json`)),
);
}
return catalog;
}

export function formatHomeString(
ctx: LanguageContext,
key: string,
vars: Record<string, string> = {},
): string {
const catalog = resolveLanguageCatalog(ctx);
const template = catalog[key] ?? SOURCE_CATALOG[key] ?? key;
return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name: string) => {
return Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : match;
});
}
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ This is mandatory respectful address, not performance: it applies even when deli
Do not force it into every sentence, but never send a response with zero direct address.
Use light nautical seasoning only when it fits: the occasional "aye", "on deck", "shipshape", "under way", or "ahoy" may land naturally.
Keep that seasoning optional and never let it obscure technical content; never use it in commits, briefs, PRs, or anything crewmates or other tools read; drop the playful flavor entirely when delivering bad news or relaying serious findings.
Captain-facing visible communication uses the home language in gitignored `config/language` (default `en`); background diagnostics may remain English.
`docs/configuration.md` owns that file contract.
For captain-facing escalation style and outcome phrasing, see section 9.

## 1. Identity and prime directives
Expand Down Expand Up @@ -63,13 +65,15 @@ README.md public overview and development notes
.claude/skills symlink to .agents/skills for claude compatibility
skills/ standalone public installer-facing skills, committed; not loaded by firstmate
bin/ helper scripts, committed; read each script's header before first use
languages/ optional tracked UI language packs as <tag>.json; LOCAL overlays live in config/languages/; see docs/configuration.md "Home language"
.env optional Relay pairing token; LOCAL, gitignored; presence-gates section 14
config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "default" = same as firstmate. Inherited as the literal file: a concrete primary adapter value also controls a secondmate home's own crewmates (section 4)
config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Inherited by secondmate homes
config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line ("<harness> [<model>] [<effort>]"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates)
config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10)
config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning
config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference"
config/language visible product-language tag; LOCAL, gitignored, and not inherited; absent defaults to en; see docs/configuration.md "Home language"
config/startup-memory-budget primary-authoritative per-home startup-memory budget; LOCAL, gitignored, materialized as 7,500 estimated tokens by locked primary bootstrap and inherited into secondmate homes; see docs/configuration.md "Startup memory budget"
config/stow-pass-horizon optional presence flag opting this home in to /stow's default-off pass-count decay horizon; LOCAL, gitignored, and not inherited; see docs/configuration.md "Stow pass horizon"
config/herdr-presentation-spaces optional "off" opt-out from, or "on" opt-in to, Herdr's default-on disposable single-task visual projection, which is unconfigured-default-on only at or above a Herdr version floor; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces"
Expand Down Expand Up @@ -462,7 +466,7 @@ When evidence uses an internal label, rewrite it before sending:
- fail-open, fails open, passive fail-open, or degraded-open -> steps aside and lets work continue when the check cannot complete, or continues without that optional protection.

Never relay worker reports, status lines, tool output, validation-state labels, or decision records verbatim into captain chat.
Read them as evidence, then send the plain-English outcome and consequence.
Read them as evidence, then send the outcome and consequence in the home language from `config/language` (default English).
Private evidence reports may retain exact identifiers, paths, status lines, validation labels, and internal terms when they are useful, but the captain-facing chat summary that points to the report still follows this translation rule.

Every escalation must stand alone and remain concise.
Expand All @@ -479,7 +483,7 @@ Reach the captain immediately for:
- A needed credential or login.

Do not surface automatic fixes, retries, routine progress, or internal supervision mechanics.
When a routine operational update's specific event requires no action but a response must be sent, reply exactly `Captain, shipshape.` without characterizing the visible session's unrelated decisions.
When a routine operational update's specific event requires no action but a response must be sent, reply with the routine no-action acknowledgment in the home language without characterizing the visible session's unrelated decisions; for English that is exactly `Captain, shipshape.`
Batch non-urgent updates into the next natural reply.
Use plain chat for a yes-or-no decision and `lavish-axi` only when several options or a structured report benefit from a visual surface.
Whenever a PR is mentioned, include its full `https://...` URL before any shorthand reference.
Expand Down
5 changes: 5 additions & 0 deletions docs/calm.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

Calm is a Pi-only conversation presentation toggle.
It is off by default, and the last `/calm` choice persists for the effective Firstmate home across Pi session starts and resumes.
Visible Pi UI strings from Calm resolve through the same home-language lookup as other Firstmate product copy: `config/language`, optional tracked packs, and local overlays, defaulting to English when that file is absent, unreadable, or names no catalog.
The built-in collision warning re-reads that lookup when it is shown.
Pi registers the `/calm` command help once when Calm loads; it stays fixed for that Calm extension lifetime and updates only after a Pi reload rebuilds extension registrations, not on `/new`, resume, or fork alone.
Background console diagnostics and internal logs remain English.
[`configuration.md`](configuration.md#home-language-configlanguage) owns the language file, tracked packs, and local overlays.

While Calm is active and an agent run is under way, Calm hides Pi's built-in `Working...` row and shows a small two-row animated boat in its place, and no separate Calm status row is added.
The water fills the usable width in standard ANSI blue and the complete boat is standard ANSI yellow.
Expand Down
22 changes: 17 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ Wake, watcher, away-mode, and Relay-specific state mechanics remain with their n
`AGENTS.md` retains the run-once and read-once operator rules, lock-refusal safety, installation consent, and direct-report recovery boundaries because those facts apply at every session start.
Ordinary dead-direct-report recovery is owned by `stuck-crewmate-recovery`, while persistent-secondmate recovery is owned by `secondmate-provisioning`.

## Home language (config/language)

The home language chooses visible Firstmate product strings such as Calm's built-in collision warning and `/calm` help text; the warning re-reads this lookup when shown, while the command help is resolved once when Calm registers with Pi and stays fixed until a Pi reload.
Store the choice in gitignored `config/language` under the effective Firstmate home, resolved from `FM_HOME`, then `FM_ROOT_OVERRIDE`, then the tracked code root, or under `FM_CONFIG_OVERRIDE` when that test and specialized-setup override is present.
The first non-empty, non-`#` line is a lowercase language tag such as `en`, `de`, `es`, or `fr-ca`.
An absent, unreadable, or unrecognized value defaults to `en`.
English is the source catalog and the fallback; no other language is preferred by the resolver.
Additional languages are optional JSON catalogs, either a tracked pack at `languages/<tag>.json` in the code root or a home-local overlay at `config/languages/<tag>.json`.
Each pack is a JSON object mapping catalog keys to translated strings; non-string values are ignored.
Keys match the English source catalog in `.pi/extensions/lib/fm-language.ts`, and missing keys fall back to English.
Values may include `{name}` placeholders that the formatter replaces at runtime.
A local overlay wins over a tracked pack for the same tag, and a more specific tag (`es-mx`) falls back through its primary tag (`es`) to English.
Background diagnostics and internal logs stay English.
This preference is local to each Firstmate home and is not part of secondmate inherited configuration.
`.pi/extensions/lib/fm-language.ts` owns lookup and formatting.

## Pi Calm preference (config/calm)

The Pi Calm extension stores the captain's home-local presentation choice in gitignored `config/calm` under the effective Firstmate home, resolved from `FM_HOME`, then `FM_ROOT_OVERRIDE`, then the tracked code root derived from the extension path, or under `FM_CONFIG_OVERRIDE` when that test and specialized-setup override is present.
Expand Down Expand Up @@ -399,10 +415,6 @@ A `command` entry gives the `PATH` comparison above, and adding `announce_patter
A tool does not always announce a new release on the command that prints its version: `no-mistakes --version` prints only the version, while its other commands carry the announcement.
`announce_args` names the command to search for the announcement in that case, and it is asked only of the copy `PATH` resolves; without it the version probe's own output is searched.
An `announce_pattern` that is not a usable extended regular expression stops `arm`, and during a sweep it is reported as that one tool's own check failure so one broken pattern never stops the other watched tools from being checked.
A `git` entry reports how many commits the local clone is behind its remote branch, and stays silent when the clone is current or ahead.
An omitted `branch` uses the remote's default branch, taken from the clone's own record of it and otherwise asked of the remote directly, so a `--single-branch` clone still resolves.
Both probe kinds are read-only and bounded, and a probe that cannot answer is reported as a check failure rather than assumed current.
See [`docs/examples/watched-tools.json`](examples/watched-tools.json) for a starting point to copy into local `config/watched-tools.json`.

Arm the check once per home with `bin/fm-tool-update-check.sh arm`.
That writes `state/tool-updates.check.sh` and binds its bytes with `bin/fm-check-register.sh`, so the existing watcher polls it on its normal cadence and turns its one line into a `check:` wake; no separate schedule is involved.
Expand Down Expand Up @@ -778,4 +790,4 @@ Teardown never removes a lock during the retry window, and after that window it
Only after those retries exhaust does it remove the lock, and only when it is provably stale - still present, mtime age at least `FM_FLEET_SYNC_PACKED_REFS_LOCK_AGE_SECS` (default 30), and no `lsof` holder of the lock file or of the clone worktree itself (a live `git` keeps that as its cwd even in the window after it closes the lock and before it exits).
A live lock, a missing `lsof`, any failed check, or any other fetch failure keeps today's behavior.
Every wait, retry, and removal is printed to stderr, and a successful recovery also prints one `recovered:` summary line to stdout so a session-start refresh - which discards fleet-sync stderr and relays only stdout - still surfaces it.
The shared staleness proof lives in `bin/fm-lock-lib.sh`, which both `fm-teardown.sh` and `fm-fleet-sync.sh` use.
The shared staleness proof lives in `bin/fm-lock-lib.sh`, which both `fm-teardown.sh` and `fm-fleet-sync.sh` use.
Loading
Loading