/** * Harnery config reader: the settings the coord/hook layer consults when it * can't see the consumer CLI's own process. * * Two layers, project-over-user (project wins field-by-field): * 1. `~/.config/harnery/config.jsonc` — user-global base (optional) * 2. `/.harnery/config.jsonc` — project override (authoritative) * * Fields owned here: `binName` (host CLI name for agent-facing strings), * `hooksSetupHint`, `hooks`, `agents`, `instructions`, `tools`, `workflow`, `skills`, `presence`, plus the tunable * `coord` (heartbeat freshness), `logs` (structured-log storage budgets), * `artifacts` (working-file retention), * `backup` (restic repo/password/prune policy), `sync` (rclone * remote/prefix), and `web` (dashboard port) sections. The `files` deny/override section * is parsed separately by `web/lib/files.ts`. * * Env vars and CLI flags override any config value per invocation (each accessor * documents its own precedence). Dependency-free (no jsonc npm dep) so it runs on * both Bun and Node, and so the ADR-009 vendored copies stay portable. */ import { readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import { coordEnv } from "../lib/env.ts"; import { DEFAULT_WEB_PORT } from "../lib/local-file-url.ts"; import { resolveEventLedgerRotateActiveBytesV3 } from "./events/v3/rotation-config.ts"; import { type PromptContextConfig, parsePromptContextConfig, } from "./hooks/prompt-context/contract.ts"; import { findCoordRoot } from "./hooks/resolve/coord-root.ts"; export { DEFAULT_WEB_PORT } from "../lib/local-file-url.ts"; export { DEFAULT_EVENT_LEDGER_ROTATE_ACTIVE_BYTES } from "./events/v3/rotation-config.ts"; /** The standalone CLI's bin name: the resolution floor when nothing else is set. */ export const DEFAULT_BIN_NAME = "harn"; /** Heartbeat-freshness default (seconds): the sweep window when nothing overrides it. */ export const DEFAULT_FRESHNESS_SECS = 600; /** Keep a fresh host reminder bounded to one short prompt-context line. */ export const MAX_HOST_PROMPT_REMINDER_CHARS = 500; export interface SessionFinalizationConfig { archiveGraceSeconds: number; idleObserveSeconds: number; idleFinalizeSeconds: number; cascadeGraceSeconds: number; reconcileIntervalSeconds: number; } export const DEFAULT_SESSION_FINALIZATION_CONFIG: SessionFinalizationConfig = { archiveGraceSeconds: 600, idleObserveSeconds: 3 * 24 * 60 * 60, idleFinalizeSeconds: 7 * 24 * 60 * 60, cascadeGraceSeconds: 60 * 60, reconcileIntervalSeconds: 15 * 60, }; export type AgentFinalizationDisposition = "git" | "output"; export interface AgentFinalizationRoot { path: string; disposition: AgentFinalizationDisposition; } interface HarneryConfig { /** Host CLI bin name, stamped by `harn init` for a consumer (e.g. "acme"). */ binName?: string; /** * Host-specific command that (re)installs the project's git hooks, surfaced * verbatim in the "commit guard not wired" nudge. Optional: harnery doesn't * own git-hook installation (each host wires its own pre-commit to invoke * `agent-coord verdict`), and the path/command is host-specific, so the host * declares it here (e.g. "scripts/setup-hooks.sh"). Unset → a generic hint. */ hooksSetupHint?: string; /** Optional host prompt-context extension. Project config only. */ hooks?: { promptContext?: unknown; }; /** * Agent-ritual policy owned by the host project. Git finalization is opt-in: * standalone Harnery and embedding hosts keep the ordinary status ritual * unless the project deliberately requires the guarded check. */ agents?: { requireGitFinalization?: boolean; finalizationRoots?: AgentFinalizationRoot[]; }; /** Host-owned agent instruction settings. */ instructions?: { hostAddendumFile?: unknown; promptReminder?: unknown; }; /** * Managed-tool provisioning consent. `{ ripgrep: { autoInstall: true } }` * lets `grep` download the pinned, checksum-verified ripgrep into the * harnery tools dir on first miss. Committed by a host repo once; absent → * a missing rg only produces a rate-limited install hint. */ tools?: { ripgrep?: { autoInstall?: boolean } }; /** * Workflow-engine defaults. `{ subscriptionOnly: true }` pins every * `workflow run` in this repo to subscription billing (API-key vars are * scrubbed from child envs) without anyone having to remember the flag. */ workflow?: { subscriptionOnly?: boolean }; /** * Cross-machine presence (ADR 0016). `{ enabled: false }` opts a repo out of * the git-refs transport (publishing `refs/harnery/presence/` to * origin + fetching peers'). Default is ON when an origin remote exists — * the zero-config story — and every operation is fail-silent. * * `relay` (optional) is the live upgrade: a wss:// URL of a presence relay * (the reference public one is wss://relay.harnery.com; self-hosters run * `harn relay serve` or deploy relay/worker/ to their own Cloudflare * account). When set, hooks keep a per-machine daemon connected to the * relay for seconds-latency presence; the git-refs transport stays on as * the floor. Unset → git-refs only. */ presence?: { enabled?: boolean; relay?: string }; /** * Coord-layer tunables. `freshness_seconds` is the heartbeat age above which * the sweeper prunes an agent (default 600). Read via `coordFreshnessSeconds()`. */ coord?: { freshness_seconds?: number; run_quality?: unknown; finalization?: { archive_grace_seconds?: number; idle_observe_seconds?: number; idle_finalize_seconds?: number; cascade_grace_seconds?: number; reconcile_interval_seconds?: number; }; }; /** Strictly validated structured-log storage overrides. */ logs?: { storage?: unknown }; /** * Universal event-ledger V3 tunables. `rotate_active_bytes` is the active * segment size at which the epoch is archived and replaced automatically * (default 32 MiB; `0` disables rotation). Read via * `eventLedgerRotateActiveBytes()`. */ events?: { rotate_active_bytes?: number; archive_max_bytes?: number; archive_max_age_days?: number; archive_keep_min?: number; archive_auto_clean?: boolean; }; /** * Managed working-artifact defaults. `default_retention_days` is the * create-time TTL when the caller does not pass `artifacts create --days`. */ artifacts?: { default_retention_days?: number; auto_clean?: boolean; max_bytes?: number; max_unit_bytes?: number; }; /** * Page review packs. `auto_clean` lets `qa-run` and `review-pack create` * delete expired packs in the artifact store before they start. Read via * `reviewPackAutoCleanEnabled()`; ships off. */ review_pack?: { auto_clean?: boolean; }; /** * `harn backup` (restic) defaults. Read via `backupConfig()`. */ backup?: { repo?: string; password_file?: string; include?: string[]; exclude?: string[]; max_bytes?: number; schedule?: { if_stale?: string; tags?: string[]; }; keep_daily?: number; keep_weekly?: number; keep_monthly?: number; }; /** * `harn sync` (rclone) defaults: the `remote` name and `prefix` subpath. Read * via `syncJsoncConfig()`. `harn sync init` also persists these to * `~/.config/harnery/sync.json`, which is consulted as a lower-precedence fallback. */ sync?: { remote?: string; prefix?: string }; /** Standalone dashboard defaults. */ web?: { port?: number; bind?: string }; [k: string]: unknown; } /** Strip `//` and `/* *​/` comments from JSONC, ignoring comment-like runs inside strings. */ export function stripJsonComments(input: string): string { let out = ""; let i = 0; let inString = false; while (i < input.length) { const ch = input[i]; if (inString) { out += ch; if (ch === "\\" && i + 1 < input.length) { out += input[i + 1]; i += 2; continue; } if (ch === '"') inString = false; i++; continue; } if (ch === '"') { inString = true; out += ch; i++; continue; } if (ch === "/" && input[i + 1] === "/") { while (i < input.length && input[i] !== "\n") i++; continue; } if (ch === "/" && input[i + 1] === "*") { i += 2; while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++; i += 2; continue; } out += ch; i++; } return out; } /** * The user-global config file, the lower-precedence base under the project file. * Honors `XDG_CONFIG_HOME` (falling back to `~/.config`) per the XDG base-dir spec. */ function userConfigPath(): string { const xdg = process.env.XDG_CONFIG_HOME; const base = xdg?.trim() ? xdg : join(homedir(), ".config"); return join(base, "harnery", "config.jsonc"); } /** File mtime in ms, or -1 when the file can't be stat'd (missing). */ function statMtime(p: string): number { try { return statSync(p).mtimeMs; } catch { return -1; } } /** Cache signature, or null when the file can't be stat'd (missing). */ function statSignature(p: string): string | null { try { const stat = statSync(p); return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`; } catch { return null; } } /** Parse one JSONC config file to an object; missing/unparseable → `{}`. */ function parseConfigFile(p: string): HarneryConfig { try { const parsed = JSON.parse(stripJsonComments(readFileSync(p, "utf8"))) as HarneryConfig | null; if (parsed && typeof parsed === "object") return parsed; } catch { /* missing or unparseable → defaults (files-section resolver fails loud; the rest is non-critical) */ } return {}; } export interface CoordRunQualityConfigSource { value: unknown; invalid: boolean; /** Stable digest seed that contains no config values when parsing failed. */ digest_seed: unknown; } export interface LogStorageConfigLayerSource { layer: "user" | "project"; value: unknown; invalid: boolean; /** Stable file signature for bounded diagnostics without retaining values. */ signature: string | null; } export interface LogStorageConfigSource { layers: readonly [LogStorageConfigLayerSource, LogStorageConfigLayerSource]; } /** * Raw user and project `logs.storage` layers with their origin intact. * * Storage retention validates these as one fail-closed unit. It cannot use the * ordinary merged reader because commands must explain whether each effective * scalar came from a user class, user family, project class, or project family. */ export function logStorageConfigSource(root: string): LogStorageConfigSource { const projectPath = join(root, ".harnery", "config.jsonc"); const user = parseConfigLayer(userConfigPath()); const project = parseConfigLayer(projectPath); const layer = (source: ReturnType): LogStorageConfigLayerSource => ({ layer: source.path_kind, value: source.config.logs?.storage, invalid: source.invalid, signature: source.signature, }); return { layers: [layer(user), layer(project)], }; } /** * Effective user-plus-project `coord.run_quality` value with parse diagnostics. * The ordinary config reader stays fail-soft; this one lets the guard visibly * disable itself rather than silently substituting defaults for malformed JSONC. */ export function coordRunQualityConfigSource(root: string): CoordRunQualityConfigSource { const projectPath = join(root, ".harnery", "config.jsonc"); const userPath = userConfigPath(); const layers = [userPath, projectPath].map((path) => parseConfigLayer(path)); const invalid = layers.filter((layer) => layer.invalid); if (invalid.length > 0) { return { value: undefined, invalid: true, digest_seed: invalid.map((layer) => ({ path_kind: layer.path_kind, signature: layer.signature, })), }; } const merged = mergeConfig(layers[0]!.config, layers[1]!.config); return { value: merged.coord?.run_quality, invalid: false, digest_seed: merged.coord?.run_quality, }; } function parseConfigLayer(path: string): { config: HarneryConfig; invalid: boolean; path_kind: "user" | "project"; signature: string | null; } { const pathKind = path === userConfigPath() ? "user" : "project"; const signature = statSignature(path); if (signature === null) return { config: {}, invalid: false, path_kind: pathKind, signature }; try { const value = JSON.parse(stripJsonComments(readFileSync(path, "utf8"))) as unknown; if (isPlainObject(value)) { return { config: value as HarneryConfig, invalid: false, path_kind: pathKind, signature }; } } catch { // The guard reports one bounded health event for this file signature. } return { config: {}, invalid: true, path_kind: pathKind, signature }; } function isPlainObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } /** * Deep-merge `override` onto `base` (override wins), recursing into plain * objects only so a project that sets just `web.port` doesn't wipe a * user-global `web.bind`. Arrays + scalars replace wholesale. */ function mergeConfig(base: HarneryConfig, override: HarneryConfig): HarneryConfig { const out: Record = { ...base }; for (const [k, v] of Object.entries(override)) { const prev = out[k]; out[k] = isPlainObject(prev) && isPlainObject(v) ? mergeConfig(prev, v) : v; } return out as HarneryConfig; } // Stat-signature-keyed per-process cache (both layers): a stat is cheap, a parse on every render isn't. let cache: { root: string; projSignature: string | null; userSignature: string | null; cfg: HarneryConfig; } | null = null; /** * The effective config for `root`: user-global (`~/.config/harnery/config.jsonc`) * as the base, the project file (`/.harnery/config.jsonc`) merged on top. * Project values win field-by-field. */ function readConfig(root: string): HarneryConfig { const projPath = join(root, ".harnery", "config.jsonc"); const userPath = userConfigPath(); const projSignature = statSignature(projPath); const userSignature = statSignature(userPath); if ( cache && cache.root === root && cache.projSignature === projSignature && cache.userSignature === userSignature ) { return cache.cfg; } const user = userSignature === null ? {} : parseConfigFile(userPath); const project = projSignature === null ? {} : parseConfigFile(projPath); const cfg = mergeConfig(user, project); cache = { root, projSignature, userSignature, cfg }; return cfg; } /** * The PROJECT config file only — no user-global merge. `pinnedBinName` uses this * so a user-global `binName` can never masquerade as a deliberate project pin * (the pin guards committed, public surfaces; see `pinnedBinName`). */ function readProjectConfig(root: string): HarneryConfig { const p = join(root, ".harnery", "config.jsonc"); return statMtime(p) === -1 ? {} : parseConfigFile(p); } /** * Resolve the host CLI's bin name for user-facing strings. Precedence: * 1. `HARNERY_BIN` env (explicit per-process override) * 2. `.harnery/config.jsonc` `binName` (stamped by `harn init`) * 3. `"harn"` (standalone default) * * `coordRoot` is resolved via `findCoordRoot()` when not passed. */ export function resolveBinName(coordRoot?: string | null): string { const env = coordEnv("BIN"); if (env?.trim()) return env.trim(); const root = coordRoot ?? findCoordRoot(); if (root) { const binName = readConfig(root).binName; if (typeof binName === "string" && binName.trim()) return binName.trim(); } return DEFAULT_BIN_NAME; } /** * The binName explicitly pinned in `/.harnery/config.jsonc`, or * null when absent. Unlike `resolveBinName()` this ignores `HARNERY_BIN` and * never falls back to the default — it answers "did someone deliberately pin * a name for THIS project?". `init` uses it so a re-run from a different host * CLI can't silently re-stamp its own name over a committed pin (the harnery * repo itself pins `"harn"` while living embedded in a host monorepo whose * CLI would otherwise stamp the host's name into public, committed surfaces). */ export function pinnedBinName(projectRoot: string): string | null { const binName = readProjectConfig(projectRoot).binName; return typeof binName === "string" && binName.trim() ? binName.trim() : null; } /** * The host's git-hook (re)install command, for the "commit guard not wired" * nudge. Returns the configured `hooksSetupHint` (e.g. "scripts/setup-hooks.sh") * or null when unset — callers fall back to a generic, host-agnostic message. * `coordRoot` is resolved via `findCoordRoot()` when not passed. */ export function resolveHooksSetupHint(coordRoot?: string | null): string | null { const root = coordRoot ?? findCoordRoot(); if (!root) return null; const hint = readConfig(root).hooksSetupHint; return typeof hint === "string" && hint.trim() ? hint.trim() : null; } /** * One project-owned reminder that a supported prompt hook can place immediately * before a model response. The value is deliberately project-only: host policy * must not follow the user-global config into unrelated repositories. It is * emitted directly from config and never copied into coordination state. * * The reminder must be one non-empty line of at most 500 characters. Invalid * values fail closed to no reminder so a malformed optional setting cannot * break the prompt hook. */ export function hostPromptReminder(coordRoot?: string | null): string | null { const root = coordRoot ?? findCoordRoot(); if (!root) return null; const value = readProjectConfig(root).instructions?.promptReminder; if (typeof value !== "string") return null; const reminder = value.trim(); if (!reminder || reminder.length > MAX_HOST_PROMPT_REMINDER_CHARS || /[\r\n]/.test(reminder)) { return null; } return reminder; } /** * Project-owned prompt-context provider settings. The user-global config is * ignored because one project's executable and data policy must not become a * default for another project. Invalid values fail closed to `null`; callers * treat that the same as a disabled provider and keep the prompt hook usable. */ export function hostPromptContextConfig(coordRoot?: string | null): PromptContextConfig | null { const root = coordRoot ?? findCoordRoot(); if (!root) return null; return parsePromptContextConfig(readProjectConfig(root).hooks?.promptContext); } /** * Whether the host requires the guarded Git check at the end of tool-using * turns. Default false: Harnery exposes `agents status --end-turn` as a capability * but does not impose a commit-and-push policy on embedding projects. * * `.harnery/config.jsonc`: * `{ "agents": { "requireGitFinalization": true } }` * * `HARNERY_AGENTS_REQUIRE_GIT_FINALIZATION=1|0` overrides per process. */ export function agentsRequireGitFinalization(coordRoot?: string | null): boolean { const env = coordEnv("AGENTS_REQUIRE_GIT_FINALIZATION"); if (env === "1") return true; if (env === "0") return false; const root = coordRoot ?? findCoordRoot(); if (!root) return false; return readConfig(root).agents?.requireGitFinalization === true; } /** * Extra roots whose guarded writes have an explicit end-turn disposition. * * This trust boundary comes only from the project config. A user-global config * may tune ordinary behavior, but it cannot grant one project filesystem * authority outside its coordination root. Paths may be absolute or relative * to the coordination root. Invalid entries are ignored here and fail closed * when the finalization policy validates them. */ export function agentsFinalizationRoots(coordRoot?: string | null): AgentFinalizationRoot[] { const root = coordRoot ?? findCoordRoot(); if (!root) return []; const entries = readProjectConfig(root).agents?.finalizationRoots; if (!Array.isArray(entries)) return []; return entries.flatMap((entry) => { if (!entry || typeof entry !== "object") return []; const path = typeof entry.path === "string" ? entry.path.trim() : ""; const disposition = entry.disposition; if (!path || (disposition !== "git" && disposition !== "output")) return []; return [{ path, disposition }]; }); } /** The status command automatic prompts and Stop remediation should request. */ export function endOfTurnStatusCommand(coordRoot?: string | null): string { const root = coordRoot ?? findCoordRoot(); const suffix = agentsRequireGitFinalization(root) ? " --end-turn" : ""; return `${resolveBinName(root)} agents status${suffix}`; } /** * Whether the host project consented to automatic ripgrep provisioning: * `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall": true } } }`. * A repo commits that once and every clone self-heals on first `grep`; without * it, a missing rg only produces a rate-limited hint (`doctor --fix` installs * explicitly). `HARNERY_TOOLS_AUTOINSTALL=1|0` overrides per process. * `coordRoot` is resolved via `findCoordRoot()` when not passed. */ export function ripgrepAutoInstall(coordRoot?: string | null): boolean { const env = coordEnv("TOOLS_AUTOINSTALL"); if (env === "1") return true; if (env === "0") return false; const root = coordRoot ?? findCoordRoot(); if (!root) return false; return readConfig(root).tools?.ripgrep?.autoInstall === true; } /** * Whether this repo pins workflow runs to subscription billing: * `.harnery/config.jsonc` `{ "workflow": { "subscriptionOnly": true } }`. * The `workflow run --subscription-only` flag turns it on per invocation; * `HARNERY_WORKFLOW_SUBSCRIPTION_ONLY=1|0` overrides per process (the `0` * escape hatch exists for a key-only CI job inside a pinned repo). * `coordRoot` is resolved via `findCoordRoot()` when not passed. */ export function workflowSubscriptionOnly(coordRoot?: string | null): boolean { const env = coordEnv("WORKFLOW_SUBSCRIPTION_ONLY"); if (env === "1") return true; if (env === "0") return false; const root = coordRoot ?? findCoordRoot(); if (!root) return false; return readConfig(root).workflow?.subscriptionOnly === true; } /** * Whether cross-machine presence (ADR 0016) is enabled for this repo. * Default ON — the transport itself additionally gates on an origin remote * existing and fails silent everywhere. Opt out via * `.harnery/config.jsonc` `{ "presence": { "enabled": false } }`; * `HARNERY_PRESENCE=1|0` overrides per process. */ export function presenceEnabled(coordRoot?: string | null): boolean { const env = coordEnv("PRESENCE"); if (env === "1") return true; if (env === "0") return false; const root = coordRoot ?? findCoordRoot(); if (!root) return false; return readConfig(root).presence?.enabled !== false; } /** * The presence relay URL for this repo, or null when the relay transport is * not configured (git-refs only). `HARNERY_PRESENCE_RELAY` overrides per * process (empty string or "0" disables). Requires `presenceEnabled()` to be * true — a disabled presence section disables the relay too. */ export function presenceRelayUrl(coordRoot?: string | null): string | null { const root = coordRoot ?? findCoordRoot(); if (!presenceEnabled(root)) return null; const env = coordEnv("PRESENCE_RELAY"); if (env !== undefined && env !== null) { const t = env.trim(); return t && t !== "0" ? t : null; } if (!root) return null; const relay = readConfig(root).presence?.relay; return typeof relay === "string" && relay.trim() ? relay.trim() : null; } /** A positive integer from `v`, else `fallback`. Floors non-integer numbers. */ function posIntOr(v: unknown, fallback: number): number { const n = typeof v === "number" ? v : Number.NaN; return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; } /** * The heartbeat-freshness window (seconds): the age above which the sweeper * prunes an agent, and the cutoff the `agents` surface uses to fold stale peers. * Precedence: * 1. `HARNERY_AGENT_COORD_FRESHNESS` env * 2. `.harnery/config.jsonc` `coord.freshness_seconds` * 3. `600` (10 minutes) * `coordRoot` is resolved via `findCoordRoot()` when not passed. */ export function coordFreshnessSeconds(coordRoot?: string | null): number { const env = coordEnv("AGENT_COORD_FRESHNESS"); if (env !== undefined) { const n = Number.parseInt(env, 10); if (Number.isFinite(n) && n > 0) return n; } const root = coordRoot ?? findCoordRoot(); if (root) return posIntOr(readConfig(root).coord?.freshness_seconds, DEFAULT_FRESHNESS_SECS); return DEFAULT_FRESHNESS_SECS; } /** * Active-segment byte size at which the V3 event ledger rotates to a fresh * epoch. Every reader validates the complete epoch, so an unbounded active * segment makes each cold read (one per hook process) scale with all history. * Precedence: * 1. `HARNERY_EVENT_V3_ROTATE_ACTIVE_BYTES` env * 2. `.harnery/config.jsonc` `events.rotate_active_bytes` * 3. 33554432 (32 MiB) * `0` (or a negative value) disables automatic rotation. */ export function eventLedgerRotateActiveBytes(coordRoot?: string | null): number { const root = coordRoot ?? findCoordRoot(); return resolveEventLedgerRotateActiveBytesV3(root); } const DEFAULT_EVENT_ARCHIVE_MAX_BYTES = 1024 * 1024 * 1024; const DEFAULT_EVENT_ARCHIVE_MAX_AGE_DAYS = 7; const DEFAULT_EVENT_ARCHIVE_KEEP_MIN = 2; export interface EventLedgerArchivePolicy { maxBytes: number; maxAgeDays: number; keepMin: number; autoClean: boolean; } /** Bounded retention for closed V3 epochs under `ledgers/v3-archives`. */ export function eventLedgerArchivePolicy(coordRoot?: string | null): EventLedgerArchivePolicy { const root = coordRoot ?? findCoordRoot(); const configured = root ? readConfig(root).events : undefined; return { maxBytes: integerSetting( coordEnv("EVENT_V3_ARCHIVE_MAX_BYTES"), configured?.archive_max_bytes, 64 * 1024 * 1024, 1024 * 1024 * 1024 * 1024, DEFAULT_EVENT_ARCHIVE_MAX_BYTES, ), maxAgeDays: integerSetting( coordEnv("EVENT_V3_ARCHIVE_MAX_AGE_DAYS"), configured?.archive_max_age_days, 1, 3650, DEFAULT_EVENT_ARCHIVE_MAX_AGE_DAYS, ), keepMin: integerSetting( coordEnv("EVENT_V3_ARCHIVE_KEEP_MIN"), configured?.archive_keep_min, 1, 1000, DEFAULT_EVENT_ARCHIVE_KEEP_MIN, ), autoClean: booleanSetting( coordEnv("EVENT_V3_ARCHIVE_AUTO_CLEAN"), configured?.archive_auto_clean, true, ), }; } function parseWebPort(value: unknown, source: string): number { const port = typeof value === "number" ? value : Number(value); if (!Number.isInteger(port) || port < 1024 || port > 65535) { throw new RangeError(`${source} must be an integer from 1024 through 65535`); } return port; } /** * Resolve the standalone dashboard port. * * Precedence: explicit `--port` flag, `HARNERY_WEB_PORT`, merged * `.harnery/config.jsonc` `web.port`, then the mnemonic built-in default 4276. */ export function resolveWebPort(explicitPort?: string, coordRoot?: string | null): number { if (explicitPort !== undefined && explicitPort.trim() !== "") { return parseWebPort(explicitPort, "--port"); } const envPort = coordEnv("WEB_PORT"); if (envPort !== undefined && envPort.trim() !== "") { return parseWebPort(envPort, "HARNERY_WEB_PORT"); } const root = coordRoot ?? findCoordRoot(); const configuredPort = root ? readConfig(root).web?.port : undefined; if (configuredPort !== undefined) { return parseWebPort(configuredPort, "web.port"); } return DEFAULT_WEB_PORT; } /** Policy for converging independent termination signals on one finalizer. */ export function sessionFinalizationConfig(coordRoot?: string | null): SessionFinalizationConfig { const root = coordRoot ?? findCoordRoot(); const configured = root ? readConfig(root).coord?.finalization : undefined; const defaults = DEFAULT_SESSION_FINALIZATION_CONFIG; const archiveGraceSeconds = posIntOr( configured?.archive_grace_seconds, defaults.archiveGraceSeconds, ); const idleObserveSeconds = posIntOr( configured?.idle_observe_seconds, defaults.idleObserveSeconds, ); const idleFinalizeSeconds = Math.max( idleObserveSeconds, posIntOr(configured?.idle_finalize_seconds, defaults.idleFinalizeSeconds), ); return { archiveGraceSeconds, idleObserveSeconds, idleFinalizeSeconds, cascadeGraceSeconds: posIntOr(configured?.cascade_grace_seconds, defaults.cascadeGraceSeconds), reconcileIntervalSeconds: posIntOr( configured?.reconcile_interval_seconds, defaults.reconcileIntervalSeconds, ), }; } /** * Default retention for a newly-created working artifact. Precedence: * `HARNERY_ARTIFACT_RETENTION_DAYS` -> project/user config * `artifacts.default_retention_days` -> 3 days. */ export function artifactDefaultRetentionDays(coordRoot?: string | null): number { const env = coordEnv("ARTIFACT_RETENTION_DAYS"); if (env !== undefined) { const n = Number(env); if (Number.isFinite(n) && n > 0 && n <= 3650) return n; } const root = coordRoot ?? findCoordRoot(); if (!root) return 3; const configured = readConfig(root).artifacts?.default_retention_days; return typeof configured === "number" && Number.isFinite(configured) && configured > 0 && configured <= 3650 ? configured : 3; } const DEFAULT_ARTIFACT_MAX_BYTES = 20 * 1024 * 1024 * 1024; const DEFAULT_ARTIFACT_MAX_UNIT_BYTES = 1024 * 1024 * 1024; /** Soft repository budget for managed working artifacts. */ export function artifactMaxBytes(coordRoot?: string | null): number { const root = coordRoot ?? findCoordRoot(); return integerSetting( coordEnv("ARTIFACT_MAX_BYTES"), root ? readConfig(root).artifacts?.max_bytes : undefined, 64 * 1024 * 1024, 1024 * 1024 * 1024 * 1024, DEFAULT_ARTIFACT_MAX_BYTES, ); } /** Size at which one bundle requires an explicit `artifacts create --big` acknowledgement. */ export function artifactMaxUnitBytes(coordRoot?: string | null): number { const root = coordRoot ?? findCoordRoot(); return integerSetting( coordEnv("ARTIFACT_MAX_UNIT_BYTES"), root ? readConfig(root).artifacts?.max_unit_bytes : undefined, 16 * 1024 * 1024, 1024 * 1024 * 1024 * 1024, DEFAULT_ARTIFACT_MAX_UNIT_BYTES, ); } /** * Whether opportunistic cleanup of expired artifact workspaces runs. * Precedence: `HARNERY_ARTIFACT_AUTO_CLEAN` (0/false disables) -> * `artifacts.auto_clean` -> enabled. The sweep only ever deletes * `managed-expired` entries via the same guarded classifier as * `artifacts clean --yes`. */ export function artifactAutoCleanEnabled(coordRoot?: string | null): boolean { const env = coordEnv("ARTIFACT_AUTO_CLEAN"); if (env !== undefined) return !(env === "0" || env.toLowerCase() === "false"); const root = coordRoot ?? findCoordRoot(); if (!root) return true; return readConfig(root).artifacts?.auto_clean !== false; } /** * Whether `qa-run` and `review-pack create` sweep expired page review packs * out of the artifact store before starting. Precedence: * `HARNERY_REVIEW_PACK_AUTO_CLEAN` (1/true enables, 0/false disables) -> * `review_pack.auto_clean` -> disabled. The sweep deletes only packs whose * manifest says `managed: true` with `retention.expires_at` in the past. */ export function reviewPackAutoCleanEnabled(coordRoot?: string | null): boolean { const env = coordEnv("REVIEW_PACK_AUTO_CLEAN"); if (env !== undefined) return env === "1" || env.toLowerCase() === "true"; const root = coordRoot ?? findCoordRoot(); if (!root) return false; return readConfig(root).review_pack?.auto_clean === true; } /** * Sweep cadence in hours. Env-only (`HARNERY_ARTIFACT_AUTO_CLEAN_INTERVAL_HOURS`, * mainly for tests). Session starts and new artifact work share the hourly * throttle so minute-scale retention is useful within long sessions. */ export function artifactAutoCleanIntervalHours(): number { const env = coordEnv("ARTIFACT_AUTO_CLEAN_INTERVAL_HOURS"); if (env !== undefined) { const n = Number(env); if (Number.isFinite(n) && n > 0 && n <= 24 * 365) return n; } return 1; } function integerSetting( envValue: string | undefined, configured: unknown, minimum: number, maximum: number, fallback: number, ): number { for (const value of [envValue, configured]) { const parsed = typeof value === "number" ? value : Number(value); if (Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum) return parsed; } return fallback; } function booleanSetting( envValue: string | undefined, configured: unknown, fallback: boolean, ): boolean { if (envValue !== undefined) { if (envValue === "1" || envValue.toLowerCase() === "true") return true; if (envValue === "0" || envValue.toLowerCase() === "false") return false; } return typeof configured === "boolean" ? configured : fallback; } /** Resolved `harn backup` defaults (restic repo/password + prune policy). */ export interface BackupConfig { repo: string; passwordFile: string; include: readonly string[]; exclude: readonly string[]; maxBytes: number; schedule: { ifStale: string; tags: readonly string[] } | null; keepDaily: number; keepWeekly: number; keepMonthly: number; } /** * `harn backup` (restic) defaults. Per field, precedence is env → config → built-in: * repo: `HARNERY_RESTIC_REPO` → `backup.repo` → `~/.cache/harnery/restic-repo` * passwordFile: `HARNERY_RESTIC_PASSWORD_FILE` → `backup.password_file` → `~/.config/harnery/restic-password` * include/exclude/maxBytes/schedule: `backup.*` → catalog defaults / 50 MiB / disabled * keepDaily/Weekly/Monthly: `backup.keep_*` → 7 / 4 / 6 * `coordRoot` is resolved via `findCoordRoot()` when not passed. */ export function backupConfig(coordRoot?: string | null): BackupConfig { const home = homedir(); const root = coordRoot ?? findCoordRoot(); const b = root ? (readConfig(root).backup ?? {}) : {}; const cfgStr = (v: unknown): string | undefined => typeof v === "string" && v.trim() ? v.trim() : undefined; const repo = coordEnv("RESTIC_REPO") ?? cfgStr(b.repo) ?? join(home, ".cache", "harnery", "restic-repo"); const envPasswordFile = coordEnv("RESTIC_PASSWORD_FILE"); const configuredPasswordFile = cfgStr(b.password_file); const passwordFile = envPasswordFile ? envPasswordFile : configuredPasswordFile ? root && !isAbsolute(configuredPasswordFile) ? resolve(root, configuredPasswordFile) : configuredPasswordFile : join(home, ".config", "harnery", "restic-password"); const stringList = (value: unknown): readonly string[] => Array.isArray(value) ? value.filter( (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, ) : []; const schedule = isPlainObject(b.schedule) ? (() => { const ifStale = cfgStr(b.schedule?.if_stale); return ifStale ? { ifStale, tags: stringList(b.schedule?.tags).map((tag) => tag.trim()) } : null; })() : null; return { repo, passwordFile, include: stringList(b.include).map((entry) => entry.trim()), exclude: stringList(b.exclude).map((entry) => entry.trim()), maxBytes: posIntOr(b.max_bytes, 50 * 1_024 * 1_024), schedule, keepDaily: posIntOr(b.keep_daily, 7), keepWeekly: posIntOr(b.keep_weekly, 4), keepMonthly: posIntOr(b.keep_monthly, 6), }; } /** * `harn sync` (rclone) remote/prefix from `.harnery/config.jsonc` `sync`, or null * when unset. This is the config-file layer only; `harn sync` consults env * (`HARNERY_SYNC_REMOTE`/`_PREFIX`) first and the `~/.config/harnery/sync.json` * file (written by `harn sync init`) as a lower-precedence fallback. * `coordRoot` is resolved via `findCoordRoot()` when not passed. */ export function syncJsoncConfig( coordRoot?: string | null, ): { remote: string; prefix: string } | null { const root = coordRoot ?? findCoordRoot(); if (!root) return null; const s = readConfig(root).sync ?? {}; if (typeof s.remote === "string" && s.remote.trim()) { const prefix = typeof s.prefix === "string" && s.prefix.trim() ? s.prefix.trim() : "harnery"; return { remote: s.remote.trim(), prefix }; } return null; }