diff --git a/assets/skills/inbox/SKILL.md b/assets/skills/inbox/SKILL.md new file mode 100644 index 0000000..242f76c --- /dev/null +++ b/assets/skills/inbox/SKILL.md @@ -0,0 +1,86 @@ +--- +name: inbox +description: Arm and verify the package-owned Conversations watcher for direct messages and subscribed channels. +--- + +# Inbox + +Use the maintained `@hasna/conversations` surfaces. There is no separate +`inbox` executable to install or copy. + +## Prepare the subscriptions + +Subscribe the session identity to every channel that can change its work: + +```bash +conversations channel subscribe --from --preview-chars 320 +``` + +Read the subscriptions back before arming: + +```bash +conversations channel subscriptions --from --json +``` + +The readback must contain the intended, non-empty channel set. Each row must +carry a seeded `since_message_id`, so the first watch cycle starts from the +subscription baseline instead of replaying history. + +## Arm the watcher + +First prove the hosted heartbeat path works: + +```bash +conversations agents heartbeat --from --json +``` + +Only after that command succeeds, arm: + +```bash +conversations watch --from --all --interval 60000 --full-content +``` + +`--all` watches direct messages plus every subscribed channel. Several +comma-separated identities may be supplied to `--from`; reads are the union +and the first identity is primary for writes. + +The watcher reports repeated poll failures as `DEGRADED` and announces +`RECOVERED` after a successful poll. Treat those lines as visibility state, +not as message content. + +## Manual fallback when hosted watch is degraded + +If the heartbeat command fails, do not claim the watcher is armed. Keep the +subscription baseline and use bounded manual reads until the hosted path is +healthy: + +```bash +conversations digest --since --json +conversations digest --to --since --json +conversations blockers --from --json +``` + +Page every digest through `has_more` and `next_cursor`. Preserve the newest +successfully read timestamp or cursor between coordination passes. A manual +read is degraded service: say so explicitly, schedule the next bounded pass, +and do not describe it as a live monitor. + +## Verify delivery, not just process lifetime + +Have a different agent send one uniquely labelled canary to a subscribed +channel and one direct message. The watcher must surface both within one poll +interval. A running process, successful subscription write, or quiet first +poll alone does not prove delivery. + +After both canaries arrive, record the local runtime gate: + +```bash +instructions managed-skills status --from --delivery-verified --json +``` + +`--delivery-verified` is an evidence assertion, not a probe. Use it only in the +same acceptance pass that observed both canaries. + +`conversations watch` does not monitor Todos assignments. If the session also +needs task-assignment awareness, keep that as a separate bounded Todos read; +do not add another Conversations wrapper or executable. diff --git a/package.json b/package.json index 925d0b8..cf94db5 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "files": [ "dist", + "assets/skills/inbox/SKILL.md", "dashboard/dist", "LICENSE", "README.md" @@ -33,8 +34,8 @@ "generate:sdk": "bun run scripts/generate-sdk.ts", "kit:check": "bunx @hasna/contracts vendor-kit --check", "typecheck": "tsc --noEmit", - "test": "bun test", - "check:package-secrets": "bun run src/cli/index.tsx package-manager-scan --fail-on-findings --home .", + "test": "env -u HASNA_INSTRUCTIONS_API_URL -u HASNA_INSTRUCTIONS_API_KEY bun test", + "check:package-secrets": "bun run src/cli/index.tsx package-manager-scan --fail-on-findings .", "dev:cli": "bun run src/cli/index.tsx", "dev:mcp": "bun run src/mcp/index.ts", "dev:serve": "bun run src/server/index.ts", diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 627e392..70dfba6 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -22,6 +22,11 @@ import { ensureProjectDashboardStandardConfig } from "../lib/project-dashboard-s import { ensureGlobalAgentRulesStandardConfig } from "../lib/global-agent-rules-standard.js"; import { ensureDangerousOperationGuardStandardConfig } from "../lib/dangerous-operation-guard-standard.js"; import { ensureCodewithSharedTodosStorageStandardConfig } from "../lib/codewith-shared-todos-storage-standard.js"; +import { + inspectManagedSkillRuntimes, + reconcileManagedSkillRuntimes, + type ManagedSkillRuntimeReconcileReport, +} from "../lib/managed-skill-runtimes.js"; import { ProjectContextError, PROJECT_CONTEXT_MAX_INPUT_BYTES, @@ -73,6 +78,26 @@ function printJson(value: unknown): void { printLine(JSON.stringify(value, null, 2)); } +function printManagedSkillRuntimeReport(report: ManagedSkillRuntimeReconcileReport): void { + for (const runtime of report.runtimes) { + if (!runtime.skill_present) continue; + const prefix = + runtime.action === "failed" + ? chalk.red("[failed]") + : runtime.dry_run + ? chalk.yellow("[dry-run]") + : runtime.manual_fallback_ready && !runtime.healthy + ? chalk.yellow("[degraded]") + : runtime.action === "unchanged" + ? chalk.dim("=") + : chalk.green("✓"); + console.log(`${prefix} ${runtime.skill} via ${runtime.runtime} — ${runtime.reason}`); + if (runtime.action === "update") { + console.log(chalk.dim(` skill contracts: ${runtime.skill_contracts_changed}`)); + } + } +} + function fmtConfig(c: Config, format: string) { if (format === "json") return JSON.stringify(c, null, 2); if (format === "compact") return `${c.slug} [${c.category}/${c.agent}] ${c.kind === "reference" ? "(ref)" : truncateMiddle(c.target_path ?? "(no path)", 72)}`; @@ -947,6 +972,8 @@ profileCmd.command("remove ").description("Remove a config fro profileCmd.command("apply [id]").description("Apply all configs in a profile to disk") .option("--dry-run", "preview without writing") + .option("--from ", "verify the hosted Conversations heartbeat for this agent") + .option("--delivery-verified", "assert channel and direct-message canaries were observed in this acceptance pass") .option("--auto", "resolve the matching profile for the current machine") .option("--hostname ", "override detected hostname for auto resolution") .option("--os ", "override detected OS for auto resolution") @@ -993,6 +1020,13 @@ profileCmd.command("apply [id]").description("Apply all configs in a profile to console.error(chalk.red(`[failed] ${failure.config_slug}: ${failure.message}`)); } if (report.failures.length > 0) process.exitCode = 1; + const runtimeReport = await reconcileManagedSkillRuntimes({ + dryRun: opts.dryRun, + agent: opts.from, + deliveryVerified: opts.deliveryVerified, + }); + printManagedSkillRuntimeReport(runtimeReport); + if (runtimeReport.failed > 0) process.exitCode = 1; console.log(chalk.dim(`\n${changed}/${results.length} changed (${selected.slug} on ${machine.hostname} ${machine.os_family}/${machine.arch})`)); } catch (e) { console.error(chalk.red(formatCliError(e))); process.exit(1); } }); @@ -1694,9 +1728,59 @@ program console.log(chalk.cyan("Missing:") + ` ${status.health.missingTargets === 0 ? chalk.green("0") : chalk.yellow(String(status.health.missingTargets))} (file not on disk)`); console.log(chalk.cyan("Secrets:") + ` ${status.health.unredactedSecretFindings === 0 ? chalk.green("0 ✓") : chalk.red(String(status.health.unredactedSecretFindings) + " ⚠")} unredacted`); console.log(chalk.cyan("Retired agents:") + ` ${status.health.retiredAgentRows === 0 ? chalk.green("0") : chalk.yellow(String(status.health.retiredAgentRows))} row(s)`); + console.log(chalk.cyan("Skill runtimes:") + ` ${status.health.missingManagedSkillRuntimes === 0 ? chalk.green(`${status.counts.managedSkillRuntimes.healthy} ready`) : chalk.yellow(`${status.health.missingManagedSkillRuntimes} missing`)} (${status.counts.managedSkillRuntimes.skillsPresent} managed skill(s) present)`); console.log(chalk.cyan("Templates:") + ` ${status.counts.configs.templates} (with {{VAR}} placeholders)`); }); +// ── managed skill runtimes ────────────────────────────────────────────────── +const managedSkillsCmd = program + .command("managed-skills") + .description("Inspect or reconcile package-owned runtime contracts for installed managed skills"); + +managedSkillsCmd + .command("status") + .option("--from ", "verify the hosted Conversations heartbeat for this agent") + .option("--delivery-verified", "assert channel and direct-message canaries were observed in this acceptance pass") + .option("--json", "output the full local runtime status as JSON") + .action((opts: { deliveryVerified?: boolean; from?: string; json?: boolean }) => { + const report = inspectManagedSkillRuntimes({ + agent: opts.from, + deliveryVerified: opts.deliveryVerified, + }); + if (opts.json) { + printJson(report); + if (report.missing > 0) process.exitCode = 1; + return; + } + if (report.skills_present === 0) { + console.log(chalk.dim("No managed skills with package-owned runtime contracts are installed.")); + return; + } + for (const runtime of report.runtimes) { + if (!runtime.skill_present) continue; + const prefix = runtime.healthy ? chalk.green("✓") : chalk.yellow("!"); + console.log(`${prefix} ${runtime.skill} via ${runtime.runtime} — ${runtime.reason}`); + } + if (report.missing > 0) process.exitCode = 1; + }); + +managedSkillsCmd + .command("apply") + .option("--dry-run", "preview without writing") + .option("--from ", "verify the hosted Conversations heartbeat for this agent") + .option("--delivery-verified", "assert channel and direct-message canaries were observed in this acceptance pass") + .option("--json", "output the reconcile report as JSON") + .action(async (opts: { deliveryVerified?: boolean; dryRun?: boolean; from?: string; json?: boolean }) => { + const report = await reconcileManagedSkillRuntimes({ + dryRun: opts.dryRun, + agent: opts.from, + deliveryVerified: opts.deliveryVerified, + }); + if (opts.json) printJson(report); + else printManagedSkillRuntimeReport(report); + if (report.failed > 0) process.exitCode = 1; + }); + // ── diff --all ──────────────────────────────────────────────────────────────── // Extend existing diff command to support --all @@ -2073,6 +2157,8 @@ program .command("bootstrap") .description("Install the full @hasna ecosystem: CLI tools + MCP servers + configs") .option("--dry-run", "show what would be installed without doing it") + .option("--from ", "verify the hosted Conversations heartbeat for this agent") + .option("--delivery-verified", "assert channel and direct-message canaries were observed in this acceptance pass") .option("--skip-mcp", "skip MCP server registration") .action(async (opts) => { const store = resolveConfigStore(); @@ -2128,7 +2214,20 @@ program console.log(chalk.dim(" would run: configs init")); } - console.log(chalk.bold("\n✓ Bootstrap complete.") + chalk.dim(" Restart Claude Code for MCP servers to activate.")); + console.log(chalk.cyan("\nReconciling managed skill runtimes:")); + const runtimeReport = await reconcileManagedSkillRuntimes({ + dryRun: opts.dryRun, + agent: opts.from, + deliveryVerified: opts.deliveryVerified, + }); + printManagedSkillRuntimeReport(runtimeReport); + if (runtimeReport.failed > 0) { + console.error(chalk.red("\nBootstrap incomplete: a managed skill runtime could not be reconciled.")); + process.exitCode = 1; + return; + } + + console.log(chalk.bold("\n✓ Bootstrap complete.") + chalk.dim(" Restart agent sessions to load updated integrations.")); }); // ── pull / push aliases ─────────────────────────────────────────────────────── diff --git a/src/index.ts b/src/index.ts index 0f637fc..693932b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,18 @@ export { uuid, now, slugify } from "./db/database.js"; // Status contract export { getConfigsStatus } from "./status.js"; export type { ConfigsStatusContract } from "./status.js"; +export { + INBOX_CONVERSATIONS_MINIMUM_VERSION, + inspectManagedSkillRuntimes, + reconcileManagedSkillRuntimes, +} from "./lib/managed-skill-runtimes.js"; +export type { + ManagedSkillRuntimeInspection, + ManagedSkillRuntimeOptions, + ManagedSkillRuntimeReconcileReport, + ManagedSkillRuntimeResult, + ManagedSkillRuntimeStatus, +} from "./lib/managed-skill-runtimes.js"; // DB — PostgreSQL migrations export { PG_MIGRATIONS } from "./db/pg-migrations.js"; diff --git a/src/lib/managed-skill-runtimes.test.ts b/src/lib/managed-skill-runtimes.test.ts new file mode 100644 index 0000000..e4aec7a --- /dev/null +++ b/src/lib/managed-skill-runtimes.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { + INBOX_CONVERSATIONS_MINIMUM_VERSION, + inspectManagedSkillRuntimes, + reconcileManagedSkillRuntimes, + writeSkillContractsTransactional, +} from "./managed-skill-runtimes"; +import { tempRootPath } from "./test-temp-root"; + +const roots: string[] = []; +const canonicalSkill = `--- +name: inbox +description: Test contract for the package-owned conversations watcher. +--- + +There is no separate inbox executable. +Run conversations watch --from --all --interval 60000 --full-content. +`; + +function makeRoot(label: string): string { + const root = tempRootPath(`managed-skill-runtimes-${label}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(root, { recursive: true }); + roots.push(root); + return root; +} + +function writeCanonicalAsset(root: string): string { + const assetPath = join(root, "canonical-inbox-SKILL.md"); + writeFileSync(assetPath, canonicalSkill); + return assetPath; +} + +function installInboxSkill(homeDir: string, agentHome = ".claude", content = "stale inbox contract\n"): string { + const skillDir = join(homeDir, agentHome, "skills", "inbox"); + mkdirSync(skillDir, { recursive: true }); + const skillPath = join(skillDir, "SKILL.md"); + writeFileSync(skillPath, content); + return skillPath; +} + +function writeConversationsRuntime( + root: string, + options: { version?: string; watchHelp?: string; heartbeatExit?: number } = {}, +): string { + const commandPath = join(root, "conversations"); + const version = options.version ?? INBOX_CONVERSATIONS_MINIMUM_VERSION; + const watchHelp = options.watchHelp ?? [ + "--from ", + "--all", + "--full-content", + ].join("\\n"); + writeFileSync( + commandPath, + `#!/usr/bin/env bun +const args = process.argv.slice(2); +if (args[0] === "--version") { + console.log(${JSON.stringify(version)}); + process.exit(0); +} +if (args[0] === "watch" && args[1] === "--help") { + console.log(${JSON.stringify(watchHelp)}); + process.exit(0); +} +if (args[0] === "agents" && args[1] === "heartbeat") { + console.log(JSON.stringify({ ok: ${options.heartbeatExit ?? 0} === 0 })); + process.exit(${options.heartbeatExit ?? 0}); +} +process.exit(2); +`, + { mode: 0o755 }, + ); + chmodSync(commandPath, 0o755); + return commandPath; +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("managed inbox skill runtime", () => { + test("does not report an installed inbox skill healthy when conversations watch is unavailable", () => { + const homeDir = makeRoot("missing-runtime"); + installInboxSkill(homeDir); + + const report = inspectManagedSkillRuntimes({ + homeDir, + conversationsCommand: join(homeDir, "missing-conversations"), + }); + + expect(report).toMatchObject({ + skills_present: 1, + healthy: 0, + missing: 1, + }); + expect(report.runtimes[0]).toMatchObject({ + skill: "inbox", + runtime: "conversations watch", + runtime_present: false, + healthy: false, + reason: "conversations command unavailable", + }); + }); + + test("dry-run predicts skill contract updates without writing or creating a legacy executable", async () => { + const root = makeRoot("dry-run"); + const homeDir = join(root, "home"); + const skillPath = installInboxSkill(homeDir); + const assetPath = writeCanonicalAsset(root); + const conversationsCommand = writeConversationsRuntime(root); + + const result = await reconcileManagedSkillRuntimes({ + homeDir, + assetPath, + conversationsCommand, + dryRun: true, + }); + + expect(result).toMatchObject({ + changed: 1, + failed: 0, + dry_run: true, + }); + expect(result.runtimes[0]).toMatchObject({ + action: "update", + skill_contracts_changed: 1, + hosted_heartbeat: "unverified", + manual_fallback_ready: true, + healthy: false, + reason: "hosted heartbeat unverified; manual fallback required", + }); + expect(readFileSync(skillPath, "utf8")).toBe("stale inbox contract\n"); + expect(existsSync(join(homeDir, ".hasna", "bin", "inbox"))).toBe(false); + expect(existsSync(join(homeDir, ".local", "bin", "inbox"))).toBe(false); + }); + + test("updates every installed skill contract and converges on conversations watch only", async () => { + const root = makeRoot("apply"); + const homeDir = join(root, "home"); + const claudeSkill = installInboxSkill(homeDir, ".claude"); + const codexSkill = installInboxSkill(homeDir, ".codex"); + const assetPath = writeCanonicalAsset(root); + const conversationsCommand = writeConversationsRuntime(root); + + const result = await reconcileManagedSkillRuntimes({ + homeDir, + assetPath, + conversationsCommand, + agent: "test-agent", + deliveryVerified: true, + }); + + expect(result).toMatchObject({ + changed: 1, + failed: 0, + dry_run: false, + }); + expect(result.runtimes[0]).toMatchObject({ + action: "update", + skill_contracts_changed: 2, + runtime_version: INBOX_CONVERSATIONS_MINIMUM_VERSION, + watch_supports_from: true, + watch_supports_all: true, + watch_supports_full_content: true, + hosted_heartbeat: "passed", + delivery_verified: true, + manual_fallback_ready: true, + healthy: true, + reason: "ready", + }); + expect(readFileSync(claudeSkill, "utf8")).toBe(canonicalSkill); + expect(readFileSync(codexSkill, "utf8")).toBe(canonicalSkill); + expect(existsSync(join(homeDir, ".hasna", "bin", "inbox"))).toBe(false); + expect(existsSync(join(homeDir, ".local", "bin", "inbox"))).toBe(false); + + const second = await reconcileManagedSkillRuntimes({ + homeDir, + assetPath, + conversationsCommand, + agent: "test-agent", + deliveryVerified: true, + }); + expect(second).toMatchObject({ changed: 0, failed: 0 }); + expect(second.runtimes[0]).toMatchObject({ action: "unchanged", healthy: true }); + }); + + test("preserves an earlier concurrent edit when a later target fails its stale-write check", () => { + const root = makeRoot("rollback-concurrent-edit"); + const firstSkill = installInboxSkill(root, ".claude", "old-first\n"); + const secondSkill = installInboxSkill(root, ".codex", "old-second\n"); + let writeCount = 0; + + const transaction = writeSkillContractsTransactional( + [ + { path: firstSkill, content: "old-first\n", mode: 0o644 }, + { path: secondSkill, content: "old-second\n", mode: 0o644 }, + ], + canonicalSkill, + { + lstat: (path) => { + try { + return lstatSync(path); + } catch { + return null; + } + }, + read: (path) => readFileSync(path, "utf8"), + write: (path, content, mode) => { + writeFileSync(path, content, { mode }); + writeCount += 1; + if (writeCount === 1) { + writeFileSync(firstSkill, "concurrent-first\n"); + writeFileSync(secondSkill, "concurrent-second\n"); + } + }, + }, + ); + + expect(transaction).toEqual({ + ok: false, + error: "managed skill changed after inspection; refusing a stale write", + rollback_conflicts: [ + `${firstSkill}: changed after this reconciliation wrote it`, + ], + }); + expect(readFileSync(firstSkill, "utf8")).toBe("concurrent-first\n"); + expect(readFileSync(secondSkill, "utf8")).toBe("concurrent-second\n"); + }); + + test("restores an earlier still-owned write when a later target fails its stale-write check", () => { + const root = makeRoot("rollback-owned-write"); + const firstSkill = installInboxSkill(root, ".claude", "old-first\n"); + const secondSkill = installInboxSkill(root, ".codex", "old-second\n"); + let writeCount = 0; + + const transaction = writeSkillContractsTransactional( + [ + { path: firstSkill, content: "old-first\n", mode: 0o644 }, + { path: secondSkill, content: "old-second\n", mode: 0o644 }, + ], + canonicalSkill, + { + lstat: (path) => { + try { + return lstatSync(path); + } catch { + return null; + } + }, + read: (path) => readFileSync(path, "utf8"), + write: (path, content, mode) => { + writeFileSync(path, content, { mode }); + writeCount += 1; + if (writeCount === 1) { + writeFileSync(secondSkill, "concurrent-second\n"); + } + }, + }, + ); + + expect(transaction).toEqual({ + ok: false, + error: "managed skill changed after inspection; refusing a stale write", + rollback_conflicts: [], + }); + expect(readFileSync(firstSkill, "utf8")).toBe("old-first\n"); + expect(readFileSync(secondSkill, "utf8")).toBe("concurrent-second\n"); + }); + + test("does not call heartbeat-only acceptance ready before channel and DM canaries", async () => { + const root = makeRoot("delivery-required"); + const homeDir = join(root, "home"); + const skillPath = installInboxSkill(homeDir); + const assetPath = writeCanonicalAsset(root); + const conversationsCommand = writeConversationsRuntime(root); + + const result = await reconcileManagedSkillRuntimes({ + homeDir, + assetPath, + conversationsCommand, + agent: "test-agent", + }); + + expect(result).toMatchObject({ changed: 1, failed: 0 }); + expect(result.runtimes[0]).toMatchObject({ + hosted_heartbeat: "passed", + delivery_verified: false, + manual_fallback_ready: true, + healthy: false, + reason: "hosted heartbeat passed; channel and DM delivery verification required", + }); + expect(readFileSync(skillPath, "utf8")).toBe(canonicalSkill); + }); + + test("fails closed for an old or incomplete conversations watcher", async () => { + const root = makeRoot("runtime-contract"); + const homeDir = join(root, "home"); + const skillPath = installInboxSkill(homeDir); + const assetPath = writeCanonicalAsset(root); + const conversationsCommand = writeConversationsRuntime(root, { + version: "0.5.27", + watchHelp: "--from \\n--full-content", + }); + + const result = await reconcileManagedSkillRuntimes({ + homeDir, + assetPath, + conversationsCommand, + }); + + expect(result).toMatchObject({ changed: 0, failed: 1 }); + expect(result.runtimes[0]).toMatchObject({ + action: "failed", + runtime_version: "0.5.27", + watch_supports_from: true, + watch_supports_all: false, + healthy: false, + }); + expect(readFileSync(skillPath, "utf8")).toBe("stale inbox contract\n"); + }); + + test("installs the manual fallback but stays degraded when hosted heartbeat fails", async () => { + const root = makeRoot("hosted-degraded"); + const homeDir = join(root, "home"); + const skillPath = installInboxSkill(homeDir); + const assetPath = writeCanonicalAsset(root); + const conversationsCommand = writeConversationsRuntime(root, { heartbeatExit: 1 }); + + const result = await reconcileManagedSkillRuntimes({ + homeDir, + assetPath, + conversationsCommand, + agent: "test-agent", + }); + + expect(result).toMatchObject({ changed: 1, failed: 0 }); + expect(result.runtimes[0]).toMatchObject({ + action: "update", + hosted_heartbeat: "failed", + manual_fallback_ready: true, + healthy: false, + reason: "hosted heartbeat failed; manual fallback required", + }); + expect(readFileSync(skillPath, "utf8")).toBe(canonicalSkill); + }); + + test("does not follow a symlinked managed skill target", async () => { + const root = makeRoot("symlink"); + const homeDir = join(root, "home"); + const externalPath = join(root, "external-skill.md"); + writeFileSync(externalPath, "external content\n"); + const skillDir = join(homeDir, ".claude", "skills", "inbox"); + mkdirSync(skillDir, { recursive: true }); + symlinkSync(externalPath, join(skillDir, "SKILL.md")); + const assetPath = writeCanonicalAsset(root); + const conversationsCommand = writeConversationsRuntime(root); + + const result = await reconcileManagedSkillRuntimes({ + homeDir, + assetPath, + conversationsCommand, + }); + + expect(result).toMatchObject({ changed: 0, failed: 1 }); + expect(result.runtimes[0]).toMatchObject({ + action: "failed", + reason: "managed skill target is not a regular file", + }); + expect(readFileSync(externalPath, "utf8")).toBe("external content\n"); + }); +}); diff --git a/src/lib/managed-skill-runtimes.ts b/src/lib/managed-skill-runtimes.ts new file mode 100644 index 0000000..3fd6a4f --- /dev/null +++ b/src/lib/managed-skill-runtimes.ts @@ -0,0 +1,537 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import type { Stats } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export const INBOX_CONVERSATIONS_MINIMUM_VERSION = "0.5.28"; + +const INBOX_SKILL_MARKERS = [ + [".claude", "skills", "inbox", "SKILL.md"], + [".codex", "skills", "inbox", "SKILL.md"], + [".codewith", "skills", "inbox", "SKILL.md"], + [".config", "opencode", "skills", "inbox", "SKILL.md"], + [".cursor", "skills", "inbox", "SKILL.md"], +] as const; + +const REQUIRED_WATCH_FLAGS = ["--from ", "--all", "--full-content"] as const; + +export type ManagedSkillRuntimeAction = "skipped" | "unchanged" | "update" | "failed"; + +export interface ManagedSkillRuntimeStatus { + skill: "inbox"; + runtime: "conversations watch"; + minimum_version: typeof INBOX_CONVERSATIONS_MINIMUM_VERSION; + skill_present: boolean; + skill_markers: string[]; + skill_contracts_current: number; + stale_skill_markers: string[]; + expected_skill_sha256: string | null; + runtime_command: string; + runtime_present: boolean; + runtime_version: string | null; + watch_supports_from: boolean; + watch_supports_all: boolean; + watch_supports_full_content: boolean; + hosted_heartbeat: "unverified" | "passed" | "failed"; + delivery_verified: boolean; + manual_fallback_ready: boolean; + healthy: boolean; + reason: string; +} + +export interface ManagedSkillRuntimeInspection { + runtimes: ManagedSkillRuntimeStatus[]; + skills_present: number; + healthy: number; + missing: number; +} + +export interface ManagedSkillRuntimeResult extends ManagedSkillRuntimeStatus { + action: ManagedSkillRuntimeAction; + dry_run: boolean; + skill_contracts_changed: number; +} + +export interface ManagedSkillRuntimeReconcileReport { + runtimes: ManagedSkillRuntimeResult[]; + changed: number; + failed: number; + dry_run: boolean; +} + +export interface ManagedSkillRuntimeOptions { + homeDir?: string; + assetPath?: string; + conversationsCommand?: string; + agent?: string; + deliveryVerified?: boolean; + dryRun?: boolean; +} + +interface CommandProbe { + ok: boolean; + output: string; +} + +interface SkillSnapshot { + path: string; + content: string | null; + mode: number | null; + regular: boolean; +} + +interface SkillWriteSnapshot { + path: string; + content: string; + mode: number; +} + +interface SkillWriteFileOperations { + lstat(path: string): Stats | null; + read(path: string): string; + write(path: string, content: string, mode: number): void; +} + +export interface SkillWriteTransactionResult { + ok: boolean; + error: string | null; + rollback_conflicts: string[]; +} + +interface InboxInspection { + status: ManagedSkillRuntimeStatus; + canonicalContent: string | null; + snapshots: SkillSnapshot[]; +} + +function sha256(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +function lstatOrNull(path: string): Stats | null { + try { + return lstatSync(path); + } catch { + return null; + } +} + +function packagedInboxSkillPath(explicitPath?: string): string { + if (explicitPath) return explicitPath; + const candidates = [ + join(import.meta.dir, "..", "..", "assets", "skills", "inbox", "SKILL.md"), + join(import.meta.dir, "..", "assets", "skills", "inbox", "SKILL.md"), + join(process.cwd(), "assets", "skills", "inbox", "SKILL.md"), + ]; + const found = candidates.find((candidate) => existsSync(candidate)); + if (!found) { + throw new Error(`packaged inbox skill contract is missing (checked ${candidates.length} package-relative locations)`); + } + return found; +} + +function readCanonicalSkill(explicitPath?: string): { content: string; sha256: string } { + const assetPath = packagedInboxSkillPath(explicitPath); + const stat = lstatOrNull(assetPath); + if (!stat?.isFile()) { + throw new Error("packaged inbox skill contract is not a regular file"); + } + const content = readFileSync(assetPath, "utf8"); + if (!content.includes("conversations watch --from --all")) { + throw new Error("packaged inbox skill contract does not declare the canonical conversations watcher"); + } + if (!content.includes("There is no separate")) { + throw new Error("packaged inbox skill contract does not retire the legacy executable"); + } + return { content, sha256: sha256(content) }; +} + +function runProbe(command: string, args: string[]): CommandProbe { + const result = spawnSync(command, args, { + encoding: "utf8", + timeout: 5_000, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error || result.status !== 0) { + return { ok: false, output: "" }; + } + return { + ok: true, + output: `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(), + }; +} + +function parseVersion(output: string): string | null { + return output.match(/\b(\d+\.\d+\.\d+)\b/)?.[1] ?? null; +} + +function compareVersions(left: string, right: string): number { + const a = left.split(".").map(Number); + const b = right.split(".").map(Number); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const delta = (a[i] ?? 0) - (b[i] ?? 0); + if (delta !== 0) return delta; + } + return 0; +} + +function inspectSkillMarkers(homeDir: string): SkillSnapshot[] { + return INBOX_SKILL_MARKERS + .map((parts) => join(homeDir, ...parts)) + .map((path): SkillSnapshot | null => { + const stat = lstatOrNull(path); + if (!stat) return null; + if (!stat.isFile()) { + return { path, content: null, mode: null, regular: false }; + } + return { + path, + content: readFileSync(path, "utf8"), + mode: stat.mode & 0o777, + regular: true, + }; + }) + .filter((snapshot): snapshot is SkillSnapshot => snapshot !== null); +} + +function inspectInbox(options: ManagedSkillRuntimeOptions): InboxInspection { + const homeDir = options.homeDir ?? homedir(); + const runtimeCommand = options.conversationsCommand ?? "conversations"; + const snapshots = inspectSkillMarkers(homeDir); + const skillPresent = snapshots.length > 0; + + let canonicalContent: string | null = null; + let canonicalSha256: string | null = null; + let assetError: string | null = null; + try { + const canonical = readCanonicalSkill(options.assetPath); + canonicalContent = canonical.content; + canonicalSha256 = canonical.sha256; + } catch (error) { + assetError = error instanceof Error ? error.message : String(error); + } + + const versionProbe = skillPresent ? runProbe(runtimeCommand, ["--version"]) : { ok: false, output: "" }; + const helpProbe = versionProbe.ok ? runProbe(runtimeCommand, ["watch", "--help"]) : { ok: false, output: "" }; + const runtimeVersion = versionProbe.ok ? parseVersion(versionProbe.output) : null; + const supportsFrom = helpProbe.ok && helpProbe.output.includes(REQUIRED_WATCH_FLAGS[0]); + const supportsAll = helpProbe.ok && helpProbe.output.includes(REQUIRED_WATCH_FLAGS[1]); + const supportsFullContent = helpProbe.ok && helpProbe.output.includes(REQUIRED_WATCH_FLAGS[2]); + const packageReady = + versionProbe.ok && + runtimeVersion !== null && + compareVersions(runtimeVersion, INBOX_CONVERSATIONS_MINIMUM_VERSION) >= 0 && + helpProbe.ok && + supportsFrom && + supportsAll && + supportsFullContent; + const heartbeatProbe = + skillPresent && packageReady && options.agent + ? runProbe(runtimeCommand, ["agents", "heartbeat", "--from", options.agent, "--json"]) + : null; + const hostedHeartbeat = + heartbeatProbe === null ? "unverified" : heartbeatProbe.ok ? "passed" : "failed"; + const deliveryVerified = hostedHeartbeat === "passed" && options.deliveryVerified === true; + const staleMarkers = + canonicalContent === null + ? snapshots.map((snapshot) => snapshot.path) + : snapshots + .filter((snapshot) => !snapshot.regular || snapshot.content !== canonicalContent) + .map((snapshot) => snapshot.path); + + let reason = "skill not installed"; + if (skillPresent) { + const nonRegular = snapshots.some((snapshot) => !snapshot.regular); + if (nonRegular) reason = "managed skill target is not a regular file"; + else if (assetError) reason = assetError; + else if (!versionProbe.ok) reason = "conversations command unavailable"; + else if (!runtimeVersion) reason = "conversations version is unreadable"; + else if (compareVersions(runtimeVersion, INBOX_CONVERSATIONS_MINIMUM_VERSION) < 0) { + reason = `conversations ${runtimeVersion} is older than ${INBOX_CONVERSATIONS_MINIMUM_VERSION}`; + } else if (!helpProbe.ok) reason = "conversations watch help is unavailable"; + else if (!supportsFrom || !supportsAll || !supportsFullContent) { + const missing = [ + !supportsFrom ? "--from" : null, + !supportsAll ? "--all" : null, + !supportsFullContent ? "--full-content" : null, + ].filter((flag): flag is string => flag !== null); + reason = `conversations watch is missing required flags: ${missing.join(", ")}`; + } else if (staleMarkers.length > 0) reason = "skill contract stale"; + else if (hostedHeartbeat === "failed") reason = "hosted heartbeat failed; manual fallback required"; + else if (hostedHeartbeat === "unverified") reason = "hosted heartbeat unverified; manual fallback required"; + else if (!deliveryVerified) reason = "hosted heartbeat passed; channel and DM delivery verification required"; + else reason = "ready"; + } + + return { + status: { + skill: "inbox", + runtime: "conversations watch", + minimum_version: INBOX_CONVERSATIONS_MINIMUM_VERSION, + skill_present: skillPresent, + skill_markers: snapshots.map((snapshot) => snapshot.path), + skill_contracts_current: snapshots.length - staleMarkers.length, + stale_skill_markers: staleMarkers, + expected_skill_sha256: canonicalSha256, + runtime_command: runtimeCommand, + runtime_present: versionProbe.ok, + runtime_version: runtimeVersion, + watch_supports_from: supportsFrom, + watch_supports_all: supportsAll, + watch_supports_full_content: supportsFullContent, + hosted_heartbeat: hostedHeartbeat, + delivery_verified: deliveryVerified, + manual_fallback_ready: skillPresent && staleMarkers.length === 0 && packageReady, + healthy: !skillPresent || reason === "ready", + reason, + }, + canonicalContent, + snapshots, + }; +} + +export function inspectManagedSkillRuntimes( + options: Omit = {}, +): ManagedSkillRuntimeInspection { + const runtime = inspectInbox(options).status; + const installed = runtime.skill_present ? [runtime] : []; + return { + runtimes: [runtime], + skills_present: installed.length, + healthy: installed.filter((item) => item.healthy).length, + missing: installed.filter((item) => !item.healthy).length, + }; +} + +function runtimeReadyForWrite(status: ManagedSkillRuntimeStatus): boolean { + return ( + status.runtime_present && + status.runtime_version !== null && + compareVersions(status.runtime_version, INBOX_CONVERSATIONS_MINIMUM_VERSION) >= 0 && + status.watch_supports_from && + status.watch_supports_all && + status.watch_supports_full_content + ); +} + +function projectUpdatedStatus( + status: ManagedSkillRuntimeStatus, + contractCount: number, +): ManagedSkillRuntimeStatus { + const healthy = status.hosted_heartbeat === "passed" && status.delivery_verified; + return { + ...status, + skill_contracts_current: contractCount, + stale_skill_markers: [], + manual_fallback_ready: true, + healthy, + reason: healthy + ? "ready" + : status.hosted_heartbeat === "failed" + ? "hosted heartbeat failed; manual fallback required" + : status.hosted_heartbeat === "unverified" + ? "hosted heartbeat unverified; manual fallback required" + : "hosted heartbeat passed; channel and DM delivery verification required", + }; +} + +function cleanup(path: string): void { + rmSync(path, { force: true }); +} + +function writeAtomic(path: string, content: string, mode: number): void { + const tempPath = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + try { + mkdirSync(dirname(path), { recursive: true, mode: 0o755 }); + writeFileSync(tempPath, content, { mode, flag: "wx" }); + renameSync(tempPath, path); + } finally { + cleanup(tempPath); + } +} + +const DEFAULT_SKILL_WRITE_FILE_OPERATIONS: SkillWriteFileOperations = { + lstat: lstatOrNull, + read: (path) => readFileSync(path, "utf8"), + write: writeAtomic, +}; + +/** + * Apply a set of inspected skill-contract writes as one best-effort + * transaction. Rollback owns a file only while it is still a regular file and + * still contains the canonical bytes written by this invocation. A later edit + * is preserved and returned as a rollback conflict instead of being replaced + * with the stale before-image. + * + * Exported from this internal module so the real transaction can be exercised + * with deterministic file-operation interleavings. It is not re-exported from + * the package root. + */ +export function writeSkillContractsTransactional( + snapshots: SkillWriteSnapshot[], + canonicalContent: string, + fileOperations: SkillWriteFileOperations = DEFAULT_SKILL_WRITE_FILE_OPERATIONS, +): SkillWriteTransactionResult { + const written: SkillWriteSnapshot[] = []; + + try { + for (const snapshot of snapshots) { + const currentStat = fileOperations.lstat(snapshot.path); + if (!currentStat?.isFile() || fileOperations.read(snapshot.path) !== snapshot.content) { + throw new Error("managed skill changed after inspection; refusing a stale write"); + } + fileOperations.write(snapshot.path, canonicalContent, snapshot.mode); + written.push(snapshot); + } + return { ok: true, error: null, rollback_conflicts: [] }; + } catch (error) { + const rollbackConflicts: string[] = []; + + for (const snapshot of written.reverse()) { + const currentStat = fileOperations.lstat(snapshot.path); + if (!currentStat?.isFile()) { + rollbackConflicts.push(`${snapshot.path}: no longer a regular file`); + continue; + } + + let currentContent: string; + try { + currentContent = fileOperations.read(snapshot.path); + } catch { + rollbackConflicts.push(`${snapshot.path}: could not read the current file`); + continue; + } + + if (currentContent !== canonicalContent) { + rollbackConflicts.push(`${snapshot.path}: changed after this reconciliation wrote it`); + continue; + } + + try { + fileOperations.write(snapshot.path, snapshot.content, snapshot.mode); + } catch { + rollbackConflicts.push(`${snapshot.path}: still owned but could not be restored`); + } + } + + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + rollback_conflicts: rollbackConflicts, + }; + } +} + +export async function reconcileManagedSkillRuntimes( + options: ManagedSkillRuntimeOptions = {}, +): Promise { + const dryRun = options.dryRun ?? false; + const before = inspectInbox(options); + const status = before.status; + + if (!status.skill_present) { + return { + runtimes: [{ ...status, action: "skipped", dry_run: dryRun, skill_contracts_changed: 0 }], + changed: 0, + failed: 0, + dry_run: dryRun, + }; + } + + if (before.snapshots.some((snapshot) => !snapshot.regular)) { + return { + runtimes: [{ ...status, action: "failed", dry_run: dryRun, skill_contracts_changed: 0 }], + changed: 0, + failed: 1, + dry_run: dryRun, + }; + } + + if (!before.canonicalContent || !runtimeReadyForWrite(status)) { + return { + runtimes: [{ ...status, action: "failed", dry_run: dryRun, skill_contracts_changed: 0 }], + changed: 0, + failed: 1, + dry_run: dryRun, + }; + } + + const staleSnapshots = before.snapshots.filter( + (snapshot) => snapshot.content !== before.canonicalContent, + ); + if (staleSnapshots.length === 0) { + return { + runtimes: [{ ...status, action: "unchanged", dry_run: dryRun, skill_contracts_changed: 0 }], + changed: 0, + failed: 0, + dry_run: dryRun, + }; + } + + if (dryRun) { + const projected = projectUpdatedStatus(status, before.snapshots.length); + return { + runtimes: [{ + ...projected, + action: "update", + dry_run: true, + skill_contracts_changed: staleSnapshots.length, + }], + changed: 1, + failed: 0, + dry_run: true, + }; + } + + const transaction = writeSkillContractsTransactional( + staleSnapshots.map((snapshot) => ({ + path: snapshot.path, + content: snapshot.content!, + mode: snapshot.mode ?? 0o644, + })), + before.canonicalContent, + ); + if (!transaction.ok) { + const rollbackConflictReason = + transaction.rollback_conflicts.length > 0 + ? `; rollback conflicts: ${transaction.rollback_conflicts.join("; ")}` + : ""; + const reason = `${transaction.error ?? "managed skill reconciliation failed"}${rollbackConflictReason}`; + return { + runtimes: [{ + ...status, + action: "failed", + dry_run: false, + skill_contracts_changed: 0, + reason, + }], + changed: 0, + failed: 1, + dry_run: false, + }; + } + + const after = inspectInbox(options).status; + const accepted = after.healthy || after.manual_fallback_ready; + return { + runtimes: [{ + ...after, + action: accepted ? "update" : "failed", + dry_run: false, + skill_contracts_changed: accepted ? staleSnapshots.length : 0, + }], + changed: accepted ? 1 : 0, + failed: accepted ? 0 : 1, + dry_run: false, + }; +} diff --git a/src/status.test.ts b/src/status.test.ts index deaa879..02b77a9 100644 --- a/src/status.test.ts +++ b/src/status.test.ts @@ -64,7 +64,7 @@ describe("getConfigsStatus", () => { addConfigToProfile(profile.id, config.id, db); registerMachine("private-host.internal", "Linux", "x64", db); - const status = await getConfigsStatus(new LocalConfigStore(db)); + const status = await getConfigsStatus(new LocalConfigStore(db), { homeDir: tempDir }); const serialized = JSON.stringify(status); const { name, version } = JSON.parse(readFileSync("package.json", "utf-8")) as { name: string; version: string }; @@ -85,11 +85,17 @@ describe("getConfigsStatus", () => { profileLinks: 1, machines: 1, knownTargets: 1, + managedSkillRuntimes: { + skillsPresent: 0, + healthy: 0, + missing: 0, + }, }, health: { status: "warn", driftedTargets: 1, retiredAgentRows: 0, + missingManagedSkillRuntimes: 0, }, safety: { includesConfigValues: false, @@ -123,12 +129,13 @@ describe("getConfigsStatus", () => { content: "stale retired content", }, db); - const status = await getConfigsStatus(new LocalConfigStore(db)); + const status = await getConfigsStatus(new LocalConfigStore(db), { homeDir: tempDir }); const serialized = JSON.stringify(status); expect(status.counts.configs.retiredAgentRows).toBe(1); expect(status.health.retiredAgentRows).toBe(1); expect(status.health.hasRetiredAgentRows).toBe(true); + expect(status.health.hasMissingManagedSkillRuntimes).toBe(false); expect(status.health.status).toBe("warn"); expect(status.health.missingTargets).toBe(0); expect(status.counts.knownTargets).toBe(0); @@ -136,4 +143,26 @@ describe("getConfigsStatus", () => { expect(serialized).not.toContain("~/.gemini/GEMINI.md"); expect(serialized).not.toContain("stale retired content"); }); + + test("reports an installed inbox skill with no conversations watcher as unhealthy metadata", async () => { + const db = getDatabase(); + const skillDir = join(tempDir, ".claude", "skills", "inbox"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: inbox\n---\n"); + + const status = await getConfigsStatus(new LocalConfigStore(db), { + homeDir: tempDir, + conversationsCommand: join(tempDir, "missing-conversations"), + }); + + expect(status.counts.managedSkillRuntimes).toEqual({ + skillsPresent: 1, + healthy: 0, + missing: 1, + }); + expect(status.health.missingManagedSkillRuntimes).toBe(1); + expect(status.health.hasMissingManagedSkillRuntimes).toBe(true); + expect(status.health.status).toBe("warn"); + expect(JSON.stringify(status)).not.toContain(skillDir); + }); }); diff --git a/src/status.ts b/src/status.ts index faeb4c7..36f92bb 100644 --- a/src/status.ts +++ b/src/status.ts @@ -4,6 +4,7 @@ import type { Config } from "./types/index.js"; import { expandPath } from "./lib/apply.js"; import { isRetiredOrUnsupportedConfigAgent } from "./lib/config-agents.js"; import { getPackageVersion } from "./lib/package-version.js"; +import { inspectManagedSkillRuntimes } from "./lib/managed-skill-runtimes.js"; import { redactContent, scanSecrets, type RedactFormat } from "./lib/redact.js"; const PACKAGE_NAME = "@hasna/instructions"; @@ -43,6 +44,11 @@ export interface ConfigsStatusContract { machines: number; snapshots: number; knownTargets: number; + managedSkillRuntimes: { + skillsPresent: number; + healthy: number; + missing: number; + }; }; health: { status: ContractStatus; @@ -51,10 +57,12 @@ export interface ConfigsStatusContract { missingTargets: number; unredactedSecretFindings: number; retiredAgentRows: number; + missingManagedSkillRuntimes: number; hasDrift: boolean; hasMissingTargets: boolean; hasUnredactedSecrets: boolean; hasRetiredAgentRows: boolean; + hasMissingManagedSkillRuntimes: boolean; }; safety: { includesConfigValues: false; @@ -87,6 +95,7 @@ function countBy(items: T[], getValue: (item: T) => string | null | undefined export async function getConfigsStatus( store: ConfigStore = resolveConfigStore(), + options: { homeDir?: string; conversationsCommand?: string } = {}, ): Promise { let databaseReachable = true; let configs: Config[] = []; @@ -145,13 +154,18 @@ export async function getConfigsStatus( } } const byCategory = Object.fromEntries(Object.entries(categoryStats).filter(([key]) => key !== "total")); + const managedSkillRuntimes = inspectManagedSkillRuntimes({ + homeDir: options.homeDir, + conversationsCommand: options.conversationsCommand, + }); const status: ContractStatus = databaseReachable && driftedTargets === 0 && missingTargets === 0 && unredactedSecretFindings === 0 && - retiredAgentRows === 0 + retiredAgentRows === 0 && + managedSkillRuntimes.missing === 0 ? "ok" : "warn"; @@ -185,6 +199,11 @@ export async function getConfigsStatus( machines, snapshots, knownTargets, + managedSkillRuntimes: { + skillsPresent: managedSkillRuntimes.skills_present, + healthy: managedSkillRuntimes.healthy, + missing: managedSkillRuntimes.missing, + }, }, health: { status, @@ -193,10 +212,12 @@ export async function getConfigsStatus( missingTargets, unredactedSecretFindings, retiredAgentRows, + missingManagedSkillRuntimes: managedSkillRuntimes.missing, hasDrift: driftedTargets > 0, hasMissingTargets: missingTargets > 0, hasUnredactedSecrets: unredactedSecretFindings > 0, hasRetiredAgentRows: retiredAgentRows > 0, + hasMissingManagedSkillRuntimes: managedSkillRuntimes.missing > 0, }, safety: { includesConfigValues: false,