From ccb4a57607f5cf4cce101b492fe2e22b944bd38a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 28 Jul 2026 20:53:25 -0400 Subject: [PATCH 1/6] feat: add safe guided project onboarding --- README.md | 31 +- docs/built-in-pack.md | 10 +- docs/codex-plugin-capability-matrix.json | 4 +- docs/codex-plugin.md | 12 +- docs/pack-upgrades.md | 13 +- docs/project-ai.md | 159 +- docs/reference.md | 23 +- plugins/fclt/scripts/fclt-mcp.cjs | 4 +- plugins/fclt/skills/fclt-setup/SKILL.md | 88 +- src/builtin-assets.ts | 2 +- src/cli-context.test.ts | 2 +- src/cli-context.ts | 2 +- src/doctor.test.ts | 47 +- src/doctor.ts | 26 +- src/fclt-mcp-plugin.test.ts | 4 +- src/index.ts | 14 + src/paths.ts | 26 + src/projects.test.ts | 711 +++++++++ src/projects.ts | 1763 ++++++++++++++++++++++ src/remote.test.ts | 173 +-- src/remote.ts | 199 +-- src/setup.test.ts | 122 +- src/setup.ts | 61 +- 23 files changed, 3117 insertions(+), 379 deletions(-) create mode 100644 src/projects.test.ts create mode 100644 src/projects.ts diff --git a/README.md b/README.md index afaaae58..98243428 100644 --- a/README.md +++ b/README.md @@ -91,17 +91,17 @@ fclt --version The npm package is named `facult` for registry compatibility. The command is `fclt`. -Then bootstrap the complete writeback/evolution loop from your home directory or a repository: +Then bootstrap the global writeback/evolution loop: ```bash fclt setup ``` -That one command safely initializes or updates global `~/.ai`, initializes the current git -repository's `/.ai` when applicable, creates review-state paths, rebuilds capability -discovery, and installs the Codex plugin when Codex is available. It preserves local edits and -existing WB/EV history, and it is safe to run again. Use `fclt setup --global-only` outside a -project or `fclt setup --no-codex-plugin` for a CLI-only install. +That command safely initializes or updates global `~/.ai`, creates review-state paths, rebuilds +capability discovery, and installs the Codex plugin when Codex is available. It does not initialize +the current repository. It preserves local edits and existing WB/EV history, and it is safe to run +again. Use `fclt setup --include-project` to include a no-write project enrollment plan, or +`fclt setup --no-codex-plugin` for a CLI-only install. One-off usage: @@ -265,14 +265,20 @@ fclt templates init operating-model --global --update --dry-run fclt templates init operating-model --global --update ``` -Create a repo-local `.ai` root: +Preview and apply a minimal repo-local `.ai` root: ```bash cd /path/to/repo -fclt templates init project-ai +fclt project init --json +fclt project init --apply --plan-sha --json fclt status --project ``` +Minimal enrollment never copies `AGENTS.md` or `CLAUDE.md`. Use +`--guidance AGENTS.md` only for an explicit, fully previewed reference to a +tracked clean rulebook. Full operating-pack install remains a separate +`fclt templates init operating-model --project` action. + Create individual capability units: ```bash @@ -566,7 +572,12 @@ Keep tracked MCP config secret-free. Use local overlays such as `mcp/servers.loc Discovery: ```bash -fclt setup [--global-only] [--no-codex-plugin] [--json] +fclt setup [--include-project] [--no-codex-plugin] [--json] +fclt projects discover --root [--root ] [--since ] [--json] +fclt projects status [--root ] [--json] +fclt project init [--project-root ] [--guidance ] [--apply --plan-sha ] [--json] +fclt project disable|ignore|inactive|remove [--project-root ] [--json] +fclt project rollback --receipt [--apply] [--json] fclt status [--json] fclt doctor [--json] [--repair] fclt paths [--json] @@ -585,7 +596,7 @@ Canonical store: ```bash fclt templates list fclt templates init operating-model [--global|--project|--root PATH] [--update] -fclt templates init project-ai [--update] +fclt templates init project-ai [--project-root PATH|--root PATH] [--guidance PATH] [--apply --plan-sha SHA] fclt templates init instruction fclt templates init snippet fclt templates init skill diff --git a/docs/built-in-pack.md b/docs/built-in-pack.md index d9a88627..f5b99b50 100644 --- a/docs/built-in-pack.md +++ b/docs/built-in-pack.md @@ -76,13 +76,19 @@ fclt templates init operating-model --global --update `--update` refreshes only files that still match the last installed pack manifest and skips local edits. See [Built-in pack upgrades](./pack-upgrades.md). -Use `project-ai` when the target is the current repo: +Use guided onboarding when the target needs only a minimal project layer: ```bash cd /path/to/repo -fclt templates init project-ai +fclt project init --json +fclt project init --apply --plan-sha --json ``` +This writes the protective `.ai/.gitignore` before generated state and does +not install this full pack or copy repo guidance. Use +`fclt templates init project-ai` only as the equivalent preview-first legacy +alias. + Legacy managed mode is a deprecated rendering layer. Inspect its plan without changing tool state: ```bash diff --git a/docs/codex-plugin-capability-matrix.json b/docs/codex-plugin-capability-matrix.json index d569cd94..6bdf426c 100644 --- a/docs/codex-plugin-capability-matrix.json +++ b/docs/codex-plugin-capability-matrix.json @@ -172,7 +172,7 @@ "risk": "reversible_mutation", "scope": "explicit global|global_and_project plus project cwd", "preview": "default; calls released setup --dry-run JSON contract", - "apply": "dryRun=false plus approve=true against the released setup contract", + "apply": "dryRun=false plus approve=true applies global setup; project enrollment remains a separate plan-hash CLI action", "rollback": "preserved existing capability state and reported repair actions", "verification": "doctor loop readiness plus fresh-session plugin discovery" }, @@ -225,7 +225,7 @@ }, { "id": "templates.init_operating_model", - "cli": "templates init operating-model|project-ai", + "cli": "project init|projects discover|projects status|templates init operating-model", "cliAvailability": "available", "mcp": { "disposition": "exposed", diff --git a/docs/codex-plugin.md b/docs/codex-plugin.md index ccc57fdd..7e4c52f8 100644 --- a/docs/codex-plugin.md +++ b/docs/codex-plugin.md @@ -76,11 +76,13 @@ For a new install, prefer the complete one-command bootstrap: fclt setup ``` -It prepares global and current-repo capability, review state, indexes, and the plugin. The same -command is available to Codex through `fclt_setup`, so a plugin-led install does not require the -user to know capability roots or state paths. The MCP form requires an explicit `global` or -`global_and_project` scope, defaults to dry-run, requires an explicit project `cwd`, and only -applies when `dryRun: false` and `approve: true` are both present. +It prepares global capability, review state, indexes, and the plugin. The same +command is available to Codex through `fclt_setup`, so a plugin-led install +does not require the user to know global state paths. The MCP form requires an +explicit `global` or `global_and_project` scope and defaults to dry-run. +`global_and_project` requires an explicit project `cwd` and returns the exact +no-write minimal enrollment plan. Applying that project plan remains a +separate CLI action requiring its returned plan hash. Use the narrow plugin-only command when the CLI loop is already healthy: diff --git a/docs/pack-upgrades.md b/docs/pack-upgrades.md index c45e4781..8b8848c5 100644 --- a/docs/pack-upgrades.md +++ b/docs/pack-upgrades.md @@ -58,9 +58,16 @@ The pack source stores the composed entry template at `snippets/templates/agents That installed `AGENTS.global.md` is not meant to hold every rule. -If first install finds existing agent guidance, `fclt` seeds `AGENTS.global.md` from it and appends the Facult operating-model frame. Global installs look for existing global tool docs such as `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md`; project installs look for repo-local `AGENTS.md` or `CLAUDE.md`. - -Seeded files are user-owned. They are intentionally excluded from the pack manifest so `--update` skips them unless you explicitly replace them with `--force` or edit them manually. +On a global first install, `fclt` can seed `AGENTS.global.md` from existing +global tool docs such as `~/.codex/AGENTS.md` and +`~/.claude/CLAUDE.md`, then append the Facult operating-model frame. Project +installs never seed from repo-local `AGENTS.md` or `CLAUDE.md`; those files +remain canonical repository guidance and require explicit reviewed +`fclt project init --guidance ` reference adoption. + +Globally seeded files are user-owned. They are intentionally excluded from the +pack manifest so `--update` skips them unless you explicitly replace them with +`--force` or edit them manually. Use: diff --git a/docs/project-ai.md b/docs/project-ai.md index 75acdf6b..d6e01d1f 100644 --- a/docs/project-ai.md +++ b/docs/project-ai.md @@ -1,108 +1,141 @@ -# Project `.ai` +# Guided Project Onboarding -A project `.ai` root stores repo-owned capability. It is for source that should travel with the codebase, not for generated state, review queues, or private local context. +Project onboarding is preview-first and minimal. It creates only the canonical +files required to identify and configure one repository; installing the full +operating pack and rendering tool output remain separate decisions. -Create one with: +## Discover + +Discovery is read-only, bounded, and restricted to explicit roots: ```bash -cd /path/to/repo -fclt templates init project-ai -fclt index --project -fclt status --project +fclt projects discover --root ~/dev --since 30d --json ``` -If automation selects a repo-local root through the environment, declare its -scope explicitly: +The result groups duplicate clones and worktrees by a stable repository +identity, reports dirty state and existing `.ai` coverage, and says when a +bound truncated the scan. Review and select repositories individually; the +command never enrolls its results. + +## Preview And Apply + +Preview the exact plan before any write: ```bash -FACULT_ROOT_DIR=/path/to/repo/.ai FACULT_ROOT_SCOPE=project fclt status --project +fclt project init --project-root /path/to/repo --json ``` -An unscoped `FACULT_ROOT_DIR` is treated as global for safety. This prevents a -custom global root that happens to be named `.ai` from becoming project state -during an ancestor search. - -Typical layout: +The plan distinguishes canonical, generated, and machine-local writes and +includes file preconditions, privacy findings, rollback behavior, and a plan +hash. Minimal enrollment writes: ```text /.ai/ + .gitignore config.toml - instructions/ - snippets/ - agents/ - skills/ - mcp/ - tools/ ``` -## What Belongs In Project `.ai` +The protective `.ai/.gitignore` is committed first and excludes `.facult/` and +`config.local.toml`. Generated indexes, the project registry, receipts, and +scheduling state stay under fclt's machine-local application-data root. + +After reviewing the entire plan, apply that unchanged plan: + +```bash +fclt project init --project-root /path/to/repo \ + --apply --plan-sha --json +``` + +Apply refuses stale source hashes or changed destination preconditions. It does +not install the operating pack, enable managed rendering, schedule a loop, or +copy repository guidance. + +`fclt templates init project-ai` remains a preview-first compatibility alias. +It has the same minimal contract and does not accept the old `--update` or +`--force` behavior. -Use project `.ai` for: +## Existing Repository Guidance -- repo-specific instructions -- project review skills -- project MCP definitions without secrets -- project snippets -- project sync policy -- canonical automation prompts that should travel with the repo +Root `AGENTS.md` or `CLAUDE.md` remains the canonical repository rulebook. +fclt never copies either file automatically into `.ai/AGENTS.global.md`. -Do not put these in project `.ai`: +To adopt a reviewed reference, name each file explicitly: -- writeback queues -- evolution proposal metadata -- generated index/graph state -- local machine paths -- secrets -- private review artifacts +```bash +fclt project init --project-root /path/to/repo \ + --guidance AGENTS.md --json +``` + +The plan previews the complete content and SHA-256 hash. Adoption is +reference-only and is refused when the source is untracked, modified, outside +the repository, secret-shaped, or contains a machine-local absolute path. +Reviewing and applying a plan therefore cannot turn a dirty checkout or a +machine-specific document into committed project capability. -Project-scoped writebacks and evolution proposals are stored in machine-local `fclt` state and mirrored for review under global `~/.ai/writebacks/projects//` and `~/.ai/evolution/projects//`. +If the full built-in operating pack is actually wanted, preview it separately: + +```bash +fclt templates init operating-model --project --dry-run +fclt templates init operating-model --project +``` -## Migration From Generated-Only Roots +Project full-pack install also does not seed `AGENTS.global.md` from +`AGENTS.md` or `CLAUDE.md`. -Some repos may contain `/.ai/.facult/ai/index.json` and `graph.json` without any canonical source. That makes the repo look like it has project AI state even though there is nothing durable to render. +## Status And Lifecycle -Current behavior: +Inspect selected roots and registered projects: ```bash -fclt status --project -fclt sync --project --dry-run +fclt projects status --root /path/to/repo --json +fclt projects status --json ``` -`status` reports `project-generated-only`, and `sync` skips until canonical source is restored or initialized. +Status explains canonical coverage, protective-ignore health, generated-index +health, guidance references, scheduler state, pending review, and duplicate +locations without exposing private file contents. -## Project Sync Policy +Lifecycle decisions are non-destructive: -Project sync is default-deny. Nothing from global or project canonical source renders into repo-local managed tool outputs unless the repo opts in. +```bash +fclt project disable --project-root /path/to/repo --json +fclt project ignore --project-root /path/to/repo --json +fclt project remove --project-root /path/to/repo --json +``` -Example: +They preserve canonical files, receipts, machine-local review history, and the +repository registry. Re-enroll an intentionally selected project with a newly +reviewed `fclt project init` plan. -```toml -version = 1 +Rollback also previews by default: -[project_sync.codex] -skills = ["project-review"] -agents = ["review-operator"] -mcp_servers = ["github"] -global_docs = true -tool_rules = true -tool_config = true +```bash +fclt project rollback --receipt --json +fclt project rollback --receipt --apply --json ``` -This includes inherited global assets. If a global skill should appear in project-managed Codex output, list it explicitly. +Rollback removes only files created by that receipt when their hashes still +match. It refuses drift and preserves the receipt and review history. -## Next +## Scope And Verification -- Read [Concepts](./concepts.md) for source, generated state, machine-local state, and rendered outputs. -- Read [Managed mode](./managed-mode.md) before syncing project assets into tool outputs. -- Read [Security and trust](./security-trust.md) before committing MCP config. +Project `.ai` stores repo-owned canonical capability. Do not place generated +state, local machine paths, secrets, writeback queues, or private review +artifacts there. Project-scoped writebacks and evolution proposals remain in +machine-local state with review mirrors under global +`~/.ai/writebacks/projects/` and `~/.ai/evolution/projects/`. -## Verification +An environment-selected project root must declare +`FACULT_ROOT_SCOPE=project`; an unscoped `FACULT_ROOT_DIR` is global for safety. -Use these commands after changing project `.ai`: +After enrollment: ```bash +fclt projects status --root /path/to/repo --json fclt status --project fclt list skills --project -fclt graph AGENTS.global.md --project fclt sync codex --project --dry-run ``` + +Read [Concepts](./concepts.md) for the state model and [Managed +mode](./managed-mode.md) before opting into rendered tool output. diff --git a/docs/reference.md b/docs/reference.md index 0c6b8cf5..5c139673 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -5,7 +5,12 @@ This page groups the main `fclt` commands by job. Use `fclt --help` and `fclt [--root ] [--since ] [--json] +fclt projects status [--root ] [--json] +fclt project init [--project-root ] [--guidance ] [--apply --plan-sha ] [--json] +fclt project disable|ignore|inactive|remove [--project-root ] [--json] +fclt project rollback --receipt [--apply] [--json] fclt status [--json] fclt doctor [--json] [--repair] fclt paths [--json] @@ -16,10 +21,11 @@ fclt show fclt find ``` -Use `fclt setup` once after installation to bootstrap global capability, the current repository -when present, review state, indexes, and optional Codex integration. It is idempotent and preserves -local edits and WB/EV history. The remaining commands let you inspect tool state without claiming -ownership of rendered files. +Use `fclt setup` once after installation to bootstrap global capability, +review state, indexes, and optional Codex integration. It is idempotent and +does not initialize the current repository. `--include-project` adds an exact, +no-write enrollment plan. Use `projects discover` for bounded read-only +inventory, then `project init` preview/apply for one selected repository. `doctor --json` is read-only and reports schema version 2 setup health, loop readiness, optional integration degradation, legacy managed/autosync recovery coverage, and recommended actions. `legacyRecovery.state` is `clear`, `contained`, `cleanup_required`, or `blocked`; cleanup is offered @@ -56,7 +62,7 @@ The graph explains how instructions, snippets, config refs, and rendered targets ```bash fclt templates list fclt templates init operating-model [--global|--project|--root PATH] [--update] [--force] -fclt templates init project-ai [--update] [--force] +fclt templates init project-ai [--project-root PATH|--root PATH] [--guidance PATH] [--apply --plan-sha SHA] fclt templates init instruction fclt templates init snippet fclt templates init skill @@ -67,7 +73,10 @@ fclt consolidate --auto keep-current --from fclt index [--force] ``` -Use these to create or normalize canonical capability in `~/.ai` or `/.ai`. +Use these to create or normalize canonical capability in `~/.ai` or +`/.ai`. `project-ai` is a compatibility alias for minimal, preview-first +`project init`; use `operating-model --project` only for an explicit full-pack +install. ## Per-asset deployment planning diff --git a/plugins/fclt/scripts/fclt-mcp.cjs b/plugins/fclt/scripts/fclt-mcp.cjs index e3ed649c..59490d06 100644 --- a/plugins/fclt/scripts/fclt-mcp.cjs +++ b/plugins/fclt/scripts/fclt-mcp.cjs @@ -16,7 +16,7 @@ const tools = [ { name: "fclt_setup", description: - "Bootstrap or repair the complete fclt writeback/evolution loop and return readiness JSON.", + "Bootstrap or repair the global fclt loop and optionally return an exact no-write project enrollment plan.", inputSchema: { type: "object", properties: { @@ -1046,7 +1046,7 @@ function commandForTool(name, args = {}) { return [ "setup", "--json", - ...(args.scope === "global" ? ["--global-only"] : []), + ...(args.scope === "global_and_project" ? ["--include-project"] : []), ...(apply ? [] : ["--dry-run"]), ...(args.installCodexPlugin === false ? ["--no-codex-plugin"] : []), ]; diff --git a/plugins/fclt/skills/fclt-setup/SKILL.md b/plugins/fclt/skills/fclt-setup/SKILL.md index 68001a17..4ad93913 100644 --- a/plugins/fclt/skills/fclt-setup/SKILL.md +++ b/plugins/fclt/skills/fclt-setup/SKILL.md @@ -12,22 +12,23 @@ Use this skill when a user wants Codex to install, update, configure, inspect, o Use it for: - checking whether `fclt` is installed and current -- initializing global `~/.ai` or project `/.ai` +- initializing global `~/.ai` +- discovering repositories and enrolling a reviewed minimal project layer - installing or refreshing the built-in operating-model pack - checking setup health with `doctor` - finding canonical, generated, runtime, and review paths ## Workflow -1. Bootstrap the complete loop with one idempotent command: +1. Bootstrap the global loop with one idempotent command: ```bash fclt setup ``` -This initializes or safely updates global capability, initializes the current git repository -when present, prepares writeback/evolution review state, and installs the Codex plugin when Codex -is available. +This initializes or safely updates global capability, prepares writeback/evolution review state, +and installs the Codex plugin when Codex is available. It does not initialize the current +repository. 2. Inspect runtime selection and compatibility with `fclt_runtime` action `status`. Report the selected executable, version, source, protocol @@ -54,7 +55,8 @@ fclt doctor --json Through MCP, call `fclt_setup` with an explicit `global` or `global_and_project` scope. Project setup also requires the exact `cwd`. Preview is the default; apply requires both `dryRun: false` and -`approve: true`. +`approve: true`. `global_and_project` returns a no-write project enrollment +plan; project application still uses the typed CLI plan-hash contract below. 4. For advanced manual recovery, initialize global capability when missing: @@ -62,25 +64,89 @@ Preview is the default; apply requires both `dryRun: false` and fclt templates init operating-model --global ``` -5. If a repo needs local capability, initialize project AI: +5. Discover candidate repositories only beneath explicit roots: ```bash -fclt templates init project-ai +fclt projects discover --root ~/dev --since 30d --json ``` -6. Refresh pack defaults non-destructively: +Discovery is bounded and read-only. Review duplicate clone/worktree groups, +dirty state, existing guidance, and repository identity before selecting a +project. Never bulk-enroll the discovery result. + +6. Preview the exact minimal project enrollment plan: + +```bash +fclt project init --project-root /path/to/repo --json +``` + +The minimal layer is `.ai/.gitignore` plus `.ai/config.toml`. It does not +install the operating pack, enable managed rendering, schedule a loop, or copy +`AGENTS.md`/`CLAUDE.md`. Review every canonical, generated, and machine-local +write plus the rollback command. + +If project guidance should be adopted, name each canonical file explicitly: + +```bash +fclt project init --project-root /path/to/repo \ + --guidance AGENTS.md --json +``` + +Guidance adoption is reference-only. fclt previews the full content and hash, +and refuses untracked, modified, secret-shaped, or machine-path-bearing input. + +7. Apply only the unchanged reviewed plan: + +```bash +fclt project init --project-root /path/to/repo \ + --apply --plan-sha --json +``` + +If options, source files, or preconditions change, discard the old hash and +preview again. + +8. Inspect health, coverage, and lifecycle: + +```bash +fclt projects status --root /path/to/repo --json +fclt project disable --project-root /path/to/repo --json +fclt project rollback --receipt --json +``` + +Disable and remove decisions preserve canonical files, receipts, and review +history. Rollback previews by default and refuses drift. + +9. Install the full operating pack only when explicitly requested: + +```bash +fclt templates init operating-model --project --dry-run +fclt templates init operating-model --project +``` + +This is distinct from minimal enrollment and does not seed project +`AGENTS.global.md` from repository guidance. + +10. Refresh global pack defaults non-destructively: ```bash fclt templates init operating-model --global --update --dry-run fclt templates init operating-model --global --update ``` -7. Use `--force` only when the user explicitly wants to replace local edits. +11. Use `--force` only when the user explicitly wants to replace local edits. ## Rules - Preserve existing `AGENTS.md`, `CLAUDE.md`, and `AGENTS.global.md` guidance. -- First install should seed from existing agent guidance when available. +- Never infer guidance adoption from filenames or copy repository guidance into + `.ai/AGENTS.global.md`. +- Treat root `AGENTS.md` or `CLAUDE.md` as canonical repository guidance unless + the user explicitly chooses another tracked, clean source. +- Write the protective `.ai/.gitignore` before generated state. +- Keep canonical project files separate from machine-local generated indexes, + registries, receipts, and scheduling state. +- Keep minimal enrollment separate from the full operating pack and managed + rendering. - Treat `doctor --json` issues as setup facts, not user-facing blame. - Treat Codex plugin registration as weaker evidence than fresh-session tool discovery. - Treat external trackers as separate integrations. Core readiness depends only on configured local evidence coverage, not a vendor plugin or token. diff --git a/src/builtin-assets.ts b/src/builtin-assets.ts index 00273084..c9e6fbe1 100644 --- a/src/builtin-assets.ts +++ b/src/builtin-assets.ts @@ -7,7 +7,7 @@ export const BUILTIN_OPERATING_MODEL_FILES = JSON.parse( export const BUILTIN_FCLT_CODEX_PLUGIN_FILES = JSON.parse( // biome-ignore lint/suspicious/noTemplateCurlyInString: Built-in plugin files intentionally contain literal render placeholders. - '{".codex-plugin/plugin.json":"{\\n \\"name\\": \\"fclt\\",\\n \\"version\\": \\"0.1.2\\",\\n \\"description\\": \\"Codex workflows and MCP tools for evidence reconciliation, writeback, evolution, and outcome review.\\",\\n \\"author\\": {\\n \\"name\\": \\"Hack Dance\\",\\n \\"url\\": \\"https://hack.dance\\"\\n },\\n \\"license\\": \\"MIT\\",\\n \\"keywords\\": [\\n \\"fclt\\",\\n \\"facult\\",\\n \\"codex\\",\\n \\"skills\\",\\n \\"mcp\\",\\n \\"writeback\\",\\n \\"evolution\\"\\n ],\\n \\"skills\\": \\"./skills/\\",\\n \\"mcpServers\\": \\"./.mcp.json\\",\\n \\"interface\\": {\\n \\"displayName\\": \\"fclt\\",\\n \\"shortDescription\\": \\"Feedback loops for AI work\\",\\n \\"longDescription\\": \\"Capture signal from real agent work, reconcile configured evidence, turn repeated findings into reviewed capability changes, and verify whether those changes improved the work that produced them.\\",\\n \\"developerName\\": \\"Hack Dance\\",\\n \\"category\\": \\"Productivity\\",\\n \\"capabilities\\": [\\"Read\\", \\"Write\\", \\"MCP\\"],\\n \\"defaultPrompt\\": [\\n \\"Use fclt to check this repo\'s AI capability setup.\\",\\n \\"Reconcile the configured evidence window and explain its coverage.\\",\\n \\"Record useful writeback from this work and review any repeated signal.\\"\\n ],\\n \\"brandColor\\": \\"#166534\\",\\n \\"composerIcon\\": \\"./assets/fclt-mark.png\\",\\n \\"logo\\": \\"./assets/fclt-mark.png\\"\\n }\\n}\\n",".mcp.json":"{\\n \\"mcpServers\\": {\\n \\"fclt\\": {\\n \\"command\\": \\"node\\",\\n \\"args\\": [\\"./scripts/fclt-mcp.cjs\\"],\\n \\"env\\": {\\n \\"FCLT_BIN\\": \\"fclt\\"\\n },\\n \\"cwd\\": \\".\\"\\n }\\n }\\n}\\n","scripts/fclt-mcp.cjs":"#!/usr/bin/env node\\n\\"use strict\\";\\n\\nconst fs = require(\\"node:fs\\");\\nconst os = require(\\"node:os\\");\\nconst path = require(\\"node:path\\");\\nconst runtime = require(\\"./fclt-runtime.cjs\\");\\n\\nconst DEFAULT_TIMEOUT_MS = Number(process.env.FCLT_MCP_TIMEOUT_MS || 60_000);\\nconst ACTIVITY_ACTION_RESOLVE_CAPABILITY = \\"activity-action-resolve-v1\\";\\nconst AUDIT_READ_ONLY_CAPABILITY = \\"audit-read-only-v1\\";\\nconst CONTENT_LENGTH_RE = /Content-Length:\\\\s*(\\\\d+)/i;\\nconst PLUGIN_ROOT = path.resolve(__dirname, \\"..\\");\\n\\nconst tools = [\\n {\\n name: \\"fclt_setup\\",\\n description:\\n \\"Bootstrap or repair the complete fclt writeback/evolution loop and return readiness JSON.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: {\\n type: \\"string\\",\\n enum: [\\"global\\", \\"global_and_project\\"],\\n },\\n cwd: { type: \\"string\\" },\\n dryRun: { type: \\"boolean\\" },\\n installCodexPlugin: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_runtime\\",\\n description:\\n \\"Discover, bootstrap, update, or roll back the verified fclt runtime used by this plugin.\\",\\n inputSchema: {\\n type: \\"object\\",\\n additionalProperties: false,\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\"status\\", \\"check\\", \\"policy\\", \\"stage\\", \\"apply\\", \\"rollback\\"],\\n },\\n version: { type: \\"string\\" },\\n expectedSha256: { type: \\"string\\", pattern: \\"^[a-f0-9]{64}$\\" },\\n expectedActiveVersion: { type: \\"string\\" },\\n pinnedVersion: { type: \\"string\\" },\\n clearPin: { type: \\"boolean\\" },\\n updateChecksEnabled: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_capability\\",\\n description:\\n \\"Inspect fclt capability, provenance, templates, snippets, adapters, and managed status without exposing secrets.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"scan\\",\\n \\"inventory\\",\\n \\"list\\",\\n \\"show\\",\\n \\"find\\",\\n \\"graph\\",\\n \\"adapters\\",\\n \\"managed_status\\",\\n \\"templates_list\\",\\n \\"snippet_list\\",\\n \\"snippet_show\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n kind: {\\n type: \\"string\\",\\n enum: [\\n \\"skills\\",\\n \\"mcp\\",\\n \\"agents\\",\\n \\"automations\\",\\n \\"snippets\\",\\n \\"instructions\\",\\n ],\\n },\\n query: { type: \\"string\\" },\\n selector: { type: \\"string\\" },\\n graphMode: { type: \\"string\\", enum: [\\"show\\", \\"deps\\", \\"dependents\\"] },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_workflow\\",\\n description:\\n \\"Run typed writeback and evolution review operations. Canonical apply and cross-scope promotion are deliberately withheld.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"writeback_list\\",\\n \\"writeback_show\\",\\n \\"writeback_group\\",\\n \\"writeback_summarize\\",\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"evolve_assess\\",\\n \\"evolve_list\\",\\n \\"evolve_show\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_verify\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n id: { type: \\"string\\" },\\n kind: { type: \\"string\\" },\\n category: {\\n type: \\"string\\",\\n enum: [\\"friction\\", \\"opportunity\\", \\"reusable-success\\"],\\n },\\n summary: { type: \\"string\\" },\\n details: { type: \\"string\\", maxLength: 2000 },\\n impact: { type: \\"string\\", maxLength: 1000 },\\n attemptedWorkaround: { type: \\"string\\", maxLength: 1000 },\\n desiredOutcome: { type: \\"string\\", maxLength: 1000 },\\n sensitivity: {\\n type: \\"string\\",\\n enum: [\\"public\\", \\"internal\\", \\"private\\"],\\n },\\n asset: { type: \\"string\\" },\\n evidence: { type: \\"array\\", items: { type: \\"string\\" } },\\n confidence: { type: \\"string\\", enum: [\\"low\\", \\"medium\\", \\"high\\"] },\\n by: { type: \\"string\\", enum: [\\"asset\\", \\"kind\\", \\"domain\\"] },\\n issue: { type: \\"string\\" },\\n disposition: {\\n type: \\"string\\",\\n enum: [\\"propose\\", \\"apply-local\\", \\"task\\", \\"resolve-watch\\", \\"defer\\"],\\n },\\n target: { type: \\"string\\" },\\n nextTrigger: { type: \\"string\\" },\\n expectedOutcome: { type: \\"string\\" },\\n append: { type: \\"string\\" },\\n reason: { type: \\"string\\" },\\n byProposal: { type: \\"string\\" },\\n effectiveness: {\\n type: \\"string\\",\\n enum: [\\"improved\\", \\"unchanged\\", \\"regressed\\", \\"inconclusive\\"],\\n },\\n note: { type: \\"string\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_sync\\",\\n description:\\n \\"Inspect managed state or preview a scoped tool sync. Apply and live adoption remain withheld pending transaction-safe APIs.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: { type: \\"string\\", enum: [\\"status\\", \\"preview\\"] },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n tool: { type: \\"string\\" },\\n },\\n required: [\\"action\\", \\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_registry\\",\\n description:\\n \\"Search and verify remote capability, preview installs and updates, run typed source reconciliation reviews, or resolve one opaque activity action locator without mutation. Registry mutation remains withheld.\\",\\n inputSchema: {\\n oneOf: [\\n {\\n type: \\"object\\",\\n properties: {\\n action: { const: \\"activity_resolve\\" },\\n locator: {\\n type: \\"string\\",\\n pattern: \\"^fclt-act-v[0-9]+\\\\\\\\.[a-f0-9]{64}\\\\\\\\.[a-f0-9]{64}$\\",\\n },\\n },\\n required: [\\"action\\", \\"locator\\"],\\n additionalProperties: false,\\n },\\n {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"search\\",\\n \\"verify_source\\",\\n \\"source_list\\",\\n \\"install_preview\\",\\n \\"update_check\\",\\n \\"reconcile_status\\",\\n \\"reconcile\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n query: { type: \\"string\\" },\\n source: { type: \\"string\\" },\\n item: { type: \\"string\\" },\\n as: { type: \\"string\\" },\\n since: {\\n type: \\"string\\",\\n pattern:\\n \\"^\\\\\\\\d{4}-\\\\\\\\d{2}-\\\\\\\\d{2}(?:T\\\\\\\\d{2}:\\\\\\\\d{2}:\\\\\\\\d{2}(?:\\\\\\\\.\\\\\\\\d+)?(?:Z|[+-]\\\\\\\\d{2}:\\\\\\\\d{2}))?$\\",\\n },\\n until: {\\n type: \\"string\\",\\n pattern:\\n \\"^\\\\\\\\d{4}-\\\\\\\\d{2}-\\\\\\\\d{2}(?:T\\\\\\\\d{2}:\\\\\\\\d{2}:\\\\\\\\d{2}(?:\\\\\\\\.\\\\\\\\d+)?(?:Z|[+-]\\\\\\\\d{2}:\\\\\\\\d{2}))?$\\",\\n },\\n sourceIds: {\\n type: \\"array\\",\\n items: {\\n type: \\"string\\",\\n pattern: \\"^[A-Za-z0-9][A-Za-z0-9._-]*$\\",\\n },\\n },\\n incremental: { type: \\"boolean\\" },\\n },\\n required: [\\"action\\"],\\n additionalProperties: false,\\n },\\n ],\\n },\\n },\\n {\\n name: \\"fclt_audit\\",\\n description:\\n \\"Run a structured, redacted, non-interactive fclt security audit with no report or index writes.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: { type: \\"string\\", enum: [\\"scan\\"] },\\n cwd: { type: \\"string\\" },\\n target: { type: \\"string\\" },\\n severity: {\\n type: \\"string\\",\\n enum: [\\"critical\\", \\"high\\", \\"medium\\", \\"low\\"],\\n },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_automation\\",\\n description:\\n \\"Read one aggregate activity set across all configured loops by default, or inspect one explicit loop scope. Schedule and canonical mutation remain CLI-only.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"autosync_status\\",\\n \\"loop_status\\",\\n \\"loop_activity\\",\\n \\"loop_preview\\",\\n ],\\n },\\n scope: {\\n type: \\"string\\",\\n enum: [\\"all\\", \\"global\\", \\"project\\"],\\n },\\n cwd: { type: \\"string\\" },\\n tool: { type: \\"string\\" },\\n },\\n required: [\\"action\\"],\\n oneOf: [\\n {\\n properties: {\\n action: { const: \\"loop_activity\\" },\\n scope: {\\n type: \\"string\\",\\n enum: [\\"all\\", \\"global\\", \\"project\\"],\\n default: \\"all\\",\\n },\\n },\\n required: [\\"action\\"],\\n },\\n {\\n properties: {\\n action: {\\n enum: [\\"autosync_status\\", \\"loop_status\\", \\"loop_preview\\"],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n },\\n required: [\\"action\\", \\"scope\\"],\\n },\\n ],\\n },\\n },\\n {\\n name: \\"fclt_status\\",\\n description:\\n \\"Return fclt status for the current, global, or project scope.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_doctor\\",\\n description: \\"Run read-only fclt doctor checks and return JSON output.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_paths\\",\\n description: \\"Return canonical, generated, review, and runtime fclt paths.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_init_operating_model\\",\\n description: \\"Install or update the built-in operating-model pack.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n update: { type: \\"boolean\\" },\\n dryRun: { type: \\"boolean\\" },\\n force: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_writeback_add\\",\\n description: \\"Record a durable fclt writeback with evidence.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n kind: { type: \\"string\\" },\\n category: {\\n type: \\"string\\",\\n enum: [\\"friction\\", \\"opportunity\\", \\"reusable-success\\"],\\n },\\n summary: { type: \\"string\\" },\\n details: { type: \\"string\\", maxLength: 2000 },\\n impact: { type: \\"string\\", maxLength: 1000 },\\n attemptedWorkaround: { type: \\"string\\", maxLength: 1000 },\\n desiredOutcome: { type: \\"string\\", maxLength: 1000 },\\n sensitivity: {\\n type: \\"string\\",\\n enum: [\\"public\\", \\"internal\\", \\"private\\"],\\n },\\n asset: { type: \\"string\\" },\\n evidence: { type: \\"string\\" },\\n confidence: {\\n type: \\"string\\",\\n enum: [\\"low\\", \\"medium\\", \\"high\\"],\\n },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\", \\"kind\\", \\"summary\\", \\"evidence\\", \\"approve\\"],\\n },\\n },\\n {\\n name: \\"fclt_writeback_review\\",\\n description: \\"List, group, or summarize current fclt writebacks.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n mode: { type: \\"string\\", enum: [\\"list\\", \\"group\\", \\"summarize\\"] },\\n by: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_evolve\\",\\n description:\\n \\"Assess, list, propose, draft, or review fclt evolution proposals.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n action: {\\n type: \\"string\\",\\n enum: [\\"assess\\", \\"list\\", \\"propose\\", \\"draft\\", \\"review\\", \\"show\\"],\\n },\\n id: { type: \\"string\\" },\\n asset: { type: \\"string\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n },\\n },\\n];\\n\\nfor (const tool of tools) {\\n if (!tool.inputSchema.oneOf || tool.inputSchema.properties) {\\n tool.inputSchema.additionalProperties = false;\\n }\\n}\\n\\nfunction isPlainObject(value) {\\n return Boolean(value) && typeof value === \\"object\\" && !Array.isArray(value);\\n}\\n\\nfunction validateToolArguments(name, args) {\\n if (!isPlainObject(args)) {\\n throw new Error(`${name} arguments must be an object`);\\n }\\n const tool = tools.find((entry) => entry.name === name);\\n if (!tool) {\\n throw new Error(`Unknown tool: ${name}`);\\n }\\n const rootSchema = tool.inputSchema;\\n const schema =\\n rootSchema.oneOf && !rootSchema.properties\\n ? rootSchema.oneOf.find((branch) => {\\n const action = branch.properties?.action;\\n return (\\n action?.const === args.action || action?.enum?.includes(args.action)\\n );\\n })\\n : rootSchema;\\n if (!schema) {\\n throw new Error(`${name}.action is not an allowed value`);\\n }\\n const properties = schema.properties || {};\\n const unknown = Object.keys(args).filter((key) => !(key in properties));\\n if (unknown.length > 0) {\\n throw new Error(\\n `${name} received unknown argument fields: ${unknown.join(\\", \\")}`\\n );\\n }\\n for (const required of schema.required || []) {\\n if (!(required in args)) {\\n throw new Error(`${name} requires ${required}`);\\n }\\n }\\n for (const [key, value] of Object.entries(args)) {\\n const property = properties[key];\\n if (property.const !== undefined && value !== property.const) {\\n throw new Error(`${name}.${key} is not an allowed value`);\\n }\\n if (!property.type) {\\n continue;\\n }\\n const validType =\\n property.type === \\"array\\"\\n ? Array.isArray(value)\\n : property.type === \\"object\\"\\n ? isPlainObject(value)\\n : typeof value === property.type;\\n if (!validType) {\\n throw new Error(`${name}.${key} must be ${property.type}`);\\n }\\n if (property.enum && !property.enum.includes(value)) {\\n throw new Error(`${name}.${key} is not an allowed value`);\\n }\\n if (property.pattern && !new RegExp(property.pattern).test(value)) {\\n throw new Error(`${name}.${key} has an invalid format`);\\n }\\n if (\\n property.type === \\"array\\" &&\\n property.items?.type &&\\n value.some((item) => typeof item !== property.items.type)\\n ) {\\n throw new Error(`${name}.${key} contains an invalid item`);\\n }\\n if (\\n property.type === \\"array\\" &&\\n property.items?.pattern &&\\n value.some((item) => !new RegExp(property.items.pattern).test(item))\\n ) {\\n throw new Error(`${name}.${key} contains an invalid item`);\\n }\\n }\\n}\\n\\nfunction scopeArgs(scope) {\\n if (scope === \\"global\\") {\\n return [\\"--global\\"];\\n }\\n if (scope === \\"project\\") {\\n return [\\"--project\\"];\\n }\\n return [];\\n}\\n\\nfunction boolFlag(name, value) {\\n return value ? [name] : [];\\n}\\n\\nfunction stringFlag(name, value) {\\n return typeof value === \\"string\\" && value.trim() ? [name, value] : [];\\n}\\n\\nfunction repeatedStringFlag(name, values) {\\n return Array.isArray(values)\\n ? values.flatMap((value) => stringFlag(name, value))\\n : [];\\n}\\n\\nfunction requireString(name, value) {\\n if (typeof value !== \\"string\\" || !value.trim()) {\\n throw new Error(`${name} is required`);\\n }\\n return value;\\n}\\n\\nfunction requireMutationApproval(name, args) {\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(`${name} requires an explicit global or project scope`);\\n }\\n if (args.approve !== true) {\\n throw new Error(`${name} requires approve=true`);\\n }\\n}\\n\\nfunction capabilityCommand(args) {\\n const action = args.action;\\n if (action === \\"scan\\") {\\n return [\\"scan\\", \\"--json\\"];\\n }\\n if (action === \\"inventory\\") {\\n return [\\"inventory\\", ...scopeArgs(args.scope), \\"--json\\"];\\n }\\n if (action === \\"list\\") {\\n return [\\"list\\", args.kind || \\"skills\\", ...scopeArgs(args.scope), \\"--json\\"];\\n }\\n if (action === \\"show\\") {\\n return [\\n \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n ...scopeArgs(args.scope),\\n ];\\n }\\n if (action === \\"find\\") {\\n return [\\n \\"find\\",\\n requireString(\\"query\\", args.query),\\n ...scopeArgs(args.scope),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"graph\\") {\\n return [\\n \\"graph\\",\\n args.graphMode || \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n ...scopeArgs(args.scope),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"adapters\\") {\\n return [\\"adapters\\", \\"--json\\"];\\n }\\n if (action === \\"managed_status\\") {\\n return [\\"managed\\", ...scopeArgs(args.scope)];\\n }\\n if (action === \\"templates_list\\") {\\n return [\\"templates\\", \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"snippet_list\\") {\\n return [\\"snippets\\", \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"snippet_show\\") {\\n return [\\n \\"snippets\\",\\n \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported capability action: ${action}`);\\n}\\n\\nconst WORKFLOW_MUTATIONS = new Set([\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"writeback_dismiss\\",\\n \\"writeback_promote\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_accept\\",\\n \\"evolve_reject\\",\\n \\"evolve_supersede\\",\\n \\"evolve_verify\\",\\n]);\\n\\nconst WORKFLOW_ACTION_FIELDS = {\\n writeback_list: [],\\n writeback_show: [\\"id\\"],\\n writeback_group: [\\"by\\"],\\n writeback_summarize: [\\"by\\"],\\n writeback_add: [\\n \\"kind\\",\\n \\"category\\",\\n \\"summary\\",\\n \\"details\\",\\n \\"impact\\",\\n \\"attemptedWorkaround\\",\\n \\"desiredOutcome\\",\\n \\"sensitivity\\",\\n \\"asset\\",\\n \\"evidence\\",\\n \\"confidence\\",\\n \\"approve\\",\\n ],\\n writeback_link: [\\"id\\", \\"issue\\", \\"approve\\"],\\n writeback_disposition: [\\n \\"id\\",\\n \\"disposition\\",\\n \\"target\\",\\n \\"nextTrigger\\",\\n \\"expectedOutcome\\",\\n \\"approve\\",\\n ],\\n evolve_assess: [\\"asset\\"],\\n evolve_list: [],\\n evolve_show: [\\"id\\"],\\n evolve_propose: [\\"asset\\", \\"approve\\"],\\n evolve_draft: [\\"id\\", \\"append\\", \\"approve\\"],\\n evolve_review: [\\"id\\", \\"approve\\"],\\n evolve_verify: [\\"id\\", \\"effectiveness\\", \\"evidence\\", \\"note\\", \\"approve\\"],\\n};\\n\\nfunction requireOnlyWorkflowFields(args) {\\n const fields = WORKFLOW_ACTION_FIELDS[args.action];\\n if (!fields) {\\n return;\\n }\\n const allowed = new Set([\\"action\\", \\"scope\\", \\"cwd\\", ...fields]);\\n const unexpected = Object.keys(args).filter((key) => !allowed.has(key));\\n if (unexpected.length > 0) {\\n throw new Error(\\n `${args.action} received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n}\\n\\nfunction workflowCommand(args) {\\n const action = args.action;\\n requireOnlyWorkflowFields(args);\\n if (WORKFLOW_MUTATIONS.has(action)) {\\n requireMutationApproval(action, args);\\n }\\n const scope = scopeArgs(args.scope);\\n if (action === \\"writeback_list\\") {\\n return [\\"ai\\", \\"writeback\\", ...scope, \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"writeback_show\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"show\\",\\n requireString(\\"id\\", args.id),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_group\\" || action === \\"writeback_summarize\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n action === \\"writeback_group\\" ? \\"group\\" : \\"summarize\\",\\n ...stringFlag(\\"--by\\", args.by),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_add\\") {\\n if (!Array.isArray(args.evidence) || args.evidence.length === 0) {\\n throw new Error(\\"writeback_add requires at least one evidence reference\\");\\n }\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"add\\",\\n \\"--kind\\",\\n requireString(\\"kind\\", args.kind),\\n \\"--summary\\",\\n requireString(\\"summary\\", args.summary),\\n ...stringFlag(\\"--asset\\", args.asset),\\n ...stringFlag(\\"--category\\", args.category),\\n ...stringFlag(\\"--details\\", args.details),\\n ...stringFlag(\\"--impact\\", args.impact),\\n ...stringFlag(\\"--attempted-workaround\\", args.attemptedWorkaround),\\n ...stringFlag(\\"--desired-outcome\\", args.desiredOutcome),\\n ...stringFlag(\\"--sensitivity\\", args.sensitivity),\\n ...repeatedStringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--confidence\\", args.confidence),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_link\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"link\\",\\n requireString(\\"id\\", args.id),\\n \\"--issue\\",\\n requireString(\\"issue\\", args.issue),\\n ];\\n }\\n if (action === \\"writeback_disposition\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"disposition\\",\\n requireString(\\"id\\", args.id),\\n \\"--type\\",\\n requireString(\\"disposition\\", args.disposition),\\n ...stringFlag(\\"--target\\", args.target),\\n ...stringFlag(\\"--next-trigger\\", args.nextTrigger),\\n ...stringFlag(\\"--expected-outcome\\", args.expectedOutcome),\\n ];\\n }\\n if (action === \\"writeback_dismiss\\" || action === \\"writeback_promote\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n action === \\"writeback_dismiss\\" ? \\"dismiss\\" : \\"promote\\",\\n requireString(\\"id\\", args.id),\\n ];\\n }\\n if (action === \\"evolve_assess\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"assess\\",\\n ...stringFlag(\\"--asset\\", args.asset),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_list\\") {\\n return [\\"ai\\", \\"evolve\\", ...scope, \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"evolve_show\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"show\\",\\n requireString(\\"id\\", args.id),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_propose\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"propose\\",\\n \\"--asset\\",\\n requireString(\\"asset\\", args.asset),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_draft\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"draft\\",\\n requireString(\\"id\\", args.id),\\n ...stringFlag(\\"--append\\", args.append),\\n ];\\n }\\n if (action === \\"evolve_review\\" || action === \\"evolve_accept\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n action === \\"evolve_review\\" ? \\"review\\" : \\"accept\\",\\n requireString(\\"id\\", args.id),\\n ];\\n }\\n if (action === \\"evolve_reject\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"reject\\",\\n requireString(\\"id\\", args.id),\\n \\"--reason\\",\\n requireString(\\"reason\\", args.reason),\\n ];\\n }\\n if (action === \\"evolve_supersede\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"supersede\\",\\n requireString(\\"id\\", args.id),\\n \\"--by\\",\\n requireString(\\"byProposal\\", args.byProposal),\\n ];\\n }\\n if (action === \\"evolve_verify\\") {\\n if (!Array.isArray(args.evidence) || args.evidence.length === 0) {\\n throw new Error(\\"evolve_verify requires at least one evidence reference\\");\\n }\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"verify\\",\\n requireString(\\"id\\", args.id),\\n \\"--effectiveness\\",\\n requireString(\\"effectiveness\\", args.effectiveness),\\n ...repeatedStringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--note\\", args.note),\\n ];\\n }\\n throw new Error(`Unsupported workflow action: ${action}`);\\n}\\n\\nfunction syncCommand(args) {\\n if (args.action === \\"status\\") {\\n return [\\"managed\\", ...scopeArgs(args.scope)];\\n }\\n if (args.action === \\"preview\\") {\\n return [\\n \\"sync\\",\\n ...(args.tool ? [args.tool] : []),\\n \\"--dry-run\\",\\n ...scopeArgs(args.scope),\\n ];\\n }\\n throw new Error(`Unsupported sync action: ${args.action}`);\\n}\\n\\nfunction requireOnlyRegistryFields(args, fields) {\\n const allowed = new Set([\\"action\\", \\"scope\\", \\"cwd\\", ...fields]);\\n const unexpected = Object.keys(args).filter((key) => !allowed.has(key));\\n if (unexpected.length > 0) {\\n throw new Error(\\n `${args.action} received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n}\\n\\nfunction registryCommand(args) {\\n if (args.action === \\"activity_resolve\\") {\\n const unexpected = Object.keys(args).filter(\\n (key) => key !== \\"action\\" && key !== \\"locator\\"\\n );\\n if (unexpected.length > 0) {\\n throw new Error(\\n `activity_resolve received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n \\"resolve\\",\\n requireString(\\"locator\\", args.locator),\\n \\"--json\\",\\n ];\\n }\\n if (\\n args.scope === \\"project\\" &&\\n args.action !== \\"reconcile_status\\" &&\\n args.action !== \\"reconcile\\"\\n ) {\\n throw new Error(`${args.action} only supports global scope`);\\n }\\n if (args.action === \\"search\\") {\\n requireOnlyRegistryFields(args, [\\"query\\"]);\\n return [\\"search\\", requireString(\\"query\\", args.query), \\"--json\\"];\\n }\\n if (args.action === \\"verify_source\\") {\\n requireOnlyRegistryFields(args, [\\"source\\"]);\\n return [\\"verify-source\\", requireString(\\"source\\", args.source), \\"--json\\"];\\n }\\n if (args.action === \\"source_list\\") {\\n requireOnlyRegistryFields(args, []);\\n return [\\"sources\\", \\"list\\", \\"--json\\"];\\n }\\n if (args.action === \\"install_preview\\") {\\n requireOnlyRegistryFields(args, [\\"item\\", \\"as\\"]);\\n return [\\n \\"install\\",\\n requireString(\\"item\\", args.item),\\n ...stringFlag(\\"--as\\", args.as),\\n \\"--dry-run\\",\\n \\"--strict-source-trust\\",\\n \\"--json\\",\\n ];\\n }\\n if (args.action === \\"update_check\\") {\\n requireOnlyRegistryFields(args, []);\\n return [\\"update\\", \\"--strict-source-trust\\", \\"--json\\"];\\n }\\n if (args.action === \\"reconcile_status\\") {\\n requireOnlyRegistryFields(args, []);\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(\\n \\"reconcile_status requires an explicit global or project scope\\"\\n );\\n }\\n return [\\"ai\\", \\"review\\", ...scopeArgs(args.scope), \\"status\\", \\"--json\\"];\\n }\\n if (args.action === \\"reconcile\\") {\\n requireOnlyRegistryFields(args, [\\n \\"since\\",\\n \\"until\\",\\n \\"sourceIds\\",\\n \\"incremental\\",\\n ]);\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(\\"reconcile requires an explicit global or project scope\\");\\n }\\n return [\\n \\"ai\\",\\n \\"review\\",\\n ...scopeArgs(args.scope),\\n \\"reconcile\\",\\n \\"--since\\",\\n requireString(\\"since\\", args.since),\\n ...stringFlag(\\"--until\\", args.until),\\n ...repeatedStringFlag(\\"--source\\", args.sourceIds),\\n ...boolFlag(\\"--incremental\\", args.incremental),\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported registry action: ${args.action}`);\\n}\\n\\nfunction isSubpath(child, parent) {\\n const relative = path.relative(parent, child);\\n return (\\n relative === \\"\\" || !(relative.startsWith(\\"..\\") || path.isAbsolute(relative))\\n );\\n}\\n\\nfunction isDirectory(candidate) {\\n try {\\n return fs.statSync(candidate).isDirectory();\\n } catch {\\n return false;\\n }\\n}\\n\\nfunction resolveWorkspaceCwd({ allowHomeFallback = true } = {}) {\\n const candidates = [\\n process.env.FCLT_MCP_WORKSPACE_CWD,\\n process.env.INIT_CWD,\\n process.env.PWD,\\n ];\\n for (const candidate of candidates) {\\n if (typeof candidate !== \\"string\\" || !candidate.trim()) {\\n continue;\\n }\\n const resolved = path.resolve(candidate);\\n const isHome = resolved === path.resolve(os.homedir());\\n if (\\n (allowHomeFallback || !isHome) &&\\n !isSubpath(resolved, PLUGIN_ROOT) &&\\n isDirectory(resolved)\\n ) {\\n return resolved;\\n }\\n }\\n if (allowHomeFallback && isDirectory(os.homedir())) {\\n return os.homedir();\\n }\\n return undefined;\\n}\\n\\nfunction resolveToolCwd(name, args = {}) {\\n if (typeof args.cwd === \\"string\\" && args.cwd.trim()) {\\n return args.cwd;\\n }\\n const inferred = resolveWorkspaceCwd({\\n allowHomeFallback: args.scope !== \\"project\\",\\n });\\n if (inferred) {\\n return inferred;\\n }\\n if (args.scope === \\"project\\") {\\n throw new Error(\\n `${name} with project scope requires a cwd for the target workspace`\\n );\\n }\\n return process.cwd();\\n}\\n\\nfunction commandForTool(name, args = {}) {\\n switch (name) {\\n case \\"fclt_setup\\": {\\n const apply = args.dryRun === false;\\n if (apply && args.approve !== true) {\\n throw new Error(\\"fclt_setup apply requires approve=true\\");\\n }\\n if (\\n args.scope === \\"global_and_project\\" &&\\n (typeof args.cwd !== \\"string\\" || !args.cwd.trim())\\n ) {\\n throw new Error(\\n \\"fclt_setup global_and_project scope requires an explicit cwd\\"\\n );\\n }\\n return [\\n \\"setup\\",\\n \\"--json\\",\\n ...(args.scope === \\"global\\" ? [\\"--global-only\\"] : []),\\n ...(apply ? [] : [\\"--dry-run\\"]),\\n ...(args.installCodexPlugin === false ? [\\"--no-codex-plugin\\"] : []),\\n ];\\n }\\n case \\"fclt_capability\\":\\n return capabilityCommand(args);\\n case \\"fclt_workflow\\":\\n return workflowCommand(args);\\n case \\"fclt_sync\\":\\n return syncCommand(args);\\n case \\"fclt_registry\\":\\n return registryCommand(args);\\n case \\"fclt_audit\\":\\n return [\\n \\"audit\\",\\n \\"--non-interactive\\",\\n ...(args.target ? [args.target] : []),\\n ...stringFlag(\\"--severity\\", args.severity),\\n \\"--json\\",\\n ];\\n case \\"fclt_automation\\":\\n if (\\n args.action !== \\"loop_activity\\" &&\\n args.scope !== \\"global\\" &&\\n args.scope !== \\"project\\"\\n ) {\\n throw new Error(`${args.action} requires global or project scope`);\\n }\\n if (args.action === \\"autosync_status\\") {\\n return [\\n \\"autosync\\",\\n \\"status\\",\\n ...(args.tool ? [args.tool] : []),\\n ...scopeArgs(args.scope),\\n ];\\n }\\n if (args.tool) {\\n throw new Error(`${args.action} does not accept tool`);\\n }\\n if (args.action === \\"loop_status\\") {\\n return [\\"ai\\", \\"loop\\", ...scopeArgs(args.scope), \\"status\\", \\"--json\\"];\\n }\\n if (args.action === \\"loop_activity\\") {\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n ...scopeArgs(args.scope),\\n \\"activity\\",\\n ...(args.scope === \\"global\\" || args.scope === \\"project\\"\\n ? []\\n : [\\"--all\\"]),\\n \\"--json\\",\\n ];\\n }\\n if (args.action === \\"loop_preview\\") {\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n ...scopeArgs(args.scope),\\n \\"run\\",\\n \\"--dry-run\\",\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported automation action: ${args.action}`);\\n case \\"fclt_status\\":\\n return [\\"status\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_doctor\\":\\n return [\\"doctor\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_paths\\":\\n return [\\"paths\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_init_operating_model\\":\\n if (args.dryRun === false && args.approve !== true) {\\n throw new Error(\\n \\"fclt_init_operating_model apply requires approve=true\\"\\n );\\n }\\n if (args.force === true && args.approve !== true) {\\n throw new Error(\\n \\"fclt_init_operating_model force requires approve=true\\"\\n );\\n }\\n return [\\n \\"templates\\",\\n \\"init\\",\\n \\"operating-model\\",\\n ...scopeArgs(args.scope),\\n ...boolFlag(\\"--update\\", args.update),\\n ...(args.dryRun === false ? [] : [\\"--dry-run\\"]),\\n ...boolFlag(\\"--force\\", args.force),\\n \\"--json\\",\\n ];\\n case \\"fclt_writeback_add\\":\\n requireMutationApproval(name, args);\\n requireString(\\"evidence\\", args.evidence);\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scopeArgs(args.scope),\\n \\"add\\",\\n \\"--kind\\",\\n args.kind,\\n \\"--summary\\",\\n args.summary,\\n ...stringFlag(\\"--asset\\", args.asset),\\n ...stringFlag(\\"--category\\", args.category),\\n ...stringFlag(\\"--details\\", args.details),\\n ...stringFlag(\\"--impact\\", args.impact),\\n ...stringFlag(\\"--attempted-workaround\\", args.attemptedWorkaround),\\n ...stringFlag(\\"--desired-outcome\\", args.desiredOutcome),\\n ...stringFlag(\\"--sensitivity\\", args.sensitivity),\\n ...stringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--confidence\\", args.confidence),\\n \\"--json\\",\\n ];\\n case \\"fclt_writeback_review\\": {\\n const mode = args.mode || \\"list\\";\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scopeArgs(args.scope),\\n mode,\\n ...stringFlag(\\"--by\\", args.by),\\n ];\\n }\\n case \\"fclt_evolve\\": {\\n const action = args.action || \\"list\\";\\n if ([\\"propose\\", \\"draft\\", \\"review\\"].includes(action)) {\\n requireMutationApproval(`fclt_evolve ${action}`, args);\\n }\\n if (action === \\"propose\\") {\\n requireString(\\"asset\\", args.asset);\\n }\\n if ([\\"draft\\", \\"review\\", \\"show\\"].includes(action)) {\\n requireString(\\"id\\", args.id);\\n }\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scopeArgs(args.scope),\\n action,\\n ...(action === \\"assess\\" || action === \\"propose\\"\\n ? stringFlag(\\"--asset\\", args.asset)\\n : []),\\n ...(args.id ? [args.id] : []),\\n ...(action === \\"assess\\" ? [\\"--json\\"] : []),\\n ];\\n }\\n default:\\n throw new Error(`Unknown tool: ${name}`);\\n }\\n}\\n\\nfunction operationMetadata(name, args, command) {\\n const action =\\n name === \\"fclt_writeback_add\\"\\n ? \\"writeback_add\\"\\n : name === \\"fclt_evolve\\"\\n ? `evolve_${args.action || \\"list\\"}`\\n : args.action || name;\\n const reviewActions = new Set([\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_verify\\",\\n \\"reconcile\\",\\n ]);\\n const preview = command.includes(\\"--dry-run\\");\\n const risk = reviewActions.has(action)\\n ? \\"review_producing\\"\\n : !preview && name === \\"fclt_setup\\"\\n ? \\"reversible_mutation\\"\\n : !preview && name === \\"fclt_init_operating_model\\"\\n ? \\"high_risk_destructive\\"\\n : \\"read_only\\";\\n return {\\n tool: name,\\n action,\\n risk,\\n scope:\\n args.scope ||\\n (name === \\"fclt_automation\\" && action === \\"loop_activity\\"\\n ? \\"all\\"\\n : \\"auto\\"),\\n target:\\n args.id ||\\n args.selector ||\\n args.asset ||\\n args.item ||\\n args.source ||\\n args.tool ||\\n null,\\n preview,\\n };\\n}\\n\\nfunction recoveryForOperation(operation, stdout) {\\n if (operation.risk === \\"review_producing\\") {\\n return {\\n canonicalCapabilityChanged: false,\\n audit: \\"native fclt review artifacts and append-only journal\\",\\n };\\n }\\n if (\\n operation.risk !== \\"reversible_mutation\\" &&\\n operation.risk !== \\"high_risk_destructive\\"\\n ) {\\n return null;\\n }\\n const report = isPlainObject(stdout) ? stdout : {};\\n return {\\n rollbackAvailable: false,\\n changedPaths: Array.isArray(report.changedPaths) ? report.changedPaths : [],\\n skippedPaths: Array.isArray(report.skippedPaths) ? report.skippedPaths : [],\\n repairActions: Array.isArray(report.repairActions)\\n ? report.repairActions\\n : [],\\n verification:\\n operation.tool === \\"fclt_setup\\"\\n ? \\"rerun fclt_setup in preview mode and verify doctor readiness\\"\\n : \\"verify doctor, generated state, authored-file hashes, and exact target paths\\",\\n };\\n}\\n\\nasync function runFclt(args, cwd, operation) {\\n const discovery = await runtime.discoverRuntime();\\n if (!discovery.selected) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"no_compatible_runtime\\",\\n message:\\n \\"No compatible fclt runtime is available. Check, stage, and apply an explicit verified version with fclt_runtime.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n if (\\n operation.tool === \\"fclt_audit\\" &&\\n !discovery.selected.capabilities?.includes(AUDIT_READ_ONLY_CAPABILITY)\\n ) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"missing_runtime_capability\\",\\n message:\\n \\"The selected fclt runtime does not advertise audit-read-only-v1; typed audit fails closed.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n if (\\n operation.action === \\"activity_resolve\\" &&\\n !discovery.selected.capabilities?.includes(\\n ACTIVITY_ACTION_RESOLVE_CAPABILITY\\n )\\n ) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"missing_runtime_capability\\",\\n message:\\n \\"The selected fclt runtime does not advertise activity-action-resolve-v1; typed activity resolution fails closed.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n const result = await runtime.runCommand(discovery.selected.executable, args, {\\n cwd: cwd || process.cwd(),\\n env: process.env,\\n timeoutMs: DEFAULT_TIMEOUT_MS,\\n });\\n const parsedStdout = parseJsonOrText(result.stdout.trim());\\n return {\\n code: result.code,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n runtime: discovery.selected,\\n result: {\\n exitCode: result.code,\\n stdout: parsedStdout,\\n stderr: result.stderr,\\n },\\n verification: {\\n status: result.code === 0 ? \\"passed\\" : \\"failed\\",\\n exitCode: result.code,\\n },\\n recovery: recoveryForOperation(operation, parsedStdout),\\n },\\n null,\\n 2\\n ),\\n };\\n}\\n\\nfunction parseJsonOrText(value) {\\n if (!value) {\\n return \\"\\";\\n }\\n try {\\n return JSON.parse(value);\\n } catch {\\n return value;\\n }\\n}\\n\\nasync function handleRuntimeTool(args = {}) {\\n const action = args.action || \\"status\\";\\n if (action === \\"status\\") {\\n return await runtime.discoverRuntime();\\n }\\n if (action === \\"check\\") {\\n return await runtime.checkRuntimeUpdate();\\n }\\n if (action === \\"policy\\") {\\n return await runtime.setRuntimePolicy({\\n approve: args.approve,\\n pinnedVersion: args.pinnedVersion,\\n clearPin: args.clearPin,\\n updateChecksEnabled: args.updateChecksEnabled,\\n });\\n }\\n if (action === \\"stage\\") {\\n return await runtime.stageRuntime({\\n approve: args.approve,\\n version: args.version,\\n });\\n }\\n if (action === \\"apply\\") {\\n return await runtime.applyStagedRuntime({\\n approve: args.approve,\\n expectedSha256: args.expectedSha256,\\n version: args.version,\\n });\\n }\\n if (action === \\"rollback\\") {\\n return await runtime.rollbackRuntime({\\n approve: args.approve,\\n expectedActiveVersion: args.expectedActiveVersion,\\n });\\n }\\n throw new Error(`Unknown runtime action: ${action}`);\\n}\\n\\nfunction runtimeOperationMetadata(args, result) {\\n const action = args.action || \\"status\\";\\n const risk =\\n action === \\"status\\" || action === \\"check\\"\\n ? \\"read_only\\"\\n : action === \\"stage\\"\\n ? \\"review_producing\\"\\n : \\"high_risk_destructive\\";\\n return {\\n operation: {\\n tool: \\"fclt_runtime\\",\\n action,\\n risk,\\n scope: \\"plugin_runtime\\",\\n target:\\n args.version ||\\n args.pinnedVersion ||\\n args.expectedActiveVersion ||\\n null,\\n approved: args.approve === true,\\n },\\n verification: {\\n status: \\"passed\\",\\n activeVersion:\\n result.active?.packageVersion ||\\n result.selected?.packageVersion ||\\n null,\\n },\\n recovery:\\n action === \\"apply\\" || action === \\"rollback\\"\\n ? {\\n rollbackAvailable: result.rollbackAvailable === true,\\n previous: result.previous || null,\\n }\\n : action === \\"policy\\"\\n ? { previousPolicy: result.previous || null }\\n : null,\\n };\\n}\\n\\nlet transportFraming = \\"content-length\\";\\n\\nfunction send(message) {\\n const body = JSON.stringify(message);\\n if (transportFraming === \\"newline\\") {\\n process.stdout.write(`${body}\\\\n`);\\n return;\\n }\\n process.stdout.write(\\n `Content-Length: ${Buffer.byteLength(body)}\\\\r\\\\n\\\\r\\\\n${body}`\\n );\\n}\\n\\nasync function handle(message) {\\n if (!message || message.id == null) {\\n return;\\n }\\n\\n try {\\n if (message.method === \\"initialize\\") {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n protocolVersion: \\"2025-06-18\\",\\n capabilities: { tools: {} },\\n serverInfo: { name: \\"fclt\\", version: runtime.pluginVersion() },\\n },\\n });\\n return;\\n }\\n if (message.method === \\"tools/list\\") {\\n send({ jsonrpc: \\"2.0\\", id: message.id, result: { tools } });\\n return;\\n }\\n if (message.method === \\"tools/call\\") {\\n const { name, arguments: args = {} } = message.params || {};\\n validateToolArguments(name, args);\\n if (name === \\"fclt_runtime\\") {\\n const result = await handleRuntimeTool(args);\\n const metadata = runtimeOperationMetadata(args, result);\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n isError: false,\\n content: [\\n {\\n type: \\"text\\",\\n text: JSON.stringify({ ...result, ...metadata }, null, 2),\\n },\\n ],\\n },\\n });\\n return;\\n }\\n const command = commandForTool(name, args);\\n const result = await runFclt(\\n command,\\n resolveToolCwd(name, args),\\n operationMetadata(name, args, command)\\n );\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n isError: result.code !== 0,\\n content: [{ type: \\"text\\", text: result.text }],\\n },\\n });\\n return;\\n }\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n error: { code: -32_601, message: `Method not found: ${message.method}` },\\n });\\n } catch (error) {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n error: {\\n code: -32_000,\\n message: error instanceof Error ? error.message : String(error),\\n },\\n });\\n }\\n}\\n\\nlet buffer = Buffer.alloc(0);\\n\\nfunction dispatch(body, framing) {\\n transportFraming = framing;\\n handle(JSON.parse(body)).catch((error) => {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: null,\\n error: {\\n code: -32_000,\\n message: error instanceof Error ? error.message : String(error),\\n },\\n });\\n });\\n}\\n\\nprocess.stdin.on(\\"data\\", (chunk) => {\\n buffer = Buffer.concat([buffer, chunk]);\\n while (true) {\\n while (buffer[0] === 10 || buffer[0] === 13) {\\n buffer = buffer.subarray(1);\\n }\\n if (buffer.length === 0) {\\n return;\\n }\\n if (buffer[0] === 123 || buffer[0] === 91) {\\n const lineEnd = buffer.indexOf(\\"\\\\n\\");\\n if (lineEnd === -1) {\\n return;\\n }\\n const body = buffer.subarray(0, lineEnd).toString(\\"utf8\\").trim();\\n buffer = buffer.subarray(lineEnd + 1);\\n if (body) {\\n dispatch(body, \\"newline\\");\\n }\\n continue;\\n }\\n const headerEnd = buffer.indexOf(\\"\\\\r\\\\n\\\\r\\\\n\\");\\n if (headerEnd === -1) {\\n return;\\n }\\n const header = buffer.slice(0, headerEnd).toString(\\"utf8\\");\\n const match = CONTENT_LENGTH_RE.exec(header);\\n if (!match) {\\n buffer = Buffer.alloc(0);\\n return;\\n }\\n const length = Number(match[1]);\\n const frameEnd = headerEnd + 4 + length;\\n if (buffer.length < frameEnd) {\\n return;\\n }\\n const body = buffer.slice(headerEnd + 4, frameEnd).toString(\\"utf8\\");\\n buffer = buffer.slice(frameEnd);\\n dispatch(body, \\"content-length\\");\\n }\\n});\\n\\nif (process.argv.includes(\\"--self-test\\")) {\\n console.log(\\n JSON.stringify(\\n {\\n pluginVersion: runtime.pluginVersion(),\\n protocolVersion: runtime.PLUGIN_PROTOCOL_VERSION,\\n tools: tools.map((tool) => tool.name),\\n },\\n null,\\n 2\\n )\\n );\\n process.exit(0);\\n}\\n","scripts/fclt-runtime.cjs":"\\"use strict\\";\\n\\nconst { spawn } = require(\\"node:child_process\\");\\nconst crypto = require(\\"node:crypto\\");\\nconst fs = require(\\"node:fs\\");\\nconst fsp = require(\\"node:fs/promises\\");\\nconst https = require(\\"node:https\\");\\nconst os = require(\\"node:os\\");\\nconst path = require(\\"node:path\\");\\n\\nconst PLUGIN_PROTOCOL_VERSION = 1;\\nconst STATE_SCHEMA_VERSION = 1;\\nconst REPOSITORY = \\"hack-dance/fclt\\";\\nconst MAX_BINARY_BYTES = 256 * 1024 * 1024;\\nconst MAX_METADATA_BYTES = 2 * 1024 * 1024;\\nconst DOWNLOAD_TIMEOUT_MS = 30_000;\\nconst COMMAND_TIMEOUT_MS = 15_000;\\nconst ALLOWED_DOWNLOAD_HOSTS = new Set([\\n \\"api.github.com\\",\\n \\"github.com\\",\\n \\"objects.githubusercontent.com\\",\\n \\"release-assets.githubusercontent.com\\",\\n]);\\nconst SEMVER_RE = /^\\\\d+\\\\.\\\\d+\\\\.\\\\d+(?:[-+][0-9A-Za-z.-]+)?$/;\\nconst SHA256_RE = /^[a-f0-9]{64}$/;\\nconst NEWLINE_RE = /\\\\r?\\\\n/;\\nconst CHECKSUM_LINE_RE = /^([a-fA-F0-9]{64})\\\\s+\\\\*?(.+)$/;\\nconst WINDOWS_SHIM_RE = /\\\\.(?:bat|cmd)$/i;\\n\\nfunction isPlainObject(value) {\\n return Boolean(value) && typeof value === \\"object\\" && !Array.isArray(value);\\n}\\n\\nfunction pluginVersion() {\\n try {\\n const manifest = JSON.parse(\\n fs.readFileSync(\\n path.resolve(__dirname, \\"..\\", \\".codex-plugin\\", \\"plugin.json\\"),\\n \\"utf8\\"\\n )\\n );\\n return typeof manifest.version === \\"string\\" ? manifest.version : \\"unknown\\";\\n } catch {\\n return \\"unknown\\";\\n }\\n}\\n\\nfunction runtimeStateRoot(env = process.env, platform = process.platform) {\\n if (env.FCLT_PLUGIN_RUNTIME_DIR) {\\n return path.resolve(env.FCLT_PLUGIN_RUNTIME_DIR);\\n }\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n if (platform === \\"darwin\\") {\\n return path.join(\\n home,\\n \\"Library\\",\\n \\"Application Support\\",\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n }\\n if (platform === \\"win32\\") {\\n return path.join(\\n env.LOCALAPPDATA || path.join(home, \\"AppData\\", \\"Local\\"),\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n }\\n return path.join(\\n env.XDG_STATE_HOME || path.join(home, \\".local\\", \\"state\\"),\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n}\\n\\nfunction installStatePaths(env = process.env, platform = process.platform) {\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n const override = env.FACULT_LOCAL_STATE_DIR?.trim();\\n const portableRoot = override\\n ? path.resolve(override)\\n : platform === \\"darwin\\"\\n ? path.join(home, \\"Library\\", \\"Application Support\\", \\"fclt\\")\\n : path.join(\\n env.XDG_STATE_HOME\\n ? path.resolve(env.XDG_STATE_HOME)\\n : path.join(home, \\".local\\", \\"state\\"),\\n \\"fclt\\"\\n );\\n const candidates = [\\n path.join(portableRoot, \\"install.json\\"),\\n ...(platform === \\"win32\\"\\n ? [\\n path.join(\\n env.LOCALAPPDATA || path.join(home, \\"AppData\\", \\"Local\\"),\\n \\"fclt\\",\\n \\"install.json\\"\\n ),\\n ]\\n : []),\\n path.join(home, \\".ai\\", \\".facult\\", \\"install.json\\"),\\n path.join(home, \\".facult\\", \\"install.json\\"),\\n path.join(home, \\".local\\", \\"share\\", \\"fclt\\", \\"install.json\\"),\\n ];\\n return [...new Set(candidates.map((candidate) => path.resolve(candidate)))];\\n}\\n\\nfunction isSubpath(child, parent) {\\n const relative = path.relative(parent, child);\\n return (\\n relative === \\"\\" || !(relative.startsWith(\\"..\\") || path.isAbsolute(relative))\\n );\\n}\\n\\nasync function assertManagedPath(target, root) {\\n const resolvedRoot = path.resolve(root);\\n const resolvedTarget = path.resolve(target);\\n if (!isSubpath(resolvedTarget, resolvedRoot)) {\\n throw new Error(\\"Runtime path escapes the managed runtime root.\\");\\n }\\n\\n const relative = path.relative(resolvedRoot, path.dirname(resolvedTarget));\\n const segments = relative ? relative.split(path.sep) : [];\\n let cursor = resolvedRoot;\\n for (const segment of segments) {\\n cursor = path.join(cursor, segment);\\n try {\\n if ((await fsp.lstat(cursor)).isSymbolicLink()) {\\n throw new Error(\\"Runtime path traverses a symbolic link.\\");\\n }\\n } catch (error) {\\n if (error && error.code === \\"ENOENT\\") {\\n continue;\\n }\\n throw error;\\n }\\n }\\n return resolvedTarget;\\n}\\n\\nasync function readJson(pathValue) {\\n try {\\n const value = JSON.parse(await fsp.readFile(pathValue, \\"utf8\\"));\\n return isPlainObject(value) ? value : null;\\n } catch {\\n return null;\\n }\\n}\\n\\nasync function runtimePolicy(options = {}) {\\n const root = runtimeStateRoot(options.env, options.platform);\\n const persisted = await readJson(path.join(root, \\"policy.json\\"));\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n updateChecksEnabled: persisted?.updateChecksEnabled !== false,\\n pinnedVersion:\\n typeof persisted?.pinnedVersion === \\"string\\" &&\\n persisted.pinnedVersion.trim()\\n ? normalizeVersion(persisted.pinnedVersion)\\n : null,\\n };\\n}\\n\\nfunction commandNames(platform = process.platform) {\\n return platform === \\"win32\\"\\n ? [\\"fclt.exe\\", \\"fclt.cmd\\", \\"facult.exe\\", \\"facult.cmd\\"]\\n : [\\"fclt\\", \\"facult\\"];\\n}\\n\\nfunction pathCandidates(env = process.env, platform = process.platform) {\\n const values = [];\\n for (const directory of (env.PATH || \\"\\").split(path.delimiter)) {\\n if (!directory) {\\n continue;\\n }\\n for (const name of commandNames(platform)) {\\n values.push(path.join(directory, name));\\n }\\n }\\n return values;\\n}\\n\\nfunction systemPathCandidates(env = process.env, platform = process.platform) {\\n if (Object.hasOwn(env, \\"FCLT_SYSTEM_PATHS\\")) {\\n return (env.FCLT_SYSTEM_PATHS || \\"\\").split(path.delimiter).filter(Boolean);\\n }\\n if (platform === \\"darwin\\") {\\n return [\\"/opt/homebrew/bin/fclt\\", \\"/usr/local/bin/fclt\\"];\\n }\\n if (platform === \\"win32\\") {\\n return [];\\n }\\n return [\\"/usr/local/bin/fclt\\", \\"/usr/bin/fclt\\"];\\n}\\n\\nfunction candidateSource(candidate) {\\n const normalized = candidate.split(\\"\\\\\\\\\\").join(\\"/\\");\\n if (normalized.includes(\\"/plugin-runtime/versions/\\")) {\\n return \\"plugin_runtime\\";\\n }\\n if (normalized.includes(\\"/mise/\\") || normalized.includes(\\"/mise/installs/\\")) {\\n return \\"mise\\";\\n }\\n if (\\n normalized.includes(\\"/Cellar/\\") ||\\n normalized.startsWith(\\"/opt/homebrew/\\")\\n ) {\\n return \\"homebrew\\";\\n }\\n if (normalized.includes(\\"/node_modules/\\") || normalized.includes(\\"/npm/\\")) {\\n return \\"npm\\";\\n }\\n if (normalized.includes(\\"/.ai/.facult/bin/\\")) {\\n return \\"canonical_install\\";\\n }\\n return \\"path\\";\\n}\\n\\nasync function activeRuntimeCandidate(root) {\\n const active = await readJson(path.join(root, \\"active.json\\"));\\n if (typeof active?.executable !== \\"string\\" || !active.executable.trim()) {\\n return null;\\n }\\n const executable = path.resolve(active.executable);\\n if (!isSubpath(executable, path.join(root, \\"versions\\"))) {\\n return null;\\n }\\n return {\\n executable,\\n source: \\"plugin_runtime\\",\\n expectedSha256: active.sha256,\\n active,\\n };\\n}\\n\\nasync function persistedInstallCandidates(\\n env = process.env,\\n platform = process.platform\\n) {\\n const candidates = [];\\n for (const statePath of installStatePaths(env, platform)) {\\n const state = await readJson(statePath);\\n if (typeof state?.binaryPath === \\"string\\" && state.binaryPath.trim()) {\\n candidates.push({\\n executable: path.resolve(state.binaryPath),\\n source:\\n typeof state.source === \\"string\\" ? state.source : \\"install_metadata\\",\\n installStatePath: statePath,\\n });\\n }\\n }\\n return candidates;\\n}\\n\\nasync function runtimeCandidates(options = {}) {\\n const env = options.env || process.env;\\n const platform = options.platform || process.platform;\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n const root = runtimeStateRoot(env, platform);\\n const candidates = [];\\n let configuredPathCandidate = null;\\n\\n if (env.FCLT_BIN?.trim()) {\\n const explicit = env.FCLT_BIN.trim();\\n if (path.isAbsolute(explicit) || explicit.includes(path.sep)) {\\n candidates.push({\\n executable: path.resolve(explicit),\\n source: \\"explicit\\",\\n });\\n } else {\\n const resolved = pathCandidates(env, platform).find(\\n (candidate) =>\\n path.basename(candidate) === explicit && fs.existsSync(candidate)\\n );\\n configuredPathCandidate = {\\n executable: resolved || explicit,\\n source: \\"configured_path\\",\\n };\\n }\\n }\\n\\n const active = await activeRuntimeCandidate(root);\\n if (active) {\\n candidates.push(active);\\n }\\n if (configuredPathCandidate) {\\n candidates.push(configuredPathCandidate);\\n }\\n candidates.push(...(await persistedInstallCandidates(env, platform)));\\n candidates.push(\\n ...pathCandidates(env, platform).map((executable) => ({\\n executable,\\n source: candidateSource(executable),\\n }))\\n );\\n\\n for (const executable of [\\n path.join(home, \\".ai\\", \\".facult\\", \\"bin\\", commandNames(platform)[0]),\\n ...systemPathCandidates(env, platform),\\n ]) {\\n candidates.push({ executable, source: candidateSource(executable) });\\n }\\n\\n const unique = [];\\n const seen = new Set();\\n for (const candidate of candidates) {\\n const key = path.resolve(candidate.executable);\\n if (!seen.has(key)) {\\n seen.add(key);\\n unique.push(candidate);\\n }\\n }\\n return unique;\\n}\\n\\nfunction runCommand(executable, args, options = {}) {\\n return new Promise((resolve) => {\\n let child;\\n try {\\n const platform = options.platform || process.platform;\\n const windowsShim =\\n platform === \\"win32\\" && WINDOWS_SHIM_RE.test(executable);\\n const command = windowsShim\\n ? options.env?.ComSpec || process.env.ComSpec || \\"cmd.exe\\"\\n : executable;\\n const commandArgs = windowsShim\\n ? [\\n \\"/d\\",\\n \\"/v:off\\",\\n \\"/s\\",\\n \\"/c\\",\\n [executable, ...args]\\n .map(\\n (value) =>\\n `\\"${String(value)\\n .replaceAll(\\"%\\", \\"%%\\")\\n .replace(/[\\\\^&|<>()!\\"]/g, \\"^$&\\")}\\"`\\n )\\n .join(\\" \\"),\\n ]\\n : args;\\n child = spawn(command, commandArgs, {\\n cwd: options.cwd || process.cwd(),\\n env: options.env || process.env,\\n stdio: [\\"ignore\\", \\"pipe\\", \\"pipe\\"],\\n });\\n } catch (error) {\\n resolve({ code: 1, stdout: \\"\\", stderr: error.message });\\n return;\\n }\\n let stdout = \\"\\";\\n let stderr = \\"\\";\\n let settled = false;\\n const timer = setTimeout(\\n () => child.kill(\\"SIGTERM\\"),\\n options.timeoutMs || COMMAND_TIMEOUT_MS\\n );\\n const finish = (code, error) => {\\n if (settled) {\\n return;\\n }\\n settled = true;\\n clearTimeout(timer);\\n resolve({\\n code,\\n stdout,\\n stderr: [stderr.trim(), error].filter(Boolean).join(\\"\\\\n\\"),\\n });\\n };\\n child.stdout.on(\\"data\\", (chunk) => {\\n stdout += chunk.toString();\\n if (stdout.length > MAX_METADATA_BYTES) {\\n child.kill(\\"SIGTERM\\");\\n }\\n });\\n child.stderr.on(\\"data\\", (chunk) => {\\n stderr += chunk.toString();\\n if (stderr.length > MAX_METADATA_BYTES) {\\n child.kill(\\"SIGTERM\\");\\n }\\n });\\n child.on(\\"error\\", (error) => finish(1, error.message));\\n child.on(\\"close\\", (code) => finish(code ?? 1));\\n });\\n}\\n\\nfunction parseProtocolReport(raw) {\\n try {\\n const report = JSON.parse(raw);\\n if (\\n !isPlainObject(report) ||\\n report.schemaVersion !== 1 ||\\n typeof report.packageVersion !== \\"string\\" ||\\n !isPlainObject(report.protocol) ||\\n !Number.isInteger(report.protocol.version) ||\\n !Number.isInteger(report.protocol.minimumPluginVersion) ||\\n !Number.isInteger(report.protocol.maximumPluginVersion) ||\\n (report.capabilities !== undefined &&\\n (!Array.isArray(report.capabilities) ||\\n report.capabilities.some((value) => typeof value !== \\"string\\")))\\n ) {\\n return null;\\n }\\n return report;\\n } catch {\\n return null;\\n }\\n}\\n\\nfunction protocolCompatibility(report) {\\n if (!report) {\\n return { compatible: false, reason: \\"missing_protocol_handshake\\" };\\n }\\n const compatible =\\n report.protocol.minimumPluginVersion <= PLUGIN_PROTOCOL_VERSION &&\\n report.protocol.maximumPluginVersion >= PLUGIN_PROTOCOL_VERSION;\\n return {\\n compatible,\\n reason: compatible ? \\"compatible\\" : \\"protocol_version_skew\\",\\n };\\n}\\n\\nasync function inspectCandidate(candidate, options = {}) {\\n const executable = path.resolve(candidate.executable);\\n try {\\n const stat = await fsp.stat(executable);\\n if (!stat.isFile()) {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"not_a_file\\",\\n };\\n }\\n } catch {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"not_found\\",\\n };\\n }\\n\\n if (candidate.source === \\"plugin_runtime\\") {\\n if (\\n typeof candidate.expectedSha256 !== \\"string\\" ||\\n !SHA256_RE.test(candidate.expectedSha256)\\n ) {\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: false,\\n reason: \\"missing_checksum\\",\\n };\\n }\\n try {\\n const actualSha256 = sha256(await fsp.readFile(executable));\\n if (actualSha256 !== candidate.expectedSha256) {\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: false,\\n reason: \\"checksum_mismatch\\",\\n };\\n }\\n } catch {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"checksum_unreadable\\",\\n };\\n }\\n }\\n\\n const result = await runCommand(executable, [\\"protocol\\", \\"--json\\"], options);\\n const report =\\n result.code === 0 ? parseProtocolReport(result.stdout.trim()) : null;\\n const compatibility = protocolCompatibility(report);\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: compatibility.compatible,\\n reason: compatibility.reason,\\n packageVersion: report?.packageVersion,\\n protocol: report?.protocol,\\n platform: report?.runtime?.platform,\\n architecture: report?.runtime?.architecture,\\n capabilities: report?.capabilities || [],\\n };\\n}\\n\\nasync function discoverRuntime(options = {}) {\\n const policy = await runtimePolicy(options);\\n const inspected = [];\\n for (const candidate of await runtimeCandidates(options)) {\\n const result = await inspectCandidate(candidate, options);\\n inspected.push(result);\\n if (result.compatible) {\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n plugin: {\\n version: pluginVersion(),\\n protocolVersion: PLUGIN_PROTOCOL_VERSION,\\n },\\n policy,\\n selected: result,\\n compatible: true,\\n requiresFreshSession: false,\\n candidates: inspected,\\n };\\n }\\n }\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n plugin: {\\n version: pluginVersion(),\\n protocolVersion: PLUGIN_PROTOCOL_VERSION,\\n },\\n policy,\\n selected: null,\\n compatible: false,\\n requiresFreshSession: false,\\n candidates: inspected,\\n };\\n}\\n\\nfunction releaseTarget(\\n platform = process.platform,\\n architecture = process.arch\\n) {\\n if (\\n platform === \\"darwin\\" &&\\n (architecture === \\"arm64\\" || architecture === \\"x64\\")\\n ) {\\n return { platform: \\"darwin\\", architecture, extension: \\"\\" };\\n }\\n if (platform === \\"linux\\" && architecture === \\"x64\\") {\\n return { platform: \\"linux\\", architecture, extension: \\"\\" };\\n }\\n if (platform === \\"win32\\" && architecture === \\"x64\\") {\\n return { platform: \\"windows\\", architecture, extension: \\".exe\\" };\\n }\\n throw new Error(\\n `Unsupported plugin runtime target: ${platform}/${architecture}`\\n );\\n}\\n\\nfunction normalizeVersion(version) {\\n const normalized = version?.startsWith(\\"v\\") ? version.slice(1) : version;\\n if (!(normalized && SEMVER_RE.test(normalized))) {\\n throw new Error(\\"Runtime version must be an explicit semantic version.\\");\\n }\\n return normalized;\\n}\\n\\nfunction assertAllowedUrl(urlValue) {\\n const url = new URL(urlValue);\\n if (url.protocol !== \\"https:\\" || !ALLOWED_DOWNLOAD_HOSTS.has(url.hostname)) {\\n throw new Error(\\n \\"Runtime downloads are restricted to approved HTTPS release hosts.\\"\\n );\\n }\\n return url;\\n}\\n\\nfunction downloadBuffer(urlValue, options = {}) {\\n const maxBytes = options.maxBytes || MAX_METADATA_BYTES;\\n const redirectsRemaining = options.redirectsRemaining ?? 5;\\n const url = assertAllowedUrl(urlValue);\\n return new Promise((resolve, reject) => {\\n const request = https.get(\\n url,\\n {\\n headers: {\\n accept: options.accept || \\"application/octet-stream\\",\\n \\"user-agent\\": \\"fclt-codex-plugin\\",\\n },\\n },\\n (response) => {\\n if (\\n response.statusCode &&\\n response.statusCode >= 300 &&\\n response.statusCode < 400 &&\\n response.headers.location\\n ) {\\n response.resume();\\n if (redirectsRemaining <= 0) {\\n reject(new Error(\\"Runtime download exceeded the redirect limit.\\"));\\n return;\\n }\\n const redirected = new URL(response.headers.location, url).toString();\\n downloadBuffer(redirected, {\\n ...options,\\n redirectsRemaining: redirectsRemaining - 1,\\n }).then(resolve, reject);\\n return;\\n }\\n if (response.statusCode !== 200) {\\n response.resume();\\n reject(\\n new Error(\\n `Runtime download failed with HTTP ${response.statusCode}.`\\n )\\n );\\n return;\\n }\\n const declaredLength = Number(response.headers[\\"content-length\\"] || 0);\\n if (declaredLength > maxBytes) {\\n response.resume();\\n reject(new Error(\\"Runtime download exceeds the allowed size.\\"));\\n return;\\n }\\n const chunks = [];\\n let total = 0;\\n response.on(\\"data\\", (chunk) => {\\n total += chunk.length;\\n if (total > maxBytes) {\\n request.destroy(\\n new Error(\\"Runtime download exceeds the allowed size.\\")\\n );\\n return;\\n }\\n chunks.push(chunk);\\n });\\n response.on(\\"end\\", () => resolve(Buffer.concat(chunks)));\\n }\\n );\\n request.setTimeout(options.timeoutMs || DOWNLOAD_TIMEOUT_MS, () => {\\n request.destroy(new Error(\\"Runtime download timed out.\\"));\\n });\\n request.on(\\"error\\", reject);\\n });\\n}\\n\\nfunction sha256(bytes) {\\n return crypto.createHash(\\"sha256\\").update(bytes).digest(\\"hex\\");\\n}\\n\\nfunction checksumForAsset(checksums, assetName) {\\n for (const line of checksums.split(NEWLINE_RE)) {\\n const match = CHECKSUM_LINE_RE.exec(line.trim());\\n if (match?.[2] === assetName) {\\n return match[1].toLowerCase();\\n }\\n }\\n throw new Error(`Published checksums do not include ${assetName}.`);\\n}\\n\\nasync function withMutationLock(root, action) {\\n await fsp.mkdir(root, { recursive: true, mode: 0o700 });\\n const lockPath = await assertManagedPath(\\n path.join(root, \\"mutation.lock\\"),\\n root\\n );\\n let handle;\\n try {\\n handle = await fsp.open(lockPath, \\"wx\\", 0o600);\\n } catch (error) {\\n if (error?.code === \\"EEXIST\\") {\\n throw new Error(\\n \\"Another fclt plugin runtime mutation is already in progress.\\"\\n );\\n }\\n throw error;\\n }\\n try {\\n return await action();\\n } finally {\\n await handle.close();\\n await fsp.rm(lockPath, { force: true });\\n }\\n}\\n\\nasync function writeJsonAtomic(pathValue, value, root) {\\n const target = await assertManagedPath(pathValue, root);\\n await fsp.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });\\n const temporary = `${target}.tmp-${crypto.randomUUID()}`;\\n await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\\\\n`, {\\n mode: 0o600,\\n });\\n await fsp.rename(temporary, target);\\n}\\n\\nfunction releaseUrls(version, target) {\\n const tag = `v${version}`;\\n const assetName = `fclt-${version}-${target.platform}-${target.architecture}${target.extension}`;\\n const base = `https://github.com/${REPOSITORY}/releases/download/${tag}`;\\n return {\\n tag,\\n assetName,\\n binaryUrl: `${base}/${assetName}`,\\n checksumUrl: `${base}/SHA256SUMS`,\\n };\\n}\\n\\nfunction releaseMetadataUrl(version) {\\n return `https://api.github.com/repos/${REPOSITORY}/releases/tags/v${version}`;\\n}\\n\\nfunction releaseAssets(metadata, version, target) {\\n if (\\n !isPlainObject(metadata) ||\\n metadata.tag_name !== `v${version}` ||\\n !Array.isArray(metadata.assets)\\n ) {\\n throw new Error(\\n \\"Release metadata does not match the requested immutable tag.\\"\\n );\\n }\\n const expected = releaseUrls(version, target);\\n const findAsset = (name) =>\\n metadata.assets.find(\\n (asset) =>\\n isPlainObject(asset) &&\\n asset.name === name &&\\n typeof asset.browser_download_url === \\"string\\"\\n );\\n const binary = findAsset(expected.assetName);\\n const checksums = findAsset(\\"SHA256SUMS\\");\\n if (!(binary && checksums)) {\\n throw new Error(\\n \\"Release metadata is missing the required runtime or checksum asset.\\"\\n );\\n }\\n assertAllowedUrl(binary.browser_download_url);\\n assertAllowedUrl(checksums.browser_download_url);\\n return { binary, checksums, expected };\\n}\\n\\nfunction verifyPublishedDigest(asset, bytes) {\\n if (typeof asset.digest !== \\"string\\" || !asset.digest.trim()) {\\n return null;\\n }\\n const [algorithm, expected] = asset.digest.toLowerCase().split(\\":\\");\\n if (algorithm !== \\"sha256\\" || !SHA256_RE.test(expected || \\"\\")) {\\n throw new Error(`Release asset ${asset.name} has an unsupported digest.`);\\n }\\n const actual = sha256(bytes);\\n if (actual !== expected) {\\n throw new Error(\\n `Release asset ${asset.name} does not match its published digest.`\\n );\\n }\\n return asset.digest.toLowerCase();\\n}\\n\\nasync function resolveLatestVersion(fetchBuffer = downloadBuffer) {\\n const bytes = await fetchBuffer(\\n `https://api.github.com/repos/${REPOSITORY}/releases/latest`,\\n {\\n maxBytes: MAX_METADATA_BYTES,\\n accept: \\"application/vnd.github+json\\",\\n }\\n );\\n const metadata = JSON.parse(bytes.toString(\\"utf8\\"));\\n if (!isPlainObject(metadata) || typeof metadata.tag_name !== \\"string\\") {\\n throw new Error(\\"Latest release metadata did not include a tag.\\");\\n }\\n return normalizeVersion(metadata.tag_name);\\n}\\n\\nasync function checkRuntimeUpdate(options = {}) {\\n const discovery = await discoverRuntime(options);\\n if (!discovery.policy.updateChecksEnabled) {\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n action: \\"check\\",\\n skipped: true,\\n reason: \\"update_checks_disabled\\",\\n currentVersion: discovery.selected?.packageVersion || null,\\n pinnedVersion: discovery.policy.pinnedVersion,\\n mutates: false,\\n };\\n }\\n const latestVersion =\\n discovery.policy.pinnedVersion ||\\n (await resolveLatestVersion(options.fetchBuffer || downloadBuffer));\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n action: \\"check\\",\\n currentVersion: discovery.selected?.packageVersion || null,\\n latestVersion,\\n channel: discovery.policy.pinnedVersion ? \\"pinned\\" : \\"latest\\",\\n updateAvailable: discovery.selected?.packageVersion !== latestVersion,\\n selected: discovery.selected,\\n mutates: false,\\n };\\n}\\n\\nasync function stageRuntime(options) {\\n if (options.approve !== true) {\\n throw new Error(\\"Staging a runtime download requires approve=true.\\");\\n }\\n const version = normalizeVersion(options.version);\\n const target = releaseTarget(options.platform, options.architecture);\\n const root = runtimeStateRoot(options.env, options.platform);\\n const fetchBuffer = options.fetchBuffer || downloadBuffer;\\n const urls = releaseUrls(version, target);\\n const policy = await runtimePolicy(options);\\n if (policy.pinnedVersion && policy.pinnedVersion !== version) {\\n throw new Error(`Runtime policy is pinned to ${policy.pinnedVersion}.`);\\n }\\n\\n return await withMutationLock(root, async () => {\\n const metadataBytes = await fetchBuffer(releaseMetadataUrl(version), {\\n maxBytes: MAX_METADATA_BYTES,\\n accept: \\"application/vnd.github+json\\",\\n });\\n const metadata = JSON.parse(metadataBytes.toString(\\"utf8\\"));\\n const assets = releaseAssets(metadata, version, target);\\n const [checksumBytes, binaryBytes] = await Promise.all([\\n fetchBuffer(assets.checksums.browser_download_url, {\\n maxBytes: MAX_METADATA_BYTES,\\n }),\\n fetchBuffer(assets.binary.browser_download_url, {\\n maxBytes: MAX_BINARY_BYTES,\\n }),\\n ]);\\n const checksumDigest = verifyPublishedDigest(\\n assets.checksums,\\n checksumBytes\\n );\\n const binaryDigest = verifyPublishedDigest(assets.binary, binaryBytes);\\n const expectedSha256 = checksumForAsset(\\n checksumBytes.toString(\\"utf8\\"),\\n urls.assetName\\n );\\n const actualSha256 = sha256(binaryBytes);\\n if (expectedSha256 !== actualSha256) {\\n throw new Error(\\n \\"Downloaded runtime checksum does not match the published SHA256SUMS entry.\\"\\n );\\n }\\n\\n const stageDir = await assertManagedPath(\\n path.join(root, \\"staged\\", version),\\n root\\n );\\n await fsp.rm(stageDir, { recursive: true, force: true });\\n await fsp.mkdir(stageDir, { recursive: true, mode: 0o700 });\\n const executable = await assertManagedPath(\\n path.join(stageDir, target.platform === \\"windows\\" ? \\"fclt.exe\\" : \\"fclt\\"),\\n root\\n );\\n await fsp.writeFile(executable, binaryBytes, { mode: 0o700 });\\n if (target.platform !== \\"windows\\") {\\n await fsp.chmod(executable, 0o700);\\n }\\n\\n const inspected = await inspectCandidate(\\n { executable, source: \\"staged_plugin_runtime\\" },\\n { env: options.env, timeoutMs: options.timeoutMs }\\n );\\n if (!inspected.compatible || inspected.packageVersion !== version) {\\n await fsp.rm(stageDir, { recursive: true, force: true });\\n throw new Error(\\n \\"Staged runtime failed version or protocol verification.\\"\\n );\\n }\\n\\n const manifest = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n version,\\n tag: urls.tag,\\n assetName: urls.assetName,\\n executable,\\n sha256: actualSha256,\\n source: {\\n repository: REPOSITORY,\\n releaseMetadataUrl: releaseMetadataUrl(version),\\n binaryUrl: assets.binary.browser_download_url,\\n binaryAssetId: assets.binary.id ?? null,\\n binaryDigest,\\n checksumUrl: assets.checksums.browser_download_url,\\n checksumAssetId: assets.checksums.id ?? null,\\n checksumDigest,\\n },\\n protocol: inspected.protocol,\\n platform: target.platform,\\n architecture: target.architecture,\\n stagedAt: new Date().toISOString(),\\n };\\n await writeJsonAtomic(path.join(stageDir, \\"manifest.json\\"), manifest, root);\\n return { action: \\"stage\\", mutatesActiveRuntime: false, manifest };\\n });\\n}\\n\\nasync function verifyManifestExecutable(manifest, root, expectedParent) {\\n if (\\n !isPlainObject(manifest) ||\\n manifest.schemaVersion !== STATE_SCHEMA_VERSION ||\\n typeof manifest.version !== \\"string\\" ||\\n typeof manifest.executable !== \\"string\\" ||\\n typeof manifest.sha256 !== \\"string\\" ||\\n !SHA256_RE.test(manifest.sha256)\\n ) {\\n throw new Error(\\"Runtime manifest is missing required verification data.\\");\\n }\\n const executable = path.resolve(manifest.executable);\\n if (!isSubpath(executable, expectedParent)) {\\n throw new Error(\\n \\"Runtime manifest executable escapes its expected directory.\\"\\n );\\n }\\n await assertManagedPath(executable, root);\\n const bytes = await fsp.readFile(executable);\\n if (sha256(bytes) !== manifest.sha256) {\\n throw new Error(\\"Runtime manifest checksum does not match its executable.\\");\\n }\\n const inspected = await inspectCandidate({\\n executable,\\n source: \\"plugin_runtime\\",\\n expectedSha256: manifest.sha256,\\n });\\n if (!inspected.compatible || inspected.packageVersion !== manifest.version) {\\n throw new Error(\\n \\"Runtime manifest executable failed protocol verification.\\"\\n );\\n }\\n return inspected;\\n}\\n\\nasync function setRuntimePolicy(options = {}) {\\n if (options.approve !== true) {\\n throw new Error(\\"Changing runtime update policy requires approve=true.\\");\\n }\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const current = await runtimePolicy(options);\\n const next = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n updateChecksEnabled:\\n typeof options.updateChecksEnabled === \\"boolean\\"\\n ? options.updateChecksEnabled\\n : current.updateChecksEnabled,\\n pinnedVersion: options.clearPin\\n ? null\\n : options.pinnedVersion\\n ? normalizeVersion(options.pinnedVersion)\\n : current.pinnedVersion,\\n updatedAt: new Date().toISOString(),\\n };\\n await writeJsonAtomic(path.join(root, \\"policy.json\\"), next, root);\\n return { action: \\"policy\\", previous: current, policy: next };\\n });\\n}\\n\\nasync function applyStagedRuntime(options) {\\n if (options.approve !== true) {\\n throw new Error(\\"Applying a runtime requires approve=true.\\");\\n }\\n const version = normalizeVersion(options.version);\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const stageDir = path.join(root, \\"staged\\", version);\\n const manifest = await readJson(path.join(stageDir, \\"manifest.json\\"));\\n if (!manifest) {\\n throw new Error(`No staged runtime exists for ${version}.`);\\n }\\n if (options.expectedSha256 !== manifest.sha256) {\\n throw new Error(\\n \\"Staged runtime precondition failed: expected checksum changed.\\"\\n );\\n }\\n await verifyManifestExecutable(manifest, root, stageDir);\\n\\n const activePath = path.join(root, \\"active.json\\");\\n const previous = await readJson(activePath);\\n const versionDir = await assertManagedPath(\\n path.join(root, \\"versions\\", version),\\n root\\n );\\n await fsp.mkdir(versionDir, { recursive: true, mode: 0o700 });\\n const executable = await assertManagedPath(\\n path.join(versionDir, path.basename(manifest.executable)),\\n root\\n );\\n const temporary = `${executable}.tmp-${crypto.randomUUID()}`;\\n await fsp.copyFile(manifest.executable, temporary);\\n if (process.platform !== \\"win32\\") {\\n await fsp.chmod(temporary, 0o700);\\n }\\n await fsp.rename(temporary, executable);\\n const activeManifest = {\\n ...manifest,\\n executable,\\n activatedAt: new Date().toISOString(),\\n previous:\\n typeof previous?.version === \\"string\\" &&\\n typeof previous?.executable === \\"string\\"\\n ? {\\n version: previous.version,\\n executable: previous.executable,\\n sha256: previous.sha256,\\n }\\n : null,\\n };\\n await writeJsonAtomic(activePath, activeManifest, root);\\n const inspected = await verifyManifestExecutable(\\n activeManifest,\\n root,\\n versionDir\\n );\\n return {\\n action: \\"apply\\",\\n active: inspected,\\n previous: activeManifest.previous,\\n rollbackAvailable: Boolean(activeManifest.previous),\\n requiresFreshSession: false,\\n };\\n });\\n}\\n\\nasync function rollbackRuntime(options = {}) {\\n if (options.approve !== true) {\\n throw new Error(\\"Rolling back a runtime requires approve=true.\\");\\n }\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const activePath = path.join(root, \\"active.json\\");\\n const active = await readJson(activePath);\\n if (!isPlainObject(active?.previous)) {\\n throw new Error(\\n \\"The active plugin runtime does not have a retained rollback target.\\"\\n );\\n }\\n if (\\n options.expectedActiveVersion &&\\n options.expectedActiveVersion !== active.version\\n ) {\\n throw new Error(\\n \\"Runtime rollback precondition failed: active version changed.\\"\\n );\\n }\\n const previous = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n version: active.previous.version,\\n executable: active.previous.executable,\\n sha256: active.previous.sha256,\\n previous: {\\n version: active.version,\\n executable: active.executable,\\n sha256: active.sha256,\\n },\\n rolledBackAt: new Date().toISOString(),\\n };\\n const previousDir = path.dirname(path.resolve(previous.executable));\\n const inspected = await verifyManifestExecutable(\\n previous,\\n root,\\n previousDir\\n );\\n await writeJsonAtomic(activePath, previous, root);\\n return {\\n action: \\"rollback\\",\\n active: inspected,\\n rolledBackFrom: active.version,\\n rollbackAvailable: true,\\n requiresFreshSession: false,\\n };\\n });\\n}\\n\\nmodule.exports = {\\n PLUGIN_PROTOCOL_VERSION,\\n applyStagedRuntime,\\n assertManagedPath,\\n checkRuntimeUpdate,\\n checksumForAsset,\\n discoverRuntime,\\n downloadBuffer,\\n normalizeVersion,\\n parseProtocolReport,\\n pluginVersion,\\n protocolCompatibility,\\n releaseTarget,\\n rollbackRuntime,\\n runCommand,\\n runtimeCandidates,\\n runtimePolicy,\\n runtimeStateRoot,\\n setRuntimePolicy,\\n sha256,\\n stageRuntime,\\n};\\n","skills/fclt-capability-review/SKILL.md":"---\\nname: fclt-capability-review\\ndescription: Inspect fclt capability roots, docs, snippets, skills, agents, MCP, and automations.\\ntags: [fclt, capability, review, inventory]\\n---\\n\\n# fclt-capability-review\\n\\n## When To Use\\nUse this skill when Codex needs to understand what capability exists before changing it.\\n\\nUse it for:\\n\\n- checking global and project `.ai` roots\\n- finding relevant skills, snippets, instructions, agents, MCP servers, or automations\\n- deciding whether a change belongs in global or project scope\\n- checking whether managed rendering is enabled or needed\\n- reviewing public/private boundaries before publishing docs or pack assets\\n\\n## Workflow\\n\\n```bash\\nfclt status --json\\nfclt inventory --json\\nfclt list skills\\nfclt list instructions\\nfclt list snippets\\nfclt graph AGENTS.global.md\\n```\\n\\nFor project work:\\n\\n```bash\\nfclt status --project --json\\nfclt inventory --project --json\\n```\\n\\n## Rules\\n\\n- Read existing repo guidance before proposing project capability.\\n- Use project scope for repo-specific commands, tests, architecture, or team workflow.\\n- Use global scope only for broadly reusable behavior.\\n- Keep generated state and review artifacts out of repo-local `.ai`.\\n- Prefer adding or updating the smallest unit: instruction, snippet, skill, agent, MCP config, or automation.\\n- Treat engineering implementation as task work. Do not force product changes\\n through capability evolution merely because evolution tools exist.\\n- Before recommending mutation, state the observed problem, evidence, why the\\n proposed capability is the correct target, risk class, expected outcome,\\n verification plan, assumptions, and recovery route.\\n- Never silently overwrite authored capability or confuse generated/rendered\\n output with canonical source.\\n\\n## Output\\n\\n- capability roots found\\n- relevant assets\\n- scope recommendation\\n- missing or stale capability\\n- safe next command\\n- deliberately withheld operations or missing safer API\\n- verification and undo path for any proposed mutation\\n","skills/fclt-evolution/SKILL.md":"---\\nname: fclt-evolution\\ndescription: Turn repeated fclt writebacks into reviewed capability changes.\\ntags: [fclt, evolution, proposals, capability]\\n---\\n\\n# fclt-evolution\\n\\n## When To Use\\nUse this skill when repeated writebacks, stale canonical assets, or a clearly missing capability should become a concrete proposal.\\n\\nDo not use it for a single weak preference or speculative idea.\\n\\n## Workflow\\n\\n1. Review signal:\\n\\n```bash\\nfclt ai review reconcile --since --until --json\\nfclt ai writeback group --by asset\\nfclt ai writeback summarize --by domain\\nfclt ai evolve list\\n```\\n\\nFor an enabled scheduled loop, inspect its durable queue and observed scheduler\\nhealth with `fclt ai loop status --json`. Use `fclt ai loop run --dry-run\\n--json` for a fresh incremental scan that does not advance cursors or write\\nreconciliation or loop state. The full queue is authoritative; the notification\\ndelta intentionally suppresses unchanged items.\\n\\n2. Assess proposal readiness before mutating state:\\n\\n```bash\\nfclt ai evolve assess --asset --json\\n```\\n\\nUse the assessment recommendation as the decision checkpoint:\\n\\n- `reconcile_sources`: run the bounded read-only source review; writebacks alone cannot prove the window is empty.\\n- `review_reconciled_signals`: review correlated dispositions and linked work without creating one proposal per ticket.\\n- `no_mutation`: do not change capability state; ask for a target or evidence.\\n- `record_more_writeback`: explain what recurrence would justify evolution and record a new writeback only if there is fresh concrete evidence.\\n- `propose`: ask before running the proposal command, then create the smallest target-specific proposal.\\n- `review_existing_proposal`: inspect or revise the existing proposal instead of creating a duplicate.\\n\\n3. Propose only when evidence is strong enough:\\n\\n```bash\\nfclt ai evolve propose\\n```\\n\\n4. Draft and inspect:\\n\\n```bash\\nfclt ai evolve draft EV-00001\\nfclt ai evolve review EV-00001\\n```\\n\\n5. Accept/apply only when scope, target, and evidence are correct:\\n\\n```bash\\nfclt ai evolve accept EV-00001\\nfclt ai evolve apply EV-00001\\n```\\n\\n6. Verify the outcome after the producing loop has had a real chance to run:\\n\\n```bash\\nfclt ai writeback link WB-00001 --issue TEAM-123\\nfclt ai writeback disposition WB-00001 --type task --target TEAM-123\\nfclt ai evolve verify EV-00001 --effectiveness improved --evidence test:post-apply\\n```\\n\\nApply is not completion. Do not resolve source writebacks until post-apply evidence shows the\\nintended behavior improved. Treat recurrence as unchanged or regressed evidence linked to the same\\nevolution, not as an unrelated singleton.\\n\\n## Proposal Kinds\\n\\n- `update_asset`\\n- `create_asset`\\n- `extract_snippet`\\n- `add_skill`\\n- `promote_asset`\\n\\n## Rules\\n\\n- Prefer the smallest valid proposal kind.\\n- Keep project-specific behavior project-scoped until reuse is proven.\\n- Ask for approval before applying global instructions, global skills, plugin behavior, or other broad shared surfaces.\\n- Reject or park proposals that are stale, duplicated, vague, or unsupported.\\n- Use the operator\'s task system for executable implementation work that needs owner, priority, or state.\\n- A no-op answer must still be useful: include the evidence grade, missing signal, next writeback target, and exact approval boundary.\\n- State the problem, source evidence, reason for the selected target, risk,\\n expected outcome, verification plan, assumptions, and undo path before any\\n review-producing or mutating action.\\n- Preview and verify the exact scope. Never overwrite authored capability\\n silently or treat generated/rendered output as the canonical target.\\n- Canonical apply and cross-scope promotion are unavailable through the plugin\\n until a transaction-safe API can return tested rollback data. Do not bypass\\n that boundary with shell or arbitrary CLI arguments.\\n- The plugin exposes only closed-schema loop status and preview actions.\\n Scheduler enable/disable/run and external tracker mutation remain outside the\\n MCP surface.\\n\\n## Output\\n\\n- proposals reviewed\\n- repeated signal\\n- assessment recommendation\\n- proposal created or updated\\n- approvals needed\\n- apply/reject/no-op rationale\\n- actual changed records/artifacts, verification result, and recovery route\\n","skills/fclt-setup/SKILL.md":"---\\nname: fclt-setup\\ndescription: Install, update, inspect, and initialize fclt from Codex.\\ntags: [fclt, setup, codex, onboarding]\\n---\\n\\n# fclt-setup\\n\\n## When To Use\\nUse this skill when a user wants Codex to install, update, configure, inspect, or repair fclt.\\n\\nUse it for:\\n\\n- checking whether `fclt` is installed and current\\n- initializing global `~/.ai` or project `/.ai`\\n- installing or refreshing the built-in operating-model pack\\n- checking setup health with `doctor`\\n- finding canonical, generated, runtime, and review paths\\n\\n## Workflow\\n\\n1. Bootstrap the complete loop with one idempotent command:\\n\\n```bash\\nfclt setup\\n```\\n\\nThis initializes or safely updates global capability, initializes the current git repository\\nwhen present, prepares writeback/evolution review state, and installs the Codex plugin when Codex\\nis available.\\n\\n2. Inspect runtime selection and compatibility with `fclt_runtime` action\\n `status`. Report the selected executable, version, source, protocol\\n compatibility, and fresh-session state.\\n\\nIf no compatible runtime is available, use the staged lifecycle:\\n\\n- `check` is read-only\\n- `stage` requires an explicit version and approval, but does not activate it\\n- `apply` requires approval plus the staged checksum precondition\\n- `rollback` verifies and restores the retained prior runtime\\n\\nNever curl-pipe code, use an unverified mutable URL, or replace an existing\\nglobal installation silently.\\n\\n3. Check current setup state and exact repair actions:\\n\\n```bash\\nfclt --version\\nfclt paths --json\\nfclt doctor --json\\n```\\n\\nThrough MCP, call `fclt_setup` with an explicit `global` or\\n`global_and_project` scope. Project setup also requires the exact `cwd`.\\nPreview is the default; apply requires both `dryRun: false` and\\n`approve: true`.\\n\\n4. For advanced manual recovery, initialize global capability when missing:\\n\\n```bash\\nfclt templates init operating-model --global\\n```\\n\\n5. If a repo needs local capability, initialize project AI:\\n\\n```bash\\nfclt templates init project-ai\\n```\\n\\n6. Refresh pack defaults non-destructively:\\n\\n```bash\\nfclt templates init operating-model --global --update --dry-run\\nfclt templates init operating-model --global --update\\n```\\n\\n7. Use `--force` only when the user explicitly wants to replace local edits.\\n\\n## Rules\\n\\n- Preserve existing `AGENTS.md`, `CLAUDE.md`, and `AGENTS.global.md` guidance.\\n- First install should seed from existing agent guidance when available.\\n- Treat `doctor --json` issues as setup facts, not user-facing blame.\\n- Treat Codex plugin registration as weaker evidence than fresh-session tool discovery.\\n- Treat external trackers as separate integrations. Core readiness depends only on configured local evidence coverage, not a vendor plugin or token.\\n- Prefer temp-root smoke tests for install/update behavior.\\n- Do not enable managed rendering unless the user wants fclt to write tool homes.\\n- Preview before mutation and state the exact global/project/plugin target.\\n- Do not report a staged runtime or installed plugin as active until the active\\n handshake and fresh-session discovery have been verified.\\n\\n## Output\\n\\n- current installed version\\n- setup health\\n- paths that matter\\n- commands run\\n- what changed\\n- problem, evidence, reason, target, risk, and expected outcome\\n- verification performed and its actual result\\n- assumptions and fresh-session state\\n- exact undo or rollback path\\n- what still needs approval\\n","skills/fclt-writeback/SKILL.md":"---\\nname: fclt-writeback\\ndescription: Record and review fclt writebacks from real agent work.\\ntags: [fclt, writeback, learning, feedback-loop]\\n---\\n\\n# fclt-writeback\\n\\n## When To Use\\nUse this skill when work reveals durable friction, missing context, weak verification, stale guidance, repeated success, or a capability gap.\\n\\nWriteback is for preserving signal. It is not for every preference or one-off annoyance.\\n\\n## Workflow\\n\\n1. Decide scope:\\n\\n- `project` when the learning depends on a repo, test harness, architecture, or workflow.\\n- `global` when the learning applies across projects or shared tool behavior.\\n\\n2. Choose the smallest target:\\n\\n- instruction\\n- snippet\\n- skill\\n- agent\\n- MCP/tool config\\n- automation\\n\\n3. Record writeback when the target and evidence are clear:\\n\\n```bash\\nfclt ai writeback add \\\\\\n --kind missing_context \\\\\\n --category opportunity \\\\\\n --summary \\"Project verification guidance was not discoverable\\" \\\\\\n --details \\"The task had to reconstruct the command from CI configuration\\" \\\\\\n --impact \\"Verification took longer and could have selected the wrong harness\\" \\\\\\n --attempted-workaround \\"Inspected package scripts and CI\\" \\\\\\n --desired-outcome \\"The supported verification command is available at task start\\" \\\\\\n --sensitivity internal \\\\\\n --evidence session: \\\\\\n --asset @project/instructions/TESTING.md\\n```\\n\\n4. Review current signal:\\n\\n```bash\\nfclt ai writeback list\\nfclt ai writeback group --by asset\\nfclt ai writeback summarize --by domain\\nfclt ai loop activity --project\\n```\\n\\n## Rules\\n\\n- Prefer one high-signal writeback over several weak ones.\\n- Include concrete evidence when possible.\\n- Capture concise context, impact, attempted workaround, desired outcome, and\\n sensitivity when they improve review quality.\\n- Never capture hidden chain-of-thought, raw transcripts, unbounded logs,\\n secrets, tokens, or credential-bearing payloads. Reference the smallest\\n redacted external evidence identifier instead.\\n- Do not copy private project detail into global writebacks.\\n- Use task tracking for executable product/tooling work; use writeback for reusable operating-layer learning.\\n- If the same signal repeats and the target is clear, hand off to `fclt-evolution`.\\n- State the observed problem, evidence, target, reason, expected outcome, and\\n assumptions before recording.\\n- Do not capture secrets, private tokens, or raw sensitive payloads as evidence.\\n- For lifecycle mutations, use an explicit scope and expected prior state.\\n Report the journal/review evidence and the available undo transition.\\n\\n## Output\\n\\n- writeback id or no-op rationale\\n- scope\\n- target asset\\n- evidence summary\\n- whether this is ready for evolution\\n- risk class and approval boundary\\n- actual changed records/artifacts\\n- verification result and recovery route\\n"}' + '{".codex-plugin/plugin.json":"{\\n \\"name\\": \\"fclt\\",\\n \\"version\\": \\"0.1.2\\",\\n \\"description\\": \\"Codex workflows and MCP tools for evidence reconciliation, writeback, evolution, and outcome review.\\",\\n \\"author\\": {\\n \\"name\\": \\"Hack Dance\\",\\n \\"url\\": \\"https://hack.dance\\"\\n },\\n \\"license\\": \\"MIT\\",\\n \\"keywords\\": [\\n \\"fclt\\",\\n \\"facult\\",\\n \\"codex\\",\\n \\"skills\\",\\n \\"mcp\\",\\n \\"writeback\\",\\n \\"evolution\\"\\n ],\\n \\"skills\\": \\"./skills/\\",\\n \\"mcpServers\\": \\"./.mcp.json\\",\\n \\"interface\\": {\\n \\"displayName\\": \\"fclt\\",\\n \\"shortDescription\\": \\"Feedback loops for AI work\\",\\n \\"longDescription\\": \\"Capture signal from real agent work, reconcile configured evidence, turn repeated findings into reviewed capability changes, and verify whether those changes improved the work that produced them.\\",\\n \\"developerName\\": \\"Hack Dance\\",\\n \\"category\\": \\"Productivity\\",\\n \\"capabilities\\": [\\"Read\\", \\"Write\\", \\"MCP\\"],\\n \\"defaultPrompt\\": [\\n \\"Use fclt to check this repo\'s AI capability setup.\\",\\n \\"Reconcile the configured evidence window and explain its coverage.\\",\\n \\"Record useful writeback from this work and review any repeated signal.\\"\\n ],\\n \\"brandColor\\": \\"#166534\\",\\n \\"composerIcon\\": \\"./assets/fclt-mark.png\\",\\n \\"logo\\": \\"./assets/fclt-mark.png\\"\\n }\\n}\\n",".mcp.json":"{\\n \\"mcpServers\\": {\\n \\"fclt\\": {\\n \\"command\\": \\"node\\",\\n \\"args\\": [\\"./scripts/fclt-mcp.cjs\\"],\\n \\"env\\": {\\n \\"FCLT_BIN\\": \\"fclt\\"\\n },\\n \\"cwd\\": \\".\\"\\n }\\n }\\n}\\n","scripts/fclt-mcp.cjs":"#!/usr/bin/env node\\n\\"use strict\\";\\n\\nconst fs = require(\\"node:fs\\");\\nconst os = require(\\"node:os\\");\\nconst path = require(\\"node:path\\");\\nconst runtime = require(\\"./fclt-runtime.cjs\\");\\n\\nconst DEFAULT_TIMEOUT_MS = Number(process.env.FCLT_MCP_TIMEOUT_MS || 60_000);\\nconst ACTIVITY_ACTION_RESOLVE_CAPABILITY = \\"activity-action-resolve-v1\\";\\nconst AUDIT_READ_ONLY_CAPABILITY = \\"audit-read-only-v1\\";\\nconst CONTENT_LENGTH_RE = /Content-Length:\\\\s*(\\\\d+)/i;\\nconst PLUGIN_ROOT = path.resolve(__dirname, \\"..\\");\\n\\nconst tools = [\\n {\\n name: \\"fclt_setup\\",\\n description:\\n \\"Bootstrap or repair the global fclt loop and optionally return an exact no-write project enrollment plan.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: {\\n type: \\"string\\",\\n enum: [\\"global\\", \\"global_and_project\\"],\\n },\\n cwd: { type: \\"string\\" },\\n dryRun: { type: \\"boolean\\" },\\n installCodexPlugin: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_runtime\\",\\n description:\\n \\"Discover, bootstrap, update, or roll back the verified fclt runtime used by this plugin.\\",\\n inputSchema: {\\n type: \\"object\\",\\n additionalProperties: false,\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\"status\\", \\"check\\", \\"policy\\", \\"stage\\", \\"apply\\", \\"rollback\\"],\\n },\\n version: { type: \\"string\\" },\\n expectedSha256: { type: \\"string\\", pattern: \\"^[a-f0-9]{64}$\\" },\\n expectedActiveVersion: { type: \\"string\\" },\\n pinnedVersion: { type: \\"string\\" },\\n clearPin: { type: \\"boolean\\" },\\n updateChecksEnabled: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_capability\\",\\n description:\\n \\"Inspect fclt capability, provenance, templates, snippets, adapters, and managed status without exposing secrets.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"scan\\",\\n \\"inventory\\",\\n \\"list\\",\\n \\"show\\",\\n \\"find\\",\\n \\"graph\\",\\n \\"adapters\\",\\n \\"managed_status\\",\\n \\"templates_list\\",\\n \\"snippet_list\\",\\n \\"snippet_show\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n kind: {\\n type: \\"string\\",\\n enum: [\\n \\"skills\\",\\n \\"mcp\\",\\n \\"agents\\",\\n \\"automations\\",\\n \\"snippets\\",\\n \\"instructions\\",\\n ],\\n },\\n query: { type: \\"string\\" },\\n selector: { type: \\"string\\" },\\n graphMode: { type: \\"string\\", enum: [\\"show\\", \\"deps\\", \\"dependents\\"] },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_workflow\\",\\n description:\\n \\"Run typed writeback and evolution review operations. Canonical apply and cross-scope promotion are deliberately withheld.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"writeback_list\\",\\n \\"writeback_show\\",\\n \\"writeback_group\\",\\n \\"writeback_summarize\\",\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"evolve_assess\\",\\n \\"evolve_list\\",\\n \\"evolve_show\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_verify\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n id: { type: \\"string\\" },\\n kind: { type: \\"string\\" },\\n category: {\\n type: \\"string\\",\\n enum: [\\"friction\\", \\"opportunity\\", \\"reusable-success\\"],\\n },\\n summary: { type: \\"string\\" },\\n details: { type: \\"string\\", maxLength: 2000 },\\n impact: { type: \\"string\\", maxLength: 1000 },\\n attemptedWorkaround: { type: \\"string\\", maxLength: 1000 },\\n desiredOutcome: { type: \\"string\\", maxLength: 1000 },\\n sensitivity: {\\n type: \\"string\\",\\n enum: [\\"public\\", \\"internal\\", \\"private\\"],\\n },\\n asset: { type: \\"string\\" },\\n evidence: { type: \\"array\\", items: { type: \\"string\\" } },\\n confidence: { type: \\"string\\", enum: [\\"low\\", \\"medium\\", \\"high\\"] },\\n by: { type: \\"string\\", enum: [\\"asset\\", \\"kind\\", \\"domain\\"] },\\n issue: { type: \\"string\\" },\\n disposition: {\\n type: \\"string\\",\\n enum: [\\"propose\\", \\"apply-local\\", \\"task\\", \\"resolve-watch\\", \\"defer\\"],\\n },\\n target: { type: \\"string\\" },\\n nextTrigger: { type: \\"string\\" },\\n expectedOutcome: { type: \\"string\\" },\\n append: { type: \\"string\\" },\\n reason: { type: \\"string\\" },\\n byProposal: { type: \\"string\\" },\\n effectiveness: {\\n type: \\"string\\",\\n enum: [\\"improved\\", \\"unchanged\\", \\"regressed\\", \\"inconclusive\\"],\\n },\\n note: { type: \\"string\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_sync\\",\\n description:\\n \\"Inspect managed state or preview a scoped tool sync. Apply and live adoption remain withheld pending transaction-safe APIs.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: { type: \\"string\\", enum: [\\"status\\", \\"preview\\"] },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n tool: { type: \\"string\\" },\\n },\\n required: [\\"action\\", \\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_registry\\",\\n description:\\n \\"Search and verify remote capability, preview installs and updates, run typed source reconciliation reviews, or resolve one opaque activity action locator without mutation. Registry mutation remains withheld.\\",\\n inputSchema: {\\n oneOf: [\\n {\\n type: \\"object\\",\\n properties: {\\n action: { const: \\"activity_resolve\\" },\\n locator: {\\n type: \\"string\\",\\n pattern: \\"^fclt-act-v[0-9]+\\\\\\\\.[a-f0-9]{64}\\\\\\\\.[a-f0-9]{64}$\\",\\n },\\n },\\n required: [\\"action\\", \\"locator\\"],\\n additionalProperties: false,\\n },\\n {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"search\\",\\n \\"verify_source\\",\\n \\"source_list\\",\\n \\"install_preview\\",\\n \\"update_check\\",\\n \\"reconcile_status\\",\\n \\"reconcile\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n query: { type: \\"string\\" },\\n source: { type: \\"string\\" },\\n item: { type: \\"string\\" },\\n as: { type: \\"string\\" },\\n since: {\\n type: \\"string\\",\\n pattern:\\n \\"^\\\\\\\\d{4}-\\\\\\\\d{2}-\\\\\\\\d{2}(?:T\\\\\\\\d{2}:\\\\\\\\d{2}:\\\\\\\\d{2}(?:\\\\\\\\.\\\\\\\\d+)?(?:Z|[+-]\\\\\\\\d{2}:\\\\\\\\d{2}))?$\\",\\n },\\n until: {\\n type: \\"string\\",\\n pattern:\\n \\"^\\\\\\\\d{4}-\\\\\\\\d{2}-\\\\\\\\d{2}(?:T\\\\\\\\d{2}:\\\\\\\\d{2}:\\\\\\\\d{2}(?:\\\\\\\\.\\\\\\\\d+)?(?:Z|[+-]\\\\\\\\d{2}:\\\\\\\\d{2}))?$\\",\\n },\\n sourceIds: {\\n type: \\"array\\",\\n items: {\\n type: \\"string\\",\\n pattern: \\"^[A-Za-z0-9][A-Za-z0-9._-]*$\\",\\n },\\n },\\n incremental: { type: \\"boolean\\" },\\n },\\n required: [\\"action\\"],\\n additionalProperties: false,\\n },\\n ],\\n },\\n },\\n {\\n name: \\"fclt_audit\\",\\n description:\\n \\"Run a structured, redacted, non-interactive fclt security audit with no report or index writes.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: { type: \\"string\\", enum: [\\"scan\\"] },\\n cwd: { type: \\"string\\" },\\n target: { type: \\"string\\" },\\n severity: {\\n type: \\"string\\",\\n enum: [\\"critical\\", \\"high\\", \\"medium\\", \\"low\\"],\\n },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_automation\\",\\n description:\\n \\"Read one aggregate activity set across all configured loops by default, or inspect one explicit loop scope. Schedule and canonical mutation remain CLI-only.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"autosync_status\\",\\n \\"loop_status\\",\\n \\"loop_activity\\",\\n \\"loop_preview\\",\\n ],\\n },\\n scope: {\\n type: \\"string\\",\\n enum: [\\"all\\", \\"global\\", \\"project\\"],\\n },\\n cwd: { type: \\"string\\" },\\n tool: { type: \\"string\\" },\\n },\\n required: [\\"action\\"],\\n oneOf: [\\n {\\n properties: {\\n action: { const: \\"loop_activity\\" },\\n scope: {\\n type: \\"string\\",\\n enum: [\\"all\\", \\"global\\", \\"project\\"],\\n default: \\"all\\",\\n },\\n },\\n required: [\\"action\\"],\\n },\\n {\\n properties: {\\n action: {\\n enum: [\\"autosync_status\\", \\"loop_status\\", \\"loop_preview\\"],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n },\\n required: [\\"action\\", \\"scope\\"],\\n },\\n ],\\n },\\n },\\n {\\n name: \\"fclt_status\\",\\n description:\\n \\"Return fclt status for the current, global, or project scope.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_doctor\\",\\n description: \\"Run read-only fclt doctor checks and return JSON output.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_paths\\",\\n description: \\"Return canonical, generated, review, and runtime fclt paths.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_init_operating_model\\",\\n description: \\"Install or update the built-in operating-model pack.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n update: { type: \\"boolean\\" },\\n dryRun: { type: \\"boolean\\" },\\n force: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_writeback_add\\",\\n description: \\"Record a durable fclt writeback with evidence.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n kind: { type: \\"string\\" },\\n category: {\\n type: \\"string\\",\\n enum: [\\"friction\\", \\"opportunity\\", \\"reusable-success\\"],\\n },\\n summary: { type: \\"string\\" },\\n details: { type: \\"string\\", maxLength: 2000 },\\n impact: { type: \\"string\\", maxLength: 1000 },\\n attemptedWorkaround: { type: \\"string\\", maxLength: 1000 },\\n desiredOutcome: { type: \\"string\\", maxLength: 1000 },\\n sensitivity: {\\n type: \\"string\\",\\n enum: [\\"public\\", \\"internal\\", \\"private\\"],\\n },\\n asset: { type: \\"string\\" },\\n evidence: { type: \\"string\\" },\\n confidence: {\\n type: \\"string\\",\\n enum: [\\"low\\", \\"medium\\", \\"high\\"],\\n },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\", \\"kind\\", \\"summary\\", \\"evidence\\", \\"approve\\"],\\n },\\n },\\n {\\n name: \\"fclt_writeback_review\\",\\n description: \\"List, group, or summarize current fclt writebacks.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n mode: { type: \\"string\\", enum: [\\"list\\", \\"group\\", \\"summarize\\"] },\\n by: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_evolve\\",\\n description:\\n \\"Assess, list, propose, draft, or review fclt evolution proposals.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n action: {\\n type: \\"string\\",\\n enum: [\\"assess\\", \\"list\\", \\"propose\\", \\"draft\\", \\"review\\", \\"show\\"],\\n },\\n id: { type: \\"string\\" },\\n asset: { type: \\"string\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n },\\n },\\n];\\n\\nfor (const tool of tools) {\\n if (!tool.inputSchema.oneOf || tool.inputSchema.properties) {\\n tool.inputSchema.additionalProperties = false;\\n }\\n}\\n\\nfunction isPlainObject(value) {\\n return Boolean(value) && typeof value === \\"object\\" && !Array.isArray(value);\\n}\\n\\nfunction validateToolArguments(name, args) {\\n if (!isPlainObject(args)) {\\n throw new Error(`${name} arguments must be an object`);\\n }\\n const tool = tools.find((entry) => entry.name === name);\\n if (!tool) {\\n throw new Error(`Unknown tool: ${name}`);\\n }\\n const rootSchema = tool.inputSchema;\\n const schema =\\n rootSchema.oneOf && !rootSchema.properties\\n ? rootSchema.oneOf.find((branch) => {\\n const action = branch.properties?.action;\\n return (\\n action?.const === args.action || action?.enum?.includes(args.action)\\n );\\n })\\n : rootSchema;\\n if (!schema) {\\n throw new Error(`${name}.action is not an allowed value`);\\n }\\n const properties = schema.properties || {};\\n const unknown = Object.keys(args).filter((key) => !(key in properties));\\n if (unknown.length > 0) {\\n throw new Error(\\n `${name} received unknown argument fields: ${unknown.join(\\", \\")}`\\n );\\n }\\n for (const required of schema.required || []) {\\n if (!(required in args)) {\\n throw new Error(`${name} requires ${required}`);\\n }\\n }\\n for (const [key, value] of Object.entries(args)) {\\n const property = properties[key];\\n if (property.const !== undefined && value !== property.const) {\\n throw new Error(`${name}.${key} is not an allowed value`);\\n }\\n if (!property.type) {\\n continue;\\n }\\n const validType =\\n property.type === \\"array\\"\\n ? Array.isArray(value)\\n : property.type === \\"object\\"\\n ? isPlainObject(value)\\n : typeof value === property.type;\\n if (!validType) {\\n throw new Error(`${name}.${key} must be ${property.type}`);\\n }\\n if (property.enum && !property.enum.includes(value)) {\\n throw new Error(`${name}.${key} is not an allowed value`);\\n }\\n if (property.pattern && !new RegExp(property.pattern).test(value)) {\\n throw new Error(`${name}.${key} has an invalid format`);\\n }\\n if (\\n property.type === \\"array\\" &&\\n property.items?.type &&\\n value.some((item) => typeof item !== property.items.type)\\n ) {\\n throw new Error(`${name}.${key} contains an invalid item`);\\n }\\n if (\\n property.type === \\"array\\" &&\\n property.items?.pattern &&\\n value.some((item) => !new RegExp(property.items.pattern).test(item))\\n ) {\\n throw new Error(`${name}.${key} contains an invalid item`);\\n }\\n }\\n}\\n\\nfunction scopeArgs(scope) {\\n if (scope === \\"global\\") {\\n return [\\"--global\\"];\\n }\\n if (scope === \\"project\\") {\\n return [\\"--project\\"];\\n }\\n return [];\\n}\\n\\nfunction boolFlag(name, value) {\\n return value ? [name] : [];\\n}\\n\\nfunction stringFlag(name, value) {\\n return typeof value === \\"string\\" && value.trim() ? [name, value] : [];\\n}\\n\\nfunction repeatedStringFlag(name, values) {\\n return Array.isArray(values)\\n ? values.flatMap((value) => stringFlag(name, value))\\n : [];\\n}\\n\\nfunction requireString(name, value) {\\n if (typeof value !== \\"string\\" || !value.trim()) {\\n throw new Error(`${name} is required`);\\n }\\n return value;\\n}\\n\\nfunction requireMutationApproval(name, args) {\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(`${name} requires an explicit global or project scope`);\\n }\\n if (args.approve !== true) {\\n throw new Error(`${name} requires approve=true`);\\n }\\n}\\n\\nfunction capabilityCommand(args) {\\n const action = args.action;\\n if (action === \\"scan\\") {\\n return [\\"scan\\", \\"--json\\"];\\n }\\n if (action === \\"inventory\\") {\\n return [\\"inventory\\", ...scopeArgs(args.scope), \\"--json\\"];\\n }\\n if (action === \\"list\\") {\\n return [\\"list\\", args.kind || \\"skills\\", ...scopeArgs(args.scope), \\"--json\\"];\\n }\\n if (action === \\"show\\") {\\n return [\\n \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n ...scopeArgs(args.scope),\\n ];\\n }\\n if (action === \\"find\\") {\\n return [\\n \\"find\\",\\n requireString(\\"query\\", args.query),\\n ...scopeArgs(args.scope),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"graph\\") {\\n return [\\n \\"graph\\",\\n args.graphMode || \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n ...scopeArgs(args.scope),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"adapters\\") {\\n return [\\"adapters\\", \\"--json\\"];\\n }\\n if (action === \\"managed_status\\") {\\n return [\\"managed\\", ...scopeArgs(args.scope)];\\n }\\n if (action === \\"templates_list\\") {\\n return [\\"templates\\", \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"snippet_list\\") {\\n return [\\"snippets\\", \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"snippet_show\\") {\\n return [\\n \\"snippets\\",\\n \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported capability action: ${action}`);\\n}\\n\\nconst WORKFLOW_MUTATIONS = new Set([\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"writeback_dismiss\\",\\n \\"writeback_promote\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_accept\\",\\n \\"evolve_reject\\",\\n \\"evolve_supersede\\",\\n \\"evolve_verify\\",\\n]);\\n\\nconst WORKFLOW_ACTION_FIELDS = {\\n writeback_list: [],\\n writeback_show: [\\"id\\"],\\n writeback_group: [\\"by\\"],\\n writeback_summarize: [\\"by\\"],\\n writeback_add: [\\n \\"kind\\",\\n \\"category\\",\\n \\"summary\\",\\n \\"details\\",\\n \\"impact\\",\\n \\"attemptedWorkaround\\",\\n \\"desiredOutcome\\",\\n \\"sensitivity\\",\\n \\"asset\\",\\n \\"evidence\\",\\n \\"confidence\\",\\n \\"approve\\",\\n ],\\n writeback_link: [\\"id\\", \\"issue\\", \\"approve\\"],\\n writeback_disposition: [\\n \\"id\\",\\n \\"disposition\\",\\n \\"target\\",\\n \\"nextTrigger\\",\\n \\"expectedOutcome\\",\\n \\"approve\\",\\n ],\\n evolve_assess: [\\"asset\\"],\\n evolve_list: [],\\n evolve_show: [\\"id\\"],\\n evolve_propose: [\\"asset\\", \\"approve\\"],\\n evolve_draft: [\\"id\\", \\"append\\", \\"approve\\"],\\n evolve_review: [\\"id\\", \\"approve\\"],\\n evolve_verify: [\\"id\\", \\"effectiveness\\", \\"evidence\\", \\"note\\", \\"approve\\"],\\n};\\n\\nfunction requireOnlyWorkflowFields(args) {\\n const fields = WORKFLOW_ACTION_FIELDS[args.action];\\n if (!fields) {\\n return;\\n }\\n const allowed = new Set([\\"action\\", \\"scope\\", \\"cwd\\", ...fields]);\\n const unexpected = Object.keys(args).filter((key) => !allowed.has(key));\\n if (unexpected.length > 0) {\\n throw new Error(\\n `${args.action} received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n}\\n\\nfunction workflowCommand(args) {\\n const action = args.action;\\n requireOnlyWorkflowFields(args);\\n if (WORKFLOW_MUTATIONS.has(action)) {\\n requireMutationApproval(action, args);\\n }\\n const scope = scopeArgs(args.scope);\\n if (action === \\"writeback_list\\") {\\n return [\\"ai\\", \\"writeback\\", ...scope, \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"writeback_show\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"show\\",\\n requireString(\\"id\\", args.id),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_group\\" || action === \\"writeback_summarize\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n action === \\"writeback_group\\" ? \\"group\\" : \\"summarize\\",\\n ...stringFlag(\\"--by\\", args.by),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_add\\") {\\n if (!Array.isArray(args.evidence) || args.evidence.length === 0) {\\n throw new Error(\\"writeback_add requires at least one evidence reference\\");\\n }\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"add\\",\\n \\"--kind\\",\\n requireString(\\"kind\\", args.kind),\\n \\"--summary\\",\\n requireString(\\"summary\\", args.summary),\\n ...stringFlag(\\"--asset\\", args.asset),\\n ...stringFlag(\\"--category\\", args.category),\\n ...stringFlag(\\"--details\\", args.details),\\n ...stringFlag(\\"--impact\\", args.impact),\\n ...stringFlag(\\"--attempted-workaround\\", args.attemptedWorkaround),\\n ...stringFlag(\\"--desired-outcome\\", args.desiredOutcome),\\n ...stringFlag(\\"--sensitivity\\", args.sensitivity),\\n ...repeatedStringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--confidence\\", args.confidence),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_link\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"link\\",\\n requireString(\\"id\\", args.id),\\n \\"--issue\\",\\n requireString(\\"issue\\", args.issue),\\n ];\\n }\\n if (action === \\"writeback_disposition\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"disposition\\",\\n requireString(\\"id\\", args.id),\\n \\"--type\\",\\n requireString(\\"disposition\\", args.disposition),\\n ...stringFlag(\\"--target\\", args.target),\\n ...stringFlag(\\"--next-trigger\\", args.nextTrigger),\\n ...stringFlag(\\"--expected-outcome\\", args.expectedOutcome),\\n ];\\n }\\n if (action === \\"writeback_dismiss\\" || action === \\"writeback_promote\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n action === \\"writeback_dismiss\\" ? \\"dismiss\\" : \\"promote\\",\\n requireString(\\"id\\", args.id),\\n ];\\n }\\n if (action === \\"evolve_assess\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"assess\\",\\n ...stringFlag(\\"--asset\\", args.asset),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_list\\") {\\n return [\\"ai\\", \\"evolve\\", ...scope, \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"evolve_show\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"show\\",\\n requireString(\\"id\\", args.id),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_propose\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"propose\\",\\n \\"--asset\\",\\n requireString(\\"asset\\", args.asset),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_draft\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"draft\\",\\n requireString(\\"id\\", args.id),\\n ...stringFlag(\\"--append\\", args.append),\\n ];\\n }\\n if (action === \\"evolve_review\\" || action === \\"evolve_accept\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n action === \\"evolve_review\\" ? \\"review\\" : \\"accept\\",\\n requireString(\\"id\\", args.id),\\n ];\\n }\\n if (action === \\"evolve_reject\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"reject\\",\\n requireString(\\"id\\", args.id),\\n \\"--reason\\",\\n requireString(\\"reason\\", args.reason),\\n ];\\n }\\n if (action === \\"evolve_supersede\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"supersede\\",\\n requireString(\\"id\\", args.id),\\n \\"--by\\",\\n requireString(\\"byProposal\\", args.byProposal),\\n ];\\n }\\n if (action === \\"evolve_verify\\") {\\n if (!Array.isArray(args.evidence) || args.evidence.length === 0) {\\n throw new Error(\\"evolve_verify requires at least one evidence reference\\");\\n }\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"verify\\",\\n requireString(\\"id\\", args.id),\\n \\"--effectiveness\\",\\n requireString(\\"effectiveness\\", args.effectiveness),\\n ...repeatedStringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--note\\", args.note),\\n ];\\n }\\n throw new Error(`Unsupported workflow action: ${action}`);\\n}\\n\\nfunction syncCommand(args) {\\n if (args.action === \\"status\\") {\\n return [\\"managed\\", ...scopeArgs(args.scope)];\\n }\\n if (args.action === \\"preview\\") {\\n return [\\n \\"sync\\",\\n ...(args.tool ? [args.tool] : []),\\n \\"--dry-run\\",\\n ...scopeArgs(args.scope),\\n ];\\n }\\n throw new Error(`Unsupported sync action: ${args.action}`);\\n}\\n\\nfunction requireOnlyRegistryFields(args, fields) {\\n const allowed = new Set([\\"action\\", \\"scope\\", \\"cwd\\", ...fields]);\\n const unexpected = Object.keys(args).filter((key) => !allowed.has(key));\\n if (unexpected.length > 0) {\\n throw new Error(\\n `${args.action} received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n}\\n\\nfunction registryCommand(args) {\\n if (args.action === \\"activity_resolve\\") {\\n const unexpected = Object.keys(args).filter(\\n (key) => key !== \\"action\\" && key !== \\"locator\\"\\n );\\n if (unexpected.length > 0) {\\n throw new Error(\\n `activity_resolve received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n \\"resolve\\",\\n requireString(\\"locator\\", args.locator),\\n \\"--json\\",\\n ];\\n }\\n if (\\n args.scope === \\"project\\" &&\\n args.action !== \\"reconcile_status\\" &&\\n args.action !== \\"reconcile\\"\\n ) {\\n throw new Error(`${args.action} only supports global scope`);\\n }\\n if (args.action === \\"search\\") {\\n requireOnlyRegistryFields(args, [\\"query\\"]);\\n return [\\"search\\", requireString(\\"query\\", args.query), \\"--json\\"];\\n }\\n if (args.action === \\"verify_source\\") {\\n requireOnlyRegistryFields(args, [\\"source\\"]);\\n return [\\"verify-source\\", requireString(\\"source\\", args.source), \\"--json\\"];\\n }\\n if (args.action === \\"source_list\\") {\\n requireOnlyRegistryFields(args, []);\\n return [\\"sources\\", \\"list\\", \\"--json\\"];\\n }\\n if (args.action === \\"install_preview\\") {\\n requireOnlyRegistryFields(args, [\\"item\\", \\"as\\"]);\\n return [\\n \\"install\\",\\n requireString(\\"item\\", args.item),\\n ...stringFlag(\\"--as\\", args.as),\\n \\"--dry-run\\",\\n \\"--strict-source-trust\\",\\n \\"--json\\",\\n ];\\n }\\n if (args.action === \\"update_check\\") {\\n requireOnlyRegistryFields(args, []);\\n return [\\"update\\", \\"--strict-source-trust\\", \\"--json\\"];\\n }\\n if (args.action === \\"reconcile_status\\") {\\n requireOnlyRegistryFields(args, []);\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(\\n \\"reconcile_status requires an explicit global or project scope\\"\\n );\\n }\\n return [\\"ai\\", \\"review\\", ...scopeArgs(args.scope), \\"status\\", \\"--json\\"];\\n }\\n if (args.action === \\"reconcile\\") {\\n requireOnlyRegistryFields(args, [\\n \\"since\\",\\n \\"until\\",\\n \\"sourceIds\\",\\n \\"incremental\\",\\n ]);\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(\\"reconcile requires an explicit global or project scope\\");\\n }\\n return [\\n \\"ai\\",\\n \\"review\\",\\n ...scopeArgs(args.scope),\\n \\"reconcile\\",\\n \\"--since\\",\\n requireString(\\"since\\", args.since),\\n ...stringFlag(\\"--until\\", args.until),\\n ...repeatedStringFlag(\\"--source\\", args.sourceIds),\\n ...boolFlag(\\"--incremental\\", args.incremental),\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported registry action: ${args.action}`);\\n}\\n\\nfunction isSubpath(child, parent) {\\n const relative = path.relative(parent, child);\\n return (\\n relative === \\"\\" || !(relative.startsWith(\\"..\\") || path.isAbsolute(relative))\\n );\\n}\\n\\nfunction isDirectory(candidate) {\\n try {\\n return fs.statSync(candidate).isDirectory();\\n } catch {\\n return false;\\n }\\n}\\n\\nfunction resolveWorkspaceCwd({ allowHomeFallback = true } = {}) {\\n const candidates = [\\n process.env.FCLT_MCP_WORKSPACE_CWD,\\n process.env.INIT_CWD,\\n process.env.PWD,\\n ];\\n for (const candidate of candidates) {\\n if (typeof candidate !== \\"string\\" || !candidate.trim()) {\\n continue;\\n }\\n const resolved = path.resolve(candidate);\\n const isHome = resolved === path.resolve(os.homedir());\\n if (\\n (allowHomeFallback || !isHome) &&\\n !isSubpath(resolved, PLUGIN_ROOT) &&\\n isDirectory(resolved)\\n ) {\\n return resolved;\\n }\\n }\\n if (allowHomeFallback && isDirectory(os.homedir())) {\\n return os.homedir();\\n }\\n return undefined;\\n}\\n\\nfunction resolveToolCwd(name, args = {}) {\\n if (typeof args.cwd === \\"string\\" && args.cwd.trim()) {\\n return args.cwd;\\n }\\n const inferred = resolveWorkspaceCwd({\\n allowHomeFallback: args.scope !== \\"project\\",\\n });\\n if (inferred) {\\n return inferred;\\n }\\n if (args.scope === \\"project\\") {\\n throw new Error(\\n `${name} with project scope requires a cwd for the target workspace`\\n );\\n }\\n return process.cwd();\\n}\\n\\nfunction commandForTool(name, args = {}) {\\n switch (name) {\\n case \\"fclt_setup\\": {\\n const apply = args.dryRun === false;\\n if (apply && args.approve !== true) {\\n throw new Error(\\"fclt_setup apply requires approve=true\\");\\n }\\n if (\\n args.scope === \\"global_and_project\\" &&\\n (typeof args.cwd !== \\"string\\" || !args.cwd.trim())\\n ) {\\n throw new Error(\\n \\"fclt_setup global_and_project scope requires an explicit cwd\\"\\n );\\n }\\n return [\\n \\"setup\\",\\n \\"--json\\",\\n ...(args.scope === \\"global_and_project\\" ? [\\"--include-project\\"] : []),\\n ...(apply ? [] : [\\"--dry-run\\"]),\\n ...(args.installCodexPlugin === false ? [\\"--no-codex-plugin\\"] : []),\\n ];\\n }\\n case \\"fclt_capability\\":\\n return capabilityCommand(args);\\n case \\"fclt_workflow\\":\\n return workflowCommand(args);\\n case \\"fclt_sync\\":\\n return syncCommand(args);\\n case \\"fclt_registry\\":\\n return registryCommand(args);\\n case \\"fclt_audit\\":\\n return [\\n \\"audit\\",\\n \\"--non-interactive\\",\\n ...(args.target ? [args.target] : []),\\n ...stringFlag(\\"--severity\\", args.severity),\\n \\"--json\\",\\n ];\\n case \\"fclt_automation\\":\\n if (\\n args.action !== \\"loop_activity\\" &&\\n args.scope !== \\"global\\" &&\\n args.scope !== \\"project\\"\\n ) {\\n throw new Error(`${args.action} requires global or project scope`);\\n }\\n if (args.action === \\"autosync_status\\") {\\n return [\\n \\"autosync\\",\\n \\"status\\",\\n ...(args.tool ? [args.tool] : []),\\n ...scopeArgs(args.scope),\\n ];\\n }\\n if (args.tool) {\\n throw new Error(`${args.action} does not accept tool`);\\n }\\n if (args.action === \\"loop_status\\") {\\n return [\\"ai\\", \\"loop\\", ...scopeArgs(args.scope), \\"status\\", \\"--json\\"];\\n }\\n if (args.action === \\"loop_activity\\") {\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n ...scopeArgs(args.scope),\\n \\"activity\\",\\n ...(args.scope === \\"global\\" || args.scope === \\"project\\"\\n ? []\\n : [\\"--all\\"]),\\n \\"--json\\",\\n ];\\n }\\n if (args.action === \\"loop_preview\\") {\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n ...scopeArgs(args.scope),\\n \\"run\\",\\n \\"--dry-run\\",\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported automation action: ${args.action}`);\\n case \\"fclt_status\\":\\n return [\\"status\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_doctor\\":\\n return [\\"doctor\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_paths\\":\\n return [\\"paths\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_init_operating_model\\":\\n if (args.dryRun === false && args.approve !== true) {\\n throw new Error(\\n \\"fclt_init_operating_model apply requires approve=true\\"\\n );\\n }\\n if (args.force === true && args.approve !== true) {\\n throw new Error(\\n \\"fclt_init_operating_model force requires approve=true\\"\\n );\\n }\\n return [\\n \\"templates\\",\\n \\"init\\",\\n \\"operating-model\\",\\n ...scopeArgs(args.scope),\\n ...boolFlag(\\"--update\\", args.update),\\n ...(args.dryRun === false ? [] : [\\"--dry-run\\"]),\\n ...boolFlag(\\"--force\\", args.force),\\n \\"--json\\",\\n ];\\n case \\"fclt_writeback_add\\":\\n requireMutationApproval(name, args);\\n requireString(\\"evidence\\", args.evidence);\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scopeArgs(args.scope),\\n \\"add\\",\\n \\"--kind\\",\\n args.kind,\\n \\"--summary\\",\\n args.summary,\\n ...stringFlag(\\"--asset\\", args.asset),\\n ...stringFlag(\\"--category\\", args.category),\\n ...stringFlag(\\"--details\\", args.details),\\n ...stringFlag(\\"--impact\\", args.impact),\\n ...stringFlag(\\"--attempted-workaround\\", args.attemptedWorkaround),\\n ...stringFlag(\\"--desired-outcome\\", args.desiredOutcome),\\n ...stringFlag(\\"--sensitivity\\", args.sensitivity),\\n ...stringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--confidence\\", args.confidence),\\n \\"--json\\",\\n ];\\n case \\"fclt_writeback_review\\": {\\n const mode = args.mode || \\"list\\";\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scopeArgs(args.scope),\\n mode,\\n ...stringFlag(\\"--by\\", args.by),\\n ];\\n }\\n case \\"fclt_evolve\\": {\\n const action = args.action || \\"list\\";\\n if ([\\"propose\\", \\"draft\\", \\"review\\"].includes(action)) {\\n requireMutationApproval(`fclt_evolve ${action}`, args);\\n }\\n if (action === \\"propose\\") {\\n requireString(\\"asset\\", args.asset);\\n }\\n if ([\\"draft\\", \\"review\\", \\"show\\"].includes(action)) {\\n requireString(\\"id\\", args.id);\\n }\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scopeArgs(args.scope),\\n action,\\n ...(action === \\"assess\\" || action === \\"propose\\"\\n ? stringFlag(\\"--asset\\", args.asset)\\n : []),\\n ...(args.id ? [args.id] : []),\\n ...(action === \\"assess\\" ? [\\"--json\\"] : []),\\n ];\\n }\\n default:\\n throw new Error(`Unknown tool: ${name}`);\\n }\\n}\\n\\nfunction operationMetadata(name, args, command) {\\n const action =\\n name === \\"fclt_writeback_add\\"\\n ? \\"writeback_add\\"\\n : name === \\"fclt_evolve\\"\\n ? `evolve_${args.action || \\"list\\"}`\\n : args.action || name;\\n const reviewActions = new Set([\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_verify\\",\\n \\"reconcile\\",\\n ]);\\n const preview = command.includes(\\"--dry-run\\");\\n const risk = reviewActions.has(action)\\n ? \\"review_producing\\"\\n : !preview && name === \\"fclt_setup\\"\\n ? \\"reversible_mutation\\"\\n : !preview && name === \\"fclt_init_operating_model\\"\\n ? \\"high_risk_destructive\\"\\n : \\"read_only\\";\\n return {\\n tool: name,\\n action,\\n risk,\\n scope:\\n args.scope ||\\n (name === \\"fclt_automation\\" && action === \\"loop_activity\\"\\n ? \\"all\\"\\n : \\"auto\\"),\\n target:\\n args.id ||\\n args.selector ||\\n args.asset ||\\n args.item ||\\n args.source ||\\n args.tool ||\\n null,\\n preview,\\n };\\n}\\n\\nfunction recoveryForOperation(operation, stdout) {\\n if (operation.risk === \\"review_producing\\") {\\n return {\\n canonicalCapabilityChanged: false,\\n audit: \\"native fclt review artifacts and append-only journal\\",\\n };\\n }\\n if (\\n operation.risk !== \\"reversible_mutation\\" &&\\n operation.risk !== \\"high_risk_destructive\\"\\n ) {\\n return null;\\n }\\n const report = isPlainObject(stdout) ? stdout : {};\\n return {\\n rollbackAvailable: false,\\n changedPaths: Array.isArray(report.changedPaths) ? report.changedPaths : [],\\n skippedPaths: Array.isArray(report.skippedPaths) ? report.skippedPaths : [],\\n repairActions: Array.isArray(report.repairActions)\\n ? report.repairActions\\n : [],\\n verification:\\n operation.tool === \\"fclt_setup\\"\\n ? \\"rerun fclt_setup in preview mode and verify doctor readiness\\"\\n : \\"verify doctor, generated state, authored-file hashes, and exact target paths\\",\\n };\\n}\\n\\nasync function runFclt(args, cwd, operation) {\\n const discovery = await runtime.discoverRuntime();\\n if (!discovery.selected) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"no_compatible_runtime\\",\\n message:\\n \\"No compatible fclt runtime is available. Check, stage, and apply an explicit verified version with fclt_runtime.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n if (\\n operation.tool === \\"fclt_audit\\" &&\\n !discovery.selected.capabilities?.includes(AUDIT_READ_ONLY_CAPABILITY)\\n ) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"missing_runtime_capability\\",\\n message:\\n \\"The selected fclt runtime does not advertise audit-read-only-v1; typed audit fails closed.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n if (\\n operation.action === \\"activity_resolve\\" &&\\n !discovery.selected.capabilities?.includes(\\n ACTIVITY_ACTION_RESOLVE_CAPABILITY\\n )\\n ) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"missing_runtime_capability\\",\\n message:\\n \\"The selected fclt runtime does not advertise activity-action-resolve-v1; typed activity resolution fails closed.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n const result = await runtime.runCommand(discovery.selected.executable, args, {\\n cwd: cwd || process.cwd(),\\n env: process.env,\\n timeoutMs: DEFAULT_TIMEOUT_MS,\\n });\\n const parsedStdout = parseJsonOrText(result.stdout.trim());\\n return {\\n code: result.code,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n runtime: discovery.selected,\\n result: {\\n exitCode: result.code,\\n stdout: parsedStdout,\\n stderr: result.stderr,\\n },\\n verification: {\\n status: result.code === 0 ? \\"passed\\" : \\"failed\\",\\n exitCode: result.code,\\n },\\n recovery: recoveryForOperation(operation, parsedStdout),\\n },\\n null,\\n 2\\n ),\\n };\\n}\\n\\nfunction parseJsonOrText(value) {\\n if (!value) {\\n return \\"\\";\\n }\\n try {\\n return JSON.parse(value);\\n } catch {\\n return value;\\n }\\n}\\n\\nasync function handleRuntimeTool(args = {}) {\\n const action = args.action || \\"status\\";\\n if (action === \\"status\\") {\\n return await runtime.discoverRuntime();\\n }\\n if (action === \\"check\\") {\\n return await runtime.checkRuntimeUpdate();\\n }\\n if (action === \\"policy\\") {\\n return await runtime.setRuntimePolicy({\\n approve: args.approve,\\n pinnedVersion: args.pinnedVersion,\\n clearPin: args.clearPin,\\n updateChecksEnabled: args.updateChecksEnabled,\\n });\\n }\\n if (action === \\"stage\\") {\\n return await runtime.stageRuntime({\\n approve: args.approve,\\n version: args.version,\\n });\\n }\\n if (action === \\"apply\\") {\\n return await runtime.applyStagedRuntime({\\n approve: args.approve,\\n expectedSha256: args.expectedSha256,\\n version: args.version,\\n });\\n }\\n if (action === \\"rollback\\") {\\n return await runtime.rollbackRuntime({\\n approve: args.approve,\\n expectedActiveVersion: args.expectedActiveVersion,\\n });\\n }\\n throw new Error(`Unknown runtime action: ${action}`);\\n}\\n\\nfunction runtimeOperationMetadata(args, result) {\\n const action = args.action || \\"status\\";\\n const risk =\\n action === \\"status\\" || action === \\"check\\"\\n ? \\"read_only\\"\\n : action === \\"stage\\"\\n ? \\"review_producing\\"\\n : \\"high_risk_destructive\\";\\n return {\\n operation: {\\n tool: \\"fclt_runtime\\",\\n action,\\n risk,\\n scope: \\"plugin_runtime\\",\\n target:\\n args.version ||\\n args.pinnedVersion ||\\n args.expectedActiveVersion ||\\n null,\\n approved: args.approve === true,\\n },\\n verification: {\\n status: \\"passed\\",\\n activeVersion:\\n result.active?.packageVersion ||\\n result.selected?.packageVersion ||\\n null,\\n },\\n recovery:\\n action === \\"apply\\" || action === \\"rollback\\"\\n ? {\\n rollbackAvailable: result.rollbackAvailable === true,\\n previous: result.previous || null,\\n }\\n : action === \\"policy\\"\\n ? { previousPolicy: result.previous || null }\\n : null,\\n };\\n}\\n\\nlet transportFraming = \\"content-length\\";\\n\\nfunction send(message) {\\n const body = JSON.stringify(message);\\n if (transportFraming === \\"newline\\") {\\n process.stdout.write(`${body}\\\\n`);\\n return;\\n }\\n process.stdout.write(\\n `Content-Length: ${Buffer.byteLength(body)}\\\\r\\\\n\\\\r\\\\n${body}`\\n );\\n}\\n\\nasync function handle(message) {\\n if (!message || message.id == null) {\\n return;\\n }\\n\\n try {\\n if (message.method === \\"initialize\\") {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n protocolVersion: \\"2025-06-18\\",\\n capabilities: { tools: {} },\\n serverInfo: { name: \\"fclt\\", version: runtime.pluginVersion() },\\n },\\n });\\n return;\\n }\\n if (message.method === \\"tools/list\\") {\\n send({ jsonrpc: \\"2.0\\", id: message.id, result: { tools } });\\n return;\\n }\\n if (message.method === \\"tools/call\\") {\\n const { name, arguments: args = {} } = message.params || {};\\n validateToolArguments(name, args);\\n if (name === \\"fclt_runtime\\") {\\n const result = await handleRuntimeTool(args);\\n const metadata = runtimeOperationMetadata(args, result);\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n isError: false,\\n content: [\\n {\\n type: \\"text\\",\\n text: JSON.stringify({ ...result, ...metadata }, null, 2),\\n },\\n ],\\n },\\n });\\n return;\\n }\\n const command = commandForTool(name, args);\\n const result = await runFclt(\\n command,\\n resolveToolCwd(name, args),\\n operationMetadata(name, args, command)\\n );\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n isError: result.code !== 0,\\n content: [{ type: \\"text\\", text: result.text }],\\n },\\n });\\n return;\\n }\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n error: { code: -32_601, message: `Method not found: ${message.method}` },\\n });\\n } catch (error) {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n error: {\\n code: -32_000,\\n message: error instanceof Error ? error.message : String(error),\\n },\\n });\\n }\\n}\\n\\nlet buffer = Buffer.alloc(0);\\n\\nfunction dispatch(body, framing) {\\n transportFraming = framing;\\n handle(JSON.parse(body)).catch((error) => {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: null,\\n error: {\\n code: -32_000,\\n message: error instanceof Error ? error.message : String(error),\\n },\\n });\\n });\\n}\\n\\nprocess.stdin.on(\\"data\\", (chunk) => {\\n buffer = Buffer.concat([buffer, chunk]);\\n while (true) {\\n while (buffer[0] === 10 || buffer[0] === 13) {\\n buffer = buffer.subarray(1);\\n }\\n if (buffer.length === 0) {\\n return;\\n }\\n if (buffer[0] === 123 || buffer[0] === 91) {\\n const lineEnd = buffer.indexOf(\\"\\\\n\\");\\n if (lineEnd === -1) {\\n return;\\n }\\n const body = buffer.subarray(0, lineEnd).toString(\\"utf8\\").trim();\\n buffer = buffer.subarray(lineEnd + 1);\\n if (body) {\\n dispatch(body, \\"newline\\");\\n }\\n continue;\\n }\\n const headerEnd = buffer.indexOf(\\"\\\\r\\\\n\\\\r\\\\n\\");\\n if (headerEnd === -1) {\\n return;\\n }\\n const header = buffer.slice(0, headerEnd).toString(\\"utf8\\");\\n const match = CONTENT_LENGTH_RE.exec(header);\\n if (!match) {\\n buffer = Buffer.alloc(0);\\n return;\\n }\\n const length = Number(match[1]);\\n const frameEnd = headerEnd + 4 + length;\\n if (buffer.length < frameEnd) {\\n return;\\n }\\n const body = buffer.slice(headerEnd + 4, frameEnd).toString(\\"utf8\\");\\n buffer = buffer.slice(frameEnd);\\n dispatch(body, \\"content-length\\");\\n }\\n});\\n\\nif (process.argv.includes(\\"--self-test\\")) {\\n console.log(\\n JSON.stringify(\\n {\\n pluginVersion: runtime.pluginVersion(),\\n protocolVersion: runtime.PLUGIN_PROTOCOL_VERSION,\\n tools: tools.map((tool) => tool.name),\\n },\\n null,\\n 2\\n )\\n );\\n process.exit(0);\\n}\\n","scripts/fclt-runtime.cjs":"\\"use strict\\";\\n\\nconst { spawn } = require(\\"node:child_process\\");\\nconst crypto = require(\\"node:crypto\\");\\nconst fs = require(\\"node:fs\\");\\nconst fsp = require(\\"node:fs/promises\\");\\nconst https = require(\\"node:https\\");\\nconst os = require(\\"node:os\\");\\nconst path = require(\\"node:path\\");\\n\\nconst PLUGIN_PROTOCOL_VERSION = 1;\\nconst STATE_SCHEMA_VERSION = 1;\\nconst REPOSITORY = \\"hack-dance/fclt\\";\\nconst MAX_BINARY_BYTES = 256 * 1024 * 1024;\\nconst MAX_METADATA_BYTES = 2 * 1024 * 1024;\\nconst DOWNLOAD_TIMEOUT_MS = 30_000;\\nconst COMMAND_TIMEOUT_MS = 15_000;\\nconst ALLOWED_DOWNLOAD_HOSTS = new Set([\\n \\"api.github.com\\",\\n \\"github.com\\",\\n \\"objects.githubusercontent.com\\",\\n \\"release-assets.githubusercontent.com\\",\\n]);\\nconst SEMVER_RE = /^\\\\d+\\\\.\\\\d+\\\\.\\\\d+(?:[-+][0-9A-Za-z.-]+)?$/;\\nconst SHA256_RE = /^[a-f0-9]{64}$/;\\nconst NEWLINE_RE = /\\\\r?\\\\n/;\\nconst CHECKSUM_LINE_RE = /^([a-fA-F0-9]{64})\\\\s+\\\\*?(.+)$/;\\nconst WINDOWS_SHIM_RE = /\\\\.(?:bat|cmd)$/i;\\n\\nfunction isPlainObject(value) {\\n return Boolean(value) && typeof value === \\"object\\" && !Array.isArray(value);\\n}\\n\\nfunction pluginVersion() {\\n try {\\n const manifest = JSON.parse(\\n fs.readFileSync(\\n path.resolve(__dirname, \\"..\\", \\".codex-plugin\\", \\"plugin.json\\"),\\n \\"utf8\\"\\n )\\n );\\n return typeof manifest.version === \\"string\\" ? manifest.version : \\"unknown\\";\\n } catch {\\n return \\"unknown\\";\\n }\\n}\\n\\nfunction runtimeStateRoot(env = process.env, platform = process.platform) {\\n if (env.FCLT_PLUGIN_RUNTIME_DIR) {\\n return path.resolve(env.FCLT_PLUGIN_RUNTIME_DIR);\\n }\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n if (platform === \\"darwin\\") {\\n return path.join(\\n home,\\n \\"Library\\",\\n \\"Application Support\\",\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n }\\n if (platform === \\"win32\\") {\\n return path.join(\\n env.LOCALAPPDATA || path.join(home, \\"AppData\\", \\"Local\\"),\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n }\\n return path.join(\\n env.XDG_STATE_HOME || path.join(home, \\".local\\", \\"state\\"),\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n}\\n\\nfunction installStatePaths(env = process.env, platform = process.platform) {\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n const override = env.FACULT_LOCAL_STATE_DIR?.trim();\\n const portableRoot = override\\n ? path.resolve(override)\\n : platform === \\"darwin\\"\\n ? path.join(home, \\"Library\\", \\"Application Support\\", \\"fclt\\")\\n : path.join(\\n env.XDG_STATE_HOME\\n ? path.resolve(env.XDG_STATE_HOME)\\n : path.join(home, \\".local\\", \\"state\\"),\\n \\"fclt\\"\\n );\\n const candidates = [\\n path.join(portableRoot, \\"install.json\\"),\\n ...(platform === \\"win32\\"\\n ? [\\n path.join(\\n env.LOCALAPPDATA || path.join(home, \\"AppData\\", \\"Local\\"),\\n \\"fclt\\",\\n \\"install.json\\"\\n ),\\n ]\\n : []),\\n path.join(home, \\".ai\\", \\".facult\\", \\"install.json\\"),\\n path.join(home, \\".facult\\", \\"install.json\\"),\\n path.join(home, \\".local\\", \\"share\\", \\"fclt\\", \\"install.json\\"),\\n ];\\n return [...new Set(candidates.map((candidate) => path.resolve(candidate)))];\\n}\\n\\nfunction isSubpath(child, parent) {\\n const relative = path.relative(parent, child);\\n return (\\n relative === \\"\\" || !(relative.startsWith(\\"..\\") || path.isAbsolute(relative))\\n );\\n}\\n\\nasync function assertManagedPath(target, root) {\\n const resolvedRoot = path.resolve(root);\\n const resolvedTarget = path.resolve(target);\\n if (!isSubpath(resolvedTarget, resolvedRoot)) {\\n throw new Error(\\"Runtime path escapes the managed runtime root.\\");\\n }\\n\\n const relative = path.relative(resolvedRoot, path.dirname(resolvedTarget));\\n const segments = relative ? relative.split(path.sep) : [];\\n let cursor = resolvedRoot;\\n for (const segment of segments) {\\n cursor = path.join(cursor, segment);\\n try {\\n if ((await fsp.lstat(cursor)).isSymbolicLink()) {\\n throw new Error(\\"Runtime path traverses a symbolic link.\\");\\n }\\n } catch (error) {\\n if (error && error.code === \\"ENOENT\\") {\\n continue;\\n }\\n throw error;\\n }\\n }\\n return resolvedTarget;\\n}\\n\\nasync function readJson(pathValue) {\\n try {\\n const value = JSON.parse(await fsp.readFile(pathValue, \\"utf8\\"));\\n return isPlainObject(value) ? value : null;\\n } catch {\\n return null;\\n }\\n}\\n\\nasync function runtimePolicy(options = {}) {\\n const root = runtimeStateRoot(options.env, options.platform);\\n const persisted = await readJson(path.join(root, \\"policy.json\\"));\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n updateChecksEnabled: persisted?.updateChecksEnabled !== false,\\n pinnedVersion:\\n typeof persisted?.pinnedVersion === \\"string\\" &&\\n persisted.pinnedVersion.trim()\\n ? normalizeVersion(persisted.pinnedVersion)\\n : null,\\n };\\n}\\n\\nfunction commandNames(platform = process.platform) {\\n return platform === \\"win32\\"\\n ? [\\"fclt.exe\\", \\"fclt.cmd\\", \\"facult.exe\\", \\"facult.cmd\\"]\\n : [\\"fclt\\", \\"facult\\"];\\n}\\n\\nfunction pathCandidates(env = process.env, platform = process.platform) {\\n const values = [];\\n for (const directory of (env.PATH || \\"\\").split(path.delimiter)) {\\n if (!directory) {\\n continue;\\n }\\n for (const name of commandNames(platform)) {\\n values.push(path.join(directory, name));\\n }\\n }\\n return values;\\n}\\n\\nfunction systemPathCandidates(env = process.env, platform = process.platform) {\\n if (Object.hasOwn(env, \\"FCLT_SYSTEM_PATHS\\")) {\\n return (env.FCLT_SYSTEM_PATHS || \\"\\").split(path.delimiter).filter(Boolean);\\n }\\n if (platform === \\"darwin\\") {\\n return [\\"/opt/homebrew/bin/fclt\\", \\"/usr/local/bin/fclt\\"];\\n }\\n if (platform === \\"win32\\") {\\n return [];\\n }\\n return [\\"/usr/local/bin/fclt\\", \\"/usr/bin/fclt\\"];\\n}\\n\\nfunction candidateSource(candidate) {\\n const normalized = candidate.split(\\"\\\\\\\\\\").join(\\"/\\");\\n if (normalized.includes(\\"/plugin-runtime/versions/\\")) {\\n return \\"plugin_runtime\\";\\n }\\n if (normalized.includes(\\"/mise/\\") || normalized.includes(\\"/mise/installs/\\")) {\\n return \\"mise\\";\\n }\\n if (\\n normalized.includes(\\"/Cellar/\\") ||\\n normalized.startsWith(\\"/opt/homebrew/\\")\\n ) {\\n return \\"homebrew\\";\\n }\\n if (normalized.includes(\\"/node_modules/\\") || normalized.includes(\\"/npm/\\")) {\\n return \\"npm\\";\\n }\\n if (normalized.includes(\\"/.ai/.facult/bin/\\")) {\\n return \\"canonical_install\\";\\n }\\n return \\"path\\";\\n}\\n\\nasync function activeRuntimeCandidate(root) {\\n const active = await readJson(path.join(root, \\"active.json\\"));\\n if (typeof active?.executable !== \\"string\\" || !active.executable.trim()) {\\n return null;\\n }\\n const executable = path.resolve(active.executable);\\n if (!isSubpath(executable, path.join(root, \\"versions\\"))) {\\n return null;\\n }\\n return {\\n executable,\\n source: \\"plugin_runtime\\",\\n expectedSha256: active.sha256,\\n active,\\n };\\n}\\n\\nasync function persistedInstallCandidates(\\n env = process.env,\\n platform = process.platform\\n) {\\n const candidates = [];\\n for (const statePath of installStatePaths(env, platform)) {\\n const state = await readJson(statePath);\\n if (typeof state?.binaryPath === \\"string\\" && state.binaryPath.trim()) {\\n candidates.push({\\n executable: path.resolve(state.binaryPath),\\n source:\\n typeof state.source === \\"string\\" ? state.source : \\"install_metadata\\",\\n installStatePath: statePath,\\n });\\n }\\n }\\n return candidates;\\n}\\n\\nasync function runtimeCandidates(options = {}) {\\n const env = options.env || process.env;\\n const platform = options.platform || process.platform;\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n const root = runtimeStateRoot(env, platform);\\n const candidates = [];\\n let configuredPathCandidate = null;\\n\\n if (env.FCLT_BIN?.trim()) {\\n const explicit = env.FCLT_BIN.trim();\\n if (path.isAbsolute(explicit) || explicit.includes(path.sep)) {\\n candidates.push({\\n executable: path.resolve(explicit),\\n source: \\"explicit\\",\\n });\\n } else {\\n const resolved = pathCandidates(env, platform).find(\\n (candidate) =>\\n path.basename(candidate) === explicit && fs.existsSync(candidate)\\n );\\n configuredPathCandidate = {\\n executable: resolved || explicit,\\n source: \\"configured_path\\",\\n };\\n }\\n }\\n\\n const active = await activeRuntimeCandidate(root);\\n if (active) {\\n candidates.push(active);\\n }\\n if (configuredPathCandidate) {\\n candidates.push(configuredPathCandidate);\\n }\\n candidates.push(...(await persistedInstallCandidates(env, platform)));\\n candidates.push(\\n ...pathCandidates(env, platform).map((executable) => ({\\n executable,\\n source: candidateSource(executable),\\n }))\\n );\\n\\n for (const executable of [\\n path.join(home, \\".ai\\", \\".facult\\", \\"bin\\", commandNames(platform)[0]),\\n ...systemPathCandidates(env, platform),\\n ]) {\\n candidates.push({ executable, source: candidateSource(executable) });\\n }\\n\\n const unique = [];\\n const seen = new Set();\\n for (const candidate of candidates) {\\n const key = path.resolve(candidate.executable);\\n if (!seen.has(key)) {\\n seen.add(key);\\n unique.push(candidate);\\n }\\n }\\n return unique;\\n}\\n\\nfunction runCommand(executable, args, options = {}) {\\n return new Promise((resolve) => {\\n let child;\\n try {\\n const platform = options.platform || process.platform;\\n const windowsShim =\\n platform === \\"win32\\" && WINDOWS_SHIM_RE.test(executable);\\n const command = windowsShim\\n ? options.env?.ComSpec || process.env.ComSpec || \\"cmd.exe\\"\\n : executable;\\n const commandArgs = windowsShim\\n ? [\\n \\"/d\\",\\n \\"/v:off\\",\\n \\"/s\\",\\n \\"/c\\",\\n [executable, ...args]\\n .map(\\n (value) =>\\n `\\"${String(value)\\n .replaceAll(\\"%\\", \\"%%\\")\\n .replace(/[\\\\^&|<>()!\\"]/g, \\"^$&\\")}\\"`\\n )\\n .join(\\" \\"),\\n ]\\n : args;\\n child = spawn(command, commandArgs, {\\n cwd: options.cwd || process.cwd(),\\n env: options.env || process.env,\\n stdio: [\\"ignore\\", \\"pipe\\", \\"pipe\\"],\\n });\\n } catch (error) {\\n resolve({ code: 1, stdout: \\"\\", stderr: error.message });\\n return;\\n }\\n let stdout = \\"\\";\\n let stderr = \\"\\";\\n let settled = false;\\n const timer = setTimeout(\\n () => child.kill(\\"SIGTERM\\"),\\n options.timeoutMs || COMMAND_TIMEOUT_MS\\n );\\n const finish = (code, error) => {\\n if (settled) {\\n return;\\n }\\n settled = true;\\n clearTimeout(timer);\\n resolve({\\n code,\\n stdout,\\n stderr: [stderr.trim(), error].filter(Boolean).join(\\"\\\\n\\"),\\n });\\n };\\n child.stdout.on(\\"data\\", (chunk) => {\\n stdout += chunk.toString();\\n if (stdout.length > MAX_METADATA_BYTES) {\\n child.kill(\\"SIGTERM\\");\\n }\\n });\\n child.stderr.on(\\"data\\", (chunk) => {\\n stderr += chunk.toString();\\n if (stderr.length > MAX_METADATA_BYTES) {\\n child.kill(\\"SIGTERM\\");\\n }\\n });\\n child.on(\\"error\\", (error) => finish(1, error.message));\\n child.on(\\"close\\", (code) => finish(code ?? 1));\\n });\\n}\\n\\nfunction parseProtocolReport(raw) {\\n try {\\n const report = JSON.parse(raw);\\n if (\\n !isPlainObject(report) ||\\n report.schemaVersion !== 1 ||\\n typeof report.packageVersion !== \\"string\\" ||\\n !isPlainObject(report.protocol) ||\\n !Number.isInteger(report.protocol.version) ||\\n !Number.isInteger(report.protocol.minimumPluginVersion) ||\\n !Number.isInteger(report.protocol.maximumPluginVersion) ||\\n (report.capabilities !== undefined &&\\n (!Array.isArray(report.capabilities) ||\\n report.capabilities.some((value) => typeof value !== \\"string\\")))\\n ) {\\n return null;\\n }\\n return report;\\n } catch {\\n return null;\\n }\\n}\\n\\nfunction protocolCompatibility(report) {\\n if (!report) {\\n return { compatible: false, reason: \\"missing_protocol_handshake\\" };\\n }\\n const compatible =\\n report.protocol.minimumPluginVersion <= PLUGIN_PROTOCOL_VERSION &&\\n report.protocol.maximumPluginVersion >= PLUGIN_PROTOCOL_VERSION;\\n return {\\n compatible,\\n reason: compatible ? \\"compatible\\" : \\"protocol_version_skew\\",\\n };\\n}\\n\\nasync function inspectCandidate(candidate, options = {}) {\\n const executable = path.resolve(candidate.executable);\\n try {\\n const stat = await fsp.stat(executable);\\n if (!stat.isFile()) {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"not_a_file\\",\\n };\\n }\\n } catch {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"not_found\\",\\n };\\n }\\n\\n if (candidate.source === \\"plugin_runtime\\") {\\n if (\\n typeof candidate.expectedSha256 !== \\"string\\" ||\\n !SHA256_RE.test(candidate.expectedSha256)\\n ) {\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: false,\\n reason: \\"missing_checksum\\",\\n };\\n }\\n try {\\n const actualSha256 = sha256(await fsp.readFile(executable));\\n if (actualSha256 !== candidate.expectedSha256) {\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: false,\\n reason: \\"checksum_mismatch\\",\\n };\\n }\\n } catch {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"checksum_unreadable\\",\\n };\\n }\\n }\\n\\n const result = await runCommand(executable, [\\"protocol\\", \\"--json\\"], options);\\n const report =\\n result.code === 0 ? parseProtocolReport(result.stdout.trim()) : null;\\n const compatibility = protocolCompatibility(report);\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: compatibility.compatible,\\n reason: compatibility.reason,\\n packageVersion: report?.packageVersion,\\n protocol: report?.protocol,\\n platform: report?.runtime?.platform,\\n architecture: report?.runtime?.architecture,\\n capabilities: report?.capabilities || [],\\n };\\n}\\n\\nasync function discoverRuntime(options = {}) {\\n const policy = await runtimePolicy(options);\\n const inspected = [];\\n for (const candidate of await runtimeCandidates(options)) {\\n const result = await inspectCandidate(candidate, options);\\n inspected.push(result);\\n if (result.compatible) {\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n plugin: {\\n version: pluginVersion(),\\n protocolVersion: PLUGIN_PROTOCOL_VERSION,\\n },\\n policy,\\n selected: result,\\n compatible: true,\\n requiresFreshSession: false,\\n candidates: inspected,\\n };\\n }\\n }\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n plugin: {\\n version: pluginVersion(),\\n protocolVersion: PLUGIN_PROTOCOL_VERSION,\\n },\\n policy,\\n selected: null,\\n compatible: false,\\n requiresFreshSession: false,\\n candidates: inspected,\\n };\\n}\\n\\nfunction releaseTarget(\\n platform = process.platform,\\n architecture = process.arch\\n) {\\n if (\\n platform === \\"darwin\\" &&\\n (architecture === \\"arm64\\" || architecture === \\"x64\\")\\n ) {\\n return { platform: \\"darwin\\", architecture, extension: \\"\\" };\\n }\\n if (platform === \\"linux\\" && architecture === \\"x64\\") {\\n return { platform: \\"linux\\", architecture, extension: \\"\\" };\\n }\\n if (platform === \\"win32\\" && architecture === \\"x64\\") {\\n return { platform: \\"windows\\", architecture, extension: \\".exe\\" };\\n }\\n throw new Error(\\n `Unsupported plugin runtime target: ${platform}/${architecture}`\\n );\\n}\\n\\nfunction normalizeVersion(version) {\\n const normalized = version?.startsWith(\\"v\\") ? version.slice(1) : version;\\n if (!(normalized && SEMVER_RE.test(normalized))) {\\n throw new Error(\\"Runtime version must be an explicit semantic version.\\");\\n }\\n return normalized;\\n}\\n\\nfunction assertAllowedUrl(urlValue) {\\n const url = new URL(urlValue);\\n if (url.protocol !== \\"https:\\" || !ALLOWED_DOWNLOAD_HOSTS.has(url.hostname)) {\\n throw new Error(\\n \\"Runtime downloads are restricted to approved HTTPS release hosts.\\"\\n );\\n }\\n return url;\\n}\\n\\nfunction downloadBuffer(urlValue, options = {}) {\\n const maxBytes = options.maxBytes || MAX_METADATA_BYTES;\\n const redirectsRemaining = options.redirectsRemaining ?? 5;\\n const url = assertAllowedUrl(urlValue);\\n return new Promise((resolve, reject) => {\\n const request = https.get(\\n url,\\n {\\n headers: {\\n accept: options.accept || \\"application/octet-stream\\",\\n \\"user-agent\\": \\"fclt-codex-plugin\\",\\n },\\n },\\n (response) => {\\n if (\\n response.statusCode &&\\n response.statusCode >= 300 &&\\n response.statusCode < 400 &&\\n response.headers.location\\n ) {\\n response.resume();\\n if (redirectsRemaining <= 0) {\\n reject(new Error(\\"Runtime download exceeded the redirect limit.\\"));\\n return;\\n }\\n const redirected = new URL(response.headers.location, url).toString();\\n downloadBuffer(redirected, {\\n ...options,\\n redirectsRemaining: redirectsRemaining - 1,\\n }).then(resolve, reject);\\n return;\\n }\\n if (response.statusCode !== 200) {\\n response.resume();\\n reject(\\n new Error(\\n `Runtime download failed with HTTP ${response.statusCode}.`\\n )\\n );\\n return;\\n }\\n const declaredLength = Number(response.headers[\\"content-length\\"] || 0);\\n if (declaredLength > maxBytes) {\\n response.resume();\\n reject(new Error(\\"Runtime download exceeds the allowed size.\\"));\\n return;\\n }\\n const chunks = [];\\n let total = 0;\\n response.on(\\"data\\", (chunk) => {\\n total += chunk.length;\\n if (total > maxBytes) {\\n request.destroy(\\n new Error(\\"Runtime download exceeds the allowed size.\\")\\n );\\n return;\\n }\\n chunks.push(chunk);\\n });\\n response.on(\\"end\\", () => resolve(Buffer.concat(chunks)));\\n }\\n );\\n request.setTimeout(options.timeoutMs || DOWNLOAD_TIMEOUT_MS, () => {\\n request.destroy(new Error(\\"Runtime download timed out.\\"));\\n });\\n request.on(\\"error\\", reject);\\n });\\n}\\n\\nfunction sha256(bytes) {\\n return crypto.createHash(\\"sha256\\").update(bytes).digest(\\"hex\\");\\n}\\n\\nfunction checksumForAsset(checksums, assetName) {\\n for (const line of checksums.split(NEWLINE_RE)) {\\n const match = CHECKSUM_LINE_RE.exec(line.trim());\\n if (match?.[2] === assetName) {\\n return match[1].toLowerCase();\\n }\\n }\\n throw new Error(`Published checksums do not include ${assetName}.`);\\n}\\n\\nasync function withMutationLock(root, action) {\\n await fsp.mkdir(root, { recursive: true, mode: 0o700 });\\n const lockPath = await assertManagedPath(\\n path.join(root, \\"mutation.lock\\"),\\n root\\n );\\n let handle;\\n try {\\n handle = await fsp.open(lockPath, \\"wx\\", 0o600);\\n } catch (error) {\\n if (error?.code === \\"EEXIST\\") {\\n throw new Error(\\n \\"Another fclt plugin runtime mutation is already in progress.\\"\\n );\\n }\\n throw error;\\n }\\n try {\\n return await action();\\n } finally {\\n await handle.close();\\n await fsp.rm(lockPath, { force: true });\\n }\\n}\\n\\nasync function writeJsonAtomic(pathValue, value, root) {\\n const target = await assertManagedPath(pathValue, root);\\n await fsp.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });\\n const temporary = `${target}.tmp-${crypto.randomUUID()}`;\\n await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\\\\n`, {\\n mode: 0o600,\\n });\\n await fsp.rename(temporary, target);\\n}\\n\\nfunction releaseUrls(version, target) {\\n const tag = `v${version}`;\\n const assetName = `fclt-${version}-${target.platform}-${target.architecture}${target.extension}`;\\n const base = `https://github.com/${REPOSITORY}/releases/download/${tag}`;\\n return {\\n tag,\\n assetName,\\n binaryUrl: `${base}/${assetName}`,\\n checksumUrl: `${base}/SHA256SUMS`,\\n };\\n}\\n\\nfunction releaseMetadataUrl(version) {\\n return `https://api.github.com/repos/${REPOSITORY}/releases/tags/v${version}`;\\n}\\n\\nfunction releaseAssets(metadata, version, target) {\\n if (\\n !isPlainObject(metadata) ||\\n metadata.tag_name !== `v${version}` ||\\n !Array.isArray(metadata.assets)\\n ) {\\n throw new Error(\\n \\"Release metadata does not match the requested immutable tag.\\"\\n );\\n }\\n const expected = releaseUrls(version, target);\\n const findAsset = (name) =>\\n metadata.assets.find(\\n (asset) =>\\n isPlainObject(asset) &&\\n asset.name === name &&\\n typeof asset.browser_download_url === \\"string\\"\\n );\\n const binary = findAsset(expected.assetName);\\n const checksums = findAsset(\\"SHA256SUMS\\");\\n if (!(binary && checksums)) {\\n throw new Error(\\n \\"Release metadata is missing the required runtime or checksum asset.\\"\\n );\\n }\\n assertAllowedUrl(binary.browser_download_url);\\n assertAllowedUrl(checksums.browser_download_url);\\n return { binary, checksums, expected };\\n}\\n\\nfunction verifyPublishedDigest(asset, bytes) {\\n if (typeof asset.digest !== \\"string\\" || !asset.digest.trim()) {\\n return null;\\n }\\n const [algorithm, expected] = asset.digest.toLowerCase().split(\\":\\");\\n if (algorithm !== \\"sha256\\" || !SHA256_RE.test(expected || \\"\\")) {\\n throw new Error(`Release asset ${asset.name} has an unsupported digest.`);\\n }\\n const actual = sha256(bytes);\\n if (actual !== expected) {\\n throw new Error(\\n `Release asset ${asset.name} does not match its published digest.`\\n );\\n }\\n return asset.digest.toLowerCase();\\n}\\n\\nasync function resolveLatestVersion(fetchBuffer = downloadBuffer) {\\n const bytes = await fetchBuffer(\\n `https://api.github.com/repos/${REPOSITORY}/releases/latest`,\\n {\\n maxBytes: MAX_METADATA_BYTES,\\n accept: \\"application/vnd.github+json\\",\\n }\\n );\\n const metadata = JSON.parse(bytes.toString(\\"utf8\\"));\\n if (!isPlainObject(metadata) || typeof metadata.tag_name !== \\"string\\") {\\n throw new Error(\\"Latest release metadata did not include a tag.\\");\\n }\\n return normalizeVersion(metadata.tag_name);\\n}\\n\\nasync function checkRuntimeUpdate(options = {}) {\\n const discovery = await discoverRuntime(options);\\n if (!discovery.policy.updateChecksEnabled) {\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n action: \\"check\\",\\n skipped: true,\\n reason: \\"update_checks_disabled\\",\\n currentVersion: discovery.selected?.packageVersion || null,\\n pinnedVersion: discovery.policy.pinnedVersion,\\n mutates: false,\\n };\\n }\\n const latestVersion =\\n discovery.policy.pinnedVersion ||\\n (await resolveLatestVersion(options.fetchBuffer || downloadBuffer));\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n action: \\"check\\",\\n currentVersion: discovery.selected?.packageVersion || null,\\n latestVersion,\\n channel: discovery.policy.pinnedVersion ? \\"pinned\\" : \\"latest\\",\\n updateAvailable: discovery.selected?.packageVersion !== latestVersion,\\n selected: discovery.selected,\\n mutates: false,\\n };\\n}\\n\\nasync function stageRuntime(options) {\\n if (options.approve !== true) {\\n throw new Error(\\"Staging a runtime download requires approve=true.\\");\\n }\\n const version = normalizeVersion(options.version);\\n const target = releaseTarget(options.platform, options.architecture);\\n const root = runtimeStateRoot(options.env, options.platform);\\n const fetchBuffer = options.fetchBuffer || downloadBuffer;\\n const urls = releaseUrls(version, target);\\n const policy = await runtimePolicy(options);\\n if (policy.pinnedVersion && policy.pinnedVersion !== version) {\\n throw new Error(`Runtime policy is pinned to ${policy.pinnedVersion}.`);\\n }\\n\\n return await withMutationLock(root, async () => {\\n const metadataBytes = await fetchBuffer(releaseMetadataUrl(version), {\\n maxBytes: MAX_METADATA_BYTES,\\n accept: \\"application/vnd.github+json\\",\\n });\\n const metadata = JSON.parse(metadataBytes.toString(\\"utf8\\"));\\n const assets = releaseAssets(metadata, version, target);\\n const [checksumBytes, binaryBytes] = await Promise.all([\\n fetchBuffer(assets.checksums.browser_download_url, {\\n maxBytes: MAX_METADATA_BYTES,\\n }),\\n fetchBuffer(assets.binary.browser_download_url, {\\n maxBytes: MAX_BINARY_BYTES,\\n }),\\n ]);\\n const checksumDigest = verifyPublishedDigest(\\n assets.checksums,\\n checksumBytes\\n );\\n const binaryDigest = verifyPublishedDigest(assets.binary, binaryBytes);\\n const expectedSha256 = checksumForAsset(\\n checksumBytes.toString(\\"utf8\\"),\\n urls.assetName\\n );\\n const actualSha256 = sha256(binaryBytes);\\n if (expectedSha256 !== actualSha256) {\\n throw new Error(\\n \\"Downloaded runtime checksum does not match the published SHA256SUMS entry.\\"\\n );\\n }\\n\\n const stageDir = await assertManagedPath(\\n path.join(root, \\"staged\\", version),\\n root\\n );\\n await fsp.rm(stageDir, { recursive: true, force: true });\\n await fsp.mkdir(stageDir, { recursive: true, mode: 0o700 });\\n const executable = await assertManagedPath(\\n path.join(stageDir, target.platform === \\"windows\\" ? \\"fclt.exe\\" : \\"fclt\\"),\\n root\\n );\\n await fsp.writeFile(executable, binaryBytes, { mode: 0o700 });\\n if (target.platform !== \\"windows\\") {\\n await fsp.chmod(executable, 0o700);\\n }\\n\\n const inspected = await inspectCandidate(\\n { executable, source: \\"staged_plugin_runtime\\" },\\n { env: options.env, timeoutMs: options.timeoutMs }\\n );\\n if (!inspected.compatible || inspected.packageVersion !== version) {\\n await fsp.rm(stageDir, { recursive: true, force: true });\\n throw new Error(\\n \\"Staged runtime failed version or protocol verification.\\"\\n );\\n }\\n\\n const manifest = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n version,\\n tag: urls.tag,\\n assetName: urls.assetName,\\n executable,\\n sha256: actualSha256,\\n source: {\\n repository: REPOSITORY,\\n releaseMetadataUrl: releaseMetadataUrl(version),\\n binaryUrl: assets.binary.browser_download_url,\\n binaryAssetId: assets.binary.id ?? null,\\n binaryDigest,\\n checksumUrl: assets.checksums.browser_download_url,\\n checksumAssetId: assets.checksums.id ?? null,\\n checksumDigest,\\n },\\n protocol: inspected.protocol,\\n platform: target.platform,\\n architecture: target.architecture,\\n stagedAt: new Date().toISOString(),\\n };\\n await writeJsonAtomic(path.join(stageDir, \\"manifest.json\\"), manifest, root);\\n return { action: \\"stage\\", mutatesActiveRuntime: false, manifest };\\n });\\n}\\n\\nasync function verifyManifestExecutable(manifest, root, expectedParent) {\\n if (\\n !isPlainObject(manifest) ||\\n manifest.schemaVersion !== STATE_SCHEMA_VERSION ||\\n typeof manifest.version !== \\"string\\" ||\\n typeof manifest.executable !== \\"string\\" ||\\n typeof manifest.sha256 !== \\"string\\" ||\\n !SHA256_RE.test(manifest.sha256)\\n ) {\\n throw new Error(\\"Runtime manifest is missing required verification data.\\");\\n }\\n const executable = path.resolve(manifest.executable);\\n if (!isSubpath(executable, expectedParent)) {\\n throw new Error(\\n \\"Runtime manifest executable escapes its expected directory.\\"\\n );\\n }\\n await assertManagedPath(executable, root);\\n const bytes = await fsp.readFile(executable);\\n if (sha256(bytes) !== manifest.sha256) {\\n throw new Error(\\"Runtime manifest checksum does not match its executable.\\");\\n }\\n const inspected = await inspectCandidate({\\n executable,\\n source: \\"plugin_runtime\\",\\n expectedSha256: manifest.sha256,\\n });\\n if (!inspected.compatible || inspected.packageVersion !== manifest.version) {\\n throw new Error(\\n \\"Runtime manifest executable failed protocol verification.\\"\\n );\\n }\\n return inspected;\\n}\\n\\nasync function setRuntimePolicy(options = {}) {\\n if (options.approve !== true) {\\n throw new Error(\\"Changing runtime update policy requires approve=true.\\");\\n }\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const current = await runtimePolicy(options);\\n const next = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n updateChecksEnabled:\\n typeof options.updateChecksEnabled === \\"boolean\\"\\n ? options.updateChecksEnabled\\n : current.updateChecksEnabled,\\n pinnedVersion: options.clearPin\\n ? null\\n : options.pinnedVersion\\n ? normalizeVersion(options.pinnedVersion)\\n : current.pinnedVersion,\\n updatedAt: new Date().toISOString(),\\n };\\n await writeJsonAtomic(path.join(root, \\"policy.json\\"), next, root);\\n return { action: \\"policy\\", previous: current, policy: next };\\n });\\n}\\n\\nasync function applyStagedRuntime(options) {\\n if (options.approve !== true) {\\n throw new Error(\\"Applying a runtime requires approve=true.\\");\\n }\\n const version = normalizeVersion(options.version);\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const stageDir = path.join(root, \\"staged\\", version);\\n const manifest = await readJson(path.join(stageDir, \\"manifest.json\\"));\\n if (!manifest) {\\n throw new Error(`No staged runtime exists for ${version}.`);\\n }\\n if (options.expectedSha256 !== manifest.sha256) {\\n throw new Error(\\n \\"Staged runtime precondition failed: expected checksum changed.\\"\\n );\\n }\\n await verifyManifestExecutable(manifest, root, stageDir);\\n\\n const activePath = path.join(root, \\"active.json\\");\\n const previous = await readJson(activePath);\\n const versionDir = await assertManagedPath(\\n path.join(root, \\"versions\\", version),\\n root\\n );\\n await fsp.mkdir(versionDir, { recursive: true, mode: 0o700 });\\n const executable = await assertManagedPath(\\n path.join(versionDir, path.basename(manifest.executable)),\\n root\\n );\\n const temporary = `${executable}.tmp-${crypto.randomUUID()}`;\\n await fsp.copyFile(manifest.executable, temporary);\\n if (process.platform !== \\"win32\\") {\\n await fsp.chmod(temporary, 0o700);\\n }\\n await fsp.rename(temporary, executable);\\n const activeManifest = {\\n ...manifest,\\n executable,\\n activatedAt: new Date().toISOString(),\\n previous:\\n typeof previous?.version === \\"string\\" &&\\n typeof previous?.executable === \\"string\\"\\n ? {\\n version: previous.version,\\n executable: previous.executable,\\n sha256: previous.sha256,\\n }\\n : null,\\n };\\n await writeJsonAtomic(activePath, activeManifest, root);\\n const inspected = await verifyManifestExecutable(\\n activeManifest,\\n root,\\n versionDir\\n );\\n return {\\n action: \\"apply\\",\\n active: inspected,\\n previous: activeManifest.previous,\\n rollbackAvailable: Boolean(activeManifest.previous),\\n requiresFreshSession: false,\\n };\\n });\\n}\\n\\nasync function rollbackRuntime(options = {}) {\\n if (options.approve !== true) {\\n throw new Error(\\"Rolling back a runtime requires approve=true.\\");\\n }\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const activePath = path.join(root, \\"active.json\\");\\n const active = await readJson(activePath);\\n if (!isPlainObject(active?.previous)) {\\n throw new Error(\\n \\"The active plugin runtime does not have a retained rollback target.\\"\\n );\\n }\\n if (\\n options.expectedActiveVersion &&\\n options.expectedActiveVersion !== active.version\\n ) {\\n throw new Error(\\n \\"Runtime rollback precondition failed: active version changed.\\"\\n );\\n }\\n const previous = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n version: active.previous.version,\\n executable: active.previous.executable,\\n sha256: active.previous.sha256,\\n previous: {\\n version: active.version,\\n executable: active.executable,\\n sha256: active.sha256,\\n },\\n rolledBackAt: new Date().toISOString(),\\n };\\n const previousDir = path.dirname(path.resolve(previous.executable));\\n const inspected = await verifyManifestExecutable(\\n previous,\\n root,\\n previousDir\\n );\\n await writeJsonAtomic(activePath, previous, root);\\n return {\\n action: \\"rollback\\",\\n active: inspected,\\n rolledBackFrom: active.version,\\n rollbackAvailable: true,\\n requiresFreshSession: false,\\n };\\n });\\n}\\n\\nmodule.exports = {\\n PLUGIN_PROTOCOL_VERSION,\\n applyStagedRuntime,\\n assertManagedPath,\\n checkRuntimeUpdate,\\n checksumForAsset,\\n discoverRuntime,\\n downloadBuffer,\\n normalizeVersion,\\n parseProtocolReport,\\n pluginVersion,\\n protocolCompatibility,\\n releaseTarget,\\n rollbackRuntime,\\n runCommand,\\n runtimeCandidates,\\n runtimePolicy,\\n runtimeStateRoot,\\n setRuntimePolicy,\\n sha256,\\n stageRuntime,\\n};\\n","skills/fclt-capability-review/SKILL.md":"---\\nname: fclt-capability-review\\ndescription: Inspect fclt capability roots, docs, snippets, skills, agents, MCP, and automations.\\ntags: [fclt, capability, review, inventory]\\n---\\n\\n# fclt-capability-review\\n\\n## When To Use\\nUse this skill when Codex needs to understand what capability exists before changing it.\\n\\nUse it for:\\n\\n- checking global and project `.ai` roots\\n- finding relevant skills, snippets, instructions, agents, MCP servers, or automations\\n- deciding whether a change belongs in global or project scope\\n- checking whether managed rendering is enabled or needed\\n- reviewing public/private boundaries before publishing docs or pack assets\\n\\n## Workflow\\n\\n```bash\\nfclt status --json\\nfclt inventory --json\\nfclt list skills\\nfclt list instructions\\nfclt list snippets\\nfclt graph AGENTS.global.md\\n```\\n\\nFor project work:\\n\\n```bash\\nfclt status --project --json\\nfclt inventory --project --json\\n```\\n\\n## Rules\\n\\n- Read existing repo guidance before proposing project capability.\\n- Use project scope for repo-specific commands, tests, architecture, or team workflow.\\n- Use global scope only for broadly reusable behavior.\\n- Keep generated state and review artifacts out of repo-local `.ai`.\\n- Prefer adding or updating the smallest unit: instruction, snippet, skill, agent, MCP config, or automation.\\n- Treat engineering implementation as task work. Do not force product changes\\n through capability evolution merely because evolution tools exist.\\n- Before recommending mutation, state the observed problem, evidence, why the\\n proposed capability is the correct target, risk class, expected outcome,\\n verification plan, assumptions, and recovery route.\\n- Never silently overwrite authored capability or confuse generated/rendered\\n output with canonical source.\\n\\n## Output\\n\\n- capability roots found\\n- relevant assets\\n- scope recommendation\\n- missing or stale capability\\n- safe next command\\n- deliberately withheld operations or missing safer API\\n- verification and undo path for any proposed mutation\\n","skills/fclt-evolution/SKILL.md":"---\\nname: fclt-evolution\\ndescription: Turn repeated fclt writebacks into reviewed capability changes.\\ntags: [fclt, evolution, proposals, capability]\\n---\\n\\n# fclt-evolution\\n\\n## When To Use\\nUse this skill when repeated writebacks, stale canonical assets, or a clearly missing capability should become a concrete proposal.\\n\\nDo not use it for a single weak preference or speculative idea.\\n\\n## Workflow\\n\\n1. Review signal:\\n\\n```bash\\nfclt ai review reconcile --since --until --json\\nfclt ai writeback group --by asset\\nfclt ai writeback summarize --by domain\\nfclt ai evolve list\\n```\\n\\nFor an enabled scheduled loop, inspect its durable queue and observed scheduler\\nhealth with `fclt ai loop status --json`. Use `fclt ai loop run --dry-run\\n--json` for a fresh incremental scan that does not advance cursors or write\\nreconciliation or loop state. The full queue is authoritative; the notification\\ndelta intentionally suppresses unchanged items.\\n\\n2. Assess proposal readiness before mutating state:\\n\\n```bash\\nfclt ai evolve assess --asset --json\\n```\\n\\nUse the assessment recommendation as the decision checkpoint:\\n\\n- `reconcile_sources`: run the bounded read-only source review; writebacks alone cannot prove the window is empty.\\n- `review_reconciled_signals`: review correlated dispositions and linked work without creating one proposal per ticket.\\n- `no_mutation`: do not change capability state; ask for a target or evidence.\\n- `record_more_writeback`: explain what recurrence would justify evolution and record a new writeback only if there is fresh concrete evidence.\\n- `propose`: ask before running the proposal command, then create the smallest target-specific proposal.\\n- `review_existing_proposal`: inspect or revise the existing proposal instead of creating a duplicate.\\n\\n3. Propose only when evidence is strong enough:\\n\\n```bash\\nfclt ai evolve propose\\n```\\n\\n4. Draft and inspect:\\n\\n```bash\\nfclt ai evolve draft EV-00001\\nfclt ai evolve review EV-00001\\n```\\n\\n5. Accept/apply only when scope, target, and evidence are correct:\\n\\n```bash\\nfclt ai evolve accept EV-00001\\nfclt ai evolve apply EV-00001\\n```\\n\\n6. Verify the outcome after the producing loop has had a real chance to run:\\n\\n```bash\\nfclt ai writeback link WB-00001 --issue TEAM-123\\nfclt ai writeback disposition WB-00001 --type task --target TEAM-123\\nfclt ai evolve verify EV-00001 --effectiveness improved --evidence test:post-apply\\n```\\n\\nApply is not completion. Do not resolve source writebacks until post-apply evidence shows the\\nintended behavior improved. Treat recurrence as unchanged or regressed evidence linked to the same\\nevolution, not as an unrelated singleton.\\n\\n## Proposal Kinds\\n\\n- `update_asset`\\n- `create_asset`\\n- `extract_snippet`\\n- `add_skill`\\n- `promote_asset`\\n\\n## Rules\\n\\n- Prefer the smallest valid proposal kind.\\n- Keep project-specific behavior project-scoped until reuse is proven.\\n- Ask for approval before applying global instructions, global skills, plugin behavior, or other broad shared surfaces.\\n- Reject or park proposals that are stale, duplicated, vague, or unsupported.\\n- Use the operator\'s task system for executable implementation work that needs owner, priority, or state.\\n- A no-op answer must still be useful: include the evidence grade, missing signal, next writeback target, and exact approval boundary.\\n- State the problem, source evidence, reason for the selected target, risk,\\n expected outcome, verification plan, assumptions, and undo path before any\\n review-producing or mutating action.\\n- Preview and verify the exact scope. Never overwrite authored capability\\n silently or treat generated/rendered output as the canonical target.\\n- Canonical apply and cross-scope promotion are unavailable through the plugin\\n until a transaction-safe API can return tested rollback data. Do not bypass\\n that boundary with shell or arbitrary CLI arguments.\\n- The plugin exposes only closed-schema loop status and preview actions.\\n Scheduler enable/disable/run and external tracker mutation remain outside the\\n MCP surface.\\n\\n## Output\\n\\n- proposals reviewed\\n- repeated signal\\n- assessment recommendation\\n- proposal created or updated\\n- approvals needed\\n- apply/reject/no-op rationale\\n- actual changed records/artifacts, verification result, and recovery route\\n","skills/fclt-setup/SKILL.md":"---\\nname: fclt-setup\\ndescription: Install, update, inspect, and initialize fclt from Codex.\\ntags: [fclt, setup, codex, onboarding]\\n---\\n\\n# fclt-setup\\n\\n## When To Use\\nUse this skill when a user wants Codex to install, update, configure, inspect, or repair fclt.\\n\\nUse it for:\\n\\n- checking whether `fclt` is installed and current\\n- initializing global `~/.ai`\\n- discovering repositories and enrolling a reviewed minimal project layer\\n- installing or refreshing the built-in operating-model pack\\n- checking setup health with `doctor`\\n- finding canonical, generated, runtime, and review paths\\n\\n## Workflow\\n\\n1. Bootstrap the global loop with one idempotent command:\\n\\n```bash\\nfclt setup\\n```\\n\\nThis initializes or safely updates global capability, prepares writeback/evolution review state,\\nand installs the Codex plugin when Codex is available. It does not initialize the current\\nrepository.\\n\\n2. Inspect runtime selection and compatibility with `fclt_runtime` action\\n `status`. Report the selected executable, version, source, protocol\\n compatibility, and fresh-session state.\\n\\nIf no compatible runtime is available, use the staged lifecycle:\\n\\n- `check` is read-only\\n- `stage` requires an explicit version and approval, but does not activate it\\n- `apply` requires approval plus the staged checksum precondition\\n- `rollback` verifies and restores the retained prior runtime\\n\\nNever curl-pipe code, use an unverified mutable URL, or replace an existing\\nglobal installation silently.\\n\\n3. Check current setup state and exact repair actions:\\n\\n```bash\\nfclt --version\\nfclt paths --json\\nfclt doctor --json\\n```\\n\\nThrough MCP, call `fclt_setup` with an explicit `global` or\\n`global_and_project` scope. Project setup also requires the exact `cwd`.\\nPreview is the default; apply requires both `dryRun: false` and\\n`approve: true`. `global_and_project` returns a no-write project enrollment\\nplan; project application still uses the typed CLI plan-hash contract below.\\n\\n4. For advanced manual recovery, initialize global capability when missing:\\n\\n```bash\\nfclt templates init operating-model --global\\n```\\n\\n5. Discover candidate repositories only beneath explicit roots:\\n\\n```bash\\nfclt projects discover --root ~/dev --since 30d --json\\n```\\n\\nDiscovery is bounded and read-only. Review duplicate clone/worktree groups,\\ndirty state, existing guidance, and repository identity before selecting a\\nproject. Never bulk-enroll the discovery result.\\n\\n6. Preview the exact minimal project enrollment plan:\\n\\n```bash\\nfclt project init --project-root /path/to/repo --json\\n```\\n\\nThe minimal layer is `.ai/.gitignore` plus `.ai/config.toml`. It does not\\ninstall the operating pack, enable managed rendering, schedule a loop, or copy\\n`AGENTS.md`/`CLAUDE.md`. Review every canonical, generated, and machine-local\\nwrite plus the rollback command.\\n\\nIf project guidance should be adopted, name each canonical file explicitly:\\n\\n```bash\\nfclt project init --project-root /path/to/repo \\\\\\n --guidance AGENTS.md --json\\n```\\n\\nGuidance adoption is reference-only. fclt previews the full content and hash,\\nand refuses untracked, modified, secret-shaped, or machine-path-bearing input.\\n\\n7. Apply only the unchanged reviewed plan:\\n\\n```bash\\nfclt project init --project-root /path/to/repo \\\\\\n --apply --plan-sha --json\\n```\\n\\nIf options, source files, or preconditions change, discard the old hash and\\npreview again.\\n\\n8. Inspect health, coverage, and lifecycle:\\n\\n```bash\\nfclt projects status --root /path/to/repo --json\\nfclt project disable --project-root /path/to/repo --json\\nfclt project rollback --receipt --json\\n```\\n\\nDisable and remove decisions preserve canonical files, receipts, and review\\nhistory. Rollback previews by default and refuses drift.\\n\\n9. Install the full operating pack only when explicitly requested:\\n\\n```bash\\nfclt templates init operating-model --project --dry-run\\nfclt templates init operating-model --project\\n```\\n\\nThis is distinct from minimal enrollment and does not seed project\\n`AGENTS.global.md` from repository guidance.\\n\\n10. Refresh global pack defaults non-destructively:\\n\\n```bash\\nfclt templates init operating-model --global --update --dry-run\\nfclt templates init operating-model --global --update\\n```\\n\\n11. Use `--force` only when the user explicitly wants to replace local edits.\\n\\n## Rules\\n\\n- Preserve existing `AGENTS.md`, `CLAUDE.md`, and `AGENTS.global.md` guidance.\\n- Never infer guidance adoption from filenames or copy repository guidance into\\n `.ai/AGENTS.global.md`.\\n- Treat root `AGENTS.md` or `CLAUDE.md` as canonical repository guidance unless\\n the user explicitly chooses another tracked, clean source.\\n- Write the protective `.ai/.gitignore` before generated state.\\n- Keep canonical project files separate from machine-local generated indexes,\\n registries, receipts, and scheduling state.\\n- Keep minimal enrollment separate from the full operating pack and managed\\n rendering.\\n- Treat `doctor --json` issues as setup facts, not user-facing blame.\\n- Treat Codex plugin registration as weaker evidence than fresh-session tool discovery.\\n- Treat external trackers as separate integrations. Core readiness depends only on configured local evidence coverage, not a vendor plugin or token.\\n- Prefer temp-root smoke tests for install/update behavior.\\n- Do not enable managed rendering unless the user wants fclt to write tool homes.\\n- Preview before mutation and state the exact global/project/plugin target.\\n- Do not report a staged runtime or installed plugin as active until the active\\n handshake and fresh-session discovery have been verified.\\n\\n## Output\\n\\n- current installed version\\n- setup health\\n- paths that matter\\n- commands run\\n- what changed\\n- problem, evidence, reason, target, risk, and expected outcome\\n- verification performed and its actual result\\n- assumptions and fresh-session state\\n- exact undo or rollback path\\n- what still needs approval\\n","skills/fclt-writeback/SKILL.md":"---\\nname: fclt-writeback\\ndescription: Record and review fclt writebacks from real agent work.\\ntags: [fclt, writeback, learning, feedback-loop]\\n---\\n\\n# fclt-writeback\\n\\n## When To Use\\nUse this skill when work reveals durable friction, missing context, weak verification, stale guidance, repeated success, or a capability gap.\\n\\nWriteback is for preserving signal. It is not for every preference or one-off annoyance.\\n\\n## Workflow\\n\\n1. Decide scope:\\n\\n- `project` when the learning depends on a repo, test harness, architecture, or workflow.\\n- `global` when the learning applies across projects or shared tool behavior.\\n\\n2. Choose the smallest target:\\n\\n- instruction\\n- snippet\\n- skill\\n- agent\\n- MCP/tool config\\n- automation\\n\\n3. Record writeback when the target and evidence are clear:\\n\\n```bash\\nfclt ai writeback add \\\\\\n --kind missing_context \\\\\\n --category opportunity \\\\\\n --summary \\"Project verification guidance was not discoverable\\" \\\\\\n --details \\"The task had to reconstruct the command from CI configuration\\" \\\\\\n --impact \\"Verification took longer and could have selected the wrong harness\\" \\\\\\n --attempted-workaround \\"Inspected package scripts and CI\\" \\\\\\n --desired-outcome \\"The supported verification command is available at task start\\" \\\\\\n --sensitivity internal \\\\\\n --evidence session: \\\\\\n --asset @project/instructions/TESTING.md\\n```\\n\\n4. Review current signal:\\n\\n```bash\\nfclt ai writeback list\\nfclt ai writeback group --by asset\\nfclt ai writeback summarize --by domain\\nfclt ai loop activity --project\\n```\\n\\n## Rules\\n\\n- Prefer one high-signal writeback over several weak ones.\\n- Include concrete evidence when possible.\\n- Capture concise context, impact, attempted workaround, desired outcome, and\\n sensitivity when they improve review quality.\\n- Never capture hidden chain-of-thought, raw transcripts, unbounded logs,\\n secrets, tokens, or credential-bearing payloads. Reference the smallest\\n redacted external evidence identifier instead.\\n- Do not copy private project detail into global writebacks.\\n- Use task tracking for executable product/tooling work; use writeback for reusable operating-layer learning.\\n- If the same signal repeats and the target is clear, hand off to `fclt-evolution`.\\n- State the observed problem, evidence, target, reason, expected outcome, and\\n assumptions before recording.\\n- Do not capture secrets, private tokens, or raw sensitive payloads as evidence.\\n- For lifecycle mutations, use an explicit scope and expected prior state.\\n Report the journal/review evidence and the available undo transition.\\n\\n## Output\\n\\n- writeback id or no-op rationale\\n- scope\\n- target asset\\n- evidence summary\\n- whether this is ready for evolution\\n- risk class and approval boundary\\n- actual changed records/artifacts\\n- verification result and recovery route\\n"}' ) as Record; export const BUILTIN_FCLT_CODEX_PLUGIN_BINARY_FILES = JSON.parse( diff --git a/src/cli-context.test.ts b/src/cli-context.test.ts index 060cddba..8a9732bf 100644 --- a/src/cli-context.test.ts +++ b/src/cli-context.test.ts @@ -141,7 +141,7 @@ describe("resolveCliContextRoot", () => { ).toThrow("No project-local .ai root found:"); expect(() => resolveCliContextRoot({ homeDir, cwd, scope: "project" }) - ).toThrow('Run "fclt templates init project-ai" in the repo first'); + ).toThrow('Run "fclt project init" to preview minimal enrollment'); }); it("does not treat the global home .ai root as project state", async () => { diff --git a/src/cli-context.ts b/src/cli-context.ts index 3c05df0f..7b078207 100644 --- a/src/cli-context.ts +++ b/src/cli-context.ts @@ -19,7 +19,7 @@ export interface ParsedCliContext { function missingProjectAiRootMessage(pathValue?: string): string { const suffix = pathValue ? `: ${pathValue}` : ""; - return `No project-local .ai root found${suffix}. Run "fclt templates init project-ai" in the repo first, or pass --root /.ai.`; + return `No project-local .ai root found${suffix}. Run "fclt project init" to preview minimal enrollment, then apply the reviewed plan, or pass --root /.ai.`; } function expandHomePath(pathValue: string, home: string): string { diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 318c95bf..a677cb80 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -1681,6 +1681,34 @@ test("project doctor accepts loop skills inherited from the global root", async expect(setupErr).toBe(""); expect(setupCode).toBe(0); expect(setupOut).not.toBe(""); + const projectSetup = Bun.spawn( + [ + "bun", + "run", + cliEntry, + "templates", + "init", + "operating-model", + "--project", + ], + { + cwd: repo, + env, + stdout: "pipe", + stderr: "pipe", + } + ); + expect(await projectSetup.exited).toBe(0); + const projectReviewSetup = Bun.spawn( + ["bun", "run", cliEntry, "ai", "review", "init", "--project"], + { + cwd: repo, + env, + stdout: "pipe", + stderr: "pipe", + } + ); + expect(await projectReviewSetup.exited).toBe(0); await Promise.all([ rm(join(repo, ".ai", "skills", "fclt-writeback"), { @@ -1719,7 +1747,7 @@ test("project doctor accepts loop skills inherited from the global root", async }; }; expect(report.health.ok).toBe(true); - expect(report.loop.state).toBe("ready"); + expect(report.loop.state).toBe("degraded"); expect(report.loop.blockers).not.toContain("writeback_skill_missing"); expect(report.loop.blockers).not.toContain("evolution_skill_missing"); expect(report.loop.capabilities.writebackSkill).toBe(true); @@ -1747,6 +1775,19 @@ test("doctor blocks loop readiness when reconciliation has no enabled sources", { cwd: repo, env, stdout: "pipe", stderr: "pipe" } ); expect(await setup.exited).toBe(0); + const projectSetup = Bun.spawn( + [ + "bun", + "run", + cliEntry, + "templates", + "init", + "operating-model", + "--project", + ], + { cwd: repo, env, stdout: "pipe", stderr: "pipe" } + ); + expect(await projectSetup.exited).toBe(0); await Bun.write( join(repo, ".ai", "reconciliation.json"), JSON.stringify({ version: 1, sources: [] }) @@ -2183,7 +2224,7 @@ test("doctor --repair does not replace project AGENTS.global.md with global defa expect(err).toBe(""); expect(out).toContain("project AGENTS.global.md"); expect(out).toContain( - `fclt templates init project-ai --project-root '${repoDir}' --force` + `fclt templates init operating-model --root '${aiRoot}' --force` ); expect(out).not.toContain("Repaired canonical AGENTS.global.md"); expect(await readFile(agentsPath, "utf8")).toBe(projectGuidance); @@ -2306,7 +2347,7 @@ test("doctor --json flags generated-only project ai roots without exiting nonzer expect(report.actions).toContainEqual( expect.objectContaining({ id: "init-project-ai", - command: `fclt templates init project-ai --project-root '${projectRoot}'`, + command: `fclt project init --project-root '${projectRoot}'`, }) ); } finally { diff --git a/src/doctor.ts b/src/doctor.ts index e8b3bee4..ede1cf52 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -1017,16 +1017,12 @@ async function inspectLegacyRecovery(args: { }; } -function projectAiInitCommand(rootDir: string, flags: string[] = []): string { +function projectAiInitCommand(rootDir: string): string { const projectRoot = projectRootFromAiRoot(rootDir); - const rootFlag = projectRoot ? "--project-root" : "--root"; const rootValue = projectRoot ?? rootDir; - return [ - "fclt templates init project-ai", - rootFlag, - shellQuote(rootValue), - ...flags, - ].join(" "); + return ["fclt project init", "--project-root", shellQuote(rootValue)].join( + " " + ); } async function inspectCanonicalGlobalDocs( @@ -1045,7 +1041,7 @@ async function inspectCanonicalGlobalDocs( const text = await readFile(pathValue, "utf8"); const issues: DoctorIssue[] = []; const refreshCommand = opts.projectRoot - ? projectAiInitCommand(rootDir, ["--force"]) + ? `fclt templates init operating-model --root ${shellQuote(rootDir)} --force` : "fclt templates init operating-model --global --force"; const docLabel = opts.projectRoot ? "project AGENTS.global.md" @@ -1625,7 +1621,7 @@ export async function buildDoctorReport(opts?: { ? "Refresh project operating model" : "Refresh global operating model", command: projectRoot - ? projectAiInitCommand(rootDir, ["--force"]) + ? `fclt templates init operating-model --root ${shellQuote(rootDir)} --force` : "fclt templates init operating-model --global --force", risk: "canonical_write", }); @@ -1658,7 +1654,7 @@ export async function buildDoctorReport(opts?: { }); actions.push({ id: "init-project-ai", - label: "Initialize project AI root", + label: "Preview minimal project enrollment", command: projectAiInitCommand(rootDir), risk: "canonical_write", }); @@ -1768,13 +1764,15 @@ export async function buildDoctorReport(opts?: { message: "Required writeback/evolution skills are missing from the selected root.", fix: projectRoot - ? "Run `fclt setup` from the project root." - : "Run `fclt setup --global-only`.", + ? "Run `fclt templates init operating-model --project` for an explicit full-pack install." + : "Run `fclt setup`.", }); actions.push({ id: "bootstrap-loop-assets", label: "Install required writeback/evolution assets", - command: projectRoot ? "fclt setup" : "fclt setup --global-only", + command: projectRoot + ? "fclt templates init operating-model --project" + : "fclt setup", risk: "canonical_write", }); } diff --git a/src/fclt-mcp-plugin.test.ts b/src/fclt-mcp-plugin.test.ts index 3aec9c66..316cb799 100644 --- a/src/fclt-mcp-plugin.test.ts +++ b/src/fclt-mcp-plugin.test.ts @@ -375,7 +375,7 @@ describe("bundled fclt MCP plugin", () => { expect(defaultResponse.result?.isError).toBe(false); expect(toolPayload(defaultResponse).result.stdout).toEqual({ cwd: await realpath(workspace), - argv: ["setup", "--json", "--global-only", "--dry-run"], + argv: ["setup", "--json", "--dry-run"], }); child.stdin.write( @@ -401,7 +401,7 @@ describe("bundled fclt MCP plugin", () => { expect(disabledResponse.result?.isError).toBe(false); expect(toolPayload(disabledResponse).result.stdout).toEqual({ cwd: await realpath(workspace), - argv: ["setup", "--json", "--no-codex-plugin"], + argv: ["setup", "--json", "--include-project", "--no-codex-plugin"], }); expect(toolPayload(disabledResponse).operation).toMatchObject({ preview: false, diff --git a/src/index.ts b/src/index.ts index b3afda7c..5e0d6814 100755 --- a/src/index.ts +++ b/src/index.ts @@ -148,6 +148,10 @@ function printHelp() { "setup", "Install narrow agent integrations without full managed mode", ], + [ + "projects/project", + "Discover repositories and preview/apply minimal project enrollment", + ], ["ai", "Capture writeback and evolve canonical assets"], ], }), @@ -1391,6 +1395,16 @@ async function main(argv: string[]) { case "setup": await import("./setup").then(({ setupCommand }) => setupCommand(rest)); return; + case "projects": + await import("./projects").then(({ projectsCommand }) => + projectsCommand(rest) + ); + return; + case "project": + await import("./projects").then(({ projectCommand }) => + projectCommand(rest) + ); + return; case "autosync": await import("./autosync").then(({ autosyncCommand }) => autosyncCommand(rest) diff --git a/src/paths.ts b/src/paths.ts index c6e04ac4..52107762 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -14,6 +14,7 @@ import { import { parseJsonLenient } from "./util/json"; const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/; +const PROJECT_REPOSITORY_ID_RE = /^repo_[a-f0-9]{24}$/; export interface FacultConfig { /** @@ -293,6 +294,31 @@ export function machineStateProjectKey( config?: FacultConfig | null ): string { const projectRoot = projectRootFromAiRoot(rootDir, home, config); + if (projectRoot) { + const projectConfigPath = join(rootDir, "config.toml"); + try { + const parsed = Bun.TOML.parse(readFileSync(projectConfigPath, "utf8")); + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + "project" in parsed + ) { + const project = (parsed as Record).project; + if (isPlainObject(project)) { + const repositoryId = project.repository_id; + if ( + typeof repositoryId === "string" && + PROJECT_REPOSITORY_ID_RE.test(repositoryId) + ) { + return repositoryId; + } + } + } + } catch { + // Fall back to the legacy path-derived key for unenrolled projects. + } + } const labelSource = projectRoot ?? rootDir; const label = basename(labelSource).trim().toLowerCase(); const slug = label.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); diff --git a/src/projects.test.ts b/src/projects.test.ts new file mode 100644 index 00000000..f9095ad6 --- /dev/null +++ b/src/projects.test.ts @@ -0,0 +1,711 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rename, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { runFixtureGit } from "../test/git-fixture"; +import { facultAiIndexPath, facultMachineStateDir } from "./paths"; +import { + applyProjectEnrollment, + buildProjectsStatus, + discoverProjects, + planProjectEnrollment, + projectCommand, + resolveRepositoryIdentity, + rollbackProjectEnrollment, +} from "./projects"; + +const originalCwd = process.cwd(); +const originalExitCode = process.exitCode; + +afterEach(() => { + process.chdir(originalCwd); + process.exitCode = originalExitCode; +}); + +async function makeFixture(): Promise<{ + root: string; + home: string; +}> { + const root = await realpath(await mkdtemp(join(tmpdir(), "fclt-projects-"))); + const home = join(root, "home"); + await mkdir(home, { recursive: true }); + return { root, home }; +} + +async function createRepository(args: { + path: string; + home: string; + files?: Record; +}): Promise { + await mkdir(dirname(args.path), { recursive: true }); + await runFixtureGit({ + argv: ["init", "-b", "main", args.path], + repoDir: args.path, + homeDir: args.home, + }); + for (const [relativePath, content] of Object.entries( + args.files ?? { "README.md": "# Fixture\n" } + )) { + const pathValue = join(args.path, relativePath); + await mkdir(dirname(pathValue), { recursive: true }); + await writeFile(pathValue, content, "utf8"); + } + await runFixtureGit({ + argv: ["add", "."], + repoDir: args.path, + homeDir: args.home, + cwd: args.path, + }); + await runFixtureGit({ + argv: [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-m", + "fixture", + ], + repoDir: args.path, + homeDir: args.home, + cwd: args.path, + }); +} + +async function listTree(root: string): Promise { + const out: string[] = []; + async function visit(current: string): Promise { + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === ".git") { + continue; + } + const pathValue = join(current, entry.name); + out.push(pathValue.slice(root.length + 1)); + if (entry.isDirectory()) { + await visit(pathValue); + } + } + } + await visit(root); + return out.sort(); +} + +describe("project discovery", () => { + it("requires explicit roots and performs no writes", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "portfolio", "alpha"); + await createRepository({ path: repo, home }); + const before = await listTree(root); + + await expect(discoverProjects({ roots: [] })).rejects.toThrow( + "requires at least one explicit --root" + ); + const discovery = await discoverProjects({ + roots: [join(root, "portfolio")], + maxVisits: 100, + maxResults: 10, + }); + + expect(discovery.projects).toHaveLength(1); + expect(discovery.projects[0]?.root).toBe(repo); + expect(discovery.bounds.truncated).toBe(false); + expect(await listTree(root)).toEqual(before); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + }); + + it("correlates duplicate clones and worktrees by portable identity", async () => { + const { root, home } = await makeFixture(); + const source = join(root, "source"); + const clone = join(root, "clone"); + const worktree = join(root, "worktree"); + await createRepository({ path: source, home }); + await runFixtureGit({ + argv: ["clone", source, clone], + repoDir: clone, + homeDir: home, + cwd: root, + }); + await runFixtureGit({ + argv: ["worktree", "add", "-b", "fixture-worktree", worktree], + repoDir: source, + homeDir: home, + cwd: source, + }); + + const discovery = await discoverProjects({ + roots: [root], + maxVisits: 100, + maxResults: 10, + }); + + expect(discovery.projects).toHaveLength(3); + expect( + new Set(discovery.projects.map((item) => item.identity.id)).size + ).toBe(1); + expect(discovery.groups[0]?.locations).toEqual( + [clone, source, worktree].sort() + ); + expect( + discovery.projects.every((item) => item.duplicateLocations === 3) + ).toBe(true); + }); + + it("preserves identity when a checkout root is renamed", async () => { + const { root, home } = await makeFixture(); + const initial = join(root, "before"); + const renamed = join(root, "after"); + await createRepository({ path: initial, home }); + const before = await resolveRepositoryIdentity(initial); + + await rename(initial, renamed); + const after = await resolveRepositoryIdentity(renamed); + + expect(after.id).toBe(before.id); + expect(after.kind).toBe("root-commit"); + expect(after.stability).toBe("portable"); + }); + + it("normalizes HTTPS and SSH URLs for stable clone identity", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + await runFixtureGit({ + argv: [ + "remote", + "add", + "origin", + "https://github.com/example/project.git", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const httpsIdentity = await resolveRepositoryIdentity(repo); + await runFixtureGit({ + argv: [ + "remote", + "set-url", + "origin", + "git@github.com:example/project.git", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const sshIdentity = await resolveRepositoryIdentity(repo); + + expect(sshIdentity.id).toBe(httpsIdentity.id); + expect(sshIdentity.fingerprint).toBe("github.com/example/project"); + }); + + it("applies the since filter without mutating repositories", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + + const discovery = await discoverProjects({ + roots: [root], + since: "1h", + now: new Date("2100-01-01T00:00:00.000Z"), + }); + + expect(discovery.projects).toEqual([]); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + }); +}); + +describe("project enrollment planning", () => { + it("is minimal, exact, no-write, and does not duplicate root guidance", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const guidance = + "# Canonical repository rules\n\n- Run the project checks.\n"; + await createRepository({ + path: repo, + home, + files: { + "AGENTS.md": guidance, + "README.md": "# Public fixture\n", + }, + }); + const before = await listTree(repo); + + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["AGENTS.md"], + }); + + expect(plan.guidancePreview).toEqual([ + { + path: "AGENTS.md", + sha256: expect.any(String), + content: guidance, + gitState: "clean-tracked", + adoption: "reference", + }, + ]); + expect(plan.canonicalWrites.map((write) => write.path)).toEqual([ + join(repo, ".ai", ".gitignore"), + join(repo, ".ai", "config.toml"), + ]); + expect( + plan.canonicalWrites.some((write) => + write.path.endsWith("AGENTS.global.md") + ) + ).toBe(false); + expect(plan.canonicalWrites[1]?.content).toContain( + 'guidance = ["AGENTS.md"]' + ); + expect(plan.protections).toEqual({ + ignoreWrittenFirst: true, + managedRendering: false, + automaticGuidanceCopy: false, + privacyFindings: [], + }); + expect(await listTree(repo)).toEqual(before); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + }); + + it("does not adopt guidance unless explicitly selected", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ + path: repo, + home, + files: { "AGENTS.md": "# Existing\n" }, + }); + + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + expect(plan.guidancePreview).toEqual([]); + expect(plan.options.guidance).toEqual([]); + expect(plan.warnings.join("\n")).toContain("not copied or adopted"); + }); + + it("keeps scheduling outside minimal enrollment", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + cadence: "weekly", + scheduling: true, + }) + ).rejects.toThrow("does not install scheduling"); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + }); + + it("refuses dirty or untracked guidance without writing", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ + path: repo, + home, + files: { "AGENTS.md": "# Reviewed\n" }, + }); + await writeFile( + join(repo, "AGENTS.md"), + "# Dirty local guidance\n", + "utf8" + ); + + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["AGENTS.md"], + }) + ).rejects.toThrow("source must be tracked and clean"); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + + await writeFile(join(repo, "CLAUDE.md"), "# Untracked\n", "utf8"); + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["CLAUDE.md"], + }) + ).rejects.toThrow("source must be tracked and clean"); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + }); + + it("refuses secret-shaped and machine-local guidance in public fixtures", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "public-repo"); + await createRepository({ + path: repo, + home, + files: { + "docs/safe.md": + "# Safe public guidance\n\nRun the documented checks.\n", + "docs/local.md": + "# Local\n\nRead /Users/example/private/config.toml.\n", + "docs/secret.md": "# Secret\n\napi_key = abcdefghijklmnop\n", + }, + }); + + const safe = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["docs/safe.md"], + }); + expect(safe.guidancePreview[0]?.path).toBe("docs/safe.md"); + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["docs/local.md"], + }) + ).rejects.toThrow("machine-local absolute path"); + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["docs/secret.md"], + }) + ).rejects.toThrow("secret-shaped content"); + }); + + it("preserves existing ignore rules and versioned canonical config", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + await mkdir(join(repo, ".ai"), { recursive: true }); + await writeFile( + join(repo, ".ai", ".gitignore"), + "/private.local\n", + "utf8" + ); + + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + expect(plan.canonicalWrites[0]?.content).toContain("/private.local"); + expect(plan.canonicalWrites[0]?.content).toContain("/.facult/"); + + await writeFile( + join(repo, ".ai", "config.toml"), + "version = 1\n\n[custom]\nowned = true\n", + "utf8" + ); + const merged = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + expect(merged.canonicalWrites[1]?.content).toContain("[custom]"); + expect(merged.canonicalWrites[1]?.content).toContain("owned = true"); + expect(merged.canonicalWrites[1]?.content).toContain("[project]"); + + await writeFile( + join(repo, ".ai", "config.toml"), + 'version = 1\n\n[project]\nrepository_id = "repo_conflict"\n', + "utf8" + ); + await expect( + planProjectEnrollment({ projectRoot: repo, homeDir: home }) + ).rejects.toThrow( + "Refusing to replace existing canonical project enrollment config" + ); + }); + + it("refuses symlinked project state and guidance", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const outside = join(root, "outside"); + await createRepository({ path: repo, home }); + await mkdir(outside, { recursive: true }); + await mkdir(join(repo, "docs"), { recursive: true }); + await writeFile(join(outside, "guidance.md"), "# Private\n", "utf8"); + await symlink(join(outside, "guidance.md"), join(repo, "docs", "link.md")); + await runFixtureGit({ + argv: ["add", "docs/link.md"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await runFixtureGit({ + argv: [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-m", + "track symlink", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["docs/link.md"], + }) + ).rejects.toThrow("source must be a regular file"); + + await symlink(outside, join(repo, ".ai")); + await expect( + planProjectEnrollment({ projectRoot: repo, homeDir: home }) + ).rejects.toThrow("unsafe project AI root"); + }); +}); + +describe("project enrollment lifecycle", () => { + it("requires the reviewed hash and writes protection before generated state", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: "wrong", + homeDir: home, + }) + ).rejects.toThrow("exact plan SHA"); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + + const result = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + now: new Date("2026-07-28T12:00:00.000Z"), + }); + + expect(result.changedPaths).toEqual([ + join(repo, ".ai", ".gitignore"), + join(repo, ".ai", "config.toml"), + ]); + expect(await readFile(join(repo, ".ai", ".gitignore"), "utf8")).toContain( + "/.facult/" + ); + expect(await Bun.file(join(repo, ".ai", ".facult")).exists()).toBe(false); + expect(await Bun.file(join(repo, ".ai", "AGENTS.global.md")).exists()).toBe( + false + ); + expect( + await Bun.file(facultAiIndexPath(home, join(repo, ".ai"))).exists() + ).toBe(true); + expect( + facultMachineStateDir(home, join(repo, ".ai")).endsWith(plan.identity.id) + ).toBe(true); + expect(await Bun.file(result.registryPath).exists()).toBe(true); + const status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(status.projects[0]?.generated).toEqual({ + index: true, + graph: true, + health: "ready", + }); + }); + + it("reports unenrolled discovered repositories without writing", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + + const status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + + expect(status.projects).toHaveLength(1); + expect(status.projects[0]).toMatchObject({ + decision: "inactive", + coverage: "inactive", + health: "degraded", + canonical: { + exists: false, + config: false, + protectiveIgnore: false, + }, + generated: { index: false, graph: false, health: "missing" }, + }); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + expect(await Bun.file(status.registryPath).exists()).toBe(false); + }); + + it("refuses a stale plan before any write", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await mkdir(join(repo, ".ai"), { recursive: true }); + await writeFile(join(repo, ".ai", ".gitignore"), "/user-change\n", "utf8"); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }) + ).rejects.toThrow("plan is stale"); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + false + ); + expect(await readFile(join(repo, ".ai", ".gitignore"), "utf8")).toBe( + "/user-change\n" + ); + }); + + it("previews and applies rollback while preserving receipts and history", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + now: new Date("2026-07-28T12:00:00.000Z"), + }); + + const preview = await rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + }); + expect(preview.applied).toBe(false); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + true + ); + + const rolledBack = await rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + apply: true, + now: new Date("2026-07-28T13:00:00.000Z"), + }); + expect(rolledBack.applied).toBe(true); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + false + ); + expect(await Bun.file(join(repo, ".ai", ".gitignore")).exists()).toBe( + false + ); + for (const preserved of rolledBack.preserved) { + expect(await Bun.file(preserved).exists()).toBe(true); + } + const status = await buildProjectsStatus({ homeDir: home }); + expect(status.projects[0]?.decision).toBe("disabled"); + expect(status.projects[0]?.coverage).toBe("inactive"); + }); + + it("disable and remove preserve canonical files and review history", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + + const previousLog = console.log; + console.log = () => undefined; + try { + await projectCommand(["disable", "--project-root", repo, "--json"], { + homeDir: home, + }); + let statusResult = await buildProjectsStatus({ homeDir: home }); + expect(statusResult.projects[0]?.decision).toBe("disabled"); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + true + ); + + await projectCommand(["remove", "--project-root", repo, "--json"], { + homeDir: home, + }); + statusResult = await buildProjectsStatus({ homeDir: home }); + expect(statusResult.projects[0]?.decision).toBe("removed"); + expect(statusResult.projects[0]?.canonical.exists).toBe(true); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + true + ); + } finally { + console.log = previousLog; + } + }); + + it("uses one machine-state key after cloning an enrolled repository", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const clone = join(root, "clone"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + await runFixtureGit({ + argv: ["add", ".ai"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await runFixtureGit({ + argv: [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-m", + "enroll", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await runFixtureGit({ + argv: ["clone", repo, clone], + repoDir: clone, + homeDir: home, + cwd: root, + }); + + expect(facultMachineStateDir(home, join(repo, ".ai"))).toBe( + facultMachineStateDir(home, join(clone, ".ai")) + ); + }); +}); diff --git a/src/projects.ts b/src/projects.ts new file mode 100644 index 00000000..ca489eca --- /dev/null +++ b/src/projects.ts @@ -0,0 +1,1763 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + lstat, + mkdir, + readdir, + readFile, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, +} from "node:path"; +import { buildIndex } from "./index-builder"; +import { + facultAiGraphPath, + facultAiIndexPath, + facultLocalStateRoot, +} from "./paths"; + +const DEFAULT_MAX_VISITS = 10_000; +const DEFAULT_MAX_RESULTS = 250; +const DISCOVERY_IGNORES = new Set([ + ".cache", + ".git", + ".next", + ".turbo", + ".venv", + "build", + "coverage", + "dist", + "node_modules", + "target", + "vendor", +]); +const PROJECT_SOURCES = new Set(["git", "guidance", "writebacks"]); +const PROJECT_CADENCES = new Set(["on-demand", "weekly", "daily"]); +const PROTECTIVE_IGNORE_LINES = [ + "# fclt machine-local and generated state", + "/.facult/", + "/config.local.toml", +]; +const SECRET_SHAPE_RE = + /(?:api[_-]?key|access[_-]?token|client[_-]?secret|password)\s*[:=]\s*["']?[^\s"'#]{8,}/i; +const PRIVATE_KEY_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/; +const LOCAL_ABSOLUTE_PATH_RE = + /(?:^|[\s"'`(])(?:\/Users\/[^/\s]+|\/home\/[^/\s]+|[A-Za-z]:\\Users\\[^\\\s]+)/m; +const REPOSITORY_ID_RE = /^repo_[a-f0-9]{24}$/; +const SCP_REMOTE_RE = /^([^@/\s]+@)?([^:/\s]+):(.+)$/; +const GIT_PROTOCOL_PREFIX_RE = /^git\+/; +const GIT_SUFFIX_RE = /\.git\/?$/; +const TRAILING_SLASH_RE = /\/+$/; +const LINE_SPLIT_RE = /\r?\n/; +const SINCE_RE = /^(\d+)([dhw])$/; +const PATH_PART_SPLIT_RE = /[\\/]/; +const RECEIPT_ID_RE = /^enroll-[a-zA-Z0-9-]+$/; +const NON_DIGIT_RE = /[^0-9]/g; +const PLAN_SHA_RE = /^[a-f0-9]{64}$/; + +export type ProjectDecision = + | "selected" + | "inactive" + | "ignored" + | "disabled" + | "removed"; +export type ProjectCadence = "on-demand" | "weekly" | "daily"; +export type ProjectSource = "git" | "guidance" | "writebacks"; + +interface GitCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface RepositoryIdentity { + id: string; + kind: "remote" | "root-commit" | "git-common-dir"; + fingerprint: string; + stability: "portable" | "machine-local"; +} + +export interface DiscoveredProject { + root: string; + name: string; + identity: RepositoryIdentity; + branch: string | null; + head: string | null; + lastCommitAt: string | null; + dirty: boolean; + canonicalAiRoot: string; + canonicalAiExists: boolean; + protectiveIgnore: boolean; + duplicateLocations: number; +} + +export interface ProjectDiscovery { + version: 1; + roots: string[]; + since: string | null; + bounds: { + maxVisits: number; + maxResults: number; + visited: number; + truncated: boolean; + }; + projects: DiscoveredProject[]; + groups: Array<{ + repositoryId: string; + locations: string[]; + }>; +} + +export interface GuidancePreview { + path: string; + sha256: string; + content: string; + gitState: "clean-tracked"; + adoption: "reference"; +} + +interface FilePrecondition { + path: string; + existed: boolean; + sha256: string | null; +} + +export interface ProjectEnrollmentPlan { + version: 1; + operation: "project-init"; + projectRoot: string; + aiRoot: string; + identity: RepositoryIdentity; + worktree: { + dirty: boolean; + branch: string | null; + head: string | null; + }; + options: { + sources: ProjectSource[]; + cadence: ProjectCadence; + scheduling: boolean; + guidance: string[]; + }; + guidancePreview: GuidancePreview[]; + canonicalWrites: Array<{ + path: string; + content: string; + reason: string; + precondition: FilePrecondition; + }>; + generatedWrites: Array<{ + path: string; + reason: string; + }>; + machineLocalWrites: Array<{ + path: string; + reason: string; + }>; + protections: { + ignoreWrittenFirst: true; + managedRendering: false; + automaticGuidanceCopy: false; + privacyFindings: string[]; + }; + warnings: string[]; + rollback: { + command: string; + preservesReviewHistory: true; + }; + planSha256: string; +} + +interface ProjectRegistryLocation { + path: string; + firstSeenAt: string; + lastSeenAt: string; +} + +interface ProjectRegistryHistory { + at: string; + action: + | "enrolled" + | "disabled" + | "ignored" + | "inactive" + | "removed" + | "rolled-back"; + root: string; + receiptId?: string; +} + +interface ProjectRegistryEntry { + repositoryId: string; + identityKind: RepositoryIdentity["kind"]; + identityFingerprint: string; + decision: ProjectDecision; + sources: ProjectSource[]; + cadence: ProjectCadence; + scheduling: boolean; + guidance: string[]; + locations: ProjectRegistryLocation[]; + lastSuccessfulRun: string | null; + pendingApprovals: string[]; + history: ProjectRegistryHistory[]; +} + +interface ProjectRegistry { + version: 1; + updatedAt: string; + projects: Record; +} + +interface EnrollmentReceipt { + version: 1; + id: string; + createdAt: string; + repositoryId: string; + projectRoot: string; + planSha256: string; + files: Array<{ + path: string; + before: string | null; + afterSha256: string; + }>; +} + +export interface ProjectCommandContext { + cwd?: string; + homeDir?: string; + now?: () => Date; +} + +interface ProjectStatusRow { + repositoryId: string; + decision: ProjectDecision; + coverage: "covered" | "partial" | "inactive"; + health: "healthy" | "degraded" | "unavailable"; + canonicalRoot: string | null; + canonical: { + exists: boolean; + config: boolean; + protectiveIgnore: boolean; + guidance: string[]; + }; + generated: { + index: boolean; + graph: boolean; + health: "ready" | "missing"; + }; + sources: ProjectSource[]; + scheduler: { + cadence: ProjectCadence; + enabled: boolean; + health: "on-demand" | "not-enabled" | "configured"; + lastSuccessfulRun: string | null; + }; + pendingApprovals: string[]; + locations: Array<{ + path: string; + exists: boolean; + dirty: boolean | null; + }>; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableJson(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +async function runGit(args: { + cwd: string; + argv: string[]; +}): Promise { + const gitBinary = Bun.which("git") ?? "/usr/bin/git"; + const proc = Bun.spawn({ + cmd: [gitBinary, ...args.argv], + cwd: args.cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { + exitCode, + stdout: stdout.trim(), + stderr: stderr.trim(), + }; +} + +async function pathExists(pathValue: string): Promise { + return await stat(pathValue) + .then(() => true) + .catch(() => false); +} + +async function fileText(pathValue: string): Promise { + return await readFile(pathValue, "utf8").catch(() => null); +} + +async function filePrecondition(pathValue: string): Promise { + const content = await fileText(pathValue); + return { + path: pathValue, + existed: content !== null, + sha256: content === null ? null : sha256(content), + }; +} + +function normalizeRemote(raw: string): string | null { + const value = raw.trim(); + if (!value || value.startsWith("/") || value.startsWith("./")) { + return null; + } + const scpMatch = value.includes("://") ? null : value.match(SCP_REMOTE_RE); + const asUrl = scpMatch + ? `ssh://${scpMatch[2]}/${scpMatch[3]}` + : value.replace(GIT_PROTOCOL_PREFIX_RE, ""); + try { + const parsed = new URL(asUrl); + if (parsed.protocol === "file:") { + return null; + } + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + parsed.hostname = parsed.hostname.toLowerCase(); + parsed.pathname = parsed.pathname + .replace(GIT_SUFFIX_RE, "") + .replace(TRAILING_SLASH_RE, ""); + const port = + parsed.port && + !( + (parsed.protocol === "https:" && parsed.port === "443") || + (parsed.protocol === "http:" && parsed.port === "80") || + (parsed.protocol === "ssh:" && parsed.port === "22") + ) + ? `:${parsed.port}` + : ""; + return `${parsed.hostname}${port}${parsed.pathname}`; + } catch { + return null; + } +} + +async function gitRoot(pathValue: string): Promise { + const result = await runGit({ + cwd: resolve(pathValue), + argv: ["rev-parse", "--show-toplevel"], + }); + if (result.exitCode !== 0 || !result.stdout) { + throw new Error(`Not a Git repository: ${resolve(pathValue)}`); + } + return await realpath(result.stdout).catch(() => resolve(result.stdout)); +} + +export async function resolveRepositoryIdentity( + projectRoot: string +): Promise { + const root = await gitRoot(projectRoot); + const remotes = await runGit({ cwd: root, argv: ["remote"] }); + if (remotes.exitCode === 0) { + for (const name of remotes.stdout.split("\n").filter(Boolean).sort()) { + const remote = await runGit({ + cwd: root, + argv: ["remote", "get-url", name], + }); + const normalized = + remote.exitCode === 0 ? normalizeRemote(remote.stdout) : null; + if (normalized) { + return { + id: `repo_${sha256(`remote:${normalized}`).slice(0, 24)}`, + kind: "remote", + fingerprint: normalized, + stability: "portable", + }; + } + } + } + + const roots = await runGit({ + cwd: root, + argv: ["rev-list", "--max-parents=0", "HEAD"], + }); + const rootCommit = roots.stdout.split("\n").filter(Boolean).sort()[0]; + if (roots.exitCode === 0 && rootCommit) { + return { + id: `repo_${sha256(`root-commit:${rootCommit}`).slice(0, 24)}`, + kind: "root-commit", + fingerprint: rootCommit, + stability: "portable", + }; + } + + const commonDir = await runGit({ + cwd: root, + argv: ["rev-parse", "--git-common-dir"], + }); + const commonPath = resolve(root, commonDir.stdout || ".git"); + return { + id: `repo_${sha256(`git-common-dir:${commonPath}`).slice(0, 24)}`, + kind: "git-common-dir", + fingerprint: sha256(commonPath), + stability: "machine-local", + }; +} + +async function inspectRepository( + rootValue: string +): Promise { + const root = await gitRoot(rootValue); + const [identity, branch, head, lastCommit, statusResult] = await Promise.all([ + resolveRepositoryIdentity(root), + runGit({ cwd: root, argv: ["branch", "--show-current"] }), + runGit({ cwd: root, argv: ["rev-parse", "--verify", "HEAD"] }), + runGit({ cwd: root, argv: ["log", "-1", "--format=%cI"] }), + runGit({ + cwd: root, + argv: ["status", "--porcelain=v1", "--untracked-files=all"], + }), + ]); + const aiRoot = join(root, ".ai"); + const ignoreText = await fileText(join(aiRoot, ".gitignore")); + return { + root, + name: basename(root), + identity, + branch: branch.exitCode === 0 && branch.stdout ? branch.stdout : null, + head: head.exitCode === 0 && head.stdout ? head.stdout : null, + lastCommitAt: + lastCommit.exitCode === 0 && lastCommit.stdout ? lastCommit.stdout : null, + dirty: statusResult.exitCode === 0 && Boolean(statusResult.stdout), + canonicalAiRoot: aiRoot, + canonicalAiExists: await pathExists(aiRoot), + protectiveIgnore: PROTECTIVE_IGNORE_LINES.slice(1).every((line) => + ignoreText?.split(LINE_SPLIT_RE).includes(line) + ), + duplicateLocations: 1, + }; +} + +function parseSince(value: string | undefined, now: Date): Date | null { + if (!value) { + return null; + } + const match = value.match(SINCE_RE); + if (!match) { + throw new Error( + "--since must use a bounded duration such as 30d, 12h, or 8w" + ); + } + const amount = Number.parseInt(match[1] ?? "", 10); + const unit = match[2]; + const multiplier = + unit === "h" + ? 60 * 60 * 1000 + : unit === "w" + ? 7 * 24 * 60 * 60 * 1000 + : 24 * 60 * 60 * 1000; + return new Date(now.getTime() - amount * multiplier); +} + +async function discoverGitRoots(args: { + roots: string[]; + maxVisits: number; + maxResults: number; +}): Promise<{ roots: string[]; visited: number; truncated: boolean }> { + const queue = [...args.roots.map((root) => resolve(root))]; + const found = new Set(); + const visitedPaths = new Set(); + let visited = 0; + let truncated = false; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || visitedPaths.has(current)) { + continue; + } + visitedPaths.add(current); + visited += 1; + if (visited > args.maxVisits || found.size >= args.maxResults) { + truncated = true; + break; + } + const entries = await readdir(current, { withFileTypes: true }).catch( + () => [] + ); + if (entries.some((entry) => entry.name === ".git")) { + found.add(await realpath(current).catch(() => current)); + } + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.isSymbolicLink() || + DISCOVERY_IGNORES.has(entry.name) + ) { + continue; + } + queue.push(join(current, entry.name)); + } + } + + return { + roots: [...found].sort(), + visited: Math.min(visited, args.maxVisits), + truncated, + }; +} + +export async function discoverProjects(args: { + roots: string[]; + since?: string; + maxVisits?: number; + maxResults?: number; + now?: Date; +}): Promise { + if (args.roots.length === 0) { + throw new Error( + "projects discover requires at least one explicit --root; home-wide discovery is never implicit" + ); + } + const roots = [...new Set(args.roots.map((root) => resolve(root)))]; + const maxVisits = args.maxVisits ?? DEFAULT_MAX_VISITS; + const maxResults = args.maxResults ?? DEFAULT_MAX_RESULTS; + if (maxVisits < 1 || maxResults < 1) { + throw new Error("discovery bounds must be positive integers"); + } + const cutoff = parseSince(args.since, args.now ?? new Date()); + const discovered = await discoverGitRoots({ roots, maxVisits, maxResults }); + const inspected = await Promise.all( + discovered.roots.map(async (root) => await inspectRepository(root)) + ); + const projects = inspected + .filter((project) => { + if (!cutoff) { + return true; + } + return ( + project.lastCommitAt !== null && + new Date(project.lastCommitAt).getTime() >= cutoff.getTime() + ); + }) + .sort((left, right) => left.root.localeCompare(right.root)); + const grouped = new Map(); + for (const project of projects) { + const locations = grouped.get(project.identity.id) ?? []; + locations.push(project.root); + grouped.set(project.identity.id, locations); + } + for (const project of projects) { + project.duplicateLocations = grouped.get(project.identity.id)?.length ?? 1; + } + return { + version: 1, + roots, + since: args.since ?? null, + bounds: { + maxVisits, + maxResults, + visited: discovered.visited, + truncated: discovered.truncated, + }, + projects, + groups: [...grouped.entries()] + .map(([repositoryId, locations]) => ({ + repositoryId, + locations: locations.sort(), + })) + .sort((left, right) => + left.repositoryId.localeCompare(right.repositoryId) + ), + }; +} + +function appendProtectiveIgnore(existing: string | null): string { + const lines = existing?.replace(/\r\n/g, "\n").split("\n") ?? []; + const out = [...lines]; + while (out.at(-1) === "") { + out.pop(); + } + for (const line of PROTECTIVE_IGNORE_LINES) { + if (!out.includes(line)) { + if (line.startsWith("#") && out.length > 0 && out.at(-1) !== "") { + out.push(""); + } + out.push(line); + } + } + return `${out.join("\n")}\n`; +} + +async function assertSafeCanonicalTargets( + projectRoot: string, + aiRoot: string +): Promise { + const aiStat = await lstat(aiRoot).catch(() => null); + if (aiStat?.isSymbolicLink() || (aiStat && !aiStat.isDirectory())) { + throw new Error(`Refusing unsafe project AI root: ${aiRoot}`); + } + const resolvedParent = await realpath(dirname(aiRoot)); + if (resolvedParent !== projectRoot) { + throw new Error(`Project AI root escapes the repository: ${aiRoot}`); + } + for (const name of [".gitignore", "config.toml"]) { + const target = join(aiRoot, name); + const targetStat = await lstat(target).catch(() => null); + if (targetStat?.isSymbolicLink() || (targetStat && !targetStat.isFile())) { + throw new Error(`Refusing unsafe canonical project file: ${target}`); + } + } +} + +function tomlString(value: string): string { + return JSON.stringify(value); +} + +function renderProjectConfig(args: { + repositoryId: string; + sources: ProjectSource[]; + guidance: string[]; + cadence: ProjectCadence; + scheduling: boolean; +}): string { + const stringArray = (values: string[]) => + `[${values.map((value) => tomlString(value)).join(", ")}]`; + const projectTable = [ + "[project]", + `repository_id = ${tomlString(args.repositoryId)}`, + `sources = ${stringArray(args.sources)}`, + `guidance = ${stringArray(args.guidance)}`, + `cadence = ${tomlString(args.cadence)}`, + `scheduling = ${args.scheduling ? "true" : "false"}`, + "managed_rendering = false", + "", + ].join("\n"); + return ["version = 1", "", projectTable].join("\n"); +} + +function mergeProjectConfig( + existing: string | null, + enrollmentConfig: string +): string { + if (existing === null || existing === enrollmentConfig) { + return enrollmentConfig; + } + if (privacyFindings(existing).length > 0) { + throw new Error( + "Refusing to modify existing canonical project config with privacy findings" + ); + } + let parsed: unknown; + try { + parsed = Bun.TOML.parse(existing); + } catch { + throw new Error("Refusing to modify invalid canonical project config"); + } + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + (parsed as Record).version !== 1 + ) { + throw new Error( + "Refusing to modify canonical project config without version = 1" + ); + } + if ("project" in (parsed as Record)) { + throw new Error( + "Refusing to replace existing canonical project enrollment config" + ); + } + const projectTable = enrollmentConfig.slice( + enrollmentConfig.indexOf("[project]") + ); + return `${existing.trimEnd()}\n\n${projectTable}`; +} + +function privacyFindings(content: string): string[] { + const findings: string[] = []; + if (SECRET_SHAPE_RE.test(content) || PRIVATE_KEY_RE.test(content)) { + findings.push("secret-shaped content"); + } + if (LOCAL_ABSOLUTE_PATH_RE.test(content)) { + findings.push("machine-local absolute path"); + } + return findings; +} + +function ensureRepoRelativeMarkdown(value: string): string { + if ( + !value || + isAbsolute(value) || + value.split(PATH_PART_SPLIT_RE).includes("..") || + !value.toLowerCase().endsWith(".md") + ) { + throw new Error( + `Guidance must be a repository-relative Markdown path: ${value}` + ); + } + const normalized = value.split(PATH_PART_SPLIT_RE).join("/"); + if (normalized.startsWith(".ai/.facult/") || normalized.startsWith(".git/")) { + throw new Error( + `Generated or Git-internal guidance cannot be adopted: ${value}` + ); + } + return normalized; +} + +async function previewGuidance(args: { + projectRoot: string; + paths: string[]; +}): Promise { + const previews: GuidancePreview[] = []; + for (const rawPath of args.paths) { + const pathValue = ensureRepoRelativeMarkdown(rawPath); + const absolutePath = resolve(args.projectRoot, pathValue); + const rel = relative(args.projectRoot, absolutePath); + if (rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`Guidance is outside the repository: ${rawPath}`); + } + const guidanceStat = await lstat(absolutePath).catch(() => null); + if (!guidanceStat?.isFile() || guidanceStat.isSymbolicLink()) { + throw new Error( + `Refusing guidance adoption from ${pathValue}: the source must be a regular file` + ); + } + const tracked = await runGit({ + cwd: args.projectRoot, + argv: ["ls-files", "--error-unmatch", "--", pathValue], + }); + const dirty = await runGit({ + cwd: args.projectRoot, + argv: ["status", "--porcelain=v1", "--", pathValue], + }); + if (tracked.exitCode !== 0 || dirty.exitCode !== 0 || dirty.stdout) { + throw new Error( + `Refusing guidance adoption from ${pathValue}: the source must be tracked and clean` + ); + } + const content = await readFile(absolutePath, "utf8").catch(() => { + throw new Error(`Unable to read guidance source: ${pathValue}`); + }); + const findings = privacyFindings(content); + if (findings.length > 0) { + throw new Error( + `Refusing guidance adoption from ${pathValue}: ${findings.join(", ")}` + ); + } + previews.push({ + path: pathValue, + sha256: sha256(content), + content, + gitState: "clean-tracked", + adoption: "reference", + }); + } + return previews; +} + +function uniqueSorted(values: T[]): T[] { + return [...new Set(values)].sort() as T[]; +} + +function projectRegistryPath(homeDir: string): string { + return join(facultLocalStateRoot(homeDir), "projects", "registry.json"); +} + +function projectReceiptsDir(homeDir: string): string { + return join(facultLocalStateRoot(homeDir), "projects", "receipts"); +} + +function emptyRegistry(): ProjectRegistry { + return { + version: 1, + updatedAt: "", + projects: {}, + }; +} + +async function loadRegistry(homeDir: string): Promise { + const pathValue = projectRegistryPath(homeDir); + const text = await fileText(pathValue); + if (!text) { + return emptyRegistry(); + } + try { + const parsed = JSON.parse(text) as ProjectRegistry; + if (parsed.version === 1 && parsed.projects) { + return parsed; + } + } catch { + // Fall through to the explicit corruption error. + } + throw new Error(`Project registry is invalid: ${pathValue}`); +} + +async function atomicWrite(pathValue: string, content: string): Promise { + await mkdir(dirname(pathValue), { recursive: true }); + const temporary = `${pathValue}.tmp-${process.pid}-${randomUUID()}`; + await writeFile(temporary, content, { + encoding: "utf8", + mode: 0o600, + }); + await rename(temporary, pathValue); +} + +async function saveRegistry(args: { + homeDir: string; + registry: ProjectRegistry; + now: string; +}): Promise { + args.registry.updatedAt = args.now; + await atomicWrite( + projectRegistryPath(args.homeDir), + `${JSON.stringify(args.registry, null, 2)}\n` + ); +} + +function planHashInput( + plan: Omit +): unknown { + return plan; +} + +export async function planProjectEnrollment(args: { + projectRoot: string; + homeDir?: string; + sources?: ProjectSource[]; + cadence?: ProjectCadence; + scheduling?: boolean; + guidance?: string[]; +}): Promise { + const homeDir = resolve(args.homeDir ?? process.env.HOME ?? homedir()); + const projectRoot = await gitRoot(args.projectRoot); + const aiRoot = join(projectRoot, ".ai"); + await assertSafeCanonicalTargets(projectRoot, aiRoot); + const identity = await resolveRepositoryIdentity(projectRoot); + if (!REPOSITORY_ID_RE.test(identity.id)) { + throw new Error("Unable to derive a valid repository identity"); + } + const sources = uniqueSorted(args.sources ?? ["git", "writebacks"]); + if (sources.some((source) => !PROJECT_SOURCES.has(source))) { + throw new Error(`Unsupported project source: ${sources.join(", ")}`); + } + const cadence = args.cadence ?? "on-demand"; + if (!PROJECT_CADENCES.has(cadence)) { + throw new Error(`Unsupported project cadence: ${cadence}`); + } + const scheduling = Boolean(args.scheduling); + if (scheduling) { + throw new Error( + "Minimal project enrollment does not install scheduling; enroll first, then enable a reviewed project loop separately" + ); + } + const guidance = uniqueSorted( + (args.guidance ?? []).map(ensureRepoRelativeMarkdown) + ); + const guidancePreview = await previewGuidance({ + projectRoot, + paths: guidance, + }); + const [worktree, existingIgnore, existingConfig] = await Promise.all([ + inspectRepository(projectRoot), + fileText(join(aiRoot, ".gitignore")), + fileText(join(aiRoot, "config.toml")), + ]); + const ignoreContent = appendProtectiveIgnore(existingIgnore); + const configSources = + guidance.length > 0 ? uniqueSorted([...sources, "guidance"]) : sources; + const enrollmentConfig = renderProjectConfig({ + repositoryId: identity.id, + sources: configSources, + guidance, + cadence, + scheduling, + }); + const configContent = mergeProjectConfig(existingConfig, enrollmentConfig); + const canonicalWrites = [ + { + path: join(aiRoot, ".gitignore"), + content: ignoreContent, + reason: + "Protect generated and machine-local fclt state before any index is built.", + precondition: await filePrecondition(join(aiRoot, ".gitignore")), + }, + { + path: join(aiRoot, "config.toml"), + content: configContent, + reason: + "Create the minimal repo-owned enrollment contract without installing the operating-model pack.", + precondition: await filePrecondition(join(aiRoot, "config.toml")), + }, + ]; + const generatedWrites = [ + { + path: facultAiIndexPath(homeDir, aiRoot), + reason: "Machine-local generated capability index.", + }, + { + path: facultAiGraphPath(homeDir, aiRoot), + reason: "Machine-local generated capability graph.", + }, + ]; + const machineLocalWrites = [ + { + path: projectRegistryPath(homeDir), + reason: "Machine-local portfolio decision and location history.", + }, + { + path: projectReceiptsDir(homeDir), + reason: "Machine-local rollback receipt.", + }, + ]; + const findings = [ + ...privacyFindings(ignoreContent), + ...privacyFindings(configContent), + ]; + if (findings.length > 0) { + throw new Error( + `Planned canonical files failed privacy checks: ${findings.join(", ")}` + ); + } + const warnings = [ + ...(worktree.dirty + ? [ + "The repository has unrelated working-tree changes. The plan will touch only the listed .ai files and will recheck their hashes before applying.", + ] + : []), + ...(identity.stability === "machine-local" + ? [ + "This repository has no portable remote or root commit; its fallback identity cannot correlate independent clones until the repository has a commit.", + ] + : []), + ...(guidance.length === 0 + ? [ + "Existing AGENTS.md or CLAUDE.md files are not copied or adopted automatically.", + ] + : []), + ]; + const withoutHash: Omit = { + version: 1, + operation: "project-init", + projectRoot, + aiRoot, + identity, + worktree: { + dirty: worktree.dirty, + branch: worktree.branch, + head: worktree.head, + }, + options: { + sources: configSources, + cadence, + scheduling, + guidance, + }, + guidancePreview, + canonicalWrites, + generatedWrites, + machineLocalWrites, + protections: { + ignoreWrittenFirst: true, + managedRendering: false, + automaticGuidanceCopy: false, + privacyFindings: [], + }, + warnings, + rollback: { + command: "Available after apply as: fclt project rollback --receipt ", + preservesReviewHistory: true, + }, + }; + return { + ...withoutHash, + planSha256: sha256(stableJson(planHashInput(withoutHash))), + }; +} + +async function verifyPreconditions(plan: ProjectEnrollmentPlan): Promise { + const { planSha256, ...withoutHash } = plan; + if ( + !PLAN_SHA_RE.test(planSha256) || + sha256(stableJson(planHashInput(withoutHash))) !== planSha256 + ) { + throw new Error("Enrollment plan content does not match its plan SHA"); + } + const currentRoot = await gitRoot(plan.projectRoot); + if (currentRoot !== plan.projectRoot) { + throw new Error("Enrollment plan repository root changed"); + } + const currentIdentity = await resolveRepositoryIdentity(currentRoot); + if ( + currentIdentity.id !== plan.identity.id || + currentIdentity.kind !== plan.identity.kind || + currentIdentity.fingerprint !== plan.identity.fingerprint + ) { + throw new Error("Enrollment plan repository identity changed"); + } + await assertSafeCanonicalTargets(plan.projectRoot, plan.aiRoot); + for (const write of plan.canonicalWrites) { + const current = await filePrecondition(write.path); + if ( + current.existed !== write.precondition.existed || + current.sha256 !== write.precondition.sha256 + ) { + throw new Error( + `Enrollment plan is stale because ${write.path} changed; generate a new plan` + ); + } + } + const guidance = await previewGuidance({ + projectRoot: plan.projectRoot, + paths: plan.options.guidance, + }); + for (const [index, preview] of plan.guidancePreview.entries()) { + const current = guidance[index]; + if ( + current?.path !== preview.path || + current.sha256 !== preview.sha256 || + current.content !== preview.content + ) { + throw new Error( + `Enrollment plan is stale because guidance changed: ${preview.path}` + ); + } + } +} + +function upsertRegistryEntry(args: { + registry: ProjectRegistry; + plan: ProjectEnrollmentPlan; + now: string; + receiptId: string; +}): void { + const current = args.registry.projects[args.plan.identity.id]; + const location = current?.locations.find( + (candidate) => candidate.path === args.plan.projectRoot + ); + const locations = current?.locations ?? []; + if (location) { + location.lastSeenAt = args.now; + } else { + locations.push({ + path: args.plan.projectRoot, + firstSeenAt: args.now, + lastSeenAt: args.now, + }); + } + args.registry.projects[args.plan.identity.id] = { + repositoryId: args.plan.identity.id, + identityKind: args.plan.identity.kind, + identityFingerprint: args.plan.identity.fingerprint, + decision: "selected", + sources: args.plan.options.sources, + cadence: args.plan.options.cadence, + scheduling: args.plan.options.scheduling, + guidance: args.plan.options.guidance, + locations: locations.sort((left, right) => + left.path.localeCompare(right.path) + ), + lastSuccessfulRun: current?.lastSuccessfulRun ?? null, + pendingApprovals: [], + history: [ + ...(current?.history ?? []), + { + at: args.now, + action: "enrolled", + root: args.plan.projectRoot, + receiptId: args.receiptId, + }, + ], + }; +} + +export async function applyProjectEnrollment(args: { + plan: ProjectEnrollmentPlan; + expectedPlanSha256: string; + homeDir?: string; + now?: Date; +}): Promise<{ + version: 1; + applied: true; + repositoryId: string; + changedPaths: string[]; + generatedPaths: string[]; + registryPath: string; + receiptId: string; + rollbackCommand: string; +}> { + if (args.plan.planSha256 !== args.expectedPlanSha256) { + throw new Error( + "Apply requires the exact plan SHA from the reviewed preview" + ); + } + await verifyPreconditions(args.plan); + const homeDir = resolve(args.homeDir ?? process.env.HOME ?? homedir()); + const expectedGeneratedPaths = [ + facultAiIndexPath(homeDir, args.plan.aiRoot), + facultAiGraphPath(homeDir, args.plan.aiRoot), + ]; + const expectedMachinePaths = [ + projectRegistryPath(homeDir), + projectReceiptsDir(homeDir), + ]; + if ( + stableJson(args.plan.generatedWrites.map((write) => write.path)) !== + stableJson(expectedGeneratedPaths) || + stableJson(args.plan.machineLocalWrites.map((write) => write.path)) !== + stableJson(expectedMachinePaths) + ) { + throw new Error( + "Enrollment plan was created for a different machine-local state root" + ); + } + const now = (args.now ?? new Date()).toISOString(); + const receiptId = `enroll-${now.replace(NON_DIGIT_RE, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`; + const before = await Promise.all( + args.plan.canonicalWrites.map(async (write) => ({ + path: write.path, + before: await fileText(write.path), + afterSha256: sha256(write.content), + })) + ); + const registry = await loadRegistry(homeDir); + const written: string[] = []; + try { + for (const write of args.plan.canonicalWrites) { + await atomicWrite(write.path, write.content); + written.push(write.path); + } + await buildIndex({ + homeDir, + rootDir: args.plan.aiRoot, + force: false, + }); + const receipt: EnrollmentReceipt = { + version: 1, + id: receiptId, + createdAt: now, + repositoryId: args.plan.identity.id, + projectRoot: args.plan.projectRoot, + planSha256: args.plan.planSha256, + files: before, + }; + const receiptPath = join(projectReceiptsDir(homeDir), `${receiptId}.json`); + await atomicWrite(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); + upsertRegistryEntry({ registry, plan: args.plan, now, receiptId }); + await saveRegistry({ homeDir, registry, now }); + } catch (error) { + for (const original of before.toReversed()) { + if (original.before === null) { + await rm(original.path, { force: true }).catch(() => undefined); + } else { + await atomicWrite(original.path, original.before).catch( + () => undefined + ); + } + } + throw error; + } + return { + version: 1, + applied: true, + repositoryId: args.plan.identity.id, + changedPaths: written, + generatedPaths: args.plan.generatedWrites.map((entry) => entry.path), + registryPath: projectRegistryPath(homeDir), + receiptId, + rollbackCommand: `fclt project rollback --receipt ${receiptId} --apply`, + }; +} + +async function readReceipt(args: { + homeDir: string; + receiptId: string; +}): Promise { + if (!RECEIPT_ID_RE.test(args.receiptId)) { + throw new Error("Invalid enrollment receipt id"); + } + const pathValue = join( + projectReceiptsDir(args.homeDir), + `${args.receiptId}.json` + ); + const parsed = JSON.parse( + await readFile(pathValue, "utf8") + ) as EnrollmentReceipt; + const validProjectRoot = + typeof parsed.projectRoot === "string" && isAbsolute(parsed.projectRoot); + const allowedFiles = new Set( + validProjectRoot + ? [ + join(parsed.projectRoot, ".ai", ".gitignore"), + join(parsed.projectRoot, ".ai", "config.toml"), + ] + : [] + ); + if ( + parsed.version !== 1 || + parsed.id !== args.receiptId || + typeof parsed.repositoryId !== "string" || + !REPOSITORY_ID_RE.test(parsed.repositoryId) || + !validProjectRoot || + typeof parsed.planSha256 !== "string" || + !PLAN_SHA_RE.test(parsed.planSha256) || + !Array.isArray(parsed.files) || + parsed.files.length !== 2 || + parsed.files.some( + (file) => + !file || + typeof file !== "object" || + typeof file.path !== "string" || + !allowedFiles.has(file.path) || + typeof file.afterSha256 !== "string" || + !PLAN_SHA_RE.test(file.afterSha256) || + (file.before !== null && typeof file.before !== "string") + ) + ) { + throw new Error(`Invalid enrollment receipt: ${args.receiptId}`); + } + return parsed; +} + +export async function rollbackProjectEnrollment(args: { + receiptId: string; + homeDir?: string; + apply?: boolean; + now?: Date; +}): Promise<{ + version: 1; + applied: boolean; + receiptId: string; + repositoryId: string; + restores: Array<{ path: string; action: "restore" | "remove" }>; + preserved: string[]; +}> { + const homeDir = resolve(args.homeDir ?? process.env.HOME ?? homedir()); + const receipt = await readReceipt({ homeDir, receiptId: args.receiptId }); + const restores = receipt.files.map((file) => ({ + path: file.path, + action: file.before === null ? ("remove" as const) : ("restore" as const), + })); + if (!args.apply) { + return { + version: 1, + applied: false, + receiptId: receipt.id, + repositoryId: receipt.repositoryId, + restores, + preserved: [ + projectRegistryPath(homeDir), + join(projectReceiptsDir(homeDir), `${receipt.id}.json`), + ], + }; + } + await assertSafeCanonicalTargets( + receipt.projectRoot, + join(receipt.projectRoot, ".ai") + ); + for (const file of receipt.files) { + const current = await fileText(file.path); + if (current === null || sha256(current) !== file.afterSha256) { + throw new Error( + `Rollback refused because an enrolled file changed after apply: ${file.path}` + ); + } + } + for (const file of receipt.files.toReversed()) { + if (file.before === null) { + await rm(file.path, { force: true }); + } else { + await atomicWrite(file.path, file.before); + } + } + const registry = await loadRegistry(homeDir); + const entry = registry.projects[receipt.repositoryId]; + if (entry) { + const now = (args.now ?? new Date()).toISOString(); + entry.decision = "disabled"; + entry.history.push({ + at: now, + action: "rolled-back", + root: receipt.projectRoot, + receiptId: receipt.id, + }); + await saveRegistry({ homeDir, registry, now }); + } + return { + version: 1, + applied: true, + receiptId: receipt.id, + repositoryId: receipt.repositoryId, + restores, + preserved: [ + projectRegistryPath(homeDir), + join(projectReceiptsDir(homeDir), `${receipt.id}.json`), + ], + }; +} + +async function recordDecision(args: { + projectRoot: string; + homeDir: string; + decision: Exclude; + now: Date; +}): Promise<{ + version: 1; + repositoryId: string; + decision: Exclude; + preserved: string[]; +}> { + const projectRoot = await gitRoot(args.projectRoot); + const identity = await resolveRepositoryIdentity(projectRoot); + const registry = await loadRegistry(args.homeDir); + const now = args.now.toISOString(); + const current = registry.projects[identity.id]; + const locations = [...(current?.locations ?? [])]; + const location = locations.find( + (candidate) => candidate.path === projectRoot + ); + if (location) { + location.lastSeenAt = now; + } else { + locations.push({ + path: projectRoot, + firstSeenAt: now, + lastSeenAt: now, + }); + } + const action = + args.decision === "disabled" + ? "disabled" + : args.decision === "removed" + ? "removed" + : args.decision; + registry.projects[identity.id] = { + repositoryId: identity.id, + identityKind: identity.kind, + identityFingerprint: identity.fingerprint, + decision: args.decision, + sources: current?.sources ?? [], + cadence: current?.cadence ?? "on-demand", + scheduling: false, + guidance: current?.guidance ?? [], + locations: locations.sort((left, right) => + left.path.localeCompare(right.path) + ), + lastSuccessfulRun: current?.lastSuccessfulRun ?? null, + pendingApprovals: current?.pendingApprovals ?? [], + history: [ + ...(current?.history ?? []), + { + at: now, + action, + root: projectRoot, + }, + ], + }; + await saveRegistry({ homeDir: args.homeDir, registry, now }); + return { + version: 1, + repositoryId: identity.id, + decision: args.decision, + preserved: [ + join(projectRoot, ".ai"), + projectRegistryPath(args.homeDir), + projectReceiptsDir(args.homeDir), + ], + }; +} + +export async function buildProjectsStatus(args: { + homeDir?: string; + discoveryRoots?: string[]; +}): Promise<{ + version: 1; + registryPath: string; + projects: ProjectStatusRow[]; +}> { + const homeDir = resolve(args.homeDir ?? process.env.HOME ?? homedir()); + const registry = await loadRegistry(homeDir); + const discovered = + args.discoveryRoots && args.discoveryRoots.length > 0 + ? await discoverProjects({ roots: args.discoveryRoots }) + : null; + const discoveredById = new Map(); + for (const project of discovered?.projects ?? []) { + const rows = discoveredById.get(project.identity.id) ?? []; + rows.push(project); + discoveredById.set(project.identity.id, rows); + } + const rows: ProjectStatusRow[] = []; + const entries = new Map( + Object.values(registry.projects).map((entry) => [entry.repositoryId, entry]) + ); + for (const [repositoryId, projects] of discoveredById) { + if (!entries.has(repositoryId)) { + const first = projects[0]; + if (!first) { + continue; + } + entries.set(repositoryId, { + repositoryId, + identityKind: first.identity.kind, + identityFingerprint: first.identity.fingerprint, + decision: "inactive", + sources: [], + cadence: "on-demand", + scheduling: false, + guidance: [], + locations: projects.map((project) => ({ + path: project.root, + firstSeenAt: "", + lastSeenAt: "", + })), + lastSuccessfulRun: null, + pendingApprovals: [], + history: [], + }); + } + } + for (const entry of entries.values()) { + const locations = new Map( + entry.locations.map((location) => [ + location.path, + { + path: location.path, + exists: false, + dirty: null as boolean | null, + }, + ]) + ); + for (const project of discoveredById.get(entry.repositoryId) ?? []) { + locations.set(project.root, { + path: project.root, + exists: true, + dirty: project.dirty, + }); + } + for (const location of locations.values()) { + if (!location.exists) { + location.exists = await pathExists(location.path); + if (location.exists) { + const inspected = await inspectRepository(location.path).catch( + () => null + ); + location.exists = inspected !== null; + location.dirty = inspected?.dirty ?? null; + } + } + } + const activeLocation = [...locations.values()].find( + (location) => location.exists + ); + const canonicalRoot = activeLocation + ? join(activeLocation.path, ".ai") + : null; + const config = canonicalRoot + ? await pathExists(join(canonicalRoot, "config.toml")) + : false; + const ignoreText = canonicalRoot + ? await fileText(join(canonicalRoot, ".gitignore")) + : null; + const protectiveIgnore = PROTECTIVE_IGNORE_LINES.slice(1).every((line) => + ignoreText?.split(LINE_SPLIT_RE).includes(line) + ); + const generatedIndex = canonicalRoot + ? await pathExists(facultAiIndexPath(homeDir, canonicalRoot)) + : false; + const generatedGraph = canonicalRoot + ? await pathExists(facultAiGraphPath(homeDir, canonicalRoot)) + : false; + const inactive = entry.decision !== "selected"; + const exists = canonicalRoot !== null && (await pathExists(canonicalRoot)); + const coverage = inactive + ? ("inactive" as const) + : config && protectiveIgnore + ? ("covered" as const) + : ("partial" as const); + const health = activeLocation + ? coverage === "covered" && generatedIndex && generatedGraph + ? ("healthy" as const) + : ("degraded" as const) + : ("unavailable" as const); + rows.push({ + repositoryId: entry.repositoryId, + decision: entry.decision, + coverage, + health, + canonicalRoot, + canonical: { + exists, + config, + protectiveIgnore, + guidance: entry.guidance, + }, + generated: { + index: generatedIndex, + graph: generatedGraph, + health: generatedIndex && generatedGraph ? "ready" : "missing", + }, + sources: entry.sources, + scheduler: { + cadence: entry.cadence, + enabled: entry.scheduling, + health: + entry.cadence === "on-demand" + ? "on-demand" + : entry.scheduling + ? "configured" + : "not-enabled", + lastSuccessfulRun: entry.lastSuccessfulRun, + }, + pendingApprovals: entry.pendingApprovals, + locations: [...locations.values()].sort((left, right) => + left.path.localeCompare(right.path) + ), + }); + } + return { + version: 1, + registryPath: projectRegistryPath(homeDir), + projects: rows.sort((left, right) => + left.repositoryId.localeCompare(right.repositoryId) + ), + }; +} + +function flagValues(argv: string[], flag: string): string[] { + const values: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === flag) { + const value = argv[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`${flag} requires a value`); + } + values.push(value); + index += 1; + } else if (arg?.startsWith(`${flag}=`)) { + values.push(arg.slice(flag.length + 1)); + } + } + return values; +} + +function flagValue(argv: string[], flag: string): string | undefined { + const values = flagValues(argv, flag); + if (values.length > 1) { + throw new Error(`${flag} may be provided only once`); + } + return values[0]; +} + +function positiveIntegerFlag(argv: string[], flag: string): number | undefined { + const value = flagValue(argv, flag); + if (value === undefined) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error(`${flag} must be a positive integer`); + } + return parsed; +} + +function printProjectsHelp(): void { + console.log(`fclt projects — bounded portfolio discovery and status + +Usage: + fclt projects discover --root PATH [--root PATH] [--since 30d] [--json] + fclt projects status [--root PATH] [--json] + +Discovery is read-only and requires explicit roots. It never enrolls repositories.`); +} + +function printProjectHelp(): void { + console.log(`fclt project — preview-first project enrollment + +Usage: + fclt project init [--project-root PATH] [--guidance PATH] [--source SOURCE] [--cadence on-demand|weekly|daily] [--json] + fclt project init --apply --plan-sha SHA [same options] + fclt project rollback --receipt ID [--apply] [--json] + fclt project disable --project-root PATH [--json] + fclt project ignore --project-root PATH [--json] + fclt project inactive --project-root PATH [--json] + fclt project remove --project-root PATH [--json] + +Init prints an exact plan and performs no writes by default. Apply requires the +SHA from that plan. Existing guidance is referenced only when explicitly +selected, tracked, clean, and privacy-safe; it is never copied automatically.`); +} + +export async function projectsCommand( + argv: string[], + context: ProjectCommandContext = {} +): Promise { + if ( + argv.length === 0 || + argv.includes("--help") || + argv.includes("-h") || + argv[0] === "help" + ) { + printProjectsHelp(); + return; + } + const command = argv[0]; + const rest = argv.slice(1); + const json = rest.includes("--json"); + try { + if (command === "discover") { + const result = await discoverProjects({ + roots: flagValues(rest, "--root"), + since: flagValue(rest, "--since"), + maxVisits: positiveIntegerFlag(rest, "--max-visits"), + maxResults: positiveIntegerFlag(rest, "--max-results"), + now: context.now?.(), + }); + console.log(JSON.stringify(result, null, 2)); + return; + } + if (command === "status") { + const result = await buildProjectsStatus({ + homeDir: context.homeDir, + discoveryRoots: flagValues(rest, "--root"), + }); + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log( + result.projects.length === 0 + ? "No project decisions recorded." + : result.projects + .map( + (project) => + `${project.repositoryId} ${project.decision} ${project.coverage} ${project.health}` + ) + .join("\n") + ); + } + return; + } + throw new Error(`Unknown projects command: ${command}`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +export async function projectCommand( + argv: string[], + context: ProjectCommandContext = {} +): Promise { + if ( + argv.length === 0 || + argv.includes("--help") || + argv.includes("-h") || + argv[0] === "help" + ) { + printProjectHelp(); + return; + } + const command = argv[0]; + const rest = argv.slice(1); + const json = rest.includes("--json"); + const homeDir = resolve(context.homeDir ?? process.env.HOME ?? homedir()); + const projectRoot = resolve( + flagValue(rest, "--project-root") ?? context.cwd ?? process.cwd() + ); + try { + if (command === "init" || command === "plan") { + const sources = flagValues(rest, "--source") as ProjectSource[]; + const plan = await planProjectEnrollment({ + projectRoot, + homeDir, + sources: sources.length > 0 ? sources : undefined, + cadence: flagValue(rest, "--cadence") as ProjectCadence | undefined, + scheduling: rest.includes("--schedule"), + guidance: flagValues(rest, "--guidance"), + }); + if (!rest.includes("--apply")) { + console.log(JSON.stringify(plan, null, 2)); + return; + } + const expectedPlanSha256 = flagValue(rest, "--plan-sha"); + if (!expectedPlanSha256) { + throw new Error("--apply requires --plan-sha from the reviewed plan"); + } + const result = await applyProjectEnrollment({ + plan, + expectedPlanSha256, + homeDir, + now: context.now?.(), + }); + console.log(JSON.stringify(result, null, 2)); + return; + } + if (command === "rollback") { + const receiptId = flagValue(rest, "--receipt"); + if (!receiptId) { + throw new Error("project rollback requires --receipt"); + } + const result = await rollbackProjectEnrollment({ + receiptId, + homeDir, + apply: rest.includes("--apply"), + now: context.now?.(), + }); + console.log(JSON.stringify(result, null, 2)); + return; + } + const decisions: Record> = { + disable: "disabled", + ignore: "ignored", + inactive: "inactive", + remove: "removed", + }; + const decision = decisions[command ?? ""]; + if (decision) { + const result = await recordDecision({ + projectRoot, + homeDir, + decision, + now: context.now?.() ?? new Date(), + }); + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log( + `${result.repositoryId}: ${result.decision}; canonical files and review history preserved` + ); + } + return; + } + throw new Error(`Unknown project command: ${command}`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/src/remote.test.ts b/src/remote.test.ts index 8b62c9c6..0ab1da62 100644 --- a/src/remote.test.ts +++ b/src/remote.test.ts @@ -5,12 +5,14 @@ import { mkdir, mkdtemp, readFile, + realpath, rm, symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { runFixtureGit } from "../test/git-fixture"; import { renderCanonicalText } from "./agents"; import { facultAiIndexPath } from "./paths"; import { @@ -53,7 +55,7 @@ function sha256Hex(input: string): string { } async function makeTempRoot(): Promise<{ home: string; root: string }> { - const dir = await mkdtemp(join(tmpdir(), "facult-remote-")); + const dir = await realpath(await mkdtemp(join(tmpdir(), "facult-remote-"))); tempDir = dir; const home = join(dir, "home"); const root = join(home, "agents", ".facult"); @@ -62,6 +64,39 @@ async function makeTempRoot(): Promise<{ home: string; root: string }> { return { home, root }; } +async function initializeGitRepository( + repoDir: string, + home: string +): Promise { + await mkdir(repoDir, { recursive: true }); + await runFixtureGit({ + argv: ["init", "-b", "main", repoDir], + repoDir, + homeDir: home, + }); + await writeFile(join(repoDir, "README.md"), "# Fixture\n"); + await runFixtureGit({ + argv: ["add", "."], + repoDir, + homeDir: home, + cwd: repoDir, + }); + await runFixtureGit({ + argv: [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-m", + "fixture", + ], + repoDir, + homeDir: home, + cwd: repoDir, + }); +} + async function withMutedConsole(fn: () => Promise) { const prevLog = console.log; const prevError = console.error; @@ -1256,7 +1291,7 @@ describe("templates command", () => { expect(automationToml).toContain("scope-promoter"); expect(automationToml).toContain("evolution-planner"); expect(automationToml).toContain("verification-auditor"); - expect(automationToml).toContain("fclt templates init project-ai"); + expect(automationToml).toContain("fclt project init --json"); expect(automationToml).toContain("blocked by missing project AI state"); expect(automationToml).toContain("not graph-backed"); expect(automationToml).toContain("Recorded writebacks"); @@ -1266,7 +1301,7 @@ describe("templates command", () => { const memory = await readFile(join(automationDir, "memory.md"), "utf8"); expect(memory).toContain("$feedback-loop-setup"); expect(memory).toContain("$capability-evolution"); - expect(memory).toContain("bootstrap baseline project AI state"); + expect(memory).toContain("preview baseline project AI state"); }); it("supports project-scoped automation scaffolding with explicit scope root", async () => { @@ -1675,59 +1710,42 @@ describe("templates command", () => { ); }); - it("scaffolds the builtin project-ai pack into a repo-local .ai", async () => { + it("previews minimal project-ai enrollment without writing", async () => { const { home } = await makeTempRoot(); const repoDir = join(home, "repo"); - await mkdir(repoDir, { recursive: true }); + await initializeGitRepository(repoDir, home); process.chdir(repoDir); - await withMutedConsole(async () => { + const { logs, errors } = await withCapturedConsole(async () => { await templatesCommand(["init", "project-ai"], { homeDir: home, cwd: repoDir, }); }); - expect( - await Bun.file( - join( - repoDir, - ".ai", - "skills", - "project-operating-layer-design", - "SKILL.md" - ) - ).exists() - ).toBe(true); - expect( - await Bun.file( - join(repoDir, ".ai", "instructions", "PROJECT_CAPABILITY.md") - ).exists() - ).toBe(true); - const evolutionText = await Bun.file( - join(repoDir, ".ai", "instructions", "EVOLUTION.md") - ).text(); - expect(evolutionText).toContain("fclt ai writeback add"); - expect(evolutionText).toContain("Current supported proposal kinds"); - - const skillText = await Bun.file( - join(repoDir, ".ai", "skills", "capability-evolution", "SKILL.md") - ).text(); - expect(skillText).toContain("Proposal Kind Selection"); - expect(skillText).toContain("fclt ai evolve draft EV-00001 --append"); - expect( - await Bun.file(facultAiIndexPath(home, join(repoDir, ".ai"))).exists() - ).toBe(true); + expect(errors).toEqual([]); + const plan = JSON.parse(logs.join("\n")) as { + projectRoot: string; + protections: { automaticGuidanceCopy: boolean }; + canonicalWrites: Array<{ path: string }>; + }; + expect(plan.projectRoot).toBe(repoDir); + expect(plan.protections.automaticGuidanceCopy).toBe(false); + expect(plan.canonicalWrites.map((write) => write.path)).toEqual([ + join(repoDir, ".ai", ".gitignore"), + join(repoDir, ".ai", "config.toml"), + ]); + expect(await Bun.file(join(repoDir, ".ai")).exists()).toBe(false); }); - it("scaffolds the builtin project-ai pack into an explicit root", async () => { + it("previews project-ai enrollment into an explicit root", async () => { const { home } = await makeTempRoot(); const repoDir = join(home, "repo"); const otherDir = join(home, "other"); - await mkdir(repoDir, { recursive: true }); + await initializeGitRepository(repoDir, home); await mkdir(otherDir, { recursive: true }); - await withMutedConsole(async () => { + const { logs } = await withCapturedConsole(async () => { await templatesCommand( ["init", "project-ai", "--root", join(repoDir, ".ai")], { @@ -1738,27 +1756,20 @@ describe("templates command", () => { }); expect( - await Bun.file( - join( - repoDir, - ".ai", - "skills", - "project-operating-layer-design", - "SKILL.md" - ) - ).exists() - ).toBe(true); + (JSON.parse(logs.join("\n")) as { projectRoot: string }).projectRoot + ).toBe(repoDir); + expect(await Bun.file(join(repoDir, ".ai")).exists()).toBe(false); expect(await Bun.file(join(otherDir, ".ai")).exists()).toBe(false); }); - it("scaffolds the builtin project-ai pack from an explicit project root", async () => { + it("previews project-ai enrollment from an explicit project root", async () => { const { home } = await makeTempRoot(); const repoDir = join(home, "repo"); const otherDir = join(home, "other"); - await mkdir(repoDir, { recursive: true }); + await initializeGitRepository(repoDir, home); await mkdir(otherDir, { recursive: true }); - await withMutedConsole(async () => { + const { logs } = await withCapturedConsole(async () => { await templatesCommand( ["init", "project-ai", "--project-root", repoDir], { @@ -1769,16 +1780,9 @@ describe("templates command", () => { }); expect( - await Bun.file( - join( - repoDir, - ".ai", - "skills", - "project-operating-layer-design", - "SKILL.md" - ) - ).exists() - ).toBe(true); + (JSON.parse(logs.join("\n")) as { projectRoot: string }).projectRoot + ).toBe(repoDir); + expect(await Bun.file(join(repoDir, ".ai")).exists()).toBe(false); expect(await Bun.file(join(otherDir, ".ai")).exists()).toBe(false); }); @@ -1786,10 +1790,10 @@ describe("templates command", () => { const { home } = await makeTempRoot(); const repoDir = join(home, "repo"); const otherDir = join(home, "other"); - await mkdir(repoDir, { recursive: true }); + await initializeGitRepository(repoDir, home); await mkdir(otherDir, { recursive: true }); - await withMutedConsole(async () => { + const { logs } = await withCapturedConsole(async () => { await templatesCommand( ["init", "project-ai", "--project-root", repoDir], { @@ -1801,16 +1805,9 @@ describe("templates command", () => { }); expect( - await Bun.file( - join( - repoDir, - ".ai", - "skills", - "project-operating-layer-design", - "SKILL.md" - ) - ).exists() - ).toBe(true); + (JSON.parse(logs.join("\n")) as { projectRoot: string }).projectRoot + ).toBe(repoDir); + expect(await Bun.file(join(repoDir, ".ai")).exists()).toBe(false); expect(await Bun.file(join(otherDir, ".ai")).exists()).toBe(false); }); @@ -1818,10 +1815,10 @@ describe("templates command", () => { const { home } = await makeTempRoot(); const repoDir = join(home, "repo"); const otherDir = join(home, "other"); - await mkdir(repoDir, { recursive: true }); + await initializeGitRepository(repoDir, home); await mkdir(otherDir, { recursive: true }); - await withMutedConsole(async () => { + const { logs } = await withCapturedConsole(async () => { await templatesCommand(["init", "project-ai", "--project-root=~/repo"], { homeDir: home, cwd: otherDir, @@ -1829,16 +1826,9 @@ describe("templates command", () => { }); expect( - await Bun.file( - join( - repoDir, - ".ai", - "skills", - "project-operating-layer-design", - "SKILL.md" - ) - ).exists() - ).toBe(true); + (JSON.parse(logs.join("\n")) as { projectRoot: string }).projectRoot + ).toBe(repoDir); + expect(await Bun.file(join(repoDir, ".ai")).exists()).toBe(false); expect(await Bun.file(join(otherDir, "~", "repo", ".ai")).exists()).toBe( false ); @@ -1996,7 +1986,7 @@ describe("templates command", () => { expect(await readFile(agentsPath, "utf8")).toBe(agentsText); }); - it("seeds project AGENTS.global.md from the repo AGENTS.md", async () => { + it("does not seed project AGENTS.global.md from repo guidance", async () => { const { home } = await makeTempRoot(); const repoDir = join(home, "repo"); await mkdir(repoDir, { recursive: true }); @@ -2017,10 +2007,13 @@ describe("templates command", () => { join(repoDir, ".ai", "AGENTS.global.md"), "utf8" ); - expect(agentsText).toContain("# Project Agent Instructions"); - expect(agentsText).toContain("- Use repo-specific checks."); - expect(agentsText).toContain("## Facult Operating Model"); + expect(agentsText).not.toContain("# Project Agent Instructions"); + expect(agentsText).not.toContain("- Use repo-specific checks."); + expect(agentsText).toContain("# Global Agent Instructions"); expect(agentsText).toContain(""); + expect( + await readFile(join(repoDir, ".ai", ".gitignore"), "utf8") + ).toContain("/.facult/"); }); it("updates unmodified builtin operating-model files using the pack manifest", async () => { diff --git a/src/remote.ts b/src/remote.ts index bf52c9f2..67f7b6df 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -458,7 +458,7 @@ Use this memory for pattern continuity: - For wide reviews, partition evidence by cwd first; do not let one repo's evidence stand in for another. - Grounding: prefer evidence from session messages, tool calls, shell commands, diffs, tests, commits, and touched files. - Threshold: only encode signal when you can name what was learned, why it matters, and the most plausible destination. -- Scope: default to project writeback only when the repo has a project-local \`.ai\` root for capability context. If a local writable repo is missing one, bootstrap baseline project AI state with \`fclt templates init project-ai\` before retrying project-scoped writeback. Writeback/evolution review artifacts still belong under global \`~/.ai/writebacks/projects/...\` and \`~/.ai/evolution/projects/...\`, not inside the repo-local \`.ai\`. If bootstrap fails or the repo is not writable, treat that as the blocker instead of silently falling back to global runtime state. +- Scope: default to project writeback only when the repo has a project-local \`.ai\` root for capability context. If a local writable repo is missing one, preview baseline project AI state with \`fclt project init --json\`, review the exact plan, and apply it with the returned plan hash only when authorized. Writeback/evolution review artifacts still belong under global \`~/.ai/writebacks/projects/...\` and \`~/.ai/evolution/projects/...\`, not inside the repo-local \`.ai\`. If enrollment is not authorized, fails, or the repo is not writable, treat that as the blocker instead of silently falling back to global runtime state. - Promote to global only when the same signal appears across multiple repos or clearly targets shared doctrine, shared agents, or shared skills. - Verification: distinguish one-off friction from a repeated pattern before escalating it. - If available, use [$feedback-loop-setup]({{feedbackLoopSkill}}) when the review needs stronger feedback loops or verification framing. @@ -484,7 +484,7 @@ Grounding rules: Decision rules: - Use \`fclt ai writeback add\` when the signal, target asset, and scope are clear. -- Before attempting project-scoped writeback, verify the cwd has a repo-local \`.ai\` root for capability context. If it does not and the cwd is a local writable repo, run \`fclt templates init project-ai\` from that repo root, then continue. Do not write writeback/evolution review artifacts into the repo-local \`.ai\`; fclt mirrors them under global \`~/.ai/writebacks/projects/...\` and \`~/.ai/evolution/projects/...\` with cwd/project metadata. If bootstrap fails or the repo is not writable, report the writeback as blocked by missing project AI state rather than falling back to merged/global runtime state. +- Before attempting project-scoped writeback, verify the cwd has a repo-local \`.ai\` root for capability context. If it does not and the cwd is a local writable repo, run \`fclt project init --json\`, review its exact writes, and apply the unchanged plan with \`--apply --plan-sha \` only when authorized. Do not write writeback/evolution review artifacts into the repo-local \`.ai\`; fclt mirrors them under global \`~/.ai/writebacks/projects/...\` and \`~/.ai/evolution/projects/...\` with cwd/project metadata. If enrollment is not authorized, fails, or the repo is not writable, report the writeback as blocked by missing project AI state rather than falling back to merged/global runtime state. - Before passing \`--asset\`, verify the target resolves in the Facult graph. If the destination is a raw file path or otherwise not graph-backed, report that as a missing-asset blocker instead of retrying blind. - Use \`fclt ai evolve\` only when repeated signal is strong enough to justify a reviewable capability change. - Prefer project scope unless the learning clearly belongs in shared global doctrine, shared agents, shared skills, or other cross-project capability. @@ -1586,6 +1586,27 @@ function serializeBuiltinPackManifest(manifest: BuiltinPackManifest): string { return `${JSON.stringify(manifest, null, 2)}\n`; } +const PROJECT_AI_PROTECTIVE_IGNORE = `# fclt machine-local and generated state +/.facult/ +/config.local.toml +`; + +function appendProjectAiProtectiveIgnore(existing: string): string { + const lines = existing.replace(/\r\n/g, "\n").split("\n"); + while (lines.at(-1) === "") { + lines.pop(); + } + for (const line of PROJECT_AI_PROTECTIVE_IGNORE.trimEnd().split("\n")) { + if (!lines.includes(line)) { + if (line.startsWith("#") && lines.length > 0 && lines.at(-1) !== "") { + lines.push(""); + } + lines.push(line); + } + } + return `${lines.join("\n")}\n`; +} + const OPERATING_MODEL_SNIPPET_FRAME = `## Working mode @@ -1635,26 +1656,15 @@ async function firstExistingFileText( } async function seedAgentsGlobalText(args: { - rootDir: string; homeDir?: string; fallbackText: string; }): Promise<{ text: string; seededFromExisting: boolean }> { const home = args.homeDir ?? homedir(); - const projectRoot = projectRootFromAiRoot(args.rootDir, home); - const seedText = await firstExistingFileText( - projectRoot - ? [ - join(projectRoot, "AGENTS.md"), - join(projectRoot, "CLAUDE.md"), - join(projectRoot, ".codex", "AGENTS.md"), - join(projectRoot, ".claude", "CLAUDE.md"), - ] - : [ - join(home, ".codex", "AGENTS.md"), - join(home, ".claude", "CLAUDE.md"), - join(home, ".cursor", "AGENTS.md"), - ] - ); + const seedText = await firstExistingFileText([ + join(home, ".codex", "AGENTS.md"), + join(home, ".claude", "CLAUDE.md"), + join(home, ".cursor", "AGENTS.md"), + ]); if (!seedText?.trim()) { return { text: args.fallbackText, seededFromExisting: false }; } @@ -1681,6 +1691,22 @@ export async function scaffoldBuiltinOperatingModelPack(args: { const manifestFiles: BuiltinPackManifest["files"] = { ...(existingManifest?.files ?? {}), }; + const projectRoot = projectRootFromAiRoot(rootDir, args.homeDir); + + if (projectRoot) { + const ignorePath = join(rootDir, ".gitignore"); + const existingIgnore = (await pathExists(ignorePath)) + ? await Bun.file(ignorePath).text() + : ""; + const protectiveIgnore = appendProjectAiProtectiveIgnore(existingIgnore); + if (protectiveIgnore !== existingIgnore) { + changedPaths.push(ignorePath); + if (!args.dryRun) { + await ensurePackDirectory(dirname(ignorePath)); + await Bun.write(ignorePath, protectiveIgnore); + } + } + } for (const sourcePath of files) { const relPath = relative(packRoot, sourcePath); @@ -1692,9 +1718,10 @@ export async function scaffoldBuiltinOperatingModelPack(args: { const rawSourceText = await Bun.file(sourcePath).text(); const targetExists = await pathExists(targetPath); const seed = - targetRelPath === "AGENTS.global.md" && !targetExists + targetRelPath === "AGENTS.global.md" && + !targetExists && + projectRoot === null ? await seedAgentsGlobalText({ - rootDir, homeDir: args.homeDir, fallbackText: rawSourceText, }) @@ -1812,33 +1839,6 @@ export async function scaffoldBuiltinOperatingModelPack(args: { }; } -export async function scaffoldBuiltinProjectAiPack(args: { - cwd?: string; - rootDir?: string; - homeDir?: string; - dryRun?: boolean; - force?: boolean; - update?: boolean; -}): Promise { - const cwd = resolve(args.cwd ?? process.cwd()); - const rootDir = args.rootDir - ? resolveCliContextRoot({ - rootArg: args.rootDir, - scope: "project", - cwd, - homeDir: args.homeDir, - }) - : join(cwd, ".ai"); - return await scaffoldBuiltinOperatingModelPack({ - rootDir, - homeDir: args.homeDir, - dryRun: args.dryRun, - force: args.force, - update: args.update, - installedAs: "project-ai", - }); -} - function compareVersions(a: string, b: string): number { const aTokens = (a.match(VERSION_TOKEN_RE) ?? []).map((t) => t.toLowerCase()); const bTokens = (b.match(VERSION_TOKEN_RE) ?? []).map((t) => t.toLowerCase()); @@ -3303,7 +3303,7 @@ function printTemplatesHelp() { "fclt templates init operating-model [--global|--project|--root PATH] [--update] [--force] [--dry-run]" ), renderCode( - "fclt templates init project-ai [--project-root PATH|--root PATH] [--update] [--force] [--dry-run]" + "fclt templates init project-ai [--project-root PATH|--root PATH] [--guidance PATH] [--apply --plan-sha SHA]" ), renderCode( "fclt templates init automation [--scope global|project|wide] [--name ] [--project-root ] [--cwds ] [--rrule ] [--status PAUSED|ACTIVE] [--yes] [--dry-run]" @@ -3314,6 +3314,7 @@ function printTemplatesHelp() { title: "Notes", lines: renderBullets([ `Templates are powered by the builtin ${renderCode(BUILTIN_INDEX_NAME)} index.`, + `${renderCode("templates init project-ai")} is a preview-first alias for minimal ${renderCode("project init")}; use ${renderCode("operating-model --project")} only for an explicit full-pack install.`, "Automation templates scaffold Codex automation files under ~/.codex/automations/.", `${renderCode("--yes")} and ${renderCode("--non-interactive")} skip scope prompts and use inferred defaults when possible.`, "Use project scope for one repo root, wide/global scope for many explicit roots.", @@ -3365,14 +3366,38 @@ function parseLongFlag(argv: string[], flag: string): string | null { return null; } +function parseLongFlags(argv: string[], flag: string): string[] { + const values: string[] = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg) { + continue; + } + if (arg === flag) { + const value = argv[i + 1]; + if (value) { + values.push(value); + } + i += 1; + } else if (arg.startsWith(`${flag}=`)) { + values.push(arg.slice(flag.length + 1)); + } + } + return values; +} + const TEMPLATE_INIT_VALUE_FLAGS = new Set([ "--automation-status", + "--cadence", "--cwds", + "--guidance", "--name", + "--plan-sha", "--project-root", "--root", "--rrule", "--scope", + "--source", "--status", ]); @@ -3857,44 +3882,48 @@ export async function templatesCommand( if (kind === "project-ai") { try { - const result = await scaffoldBuiltinProjectAiPack({ - cwd: ctx.cwd, - rootDir: - parsedArgs.rootArg ?? - (parsedArgs.projectRootArg - ? projectAiRootFromProjectArg( - parsedArgs.projectRootArg, - ctx.homeDir - ) - : ctx.rootDir), - homeDir: ctx.homeDir, - dryRun, - force, - update, - }); - if (json) { - console.log(JSON.stringify(result, null, 2)); - return; + if (force || update) { + throw new Error( + "project-ai is now a minimal preview-first enrollment alias; use operating-model --project for an explicit full-pack install" + ); } - const action = dryRun ? "Would scaffold" : "Scaffolded"; - console.log( - renderPage({ - title: `fclt templates init ${kind}`, - subtitle: `${action} ${result.installedAs}`, - sections: [ - { - title: "Changed Paths", - lines: renderBullets(result.changedPaths), - }, - ...(result.skippedPaths?.length - ? [ - { - title: "Skipped Local Edits", - lines: renderBullets(result.skippedPaths), - }, - ] - : []), - ], + if (args.includes("--schedule")) { + throw new Error( + "Minimal project enrollment does not install scheduling; enroll first, then enable a reviewed project loop separately" + ); + } + const selectedRoot = parsedArgs.projectRootArg + ? resolve( + expandHomePath(parsedArgs.projectRootArg, ctx.homeDir ?? homedir()) + ) + : parsedArgs.rootArg + ? dirname( + projectAiRootFromProjectArg(parsedArgs.rootArg, ctx.homeDir) + ) + : ctx.rootDir + ? dirname(resolve(ctx.rootDir)) + : resolve(ctx.cwd ?? process.cwd()); + const forwarded = ["init", "--project-root", selectedRoot]; + for (const flag of ["--guidance", "--source"]) { + for (const value of parseLongFlags(args, flag)) { + forwarded.push(flag, value); + } + } + for (const flag of ["--cadence", "--plan-sha"]) { + const value = parseLongFlag(args, flag); + if (value) { + forwarded.push(flag, value); + } + } + for (const flag of ["--apply", "--json"]) { + if (args.includes(flag)) { + forwarded.push(flag); + } + } + await import("./projects").then(({ projectCommand }) => + projectCommand(forwarded, { + cwd: ctx.cwd, + homeDir: ctx.homeDir, }) ); return; diff --git a/src/setup.test.ts b/src/setup.test.ts index 47320b9a..fe4ba563 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -276,7 +276,7 @@ describe("zero-config setup", () => { expect(await Bun.file(configPath).text()).toBe("{invalid\n"); }); - it("bootstraps an isolated CLI-only home and project idempotently", async () => { + it("keeps setup global and returns a no-write project plan only when requested", async () => { const home = await tempHome("fclt-setup-cli-"); const repo = await initRepo(home); @@ -289,74 +289,84 @@ describe("zero-config setup", () => { expect(first.stderr).toBe(""); const result = JSON.parse(first.stdout) as { health: string; - projectRoot: string; + projectRoot: string | null; + projectEnrollmentPlan: null; readiness: { global: { loop: { state: string; capabilities: Record }; }; - project: { - loop: { state: string; capabilities: Record }; - }; + project: null; }; }; expect(result.health).toBe("ready"); - expect(result.projectRoot).toBe(join(repo, ".ai")); + expect(result.projectRoot).toBeNull(); + expect(result.projectEnrollmentPlan).toBeNull(); expect(result.readiness.global.loop.state).toBe("ready"); - expect(result.readiness.project.loop.state).toBe("ready"); + expect(result.readiness.project).toBeNull(); expect(result.readiness.global.loop.capabilities.writebackSkill).toBe(true); expect(result.readiness.global.loop.capabilities.evolutionSkill).toBe(true); expect( result.readiness.global.loop.capabilities.reconciliation ).toMatchObject({ configured: true, sourceCount: 1 }); - expect( - result.readiness.project.loop.capabilities.reconciliation - ).toMatchObject({ configured: true, sourceCount: 2 }); expect( JSON.parse( await Bun.file(join(home, ".ai", "reconciliation.json")).text() ).sources ).toHaveLength(1); - expect( - JSON.parse( - await Bun.file(join(repo, ".ai", "reconciliation.json")).text() - ).sources - ).toHaveLength(2); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); - const add = await runCli({ + const preview = await runCli({ home, cwd: repo, - argv: [ - "ai", - "writeback", - "add", - "--kind", - "reusable_pattern", - "--summary", - "Preserve this history across setup reruns.", - "--asset", - "skill:capability-evolution", - "--evidence", - "test:idempotent-setup", - "--project", - ], + argv: ["setup", "--include-project", "--json", "--no-codex-plugin"], }); - expect(add.code).toBe(0); + expect(preview.code).toBe(0); + const planned = JSON.parse(preview.stdout) as { + projectRoot: string; + projectEnrollmentPlan: { + projectRoot: string; + planSha256: string; + protections: { automaticGuidanceCopy: boolean }; + }; + repairActions: Array<{ command: string }>; + }; + expect(planned.projectRoot).toBe(join(repo, ".ai")); + expect(planned.projectEnrollmentPlan.projectRoot).toBe(repo); + expect( + planned.projectEnrollmentPlan.protections.automaticGuidanceCopy + ).toBe(false); + expect(planned.repairActions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: expect.stringContaining( + `--plan-sha ${planned.projectEnrollmentPlan.planSha256}` + ), + }), + ]) + ); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); - const second = await runCli({ + const apply = await runCli({ home, cwd: repo, - argv: ["setup", "--json", "--no-codex-plugin"], + argv: [ + "project", + "init", + "--project-root", + repo, + "--apply", + "--plan-sha", + planned.projectEnrollmentPlan.planSha256, + "--json", + ], }); - expect(second.code).toBe(0); - expect((JSON.parse(second.stdout) as { health: string }).health).toBe( - "ready" + expect(apply.code).toBe(0); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + true + ); + expect(await Bun.file(join(repo, ".ai", "AGENTS.global.md")).exists()).toBe( + false ); - const list = await runCli({ - home, - cwd: repo, - argv: ["ai", "writeback", "list", "--project", "--json"], - }); - expect(list.stdout).toContain("Preserve this history"); }, 20_000); it("does not bootstrap a nested project inside a git-backed global root", async () => { @@ -380,7 +390,7 @@ describe("zero-config setup", () => { expect(result.readiness.project).toBeNull(); }, 20_000); - it("records and assesses project writeback using only the documented bootstrap", async () => { + it("records and assesses project writeback after explicit enrollment", async () => { const home = await tempHome("fclt-setup-loop-"); const repo = await initRepo(home); expect( @@ -392,6 +402,32 @@ describe("zero-config setup", () => { }) ).code ).toBe(0); + const preview = await runCli({ + home, + cwd: repo, + argv: ["project", "init", "--project-root", repo, "--json"], + }); + expect(preview.code).toBe(0); + const planSha256 = (JSON.parse(preview.stdout) as { planSha256: string }) + .planSha256; + expect( + ( + await runCli({ + home, + cwd: repo, + argv: [ + "project", + "init", + "--project-root", + repo, + "--apply", + "--plan-sha", + planSha256, + "--json", + ], + }) + ).code + ).toBe(0); const add = await runCli({ home, diff --git a/src/setup.ts b/src/setup.ts index 76e8f8a8..34bbb4d6 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -5,11 +5,11 @@ import { resolveCliContextRoot } from "./cli-context"; import { buildDoctorReport, type DoctorReport } from "./doctor"; import { type SetupCodexPluginResult, setupCodexPlugin } from "./manage"; import { facultAiReconciliationConfigPath } from "./paths"; +import { type ProjectEnrollmentPlan, planProjectEnrollment } from "./projects"; import { initializeReconciliationConfig } from "./reconciliation-config"; import { findGitRootFromPath, scaffoldBuiltinOperatingModelPack, - scaffoldBuiltinProjectAiPack, } from "./remote"; export interface BootstrapOptions { @@ -30,6 +30,7 @@ export interface BootstrapResult { homeDir: string; globalRoot: string; projectRoot: string | null; + projectEnrollmentPlan: ProjectEnrollmentPlan | null; changedPaths: string[]; skippedPaths: string[]; codexPlugin: SetupCodexPluginResult | null; @@ -89,7 +90,7 @@ export async function bootstrapFclt( scope: "global", }); const detectedProject = findGitRootFromPath(cwd); - const includeProject = opts.includeProject ?? detectedProject !== null; + const includeProject = opts.includeProject ?? false; const projectCandidateRoot = resolve(detectedProject ?? cwd, ".ai"); const projectTargetsGlobalRoot = (detectedProject !== null && resolve(detectedProject) === globalRoot) || @@ -98,6 +99,13 @@ export async function bootstrapFclt( includeProject && !projectTargetsGlobalRoot ? projectCandidateRoot : null; const changedPaths: string[] = []; const skippedPaths: string[] = []; + const projectEnrollmentPlan = + projectRoot && detectedProject + ? await planProjectEnrollment({ + projectRoot: detectedProject, + homeDir, + }) + : null; const globalInstall = await scaffoldBuiltinOperatingModelPack({ rootDir: globalRoot, @@ -124,32 +132,6 @@ export async function bootstrapFclt( await refreshAiReviewArtifacts({ homeDir, rootDir: globalRoot }); } - if (projectRoot) { - const projectInstall = await scaffoldBuiltinProjectAiPack({ - cwd: detectedProject ?? cwd, - rootDir: projectRoot, - homeDir, - dryRun: opts.dryRun, - update: true, - }); - changedPaths.push(...projectInstall.changedPaths); - skippedPaths.push(...(projectInstall.skippedPaths ?? [])); - const projectReconciliation = await initializeReconciliationForSetup({ - homeDir, - rootDir: projectRoot, - scope: "project", - dryRun: opts.dryRun, - }); - if (projectReconciliation?.created) { - changedPaths.push(projectReconciliation.path); - } else if (!projectReconciliation) { - skippedPaths.push(facultAiReconciliationConfigPath(homeDir, projectRoot)); - } - if (!opts.dryRun) { - await refreshAiReviewArtifacts({ homeDir, rootDir: projectRoot }); - } - } - const codexBin = opts.codexBin === undefined ? Bun.which("codex") : opts.codexBin; const installCodexPlugin = @@ -169,7 +151,7 @@ export async function bootstrapFclt( const [globalReadiness, projectReadiness] = await Promise.all([ buildDoctorReport({ cwd, homeDir, rootArg: globalRoot, scope: "global" }), - projectRoot + projectRoot && (await Bun.file(projectRoot).exists()) ? buildDoctorReport({ cwd, homeDir, @@ -194,6 +176,16 @@ export async function bootstrapFclt( const repairActions = [ ...reportRepairs(globalReadiness, "global"), ...(projectReadiness ? reportRepairs(projectReadiness, "project") : []), + ...(projectEnrollmentPlan + ? [ + { + scope: "project" as const, + command: `fclt project init --project-root '${projectEnrollmentPlan.projectRoot}' --apply --plan-sha ${projectEnrollmentPlan.planSha256}`, + reason: + "Review the project enrollment plan before applying its minimal canonical layer.", + }, + ] + : []), ...(pluginFailed ? [ { @@ -227,6 +219,7 @@ export async function bootstrapFclt( homeDir, globalRoot, projectRoot, + projectEnrollmentPlan, changedPaths: uniqueSorted(changedPaths), skippedPaths: uniqueSorted(skippedPaths), codexPlugin, @@ -242,17 +235,17 @@ function printHelp(): void { console.log(`fclt setup — bootstrap a healthy writeback/evolution loop Usage: - fclt setup [--json] [--dry-run] [--global-only] [--no-codex-plugin] + fclt setup [--json] [--dry-run] [--include-project] [--no-codex-plugin] fclt setup codex-plugin [--dry-run] [--json] [--no-codex-install] The default command initializes or safely updates the global operating model, -initializes the current git repository when present, prepares review state, and -installs the Codex plugin when Codex is available. It is safe to run again. +prepares review state, and installs the Codex plugin when Codex is available. +Project enrollment is separate and preview-first. Options: --json Print machine-readable bootstrap and readiness output --dry-run Report planned writes without changing state - --global-only Do not initialize the current repository + --include-project Include an exact no-write project enrollment plan --no-codex-plugin Keep setup CLI-only even when Codex is available --no-codex-install Prepare plugin files without running codex plugin add `); @@ -283,7 +276,7 @@ export async function setupCommand(argv: string[]): Promise { try { const result = await bootstrapFclt({ dryRun: args.includes("--dry-run"), - includeProject: args.includes("--global-only") ? false : undefined, + includeProject: args.includes("--include-project"), installCodexPlugin: args.includes("--no-codex-plugin") ? false : undefined, From 531e948737a531bc20ca76765c6950bd9e0435cc Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 28 Jul 2026 22:39:49 -0400 Subject: [PATCH 2/6] feat: harden guided project onboarding --- README.md | 3 + docs/codex-plugin-capability-matrix.json | 2 +- docs/codex-plugin.md | 6 +- docs/project-ai.md | 49 +- plugins/fclt/.codex-plugin/plugin.json | 2 +- plugins/fclt/skills/fclt-setup/SKILL.md | 13 +- src/audit/safe-openat.test.ts | 259 +- src/audit/safe-openat.ts | 1136 +++- src/builtin-assets.ts | 2 +- src/doctor.test.ts | 3 +- src/doctor.ts | 4 +- src/index-builder.ts | 21 +- src/manage.test.ts | 30 +- src/paths.ts | 178 +- src/projects.test.ts | 5985 +++++++++++++++++++++- src/projects.ts | 4753 +++++++++++++++-- src/remote.test.ts | 307 +- src/remote.ts | 156 +- src/repository-identity.test.ts | 193 + src/repository-identity.ts | 449 ++ src/setup.test.ts | 189 +- src/setup.ts | 18 +- 22 files changed, 12888 insertions(+), 870 deletions(-) create mode 100644 src/repository-identity.test.ts create mode 100644 src/repository-identity.ts diff --git a/README.md b/README.md index 98243428..0ab520da 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,9 @@ Minimal enrollment never copies `AGENTS.md` or `CLAUDE.md`. Use `--guidance AGENTS.md` only for an explicit, fully previewed reference to a tracked clean rulebook. Full operating-pack install remains a separate `fclt templates init operating-model --project` action. +Project discovery, status, and rollback preview remain read-only on Windows; +project enrollment and registry-mutating lifecycle actions currently require +Linux or macOS conditional replacement support. Create individual capability units: diff --git a/docs/codex-plugin-capability-matrix.json b/docs/codex-plugin-capability-matrix.json index 6bdf426c..89189cd2 100644 --- a/docs/codex-plugin-capability-matrix.json +++ b/docs/codex-plugin-capability-matrix.json @@ -3,7 +3,7 @@ "matrixVersion": 1, "generatedFrom": { "packageVersion": "2.28.0", - "pluginVersion": "0.1.2", + "pluginVersion": "0.1.3", "auditedAt": "2026-07-14T00:00:00Z" }, "dispositions": ["exposed", "withheld", "blocked_safer_api"], diff --git a/docs/codex-plugin.md b/docs/codex-plugin.md index 7e4c52f8..2bed1250 100644 --- a/docs/codex-plugin.md +++ b/docs/codex-plugin.md @@ -105,9 +105,9 @@ inherit the shell that contains Node, Bun, mise, or a package-manager shim. When the `codex` command is available, setup runs `codex plugin add fclt@ --json`. Codex installs the plugin cache under `~/.codex/plugins/cache//fclt/` using its own version -directory. The read-only audit capability gate ships in plugin `0.1.2`; its -version bump prevents Codex from selecting the pre-gate `0.1.1` cached wrapper -after an upgrade. Setup fails closed unless Codex's install result, installed +directory. The safe project-onboarding contract ships in plugin `0.1.3`; its +version bump prevents Codex from selecting the pre-onboarding `0.1.2` cached +wrapper after an upgrade. Setup fails closed unless Codex's install result, installed payload hash, and post-install plugin list all select the bundled version as installed and enabled. diff --git a/docs/project-ai.md b/docs/project-ai.md index d6e01d1f..35680f04 100644 --- a/docs/project-ai.md +++ b/docs/project-ai.md @@ -12,9 +12,16 @@ Discovery is read-only, bounded, and restricted to explicit roots: fclt projects discover --root ~/dev --since 30d --json ``` -The result groups duplicate clones and worktrees by a stable repository +The result groups duplicate clones and worktrees by a stable portfolio identity, reports dirty state and existing `.ai` coverage, and says when a -bound truncated the scan. Review and select repositories individually; the +bound truncated the scan. A normalized remote is the portable repository +discriminator when one exists, so an upstream repository and its fork are not +merged merely because they share initial history. Root-commit and local Git +common-directory aliases migrate earlier records, while the machine-local +registry preserves the selected primary when remotes are later added, renamed, +or removed. Each checkout/worktree also has a separate location-derived +execution identity because its generated index and graph contain +location-specific paths. Review and select repositories individually; the command never enrolls its results. ## Preview And Apply @@ -25,9 +32,10 @@ Preview the exact plan before any write: fclt project init --project-root /path/to/repo --json ``` -The plan distinguishes canonical, generated, and machine-local writes and -includes file preconditions, privacy findings, rollback behavior, and a plan -hash. Minimal enrollment writes: +The plan distinguishes canonical, generated, and machine-local writes, names +the execution-specific index and graph paths, and includes file preconditions, +privacy findings, rollback behavior, and a plan hash. Minimal enrollment +writes: ```text /.ai/ @@ -46,9 +54,13 @@ fclt project init --project-root /path/to/repo \ --apply --plan-sha --json ``` -Apply refuses stale source hashes or changed destination preconditions. It does -not install the operating pack, enable managed rendering, schedule a loop, or -copy repository guidance. +Apply refuses stale source hashes, changed destination preconditions, symlinked +generated targets, or an execution identity mismatch. One machine-local +transaction lock serializes canonical files, generated state, the portfolio +registry, and the final receipt. A failed late stage restores only bytes still +owned by that transaction, so a concurrent user edit is preserved. It does not +install the operating pack, enable managed rendering, schedule a loop, or copy +repository guidance. `fclt templates init project-ai` remains a preview-first compatibility alias. It has the same minimal contract and does not accept the old `--update` or @@ -67,8 +79,10 @@ fclt project init --project-root /path/to/repo \ ``` The plan previews the complete content and SHA-256 hash. Adoption is -reference-only and is refused when the source is untracked, modified, outside -the repository, secret-shaped, or contains a machine-local absolute path. +reference-only and is refused when the source is untracked, modified, marked +`assume-unchanged` or `skip-worktree`, outside the repository, secret-shaped, +or contains a machine-local absolute path. fclt compares the worktree, index, +and `HEAD` blob identities directly rather than trusting porcelain status. Reviewing and applying a plan therefore cannot turn a dirty checkout or a machine-specific document into committed project capability. @@ -114,8 +128,19 @@ fclt project rollback --receipt --json fclt project rollback --receipt --apply --json ``` -Rollback removes only files created by that receipt when their hashes still -match. It refuses drift and preserves the receipt and review history. +Rollback changes only files still matching the receipt and only when that +receipt remains the active enrollment for its checkout. It refuses drift and +older receipts, serializes against apply, and preserves the receipt and review +history. + +On Linux and macOS, supported mutations are serialized and bind registry +replacement to the captured contents, mode, and inode through a conditional +exchange or no-replace commit. A non-cooperating writer at the final commit +boundary is preserved and the fclt transaction fails closed. Windows supports +read-only discovery, status, and rollback preview, but project enrollment, +rollback apply, and registry decisions (`disable`, `ignore`, `inactive`, and +`remove`) remain unavailable until Windows has an equivalent conditional +replacement primitive. ## Scope And Verification diff --git a/plugins/fclt/.codex-plugin/plugin.json b/plugins/fclt/.codex-plugin/plugin.json index ef067f12..89119df7 100644 --- a/plugins/fclt/.codex-plugin/plugin.json +++ b/plugins/fclt/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "fclt", - "version": "0.1.2", + "version": "0.1.3", "description": "Codex workflows and MCP tools for evidence reconciliation, writeback, evolution, and outcome review.", "author": { "name": "Hack Dance", diff --git a/plugins/fclt/skills/fclt-setup/SKILL.md b/plugins/fclt/skills/fclt-setup/SKILL.md index 4ad93913..7573b72a 100644 --- a/plugins/fclt/skills/fclt-setup/SKILL.md +++ b/plugins/fclt/skills/fclt-setup/SKILL.md @@ -71,8 +71,9 @@ fclt projects discover --root ~/dev --since 30d --json ``` Discovery is bounded and read-only. Review duplicate clone/worktree groups, -dirty state, existing guidance, and repository identity before selecting a -project. Never bulk-enroll the discovery result. +dirty state, existing guidance, stable portfolio identity, and the separate +checkout/worktree execution identity before selecting a project. Never +bulk-enroll the discovery result. 6. Preview the exact minimal project enrollment plan: @@ -93,7 +94,9 @@ fclt project init --project-root /path/to/repo \ ``` Guidance adoption is reference-only. fclt previews the full content and hash, -and refuses untracked, modified, secret-shaped, or machine-path-bearing input. +and refuses untracked, modified, `assume-unchanged`, `skip-worktree`, +secret-shaped, or machine-path-bearing input. Cleanliness requires identical +worktree, index, and `HEAD` blobs. 7. Apply only the unchanged reviewed plan: @@ -103,7 +106,9 @@ fclt project init --project-root /path/to/repo \ ``` If options, source files, or preconditions change, discard the old hash and -preview again. +preview again. Apply serializes portfolio registry mutations, isolates +location-bearing generated state per checkout/worktree, refuses symlinked +generated targets, and publishes the receipt only after the registry commit. 8. Inspect health, coverage, and lifecycle: diff --git a/src/audit/safe-openat.test.ts b/src/audit/safe-openat.test.ts index ea8623dc..b32f0bea 100644 --- a/src/audit/safe-openat.test.ts +++ b/src/audit/safe-openat.test.ts @@ -1,6 +1,18 @@ import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { closeSync, constants, openSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -8,7 +20,9 @@ import { darwinDirectoryStreamSymbols, linuxLibcCandidates, readDirectoryEntriesAt, + replaceVerifiedFileAt, resolveLinuxLibcPath, + unlinkVerifiedFileAt, } from "./safe-openat"; test("Darwin directory reads select the SDK inode ABI for each architecture", () => { @@ -148,3 +162,246 @@ test("runtime libc resolves and descriptor-bound directory reads work", async () await rm(root, { force: true, recursive: true }); } }); + +test("verified replacement compensates an existing target swapped at the exchange boundary", async () => { + if (!(process.platform === "darwin" || process.platform === "linux")) { + return; + } + const root = await realpath( + await mkdtemp(join(tmpdir(), "fclt-safe-replace-runtime-")) + ); + const target = join(root, "entry.txt"); + const displaced = join(root, "entry.reviewed.txt"); + const reviewed = "reviewed\n"; + const concurrent = "concurrent\n"; + await writeFile(target, reviewed); + await chmod(target, 0o640); + const identity = await lstat(target); + try { + await expect( + replaceVerifiedFileAt({ + beforeExchange: async () => { + await rename(target, displaced); + await writeFile(target, concurrent); + }, + contents: "replacement\n", + directoryPath: root, + expected: { + contents: reviewed, + identity: { dev: identity.dev, ino: identity.ino }, + mode: 0o640, + }, + fileName: "entry.txt", + maxBytes: 1024, + mode: 0o640, + }) + ).rejects.toThrow("conditional commit boundary"); + + expect(await readFile(target, "utf8")).toBe(concurrent); + expect(await readFile(displaced, "utf8")).toBe(reviewed); + expect( + (await readdir(root)).filter((name) => name.endsWith(".tmp")) + ).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test("verified replacement uses no-replace creation when an absent target races", async () => { + if (!(process.platform === "darwin" || process.platform === "linux")) { + return; + } + const root = await realpath( + await mkdtemp(join(tmpdir(), "fclt-safe-create-runtime-")) + ); + const target = join(root, "entry.txt"); + try { + await expect( + replaceVerifiedFileAt({ + beforeExchange: async () => { + await writeFile(target, "concurrent\n"); + }, + contents: "replacement\n", + directoryPath: root, + expected: null, + fileName: "entry.txt", + maxBytes: 1024, + mode: 0o644, + }) + ).rejects.toThrow("no-replace commit boundary"); + + expect(await readFile(target, "utf8")).toBe("concurrent\n"); + expect( + (await readdir(root)).filter((name) => name.endsWith(".tmp")) + ).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test("verified replacement rejects a rebound parent before exchange", async () => { + if (!(process.platform === "darwin" || process.platform === "linux")) { + return; + } + const root = await realpath( + await mkdtemp(join(tmpdir(), "fclt-safe-replace-parent-")) + ); + const directory = join(root, ".ai"); + const displacedDirectory = join(root, ".ai-reviewed"); + const target = join(directory, "entry.txt"); + await mkdir(directory); + await writeFile(target, "reviewed\n"); + try { + await expect( + replaceVerifiedFileAt({ + beforeExchange: async () => { + await rename(directory, displacedDirectory); + await mkdir(directory); + await writeFile(target, "concurrent\n"); + }, + contents: "replacement\n", + directoryPath: directory, + expected: { contents: "reviewed\n", mode: 0o644 }, + fileName: "entry.txt", + maxBytes: 1024, + mode: 0o644, + safeRoot: root, + }) + ).rejects.toThrow("directory changed before conditional replace"); + + expect(await readFile(target, "utf8")).toBe("concurrent\n"); + expect(await readFile(join(displacedDirectory, "entry.txt"), "utf8")).toBe( + "reviewed\n" + ); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test("Windows verified replacement fails closed without an equivalent conditional commit", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "fclt-safe-replace-windows-")) + ); + const target = join(root, "entry.txt"); + try { + await expect( + replaceVerifiedFileAt({ + contents: "replacement\n", + directoryPath: root, + expected: null, + fileName: "entry.txt", + maxBytes: 1024, + mode: 0o644, + platform: "win32", + }) + ).rejects.toThrow("unsupported on win32"); + expect(await Bun.file(target).exists()).toBe(false); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test("verified unlink detects reappearance and restores the expected canonical file", async () => { + if (!(process.platform === "darwin" || process.platform === "linux")) { + return; + } + const root = await realpath( + await mkdtemp(join(tmpdir(), "fclt-safe-unlink-runtime-")) + ); + const target = join(root, "entry.txt"); + const expected = "expected\n"; + await writeFile(target, expected); + try { + await expect( + unlinkVerifiedFileAt({ + afterQuarantine: async () => { + await writeFile(target, "reappeared\n"); + }, + directoryPath: root, + expectedSha256: createHash("sha256").update(expected).digest("hex"), + fileName: "entry.txt", + maxBytes: 1024, + }) + ).rejects.toThrow("target reappeared"); + + expect(await readFile(target, "utf8")).toBe(expected); + const quarantines = (await readdir(root)).filter((name) => + name.endsWith(".rollback") + ); + expect(quarantines).toHaveLength(1); + expect(await readFile(join(root, quarantines[0] ?? ""), "utf8")).toBe( + "reappeared\n" + ); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test("Windows verified unlink preserves a canonical leaf swapped before quarantine", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "fclt-safe-unlink-windows-")) + ); + const target = join(root, "entry.txt"); + const displaced = join(root, "entry.original.txt"); + const expected = "expected\n"; + const replacement = "replacement\n"; + await writeFile(target, expected); + try { + await expect( + unlinkVerifiedFileAt({ + beforeCommit: async () => { + await rename(target, displaced); + await writeFile(target, replacement); + }, + directoryPath: root, + expectedSha256: createHash("sha256").update(expected).digest("hex"), + fileName: "entry.txt", + maxBytes: 1024, + platform: "win32", + }) + ).rejects.toThrow("target changed at quarantine boundary"); + + expect(await readFile(target, "utf8")).toBe(replacement); + expect(await readFile(displaced, "utf8")).toBe(expected); + expect( + (await readdir(root)).filter((name) => name.endsWith(".rollback")) + ).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test("Windows verified unlink compensates canonical reappearance without deleting it", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "fclt-safe-unlink-windows-reappear-")) + ); + const target = join(root, "entry.txt"); + const expected = "expected\n"; + const reappeared = "reappeared\n"; + await writeFile(target, expected); + try { + await expect( + unlinkVerifiedFileAt({ + afterQuarantine: async () => { + await writeFile(target, reappeared); + }, + directoryPath: root, + expectedSha256: createHash("sha256").update(expected).digest("hex"), + fileName: "entry.txt", + maxBytes: 1024, + platform: "win32", + }) + ).rejects.toThrow("target reappeared"); + + expect(await readFile(target, "utf8")).toBe(expected); + const entries = await readdir(root); + expect(entries.filter((name) => name.endsWith(".rollback"))).toEqual([]); + const preserved = entries.filter((name) => name.endsWith(".preserved")); + expect(preserved).toHaveLength(1); + expect(await readFile(join(root, preserved[0] ?? ""), "utf8")).toBe( + reappeared + ); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/src/audit/safe-openat.ts b/src/audit/safe-openat.ts index ba1231ff..ae8932e8 100644 --- a/src/audit/safe-openat.ts +++ b/src/audit/safe-openat.ts @@ -17,9 +17,11 @@ import { openSync, readSync, realpathSync, + renameSync, type Stats, + unlinkSync, } from "node:fs"; -import { isAbsolute, join, normalize, sep } from "node:path"; +import { isAbsolute, join, normalize, relative, resolve, sep } from "node:path"; const DIRECTORY_ENTRY_TYPES = { blockDevice: 6, @@ -536,6 +538,1138 @@ export function openReadOnlyAt(args: { } } +interface VerifiedFileUnlinkOptions { + /** @internal Adversarial test hook; production callers must not set this. */ + afterQuarantine?: () => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeCommit?: () => Promise; + directoryPath: string; + expectedSha256: string; + fileName: string; + maxBytes: number; + /** @internal Platform branch override for cross-platform regression tests. */ + platform?: NodeJS.Platform; + safeRoot?: string; +} + +interface VerifiedFileReplaceOptions { + /** @internal Adversarial test hook; production callers must not set this. */ + beforeCommit?: () => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeExchange?: () => Promise; + contents: string; + directoryPath: string; + expected: { + contents: string; + identity?: { + dev: number; + ino: number; + }; + mode: number; + } | null; + fileName: string; + maxBytes: number; + mode: number; + /** @internal Platform branch override for cross-platform regression tests. */ + platform?: NodeJS.Platform; + safeRoot?: string; +} + +interface PathBoundFileSnapshot { + metadata: Stats; + sha256: string; +} + +interface BoundSafeRoot { + metadata: Stats; + path: string; + platform: NodeJS.Platform; +} + +function lstatIfPresent(pathValue: string): Stats | null { + try { + return lstatSync(pathValue); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + +function pathBoundFileSnapshot(args: { + label: string; + maxBytes: number; + path: string; +}): PathBoundFileSnapshot { + const pathMetadata = lstatSync(args.path); + if ( + pathMetadata.isSymbolicLink() || + !pathMetadata.isFile() || + pathMetadata.nlink !== 1 || + !Number.isSafeInteger(pathMetadata.size) || + pathMetadata.size < 0 || + pathMetadata.size > args.maxBytes + ) { + throw new Error(`${args.label} is unsafe`); + } + const fd = openSync( + args.path, + constants.O_RDONLY + (constants.O_NOFOLLOW ?? 0) + ); + try { + const before = fstatSync(fd); + if (!sameObjectIdentity(pathMetadata, before)) { + throw new Error(`${args.label} mapping changed before read`); + } + const bytes = Buffer.alloc(before.size); + let offset = 0; + while (offset < bytes.length) { + const count = readSync(fd, bytes, offset, bytes.length - offset, offset); + if (count <= 0) { + throw new Error(`${args.label} changed while reading`); + } + offset += count; + } + const after = fstatSync(fd); + const rebound = lstatSync(args.path); + if ( + !sameFileIdentity(before, after) || + rebound.isSymbolicLink() || + !sameObjectIdentity(rebound, after) + ) { + throw new Error(`${args.label} changed while reading`); + } + return { + metadata: before, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + } finally { + closeSync(fd); + } +} + +function windowsPathKey(pathValue: string): string { + return normalize(pathValue).toLowerCase(); +} + +function platformPathKey(pathValue: string, platform: NodeJS.Platform): string { + return platform === "win32" + ? windowsPathKey(pathValue) + : normalize(pathValue); +} + +function captureSafeRootBoundary(args: { + directoryPath: string; + platform: NodeJS.Platform; + safeRoot?: string; +}): BoundSafeRoot | null { + if (!args.safeRoot) { + return null; + } + const safeRoot = resolve(args.safeRoot); + if ( + !isAbsolute(args.safeRoot) || + normalize(args.safeRoot) !== args.safeRoot + ) { + throw new Error("Verified canonical unlink safe root is unsupported"); + } + const rel = relative(safeRoot, args.directoryPath); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error("Verified canonical unlink escapes its safe root"); + } + const metadata = lstatSync(safeRoot); + if ( + metadata.isSymbolicLink() || + !metadata.isDirectory() || + platformPathKey(realpathSync(safeRoot), args.platform) !== + platformPathKey(safeRoot, args.platform) + ) { + throw new Error("Verified canonical unlink safe root is unsafe"); + } + return { + metadata, + path: safeRoot, + platform: args.platform, + }; +} + +function assertSafeRootBoundary( + boundary: BoundSafeRoot | null, + directoryPath: string +): void { + if (!boundary) { + return; + } + const current = lstatSync(boundary.path); + const rel = relative(boundary.path, directoryPath); + if ( + current.isSymbolicLink() || + !current.isDirectory() || + !sameObjectIdentity(current, boundary.metadata) || + platformPathKey(realpathSync(boundary.path), boundary.platform) !== + platformPathKey(boundary.path, boundary.platform) || + rel === ".." || + rel.startsWith(`..${sep}`) || + isAbsolute(rel) + ) { + throw new Error("Verified canonical unlink safe root changed"); + } +} + +async function unlinkVerifiedFileOnWindows( + args: VerifiedFileUnlinkOptions, + safeRootBoundary: BoundSafeRoot | null +): Promise { + const targetPath = join(args.directoryPath, args.fileName); + const quarantinePath = join( + args.directoryPath, + `.${args.fileName}.${randomUUID()}.rollback` + ); + const directoryIdentity = lstatSync(args.directoryPath); + let quarantineActive = false; + const assertDirectoryMapping = (): void => { + assertSafeRootBoundary(safeRootBoundary, args.directoryPath); + const current = lstatSync(args.directoryPath); + if ( + directoryIdentity.isSymbolicLink() || + !directoryIdentity.isDirectory() || + current.isSymbolicLink() || + !sameObjectIdentity(current, directoryIdentity) || + windowsPathKey(realpathSync(args.directoryPath)) !== + windowsPathKey(args.directoryPath) + ) { + throw new Error("Bound canonical directory changed before unlink"); + } + }; + const verifyExpected = ( + snapshot: PathBoundFileSnapshot, + expectedMetadata?: Stats + ): void => { + if ( + snapshot.sha256 !== args.expectedSha256 || + (expectedMetadata && + !sameObjectIdentity(snapshot.metadata, expectedMetadata)) + ) { + throw new Error( + "Bound canonical file no longer matches its rollback receipt" + ); + } + }; + const renameWithoutOverwrite = ( + source: string, + destination: string + ): void => { + if (lstatIfPresent(destination)) { + throw new Error("Canonical quarantine destination appeared"); + } + renameSync(source, destination); + }; + const compensateQuarantine = (quarantinedMetadata: Stats): void => { + const canonicalMetadata = lstatIfPresent(targetPath); + let preservedPath: string | null = null; + if (canonicalMetadata) { + preservedPath = join( + args.directoryPath, + `.${args.fileName}.${randomUUID()}.preserved` + ); + renameWithoutOverwrite(targetPath, preservedPath); + } + try { + renameWithoutOverwrite(quarantinePath, targetPath); + } catch (error) { + if ( + preservedPath && + !lstatIfPresent(targetPath) && + lstatIfPresent(preservedPath) + ) { + renameWithoutOverwrite(preservedPath, targetPath); + } + throw error; + } + const restored = pathBoundFileSnapshot({ + label: "Compensated canonical file", + maxBytes: args.maxBytes, + path: targetPath, + }); + if (!sameObjectIdentity(restored.metadata, quarantinedMetadata)) { + throw new Error("Canonical quarantine compensation changed identity"); + } + if (canonicalMetadata && preservedPath) { + const preserved = pathBoundFileSnapshot({ + label: "Reappeared canonical file", + maxBytes: args.maxBytes, + path: preservedPath, + }); + if (!sameObjectIdentity(preserved.metadata, canonicalMetadata)) { + throw new Error( + "Reappeared canonical file changed during compensation" + ); + } + } + quarantineActive = false; + }; + + try { + assertDirectoryMapping(); + const initial = pathBoundFileSnapshot({ + label: "Bound canonical file", + maxBytes: args.maxBytes, + path: targetPath, + }); + verifyExpected(initial); + const current = pathBoundFileSnapshot({ + label: "Bound canonical file", + maxBytes: args.maxBytes, + path: targetPath, + }); + verifyExpected(current, initial.metadata); + await args.beforeCommit?.(); + assertDirectoryMapping(); + renameWithoutOverwrite(targetPath, quarantinePath); + quarantineActive = true; + const quarantined = pathBoundFileSnapshot({ + label: "Quarantined canonical file", + maxBytes: args.maxBytes, + path: quarantinePath, + }); + try { + verifyExpected(quarantined, current.metadata); + } catch (error) { + compensateQuarantine(quarantined.metadata); + throw new Error("Canonical target changed at quarantine boundary", { + cause: error, + }); + } + await args.afterQuarantine?.(); + if (lstatIfPresent(targetPath)) { + compensateQuarantine(quarantined.metadata); + throw new Error("Canonical target reappeared during path-bound removal"); + } + assertDirectoryMapping(); + const finalQuarantine = pathBoundFileSnapshot({ + label: "Quarantined canonical file", + maxBytes: args.maxBytes, + path: quarantinePath, + }); + verifyExpected(finalQuarantine, current.metadata); + unlinkSync(quarantinePath); + quarantineActive = false; + } catch (error) { + if (quarantineActive) { + try { + const quarantined = pathBoundFileSnapshot({ + label: "Quarantined canonical file", + maxBytes: args.maxBytes, + path: quarantinePath, + }); + compensateQuarantine(quarantined.metadata); + } catch (compensationError) { + throw new AggregateError( + [error, compensationError], + "Canonical quarantine removal failed and compensation was incomplete" + ); + } + } + throw error; + } +} + +export async function unlinkVerifiedFileAt( + args: VerifiedFileUnlinkOptions +): Promise { + const platform = args.platform ?? process.platform; + if ( + !isAbsolute(args.directoryPath) || + normalize(args.directoryPath) !== args.directoryPath || + !args.fileName || + args.fileName === "." || + args.fileName === ".." || + args.fileName.includes("/") || + args.fileName.includes("\\") + ) { + throw new Error("Verified canonical unlink is unsupported"); + } + const safeRootBoundary = captureSafeRootBoundary({ + directoryPath: args.directoryPath, + platform, + safeRoot: args.safeRoot, + }); + if (platform === "win32") { + await unlinkVerifiedFileOnWindows(args, safeRootBoundary); + return; + } + if (!auditReportPersistenceSupported(platform)) { + throw new Error("Descriptor-relative verified unlink is unsupported"); + } + const configuration = platformConfiguration(); + const definitions: Record = { + fsync: { args: [FFIType.i32], returns: FFIType.i32 }, + openat: { + args: [FFIType.i32, FFIType.ptr, FFIType.i32, FFIType.i32], + returns: FFIType.i32, + }, + unlinkat: { + args: [FFIType.i32, FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + }; + if (process.platform === "darwin") { + definitions.renameatx_np = { + args: [FFIType.i32, FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.u32], + returns: FFIType.i32, + }; + definitions.__error = { args: [], returns: FFIType.ptr }; + } else { + definitions.renameat2 = { + args: [FFIType.i32, FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.u32], + returns: FFIType.i32, + }; + definitions.__errno_location = { args: [], returns: FFIType.ptr }; + } + const libc = openSystemLibc(configuration, definitions); + const symbols = libc.symbols as unknown as PrivateFileMutationSymbols; + const errnoPointer = + process.platform === "darwin" + ? symbols.__error!() + : symbols.__errno_location!(); + const errno = new DataView(toArrayBuffer(errnoPointer!, 0, 4)); + const directoryFlags = + constants.O_RDONLY + + (constants.O_DIRECTORY ?? 0) + + (constants.O_NOFOLLOW ?? 0) + + (constants.O_NONBLOCK ?? 0) + + ((constants as typeof constants & { O_CLOEXEC?: number }).O_CLOEXEC ?? 0); + const name = Buffer.from(`${args.fileName}\0`); + const quarantineName = Buffer.from( + `.${args.fileName}.${randomUUID()}.rollback\0` + ); + let directoryFd = -1; + let fileFd = -1; + let quarantineActive = false; + const assertDirectoryMapping = (expected: Stats): void => { + assertSafeRootBoundary(safeRootBoundary, args.directoryPath); + const descriptorMetadata = fstatSync(directoryFd); + const pathMetadata = lstatSync(args.directoryPath); + if ( + pathMetadata.isSymbolicLink() || + !pathMetadata.isDirectory() || + !sameObjectIdentity(descriptorMetadata, expected) || + !sameObjectIdentity(pathMetadata, expected) || + realpathSync(args.directoryPath) !== args.directoryPath + ) { + throw new Error("Bound canonical directory changed before unlink"); + } + }; + const openExisting = (entryName: Buffer): number | null => { + errno.setInt32(0, 0, true); + const fd = symbols.openat( + directoryFd, + ptr(entryName), + safeExistingOpenFlags(), + 0 + ); + if (fd >= 0) { + return fd; + } + if (errno.getInt32(0, true) === 2) { + return null; + } + throw new Error("Descriptor-relative canonical open failed closed"); + }; + const readSnapshot = ( + entryName: Buffer, + label: string + ): { fd: number; metadata: Stats; sha256: string } => { + const fd = openExisting(entryName); + if (fd === null) { + throw new Error(`${label} disappeared`); + } + try { + const before = fstatSync(fd); + if ( + !before.isFile() || + before.nlink !== 1 || + !Number.isSafeInteger(before.size) || + before.size < 0 || + before.size > args.maxBytes + ) { + throw new Error(`${label} is unsafe`); + } + const bytes = Buffer.alloc(before.size); + let offset = 0; + while (offset < bytes.length) { + const count = readSync( + fd, + bytes, + offset, + bytes.length - offset, + offset + ); + if (count <= 0) { + throw new Error(`${label} changed while reading`); + } + offset += count; + } + const after = fstatSync(fd); + if (!sameFileIdentity(before, after)) { + throw new Error(`${label} changed while reading`); + } + return { + fd, + metadata: before, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + } catch (error) { + closeSync(fd); + throw error; + } + }; + const verifyExpected = ( + snapshot: { metadata: Stats; sha256: string }, + expectedMetadata?: Stats + ): void => { + if ( + snapshot.sha256 !== args.expectedSha256 || + (expectedMetadata && + !sameObjectIdentity(snapshot.metadata, expectedMetadata)) + ) { + throw new Error( + "Bound canonical file no longer matches its rollback receipt" + ); + } + }; + const renameNoReplace = (source: Buffer, destination: Buffer): void => { + const result = + process.platform === "darwin" + ? symbols.renameatx_np!( + directoryFd, + ptr(source), + directoryFd, + ptr(destination), + 4 + ) + : symbols.renameat2!( + directoryFd, + ptr(source), + directoryFd, + ptr(destination), + 1 + ); + if (result !== 0) { + throw new Error("Canonical quarantine rename failed closed"); + } + }; + const exchange = (left: Buffer, right: Buffer): void => { + const result = + process.platform === "darwin" + ? symbols.renameatx_np!( + directoryFd, + ptr(left), + directoryFd, + ptr(right), + 2 + ) + : symbols.renameat2!( + directoryFd, + ptr(left), + directoryFd, + ptr(right), + 2 + ); + if (result !== 0) { + throw new Error("Canonical quarantine compensation failed closed"); + } + }; + const compensateQuarantine = (quarantinedMetadata: Stats): void => { + const canonicalFd = openExisting(name); + if (canonicalFd === null) { + renameNoReplace(quarantineName, name); + } else { + const canonicalMetadata = fstatSync(canonicalFd); + closeSync(canonicalFd); + exchange(quarantineName, name); + const preserved = readSnapshot( + quarantineName, + "Reappeared canonical file" + ); + try { + if (!sameObjectIdentity(preserved.metadata, canonicalMetadata)) { + throw new Error( + "Reappeared canonical file changed during compensation" + ); + } + } finally { + closeSync(preserved.fd); + } + } + const restored = readSnapshot(name, "Compensated canonical file"); + try { + if (!sameObjectIdentity(restored.metadata, quarantinedMetadata)) { + throw new Error("Canonical quarantine compensation changed identity"); + } + } finally { + closeSync(restored.fd); + } + quarantineActive = false; + symbols.fsync(directoryFd); + }; + + try { + directoryFd = openSync(args.directoryPath, directoryFlags); + const directoryIdentity = fstatSync(directoryFd); + assertDirectoryMapping(directoryIdentity); + const initial = readSnapshot(name, "Bound canonical file"); + verifyExpected(initial); + closeSync(initial.fd); + + assertDirectoryMapping(directoryIdentity); + const current = readSnapshot(name, "Bound canonical file"); + verifyExpected(current, initial.metadata); + fileFd = current.fd; + await args.beforeCommit?.(); + assertDirectoryMapping(directoryIdentity); + + renameNoReplace(name, quarantineName); + quarantineActive = true; + const quarantined = readSnapshot( + quarantineName, + "Quarantined canonical file" + ); + try { + verifyExpected(quarantined, current.metadata); + } catch (error) { + closeSync(quarantined.fd); + compensateQuarantine(quarantined.metadata); + throw new Error("Canonical target changed at quarantine boundary", { + cause: error, + }); + } + closeSync(quarantined.fd); + await args.afterQuarantine?.(); + const reappearedFd = openExisting(name); + if (reappearedFd !== null) { + closeSync(reappearedFd); + compensateQuarantine(quarantined.metadata); + throw new Error( + "Canonical target reappeared during descriptor-relative removal" + ); + } + assertDirectoryMapping(directoryIdentity); + const finalQuarantine = readSnapshot( + quarantineName, + "Quarantined canonical file" + ); + try { + verifyExpected(finalQuarantine, current.metadata); + } finally { + closeSync(finalQuarantine.fd); + } + if (symbols.unlinkat(directoryFd, ptr(quarantineName), 0) !== 0) { + throw new Error("Descriptor-relative quarantine unlink failed closed"); + } + quarantineActive = false; + symbols.fsync(directoryFd); + } catch (error) { + if (quarantineActive) { + try { + const quarantined = readSnapshot( + quarantineName, + "Quarantined canonical file" + ); + closeSync(quarantined.fd); + compensateQuarantine(quarantined.metadata); + } catch (compensationError) { + throw new AggregateError( + [error, compensationError], + "Canonical quarantine removal failed and compensation was incomplete" + ); + } + } + throw error; + } finally { + if (fileFd >= 0) { + closeSync(fileFd); + } + if (directoryFd >= 0) { + closeSync(directoryFd); + } + libc.close(); + } +} + +function replaceVerifiedFileOnWindows( + args: VerifiedFileReplaceOptions, + safeRootBoundary: BoundSafeRoot | null +): void { + const targetPath = join(args.directoryPath, args.fileName); + const directoryIdentity = lstatSync(args.directoryPath); + const existing = lstatIfPresent(targetPath); + const assertDirectoryMapping = (): void => { + assertSafeRootBoundary(safeRootBoundary, args.directoryPath); + const current = lstatSync(args.directoryPath); + if ( + directoryIdentity.isSymbolicLink() || + !directoryIdentity.isDirectory() || + current.isSymbolicLink() || + !sameObjectIdentity(current, directoryIdentity) || + windowsPathKey(realpathSync(args.directoryPath)) !== + windowsPathKey(args.directoryPath) + ) { + throw new Error( + "Bound canonical directory changed before conditional replace" + ); + } + }; + + assertDirectoryMapping(); + if (existing === null) { + if (args.expected !== null) { + throw new Error("Bound canonical file disappeared before commit"); + } + throw new Error("Verified canonical creation is unsupported on win32"); + } + if (args.expected === null) { + throw new Error("Canonical target appeared before the no-replace commit"); + } + const snapshot = pathBoundFileSnapshot({ + label: "Bound canonical file", + maxBytes: args.maxBytes, + path: targetPath, + }); + const expectedIdentity = args.expected.identity; + if ( + snapshot.sha256 !== + createHash("sha256").update(args.expected.contents).digest("hex") || + permissionBits(snapshot.metadata.mode) !== args.expected.mode || + (expectedIdentity && + (snapshot.metadata.dev !== expectedIdentity.dev || + snapshot.metadata.ino !== expectedIdentity.ino)) + ) { + throw new Error("Bound canonical file changed before commit"); + } + if ( + args.contents === args.expected.contents && + args.mode === args.expected.mode + ) { + return; + } + throw new Error( + "Verified replacement of an existing canonical file is unsupported on win32" + ); +} + +export async function replaceVerifiedFileAt( + args: VerifiedFileReplaceOptions +): Promise { + const platform = args.platform ?? process.platform; + if ( + !isAbsolute(args.directoryPath) || + normalize(args.directoryPath) !== args.directoryPath || + !args.fileName || + args.fileName === "." || + args.fileName === ".." || + args.fileName.includes("/") || + args.fileName.includes("\\") || + !Number.isSafeInteger(args.maxBytes) || + args.maxBytes < 0 || + !Number.isSafeInteger(args.mode) || + args.mode < 0 || + args.mode > 0o777 || + (args.expected !== null && + (!Number.isSafeInteger(args.expected.mode) || + args.expected.mode < 0 || + args.expected.mode > 0o777)) || + (args.expected?.identity !== undefined && + (!Number.isSafeInteger(args.expected.identity.dev) || + args.expected.identity.dev < 0 || + !Number.isSafeInteger(args.expected.identity.ino) || + args.expected.identity.ino < 0)) + ) { + throw new Error("Verified canonical replacement is unsupported"); + } + const safeRootBoundary = captureSafeRootBoundary({ + directoryPath: args.directoryPath, + platform, + safeRoot: args.safeRoot, + }); + if (platform === "win32") { + replaceVerifiedFileOnWindows(args, safeRootBoundary); + return; + } + if ( + platform !== process.platform || + !auditReportPersistenceSupported(platform) + ) { + throw new Error( + `Descriptor-relative canonical replacement is unsupported on ${platform}` + ); + } + + const configuration = platformConfiguration(); + const definitions: Record = { + close: { args: [FFIType.i32], returns: FFIType.i32 }, + fchmod: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 }, + fsync: { args: [FFIType.i32], returns: FFIType.i32 }, + linkat: { + args: [FFIType.i32, FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + openat: { + args: [FFIType.i32, FFIType.ptr, FFIType.i32, FFIType.i32], + returns: FFIType.i32, + }, + read: { + args: [FFIType.i32, FFIType.ptr, FFIType.u64], + returns: FFIType.i64, + }, + unlinkat: { + args: [FFIType.i32, FFIType.ptr, FFIType.i32], + returns: FFIType.i32, + }, + write: { + args: [FFIType.i32, FFIType.ptr, FFIType.u64], + returns: FFIType.i64, + }, + }; + if (process.platform === "darwin") { + definitions.renameatx_np = { + args: [FFIType.i32, FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.u32], + returns: FFIType.i32, + }; + definitions.__error = { args: [], returns: FFIType.ptr }; + } else { + definitions.renameat2 = { + args: [FFIType.i32, FFIType.ptr, FFIType.i32, FFIType.ptr, FFIType.u32], + returns: FFIType.i32, + }; + definitions.__errno_location = { args: [], returns: FFIType.ptr }; + } + const libc = openSystemLibc(configuration, definitions); + const symbols = libc.symbols as unknown as PrivateFileMutationSymbols; + const errnoPointer = + process.platform === "darwin" + ? symbols.__error!() + : symbols.__errno_location!(); + const errno = new DataView(toArrayBuffer(errnoPointer!, 0, 4)); + const directoryFlags = + constants.O_RDONLY + + (constants.O_DIRECTORY ?? 0) + + (constants.O_NOFOLLOW ?? 0) + + (constants.O_NONBLOCK ?? 0) + + ((constants as typeof constants & { O_CLOEXEC?: number }).O_CLOEXEC ?? 0); + const targetName = Buffer.from(`${args.fileName}\0`); + const temporaryName = Buffer.from(`.${args.fileName}.${randomUUID()}.tmp\0`); + let directoryFd = -1; + let existingFd = -1; + let temporaryFd = -1; + let preserveTemporary = false; + + const openExisting = (name: Buffer): number | null => { + errno.setInt32(0, 0, true); + const descriptor = symbols.openat( + directoryFd, + ptr(name), + safeExistingOpenFlags(), + 0 + ); + if (descriptor >= 0) { + return descriptor; + } + if (errno.getInt32(0, true) === 2) { + return null; + } + throw new Error("Descriptor-relative canonical open failed closed"); + }; + const readSnapshot = ( + name: Buffer, + label: string + ): { contents: Buffer; descriptor: number; metadata: Stats } | null => { + const descriptor = openExisting(name); + if (descriptor === null) { + return null; + } + try { + const before = fstatSync(descriptor); + if ( + !before.isFile() || + before.nlink !== 1 || + !Number.isSafeInteger(before.size) || + before.size < 0 || + before.size > args.maxBytes + ) { + throw new Error(`${label} is unsafe`); + } + const bytes = Buffer.alloc(before.size); + let offset = 0; + while (offset < bytes.length) { + const count = readSync( + descriptor, + bytes, + offset, + bytes.length - offset, + offset + ); + if (count <= 0) { + throw new Error(`${label} changed while reading`); + } + offset += count; + } + const after = fstatSync(descriptor); + if (!sameFileIdentity(before, after)) { + throw new Error(`${label} changed while reading`); + } + return { + contents: bytes, + descriptor, + metadata: before, + }; + } catch (error) { + symbols.close(descriptor); + throw error; + } + }; + const assertDirectoryMapping = (expected: Stats): void => { + assertSafeRootBoundary(safeRootBoundary, args.directoryPath); + const descriptorMetadata = fstatSync(directoryFd); + const pathMetadata = lstatSync(args.directoryPath); + if ( + pathMetadata.isSymbolicLink() || + !pathMetadata.isDirectory() || + !sameObjectIdentity(descriptorMetadata, expected) || + !sameObjectIdentity(pathMetadata, expected) || + realpathSync(args.directoryPath) !== args.directoryPath + ) { + throw new Error( + "Bound canonical directory changed before conditional replace" + ); + } + }; + const exchange = (): void => { + const result = + process.platform === "darwin" + ? symbols.renameatx_np!( + directoryFd, + ptr(temporaryName), + directoryFd, + ptr(targetName), + 2 + ) + : symbols.renameat2!( + directoryFd, + ptr(temporaryName), + directoryFd, + ptr(targetName), + 2 + ); + if (result !== 0) { + throw new Error("Canonical conditional exchange failed closed"); + } + }; + + try { + directoryFd = openSync(args.directoryPath, directoryFlags); + const directoryIdentity = fstatSync(directoryFd); + assertDirectoryMapping(directoryIdentity); + const existing = readSnapshot(targetName, "Bound canonical file"); + existingFd = existing?.descriptor ?? -1; + const bytes = Buffer.from(args.contents); + const expectedBytes = + args.expected === null ? null : Buffer.from(args.expected.contents); + if (bytes.byteLength > args.maxBytes) { + throw new Error("Verified canonical replacement exceeds its byte limit"); + } + if (expectedBytes && expectedBytes.byteLength > args.maxBytes) { + throw new Error("Verified canonical expectation exceeds its byte limit"); + } + if (existing === null && args.expected !== null) { + throw new Error("Bound canonical file disappeared before commit"); + } + if (existing !== null && args.expected === null) { + throw new Error("Canonical target appeared before the no-replace commit"); + } + if (existing && args.expected) { + const expectedIdentity = args.expected.identity; + if ( + !existing.contents.equals(expectedBytes!) || + permissionBits(existing.metadata.mode) !== args.expected.mode || + (expectedIdentity && + (existing.metadata.dev !== expectedIdentity.dev || + existing.metadata.ino !== expectedIdentity.ino)) + ) { + throw new Error("Bound canonical file changed before commit"); + } + if (bytes.equals(existing.contents) && args.mode === args.expected.mode) { + return; + } + } + temporaryFd = symbols.openat( + directoryFd, + ptr(temporaryName), + configuration.createExclusive, + args.mode + ); + if (temporaryFd < 0 || symbols.fchmod(temporaryFd, args.mode) !== 0) { + throw new Error("Descriptor-relative canonical staging failed closed"); + } + let offset = 0; + while (offset < bytes.length) { + const count = Number( + symbols.write(temporaryFd, ptr(bytes, offset), bytes.length - offset) + ); + if (count <= 0) { + throw new Error("Could not stage verified canonical replacement"); + } + offset += count; + } + if (symbols.fsync(temporaryFd) !== 0) { + throw new Error("Could not sync verified canonical replacement"); + } + const temporaryMetadata = fstatSync(temporaryFd); + if ( + !temporaryMetadata.isFile() || + temporaryMetadata.nlink !== 1 || + temporaryMetadata.size !== bytes.byteLength || + permissionBits(temporaryMetadata.mode) !== args.mode + ) { + throw new Error("Verified canonical temporary file is unsafe"); + } + + await args.beforeCommit?.(); + assertDirectoryMapping(directoryIdentity); + if (existing === null) { + await args.beforeExchange?.(); + assertDirectoryMapping(directoryIdentity); + if ( + symbols.linkat( + directoryFd, + ptr(temporaryName), + directoryFd, + ptr(targetName), + 0 + ) !== 0 + ) { + throw new Error( + "Canonical target appeared at the no-replace commit boundary" + ); + } + const committedFd = openExisting(targetName); + if (committedFd === null) { + throw new Error( + "Canonical target disappeared at the no-replace commit boundary" + ); + } + const committedMetadata = fstatSync(committedFd); + symbols.close(committedFd); + if (!sameObjectIdentity(temporaryMetadata, committedMetadata)) { + throw new Error( + "Canonical target changed at the no-replace commit boundary" + ); + } + try { + assertDirectoryMapping(directoryIdentity); + } catch (error) { + const reboundFd = openExisting(targetName); + if (reboundFd !== null) { + const reboundMetadata = fstatSync(reboundFd); + symbols.close(reboundFd); + if (sameObjectIdentity(temporaryMetadata, reboundMetadata)) { + symbols.unlinkat(directoryFd, ptr(targetName), 0); + } + } + throw error; + } + symbols.unlinkat(directoryFd, ptr(temporaryName), 0); + symbols.fsync(directoryFd); + return; + } + if (!sameFileIdentity(existing.metadata, fstatSync(existingFd))) { + throw new Error("Bound canonical file changed before exchange"); + } + + await args.beforeExchange?.(); + assertDirectoryMapping(directoryIdentity); + exchange(); + const displaced = readSnapshot(temporaryName, "Displaced canonical file"); + const displacedMatches = + displaced !== null && + sameObjectIdentity(existing.metadata, displaced.metadata) && + existing.metadata.mode === displaced.metadata.mode && + existing.contents.equals(displaced.contents); + if (!displacedMatches) { + const stagedCurrent = readSnapshot(targetName, "Staged canonical file"); + const currentTemporaryMetadata = fstatSync(temporaryFd); + const canCompensate = + displaced !== null && + stagedCurrent !== null && + sameObjectIdentity(currentTemporaryMetadata, stagedCurrent.metadata) && + currentTemporaryMetadata.mode === stagedCurrent.metadata.mode && + stagedCurrent.contents.equals(bytes); + if (stagedCurrent) { + symbols.close(stagedCurrent.descriptor); + } + if (displaced) { + symbols.close(displaced.descriptor); + } + if (!canCompensate) { + preserveTemporary = true; + throw new Error( + "Canonical target changed at the conditional commit boundary; displaced data was preserved" + ); + } + exchange(); + throw new Error( + "Canonical target changed at the conditional commit boundary" + ); + } + try { + assertDirectoryMapping(directoryIdentity); + } catch (error) { + symbols.close(displaced.descriptor); + const stagedCurrent = readSnapshot(targetName, "Staged canonical file"); + const currentTemporaryMetadata = fstatSync(temporaryFd); + const canCompensate = + stagedCurrent !== null && + sameObjectIdentity(currentTemporaryMetadata, stagedCurrent.metadata) && + currentTemporaryMetadata.mode === stagedCurrent.metadata.mode && + stagedCurrent.contents.equals(bytes); + if (stagedCurrent) { + symbols.close(stagedCurrent.descriptor); + } + if (!canCompensate) { + preserveTemporary = true; + throw new AggregateError( + [error], + "Canonical parent changed at the conditional commit boundary; displaced data was preserved" + ); + } + exchange(); + throw error; + } + symbols.close(displaced.descriptor); + + // The exchange is the conditional commit point. Once the displaced file + // is proven to be the captured target, later edits to the new canonical + // inode are post-commit user activity and must not trigger compensation. + symbols.unlinkat(directoryFd, ptr(temporaryName), 0); + symbols.fsync(directoryFd); + } finally { + if (existingFd >= 0) { + symbols.close(existingFd); + } + if (temporaryFd >= 0) { + symbols.close(temporaryFd); + } + if (directoryFd >= 0 && !preserveTemporary) { + symbols.unlinkat(directoryFd, ptr(temporaryName), 0); + } + if (directoryFd >= 0) { + closeSync(directoryFd); + } + libc.close(); + } +} + export function writeExclusiveAt(args: { contents: string; directoryFd: number; diff --git a/src/builtin-assets.ts b/src/builtin-assets.ts index c9e6fbe1..95fa85e0 100644 --- a/src/builtin-assets.ts +++ b/src/builtin-assets.ts @@ -7,7 +7,7 @@ export const BUILTIN_OPERATING_MODEL_FILES = JSON.parse( export const BUILTIN_FCLT_CODEX_PLUGIN_FILES = JSON.parse( // biome-ignore lint/suspicious/noTemplateCurlyInString: Built-in plugin files intentionally contain literal render placeholders. - '{".codex-plugin/plugin.json":"{\\n \\"name\\": \\"fclt\\",\\n \\"version\\": \\"0.1.2\\",\\n \\"description\\": \\"Codex workflows and MCP tools for evidence reconciliation, writeback, evolution, and outcome review.\\",\\n \\"author\\": {\\n \\"name\\": \\"Hack Dance\\",\\n \\"url\\": \\"https://hack.dance\\"\\n },\\n \\"license\\": \\"MIT\\",\\n \\"keywords\\": [\\n \\"fclt\\",\\n \\"facult\\",\\n \\"codex\\",\\n \\"skills\\",\\n \\"mcp\\",\\n \\"writeback\\",\\n \\"evolution\\"\\n ],\\n \\"skills\\": \\"./skills/\\",\\n \\"mcpServers\\": \\"./.mcp.json\\",\\n \\"interface\\": {\\n \\"displayName\\": \\"fclt\\",\\n \\"shortDescription\\": \\"Feedback loops for AI work\\",\\n \\"longDescription\\": \\"Capture signal from real agent work, reconcile configured evidence, turn repeated findings into reviewed capability changes, and verify whether those changes improved the work that produced them.\\",\\n \\"developerName\\": \\"Hack Dance\\",\\n \\"category\\": \\"Productivity\\",\\n \\"capabilities\\": [\\"Read\\", \\"Write\\", \\"MCP\\"],\\n \\"defaultPrompt\\": [\\n \\"Use fclt to check this repo\'s AI capability setup.\\",\\n \\"Reconcile the configured evidence window and explain its coverage.\\",\\n \\"Record useful writeback from this work and review any repeated signal.\\"\\n ],\\n \\"brandColor\\": \\"#166534\\",\\n \\"composerIcon\\": \\"./assets/fclt-mark.png\\",\\n \\"logo\\": \\"./assets/fclt-mark.png\\"\\n }\\n}\\n",".mcp.json":"{\\n \\"mcpServers\\": {\\n \\"fclt\\": {\\n \\"command\\": \\"node\\",\\n \\"args\\": [\\"./scripts/fclt-mcp.cjs\\"],\\n \\"env\\": {\\n \\"FCLT_BIN\\": \\"fclt\\"\\n },\\n \\"cwd\\": \\".\\"\\n }\\n }\\n}\\n","scripts/fclt-mcp.cjs":"#!/usr/bin/env node\\n\\"use strict\\";\\n\\nconst fs = require(\\"node:fs\\");\\nconst os = require(\\"node:os\\");\\nconst path = require(\\"node:path\\");\\nconst runtime = require(\\"./fclt-runtime.cjs\\");\\n\\nconst DEFAULT_TIMEOUT_MS = Number(process.env.FCLT_MCP_TIMEOUT_MS || 60_000);\\nconst ACTIVITY_ACTION_RESOLVE_CAPABILITY = \\"activity-action-resolve-v1\\";\\nconst AUDIT_READ_ONLY_CAPABILITY = \\"audit-read-only-v1\\";\\nconst CONTENT_LENGTH_RE = /Content-Length:\\\\s*(\\\\d+)/i;\\nconst PLUGIN_ROOT = path.resolve(__dirname, \\"..\\");\\n\\nconst tools = [\\n {\\n name: \\"fclt_setup\\",\\n description:\\n \\"Bootstrap or repair the global fclt loop and optionally return an exact no-write project enrollment plan.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: {\\n type: \\"string\\",\\n enum: [\\"global\\", \\"global_and_project\\"],\\n },\\n cwd: { type: \\"string\\" },\\n dryRun: { type: \\"boolean\\" },\\n installCodexPlugin: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_runtime\\",\\n description:\\n \\"Discover, bootstrap, update, or roll back the verified fclt runtime used by this plugin.\\",\\n inputSchema: {\\n type: \\"object\\",\\n additionalProperties: false,\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\"status\\", \\"check\\", \\"policy\\", \\"stage\\", \\"apply\\", \\"rollback\\"],\\n },\\n version: { type: \\"string\\" },\\n expectedSha256: { type: \\"string\\", pattern: \\"^[a-f0-9]{64}$\\" },\\n expectedActiveVersion: { type: \\"string\\" },\\n pinnedVersion: { type: \\"string\\" },\\n clearPin: { type: \\"boolean\\" },\\n updateChecksEnabled: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_capability\\",\\n description:\\n \\"Inspect fclt capability, provenance, templates, snippets, adapters, and managed status without exposing secrets.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"scan\\",\\n \\"inventory\\",\\n \\"list\\",\\n \\"show\\",\\n \\"find\\",\\n \\"graph\\",\\n \\"adapters\\",\\n \\"managed_status\\",\\n \\"templates_list\\",\\n \\"snippet_list\\",\\n \\"snippet_show\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n kind: {\\n type: \\"string\\",\\n enum: [\\n \\"skills\\",\\n \\"mcp\\",\\n \\"agents\\",\\n \\"automations\\",\\n \\"snippets\\",\\n \\"instructions\\",\\n ],\\n },\\n query: { type: \\"string\\" },\\n selector: { type: \\"string\\" },\\n graphMode: { type: \\"string\\", enum: [\\"show\\", \\"deps\\", \\"dependents\\"] },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_workflow\\",\\n description:\\n \\"Run typed writeback and evolution review operations. Canonical apply and cross-scope promotion are deliberately withheld.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"writeback_list\\",\\n \\"writeback_show\\",\\n \\"writeback_group\\",\\n \\"writeback_summarize\\",\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"evolve_assess\\",\\n \\"evolve_list\\",\\n \\"evolve_show\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_verify\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n id: { type: \\"string\\" },\\n kind: { type: \\"string\\" },\\n category: {\\n type: \\"string\\",\\n enum: [\\"friction\\", \\"opportunity\\", \\"reusable-success\\"],\\n },\\n summary: { type: \\"string\\" },\\n details: { type: \\"string\\", maxLength: 2000 },\\n impact: { type: \\"string\\", maxLength: 1000 },\\n attemptedWorkaround: { type: \\"string\\", maxLength: 1000 },\\n desiredOutcome: { type: \\"string\\", maxLength: 1000 },\\n sensitivity: {\\n type: \\"string\\",\\n enum: [\\"public\\", \\"internal\\", \\"private\\"],\\n },\\n asset: { type: \\"string\\" },\\n evidence: { type: \\"array\\", items: { type: \\"string\\" } },\\n confidence: { type: \\"string\\", enum: [\\"low\\", \\"medium\\", \\"high\\"] },\\n by: { type: \\"string\\", enum: [\\"asset\\", \\"kind\\", \\"domain\\"] },\\n issue: { type: \\"string\\" },\\n disposition: {\\n type: \\"string\\",\\n enum: [\\"propose\\", \\"apply-local\\", \\"task\\", \\"resolve-watch\\", \\"defer\\"],\\n },\\n target: { type: \\"string\\" },\\n nextTrigger: { type: \\"string\\" },\\n expectedOutcome: { type: \\"string\\" },\\n append: { type: \\"string\\" },\\n reason: { type: \\"string\\" },\\n byProposal: { type: \\"string\\" },\\n effectiveness: {\\n type: \\"string\\",\\n enum: [\\"improved\\", \\"unchanged\\", \\"regressed\\", \\"inconclusive\\"],\\n },\\n note: { type: \\"string\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_sync\\",\\n description:\\n \\"Inspect managed state or preview a scoped tool sync. Apply and live adoption remain withheld pending transaction-safe APIs.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: { type: \\"string\\", enum: [\\"status\\", \\"preview\\"] },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n tool: { type: \\"string\\" },\\n },\\n required: [\\"action\\", \\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_registry\\",\\n description:\\n \\"Search and verify remote capability, preview installs and updates, run typed source reconciliation reviews, or resolve one opaque activity action locator without mutation. Registry mutation remains withheld.\\",\\n inputSchema: {\\n oneOf: [\\n {\\n type: \\"object\\",\\n properties: {\\n action: { const: \\"activity_resolve\\" },\\n locator: {\\n type: \\"string\\",\\n pattern: \\"^fclt-act-v[0-9]+\\\\\\\\.[a-f0-9]{64}\\\\\\\\.[a-f0-9]{64}$\\",\\n },\\n },\\n required: [\\"action\\", \\"locator\\"],\\n additionalProperties: false,\\n },\\n {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"search\\",\\n \\"verify_source\\",\\n \\"source_list\\",\\n \\"install_preview\\",\\n \\"update_check\\",\\n \\"reconcile_status\\",\\n \\"reconcile\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n query: { type: \\"string\\" },\\n source: { type: \\"string\\" },\\n item: { type: \\"string\\" },\\n as: { type: \\"string\\" },\\n since: {\\n type: \\"string\\",\\n pattern:\\n \\"^\\\\\\\\d{4}-\\\\\\\\d{2}-\\\\\\\\d{2}(?:T\\\\\\\\d{2}:\\\\\\\\d{2}:\\\\\\\\d{2}(?:\\\\\\\\.\\\\\\\\d+)?(?:Z|[+-]\\\\\\\\d{2}:\\\\\\\\d{2}))?$\\",\\n },\\n until: {\\n type: \\"string\\",\\n pattern:\\n \\"^\\\\\\\\d{4}-\\\\\\\\d{2}-\\\\\\\\d{2}(?:T\\\\\\\\d{2}:\\\\\\\\d{2}:\\\\\\\\d{2}(?:\\\\\\\\.\\\\\\\\d+)?(?:Z|[+-]\\\\\\\\d{2}:\\\\\\\\d{2}))?$\\",\\n },\\n sourceIds: {\\n type: \\"array\\",\\n items: {\\n type: \\"string\\",\\n pattern: \\"^[A-Za-z0-9][A-Za-z0-9._-]*$\\",\\n },\\n },\\n incremental: { type: \\"boolean\\" },\\n },\\n required: [\\"action\\"],\\n additionalProperties: false,\\n },\\n ],\\n },\\n },\\n {\\n name: \\"fclt_audit\\",\\n description:\\n \\"Run a structured, redacted, non-interactive fclt security audit with no report or index writes.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: { type: \\"string\\", enum: [\\"scan\\"] },\\n cwd: { type: \\"string\\" },\\n target: { type: \\"string\\" },\\n severity: {\\n type: \\"string\\",\\n enum: [\\"critical\\", \\"high\\", \\"medium\\", \\"low\\"],\\n },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_automation\\",\\n description:\\n \\"Read one aggregate activity set across all configured loops by default, or inspect one explicit loop scope. Schedule and canonical mutation remain CLI-only.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"autosync_status\\",\\n \\"loop_status\\",\\n \\"loop_activity\\",\\n \\"loop_preview\\",\\n ],\\n },\\n scope: {\\n type: \\"string\\",\\n enum: [\\"all\\", \\"global\\", \\"project\\"],\\n },\\n cwd: { type: \\"string\\" },\\n tool: { type: \\"string\\" },\\n },\\n required: [\\"action\\"],\\n oneOf: [\\n {\\n properties: {\\n action: { const: \\"loop_activity\\" },\\n scope: {\\n type: \\"string\\",\\n enum: [\\"all\\", \\"global\\", \\"project\\"],\\n default: \\"all\\",\\n },\\n },\\n required: [\\"action\\"],\\n },\\n {\\n properties: {\\n action: {\\n enum: [\\"autosync_status\\", \\"loop_status\\", \\"loop_preview\\"],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n },\\n required: [\\"action\\", \\"scope\\"],\\n },\\n ],\\n },\\n },\\n {\\n name: \\"fclt_status\\",\\n description:\\n \\"Return fclt status for the current, global, or project scope.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_doctor\\",\\n description: \\"Run read-only fclt doctor checks and return JSON output.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_paths\\",\\n description: \\"Return canonical, generated, review, and runtime fclt paths.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_init_operating_model\\",\\n description: \\"Install or update the built-in operating-model pack.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n update: { type: \\"boolean\\" },\\n dryRun: { type: \\"boolean\\" },\\n force: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_writeback_add\\",\\n description: \\"Record a durable fclt writeback with evidence.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n kind: { type: \\"string\\" },\\n category: {\\n type: \\"string\\",\\n enum: [\\"friction\\", \\"opportunity\\", \\"reusable-success\\"],\\n },\\n summary: { type: \\"string\\" },\\n details: { type: \\"string\\", maxLength: 2000 },\\n impact: { type: \\"string\\", maxLength: 1000 },\\n attemptedWorkaround: { type: \\"string\\", maxLength: 1000 },\\n desiredOutcome: { type: \\"string\\", maxLength: 1000 },\\n sensitivity: {\\n type: \\"string\\",\\n enum: [\\"public\\", \\"internal\\", \\"private\\"],\\n },\\n asset: { type: \\"string\\" },\\n evidence: { type: \\"string\\" },\\n confidence: {\\n type: \\"string\\",\\n enum: [\\"low\\", \\"medium\\", \\"high\\"],\\n },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\", \\"kind\\", \\"summary\\", \\"evidence\\", \\"approve\\"],\\n },\\n },\\n {\\n name: \\"fclt_writeback_review\\",\\n description: \\"List, group, or summarize current fclt writebacks.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n mode: { type: \\"string\\", enum: [\\"list\\", \\"group\\", \\"summarize\\"] },\\n by: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_evolve\\",\\n description:\\n \\"Assess, list, propose, draft, or review fclt evolution proposals.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n action: {\\n type: \\"string\\",\\n enum: [\\"assess\\", \\"list\\", \\"propose\\", \\"draft\\", \\"review\\", \\"show\\"],\\n },\\n id: { type: \\"string\\" },\\n asset: { type: \\"string\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n },\\n },\\n];\\n\\nfor (const tool of tools) {\\n if (!tool.inputSchema.oneOf || tool.inputSchema.properties) {\\n tool.inputSchema.additionalProperties = false;\\n }\\n}\\n\\nfunction isPlainObject(value) {\\n return Boolean(value) && typeof value === \\"object\\" && !Array.isArray(value);\\n}\\n\\nfunction validateToolArguments(name, args) {\\n if (!isPlainObject(args)) {\\n throw new Error(`${name} arguments must be an object`);\\n }\\n const tool = tools.find((entry) => entry.name === name);\\n if (!tool) {\\n throw new Error(`Unknown tool: ${name}`);\\n }\\n const rootSchema = tool.inputSchema;\\n const schema =\\n rootSchema.oneOf && !rootSchema.properties\\n ? rootSchema.oneOf.find((branch) => {\\n const action = branch.properties?.action;\\n return (\\n action?.const === args.action || action?.enum?.includes(args.action)\\n );\\n })\\n : rootSchema;\\n if (!schema) {\\n throw new Error(`${name}.action is not an allowed value`);\\n }\\n const properties = schema.properties || {};\\n const unknown = Object.keys(args).filter((key) => !(key in properties));\\n if (unknown.length > 0) {\\n throw new Error(\\n `${name} received unknown argument fields: ${unknown.join(\\", \\")}`\\n );\\n }\\n for (const required of schema.required || []) {\\n if (!(required in args)) {\\n throw new Error(`${name} requires ${required}`);\\n }\\n }\\n for (const [key, value] of Object.entries(args)) {\\n const property = properties[key];\\n if (property.const !== undefined && value !== property.const) {\\n throw new Error(`${name}.${key} is not an allowed value`);\\n }\\n if (!property.type) {\\n continue;\\n }\\n const validType =\\n property.type === \\"array\\"\\n ? Array.isArray(value)\\n : property.type === \\"object\\"\\n ? isPlainObject(value)\\n : typeof value === property.type;\\n if (!validType) {\\n throw new Error(`${name}.${key} must be ${property.type}`);\\n }\\n if (property.enum && !property.enum.includes(value)) {\\n throw new Error(`${name}.${key} is not an allowed value`);\\n }\\n if (property.pattern && !new RegExp(property.pattern).test(value)) {\\n throw new Error(`${name}.${key} has an invalid format`);\\n }\\n if (\\n property.type === \\"array\\" &&\\n property.items?.type &&\\n value.some((item) => typeof item !== property.items.type)\\n ) {\\n throw new Error(`${name}.${key} contains an invalid item`);\\n }\\n if (\\n property.type === \\"array\\" &&\\n property.items?.pattern &&\\n value.some((item) => !new RegExp(property.items.pattern).test(item))\\n ) {\\n throw new Error(`${name}.${key} contains an invalid item`);\\n }\\n }\\n}\\n\\nfunction scopeArgs(scope) {\\n if (scope === \\"global\\") {\\n return [\\"--global\\"];\\n }\\n if (scope === \\"project\\") {\\n return [\\"--project\\"];\\n }\\n return [];\\n}\\n\\nfunction boolFlag(name, value) {\\n return value ? [name] : [];\\n}\\n\\nfunction stringFlag(name, value) {\\n return typeof value === \\"string\\" && value.trim() ? [name, value] : [];\\n}\\n\\nfunction repeatedStringFlag(name, values) {\\n return Array.isArray(values)\\n ? values.flatMap((value) => stringFlag(name, value))\\n : [];\\n}\\n\\nfunction requireString(name, value) {\\n if (typeof value !== \\"string\\" || !value.trim()) {\\n throw new Error(`${name} is required`);\\n }\\n return value;\\n}\\n\\nfunction requireMutationApproval(name, args) {\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(`${name} requires an explicit global or project scope`);\\n }\\n if (args.approve !== true) {\\n throw new Error(`${name} requires approve=true`);\\n }\\n}\\n\\nfunction capabilityCommand(args) {\\n const action = args.action;\\n if (action === \\"scan\\") {\\n return [\\"scan\\", \\"--json\\"];\\n }\\n if (action === \\"inventory\\") {\\n return [\\"inventory\\", ...scopeArgs(args.scope), \\"--json\\"];\\n }\\n if (action === \\"list\\") {\\n return [\\"list\\", args.kind || \\"skills\\", ...scopeArgs(args.scope), \\"--json\\"];\\n }\\n if (action === \\"show\\") {\\n return [\\n \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n ...scopeArgs(args.scope),\\n ];\\n }\\n if (action === \\"find\\") {\\n return [\\n \\"find\\",\\n requireString(\\"query\\", args.query),\\n ...scopeArgs(args.scope),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"graph\\") {\\n return [\\n \\"graph\\",\\n args.graphMode || \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n ...scopeArgs(args.scope),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"adapters\\") {\\n return [\\"adapters\\", \\"--json\\"];\\n }\\n if (action === \\"managed_status\\") {\\n return [\\"managed\\", ...scopeArgs(args.scope)];\\n }\\n if (action === \\"templates_list\\") {\\n return [\\"templates\\", \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"snippet_list\\") {\\n return [\\"snippets\\", \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"snippet_show\\") {\\n return [\\n \\"snippets\\",\\n \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported capability action: ${action}`);\\n}\\n\\nconst WORKFLOW_MUTATIONS = new Set([\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"writeback_dismiss\\",\\n \\"writeback_promote\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_accept\\",\\n \\"evolve_reject\\",\\n \\"evolve_supersede\\",\\n \\"evolve_verify\\",\\n]);\\n\\nconst WORKFLOW_ACTION_FIELDS = {\\n writeback_list: [],\\n writeback_show: [\\"id\\"],\\n writeback_group: [\\"by\\"],\\n writeback_summarize: [\\"by\\"],\\n writeback_add: [\\n \\"kind\\",\\n \\"category\\",\\n \\"summary\\",\\n \\"details\\",\\n \\"impact\\",\\n \\"attemptedWorkaround\\",\\n \\"desiredOutcome\\",\\n \\"sensitivity\\",\\n \\"asset\\",\\n \\"evidence\\",\\n \\"confidence\\",\\n \\"approve\\",\\n ],\\n writeback_link: [\\"id\\", \\"issue\\", \\"approve\\"],\\n writeback_disposition: [\\n \\"id\\",\\n \\"disposition\\",\\n \\"target\\",\\n \\"nextTrigger\\",\\n \\"expectedOutcome\\",\\n \\"approve\\",\\n ],\\n evolve_assess: [\\"asset\\"],\\n evolve_list: [],\\n evolve_show: [\\"id\\"],\\n evolve_propose: [\\"asset\\", \\"approve\\"],\\n evolve_draft: [\\"id\\", \\"append\\", \\"approve\\"],\\n evolve_review: [\\"id\\", \\"approve\\"],\\n evolve_verify: [\\"id\\", \\"effectiveness\\", \\"evidence\\", \\"note\\", \\"approve\\"],\\n};\\n\\nfunction requireOnlyWorkflowFields(args) {\\n const fields = WORKFLOW_ACTION_FIELDS[args.action];\\n if (!fields) {\\n return;\\n }\\n const allowed = new Set([\\"action\\", \\"scope\\", \\"cwd\\", ...fields]);\\n const unexpected = Object.keys(args).filter((key) => !allowed.has(key));\\n if (unexpected.length > 0) {\\n throw new Error(\\n `${args.action} received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n}\\n\\nfunction workflowCommand(args) {\\n const action = args.action;\\n requireOnlyWorkflowFields(args);\\n if (WORKFLOW_MUTATIONS.has(action)) {\\n requireMutationApproval(action, args);\\n }\\n const scope = scopeArgs(args.scope);\\n if (action === \\"writeback_list\\") {\\n return [\\"ai\\", \\"writeback\\", ...scope, \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"writeback_show\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"show\\",\\n requireString(\\"id\\", args.id),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_group\\" || action === \\"writeback_summarize\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n action === \\"writeback_group\\" ? \\"group\\" : \\"summarize\\",\\n ...stringFlag(\\"--by\\", args.by),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_add\\") {\\n if (!Array.isArray(args.evidence) || args.evidence.length === 0) {\\n throw new Error(\\"writeback_add requires at least one evidence reference\\");\\n }\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"add\\",\\n \\"--kind\\",\\n requireString(\\"kind\\", args.kind),\\n \\"--summary\\",\\n requireString(\\"summary\\", args.summary),\\n ...stringFlag(\\"--asset\\", args.asset),\\n ...stringFlag(\\"--category\\", args.category),\\n ...stringFlag(\\"--details\\", args.details),\\n ...stringFlag(\\"--impact\\", args.impact),\\n ...stringFlag(\\"--attempted-workaround\\", args.attemptedWorkaround),\\n ...stringFlag(\\"--desired-outcome\\", args.desiredOutcome),\\n ...stringFlag(\\"--sensitivity\\", args.sensitivity),\\n ...repeatedStringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--confidence\\", args.confidence),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_link\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"link\\",\\n requireString(\\"id\\", args.id),\\n \\"--issue\\",\\n requireString(\\"issue\\", args.issue),\\n ];\\n }\\n if (action === \\"writeback_disposition\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"disposition\\",\\n requireString(\\"id\\", args.id),\\n \\"--type\\",\\n requireString(\\"disposition\\", args.disposition),\\n ...stringFlag(\\"--target\\", args.target),\\n ...stringFlag(\\"--next-trigger\\", args.nextTrigger),\\n ...stringFlag(\\"--expected-outcome\\", args.expectedOutcome),\\n ];\\n }\\n if (action === \\"writeback_dismiss\\" || action === \\"writeback_promote\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n action === \\"writeback_dismiss\\" ? \\"dismiss\\" : \\"promote\\",\\n requireString(\\"id\\", args.id),\\n ];\\n }\\n if (action === \\"evolve_assess\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"assess\\",\\n ...stringFlag(\\"--asset\\", args.asset),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_list\\") {\\n return [\\"ai\\", \\"evolve\\", ...scope, \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"evolve_show\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"show\\",\\n requireString(\\"id\\", args.id),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_propose\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"propose\\",\\n \\"--asset\\",\\n requireString(\\"asset\\", args.asset),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_draft\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"draft\\",\\n requireString(\\"id\\", args.id),\\n ...stringFlag(\\"--append\\", args.append),\\n ];\\n }\\n if (action === \\"evolve_review\\" || action === \\"evolve_accept\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n action === \\"evolve_review\\" ? \\"review\\" : \\"accept\\",\\n requireString(\\"id\\", args.id),\\n ];\\n }\\n if (action === \\"evolve_reject\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"reject\\",\\n requireString(\\"id\\", args.id),\\n \\"--reason\\",\\n requireString(\\"reason\\", args.reason),\\n ];\\n }\\n if (action === \\"evolve_supersede\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"supersede\\",\\n requireString(\\"id\\", args.id),\\n \\"--by\\",\\n requireString(\\"byProposal\\", args.byProposal),\\n ];\\n }\\n if (action === \\"evolve_verify\\") {\\n if (!Array.isArray(args.evidence) || args.evidence.length === 0) {\\n throw new Error(\\"evolve_verify requires at least one evidence reference\\");\\n }\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"verify\\",\\n requireString(\\"id\\", args.id),\\n \\"--effectiveness\\",\\n requireString(\\"effectiveness\\", args.effectiveness),\\n ...repeatedStringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--note\\", args.note),\\n ];\\n }\\n throw new Error(`Unsupported workflow action: ${action}`);\\n}\\n\\nfunction syncCommand(args) {\\n if (args.action === \\"status\\") {\\n return [\\"managed\\", ...scopeArgs(args.scope)];\\n }\\n if (args.action === \\"preview\\") {\\n return [\\n \\"sync\\",\\n ...(args.tool ? [args.tool] : []),\\n \\"--dry-run\\",\\n ...scopeArgs(args.scope),\\n ];\\n }\\n throw new Error(`Unsupported sync action: ${args.action}`);\\n}\\n\\nfunction requireOnlyRegistryFields(args, fields) {\\n const allowed = new Set([\\"action\\", \\"scope\\", \\"cwd\\", ...fields]);\\n const unexpected = Object.keys(args).filter((key) => !allowed.has(key));\\n if (unexpected.length > 0) {\\n throw new Error(\\n `${args.action} received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n}\\n\\nfunction registryCommand(args) {\\n if (args.action === \\"activity_resolve\\") {\\n const unexpected = Object.keys(args).filter(\\n (key) => key !== \\"action\\" && key !== \\"locator\\"\\n );\\n if (unexpected.length > 0) {\\n throw new Error(\\n `activity_resolve received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n \\"resolve\\",\\n requireString(\\"locator\\", args.locator),\\n \\"--json\\",\\n ];\\n }\\n if (\\n args.scope === \\"project\\" &&\\n args.action !== \\"reconcile_status\\" &&\\n args.action !== \\"reconcile\\"\\n ) {\\n throw new Error(`${args.action} only supports global scope`);\\n }\\n if (args.action === \\"search\\") {\\n requireOnlyRegistryFields(args, [\\"query\\"]);\\n return [\\"search\\", requireString(\\"query\\", args.query), \\"--json\\"];\\n }\\n if (args.action === \\"verify_source\\") {\\n requireOnlyRegistryFields(args, [\\"source\\"]);\\n return [\\"verify-source\\", requireString(\\"source\\", args.source), \\"--json\\"];\\n }\\n if (args.action === \\"source_list\\") {\\n requireOnlyRegistryFields(args, []);\\n return [\\"sources\\", \\"list\\", \\"--json\\"];\\n }\\n if (args.action === \\"install_preview\\") {\\n requireOnlyRegistryFields(args, [\\"item\\", \\"as\\"]);\\n return [\\n \\"install\\",\\n requireString(\\"item\\", args.item),\\n ...stringFlag(\\"--as\\", args.as),\\n \\"--dry-run\\",\\n \\"--strict-source-trust\\",\\n \\"--json\\",\\n ];\\n }\\n if (args.action === \\"update_check\\") {\\n requireOnlyRegistryFields(args, []);\\n return [\\"update\\", \\"--strict-source-trust\\", \\"--json\\"];\\n }\\n if (args.action === \\"reconcile_status\\") {\\n requireOnlyRegistryFields(args, []);\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(\\n \\"reconcile_status requires an explicit global or project scope\\"\\n );\\n }\\n return [\\"ai\\", \\"review\\", ...scopeArgs(args.scope), \\"status\\", \\"--json\\"];\\n }\\n if (args.action === \\"reconcile\\") {\\n requireOnlyRegistryFields(args, [\\n \\"since\\",\\n \\"until\\",\\n \\"sourceIds\\",\\n \\"incremental\\",\\n ]);\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(\\"reconcile requires an explicit global or project scope\\");\\n }\\n return [\\n \\"ai\\",\\n \\"review\\",\\n ...scopeArgs(args.scope),\\n \\"reconcile\\",\\n \\"--since\\",\\n requireString(\\"since\\", args.since),\\n ...stringFlag(\\"--until\\", args.until),\\n ...repeatedStringFlag(\\"--source\\", args.sourceIds),\\n ...boolFlag(\\"--incremental\\", args.incremental),\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported registry action: ${args.action}`);\\n}\\n\\nfunction isSubpath(child, parent) {\\n const relative = path.relative(parent, child);\\n return (\\n relative === \\"\\" || !(relative.startsWith(\\"..\\") || path.isAbsolute(relative))\\n );\\n}\\n\\nfunction isDirectory(candidate) {\\n try {\\n return fs.statSync(candidate).isDirectory();\\n } catch {\\n return false;\\n }\\n}\\n\\nfunction resolveWorkspaceCwd({ allowHomeFallback = true } = {}) {\\n const candidates = [\\n process.env.FCLT_MCP_WORKSPACE_CWD,\\n process.env.INIT_CWD,\\n process.env.PWD,\\n ];\\n for (const candidate of candidates) {\\n if (typeof candidate !== \\"string\\" || !candidate.trim()) {\\n continue;\\n }\\n const resolved = path.resolve(candidate);\\n const isHome = resolved === path.resolve(os.homedir());\\n if (\\n (allowHomeFallback || !isHome) &&\\n !isSubpath(resolved, PLUGIN_ROOT) &&\\n isDirectory(resolved)\\n ) {\\n return resolved;\\n }\\n }\\n if (allowHomeFallback && isDirectory(os.homedir())) {\\n return os.homedir();\\n }\\n return undefined;\\n}\\n\\nfunction resolveToolCwd(name, args = {}) {\\n if (typeof args.cwd === \\"string\\" && args.cwd.trim()) {\\n return args.cwd;\\n }\\n const inferred = resolveWorkspaceCwd({\\n allowHomeFallback: args.scope !== \\"project\\",\\n });\\n if (inferred) {\\n return inferred;\\n }\\n if (args.scope === \\"project\\") {\\n throw new Error(\\n `${name} with project scope requires a cwd for the target workspace`\\n );\\n }\\n return process.cwd();\\n}\\n\\nfunction commandForTool(name, args = {}) {\\n switch (name) {\\n case \\"fclt_setup\\": {\\n const apply = args.dryRun === false;\\n if (apply && args.approve !== true) {\\n throw new Error(\\"fclt_setup apply requires approve=true\\");\\n }\\n if (\\n args.scope === \\"global_and_project\\" &&\\n (typeof args.cwd !== \\"string\\" || !args.cwd.trim())\\n ) {\\n throw new Error(\\n \\"fclt_setup global_and_project scope requires an explicit cwd\\"\\n );\\n }\\n return [\\n \\"setup\\",\\n \\"--json\\",\\n ...(args.scope === \\"global_and_project\\" ? [\\"--include-project\\"] : []),\\n ...(apply ? [] : [\\"--dry-run\\"]),\\n ...(args.installCodexPlugin === false ? [\\"--no-codex-plugin\\"] : []),\\n ];\\n }\\n case \\"fclt_capability\\":\\n return capabilityCommand(args);\\n case \\"fclt_workflow\\":\\n return workflowCommand(args);\\n case \\"fclt_sync\\":\\n return syncCommand(args);\\n case \\"fclt_registry\\":\\n return registryCommand(args);\\n case \\"fclt_audit\\":\\n return [\\n \\"audit\\",\\n \\"--non-interactive\\",\\n ...(args.target ? [args.target] : []),\\n ...stringFlag(\\"--severity\\", args.severity),\\n \\"--json\\",\\n ];\\n case \\"fclt_automation\\":\\n if (\\n args.action !== \\"loop_activity\\" &&\\n args.scope !== \\"global\\" &&\\n args.scope !== \\"project\\"\\n ) {\\n throw new Error(`${args.action} requires global or project scope`);\\n }\\n if (args.action === \\"autosync_status\\") {\\n return [\\n \\"autosync\\",\\n \\"status\\",\\n ...(args.tool ? [args.tool] : []),\\n ...scopeArgs(args.scope),\\n ];\\n }\\n if (args.tool) {\\n throw new Error(`${args.action} does not accept tool`);\\n }\\n if (args.action === \\"loop_status\\") {\\n return [\\"ai\\", \\"loop\\", ...scopeArgs(args.scope), \\"status\\", \\"--json\\"];\\n }\\n if (args.action === \\"loop_activity\\") {\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n ...scopeArgs(args.scope),\\n \\"activity\\",\\n ...(args.scope === \\"global\\" || args.scope === \\"project\\"\\n ? []\\n : [\\"--all\\"]),\\n \\"--json\\",\\n ];\\n }\\n if (args.action === \\"loop_preview\\") {\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n ...scopeArgs(args.scope),\\n \\"run\\",\\n \\"--dry-run\\",\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported automation action: ${args.action}`);\\n case \\"fclt_status\\":\\n return [\\"status\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_doctor\\":\\n return [\\"doctor\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_paths\\":\\n return [\\"paths\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_init_operating_model\\":\\n if (args.dryRun === false && args.approve !== true) {\\n throw new Error(\\n \\"fclt_init_operating_model apply requires approve=true\\"\\n );\\n }\\n if (args.force === true && args.approve !== true) {\\n throw new Error(\\n \\"fclt_init_operating_model force requires approve=true\\"\\n );\\n }\\n return [\\n \\"templates\\",\\n \\"init\\",\\n \\"operating-model\\",\\n ...scopeArgs(args.scope),\\n ...boolFlag(\\"--update\\", args.update),\\n ...(args.dryRun === false ? [] : [\\"--dry-run\\"]),\\n ...boolFlag(\\"--force\\", args.force),\\n \\"--json\\",\\n ];\\n case \\"fclt_writeback_add\\":\\n requireMutationApproval(name, args);\\n requireString(\\"evidence\\", args.evidence);\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scopeArgs(args.scope),\\n \\"add\\",\\n \\"--kind\\",\\n args.kind,\\n \\"--summary\\",\\n args.summary,\\n ...stringFlag(\\"--asset\\", args.asset),\\n ...stringFlag(\\"--category\\", args.category),\\n ...stringFlag(\\"--details\\", args.details),\\n ...stringFlag(\\"--impact\\", args.impact),\\n ...stringFlag(\\"--attempted-workaround\\", args.attemptedWorkaround),\\n ...stringFlag(\\"--desired-outcome\\", args.desiredOutcome),\\n ...stringFlag(\\"--sensitivity\\", args.sensitivity),\\n ...stringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--confidence\\", args.confidence),\\n \\"--json\\",\\n ];\\n case \\"fclt_writeback_review\\": {\\n const mode = args.mode || \\"list\\";\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scopeArgs(args.scope),\\n mode,\\n ...stringFlag(\\"--by\\", args.by),\\n ];\\n }\\n case \\"fclt_evolve\\": {\\n const action = args.action || \\"list\\";\\n if ([\\"propose\\", \\"draft\\", \\"review\\"].includes(action)) {\\n requireMutationApproval(`fclt_evolve ${action}`, args);\\n }\\n if (action === \\"propose\\") {\\n requireString(\\"asset\\", args.asset);\\n }\\n if ([\\"draft\\", \\"review\\", \\"show\\"].includes(action)) {\\n requireString(\\"id\\", args.id);\\n }\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scopeArgs(args.scope),\\n action,\\n ...(action === \\"assess\\" || action === \\"propose\\"\\n ? stringFlag(\\"--asset\\", args.asset)\\n : []),\\n ...(args.id ? [args.id] : []),\\n ...(action === \\"assess\\" ? [\\"--json\\"] : []),\\n ];\\n }\\n default:\\n throw new Error(`Unknown tool: ${name}`);\\n }\\n}\\n\\nfunction operationMetadata(name, args, command) {\\n const action =\\n name === \\"fclt_writeback_add\\"\\n ? \\"writeback_add\\"\\n : name === \\"fclt_evolve\\"\\n ? `evolve_${args.action || \\"list\\"}`\\n : args.action || name;\\n const reviewActions = new Set([\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_verify\\",\\n \\"reconcile\\",\\n ]);\\n const preview = command.includes(\\"--dry-run\\");\\n const risk = reviewActions.has(action)\\n ? \\"review_producing\\"\\n : !preview && name === \\"fclt_setup\\"\\n ? \\"reversible_mutation\\"\\n : !preview && name === \\"fclt_init_operating_model\\"\\n ? \\"high_risk_destructive\\"\\n : \\"read_only\\";\\n return {\\n tool: name,\\n action,\\n risk,\\n scope:\\n args.scope ||\\n (name === \\"fclt_automation\\" && action === \\"loop_activity\\"\\n ? \\"all\\"\\n : \\"auto\\"),\\n target:\\n args.id ||\\n args.selector ||\\n args.asset ||\\n args.item ||\\n args.source ||\\n args.tool ||\\n null,\\n preview,\\n };\\n}\\n\\nfunction recoveryForOperation(operation, stdout) {\\n if (operation.risk === \\"review_producing\\") {\\n return {\\n canonicalCapabilityChanged: false,\\n audit: \\"native fclt review artifacts and append-only journal\\",\\n };\\n }\\n if (\\n operation.risk !== \\"reversible_mutation\\" &&\\n operation.risk !== \\"high_risk_destructive\\"\\n ) {\\n return null;\\n }\\n const report = isPlainObject(stdout) ? stdout : {};\\n return {\\n rollbackAvailable: false,\\n changedPaths: Array.isArray(report.changedPaths) ? report.changedPaths : [],\\n skippedPaths: Array.isArray(report.skippedPaths) ? report.skippedPaths : [],\\n repairActions: Array.isArray(report.repairActions)\\n ? report.repairActions\\n : [],\\n verification:\\n operation.tool === \\"fclt_setup\\"\\n ? \\"rerun fclt_setup in preview mode and verify doctor readiness\\"\\n : \\"verify doctor, generated state, authored-file hashes, and exact target paths\\",\\n };\\n}\\n\\nasync function runFclt(args, cwd, operation) {\\n const discovery = await runtime.discoverRuntime();\\n if (!discovery.selected) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"no_compatible_runtime\\",\\n message:\\n \\"No compatible fclt runtime is available. Check, stage, and apply an explicit verified version with fclt_runtime.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n if (\\n operation.tool === \\"fclt_audit\\" &&\\n !discovery.selected.capabilities?.includes(AUDIT_READ_ONLY_CAPABILITY)\\n ) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"missing_runtime_capability\\",\\n message:\\n \\"The selected fclt runtime does not advertise audit-read-only-v1; typed audit fails closed.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n if (\\n operation.action === \\"activity_resolve\\" &&\\n !discovery.selected.capabilities?.includes(\\n ACTIVITY_ACTION_RESOLVE_CAPABILITY\\n )\\n ) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"missing_runtime_capability\\",\\n message:\\n \\"The selected fclt runtime does not advertise activity-action-resolve-v1; typed activity resolution fails closed.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n const result = await runtime.runCommand(discovery.selected.executable, args, {\\n cwd: cwd || process.cwd(),\\n env: process.env,\\n timeoutMs: DEFAULT_TIMEOUT_MS,\\n });\\n const parsedStdout = parseJsonOrText(result.stdout.trim());\\n return {\\n code: result.code,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n runtime: discovery.selected,\\n result: {\\n exitCode: result.code,\\n stdout: parsedStdout,\\n stderr: result.stderr,\\n },\\n verification: {\\n status: result.code === 0 ? \\"passed\\" : \\"failed\\",\\n exitCode: result.code,\\n },\\n recovery: recoveryForOperation(operation, parsedStdout),\\n },\\n null,\\n 2\\n ),\\n };\\n}\\n\\nfunction parseJsonOrText(value) {\\n if (!value) {\\n return \\"\\";\\n }\\n try {\\n return JSON.parse(value);\\n } catch {\\n return value;\\n }\\n}\\n\\nasync function handleRuntimeTool(args = {}) {\\n const action = args.action || \\"status\\";\\n if (action === \\"status\\") {\\n return await runtime.discoverRuntime();\\n }\\n if (action === \\"check\\") {\\n return await runtime.checkRuntimeUpdate();\\n }\\n if (action === \\"policy\\") {\\n return await runtime.setRuntimePolicy({\\n approve: args.approve,\\n pinnedVersion: args.pinnedVersion,\\n clearPin: args.clearPin,\\n updateChecksEnabled: args.updateChecksEnabled,\\n });\\n }\\n if (action === \\"stage\\") {\\n return await runtime.stageRuntime({\\n approve: args.approve,\\n version: args.version,\\n });\\n }\\n if (action === \\"apply\\") {\\n return await runtime.applyStagedRuntime({\\n approve: args.approve,\\n expectedSha256: args.expectedSha256,\\n version: args.version,\\n });\\n }\\n if (action === \\"rollback\\") {\\n return await runtime.rollbackRuntime({\\n approve: args.approve,\\n expectedActiveVersion: args.expectedActiveVersion,\\n });\\n }\\n throw new Error(`Unknown runtime action: ${action}`);\\n}\\n\\nfunction runtimeOperationMetadata(args, result) {\\n const action = args.action || \\"status\\";\\n const risk =\\n action === \\"status\\" || action === \\"check\\"\\n ? \\"read_only\\"\\n : action === \\"stage\\"\\n ? \\"review_producing\\"\\n : \\"high_risk_destructive\\";\\n return {\\n operation: {\\n tool: \\"fclt_runtime\\",\\n action,\\n risk,\\n scope: \\"plugin_runtime\\",\\n target:\\n args.version ||\\n args.pinnedVersion ||\\n args.expectedActiveVersion ||\\n null,\\n approved: args.approve === true,\\n },\\n verification: {\\n status: \\"passed\\",\\n activeVersion:\\n result.active?.packageVersion ||\\n result.selected?.packageVersion ||\\n null,\\n },\\n recovery:\\n action === \\"apply\\" || action === \\"rollback\\"\\n ? {\\n rollbackAvailable: result.rollbackAvailable === true,\\n previous: result.previous || null,\\n }\\n : action === \\"policy\\"\\n ? { previousPolicy: result.previous || null }\\n : null,\\n };\\n}\\n\\nlet transportFraming = \\"content-length\\";\\n\\nfunction send(message) {\\n const body = JSON.stringify(message);\\n if (transportFraming === \\"newline\\") {\\n process.stdout.write(`${body}\\\\n`);\\n return;\\n }\\n process.stdout.write(\\n `Content-Length: ${Buffer.byteLength(body)}\\\\r\\\\n\\\\r\\\\n${body}`\\n );\\n}\\n\\nasync function handle(message) {\\n if (!message || message.id == null) {\\n return;\\n }\\n\\n try {\\n if (message.method === \\"initialize\\") {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n protocolVersion: \\"2025-06-18\\",\\n capabilities: { tools: {} },\\n serverInfo: { name: \\"fclt\\", version: runtime.pluginVersion() },\\n },\\n });\\n return;\\n }\\n if (message.method === \\"tools/list\\") {\\n send({ jsonrpc: \\"2.0\\", id: message.id, result: { tools } });\\n return;\\n }\\n if (message.method === \\"tools/call\\") {\\n const { name, arguments: args = {} } = message.params || {};\\n validateToolArguments(name, args);\\n if (name === \\"fclt_runtime\\") {\\n const result = await handleRuntimeTool(args);\\n const metadata = runtimeOperationMetadata(args, result);\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n isError: false,\\n content: [\\n {\\n type: \\"text\\",\\n text: JSON.stringify({ ...result, ...metadata }, null, 2),\\n },\\n ],\\n },\\n });\\n return;\\n }\\n const command = commandForTool(name, args);\\n const result = await runFclt(\\n command,\\n resolveToolCwd(name, args),\\n operationMetadata(name, args, command)\\n );\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n isError: result.code !== 0,\\n content: [{ type: \\"text\\", text: result.text }],\\n },\\n });\\n return;\\n }\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n error: { code: -32_601, message: `Method not found: ${message.method}` },\\n });\\n } catch (error) {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n error: {\\n code: -32_000,\\n message: error instanceof Error ? error.message : String(error),\\n },\\n });\\n }\\n}\\n\\nlet buffer = Buffer.alloc(0);\\n\\nfunction dispatch(body, framing) {\\n transportFraming = framing;\\n handle(JSON.parse(body)).catch((error) => {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: null,\\n error: {\\n code: -32_000,\\n message: error instanceof Error ? error.message : String(error),\\n },\\n });\\n });\\n}\\n\\nprocess.stdin.on(\\"data\\", (chunk) => {\\n buffer = Buffer.concat([buffer, chunk]);\\n while (true) {\\n while (buffer[0] === 10 || buffer[0] === 13) {\\n buffer = buffer.subarray(1);\\n }\\n if (buffer.length === 0) {\\n return;\\n }\\n if (buffer[0] === 123 || buffer[0] === 91) {\\n const lineEnd = buffer.indexOf(\\"\\\\n\\");\\n if (lineEnd === -1) {\\n return;\\n }\\n const body = buffer.subarray(0, lineEnd).toString(\\"utf8\\").trim();\\n buffer = buffer.subarray(lineEnd + 1);\\n if (body) {\\n dispatch(body, \\"newline\\");\\n }\\n continue;\\n }\\n const headerEnd = buffer.indexOf(\\"\\\\r\\\\n\\\\r\\\\n\\");\\n if (headerEnd === -1) {\\n return;\\n }\\n const header = buffer.slice(0, headerEnd).toString(\\"utf8\\");\\n const match = CONTENT_LENGTH_RE.exec(header);\\n if (!match) {\\n buffer = Buffer.alloc(0);\\n return;\\n }\\n const length = Number(match[1]);\\n const frameEnd = headerEnd + 4 + length;\\n if (buffer.length < frameEnd) {\\n return;\\n }\\n const body = buffer.slice(headerEnd + 4, frameEnd).toString(\\"utf8\\");\\n buffer = buffer.slice(frameEnd);\\n dispatch(body, \\"content-length\\");\\n }\\n});\\n\\nif (process.argv.includes(\\"--self-test\\")) {\\n console.log(\\n JSON.stringify(\\n {\\n pluginVersion: runtime.pluginVersion(),\\n protocolVersion: runtime.PLUGIN_PROTOCOL_VERSION,\\n tools: tools.map((tool) => tool.name),\\n },\\n null,\\n 2\\n )\\n );\\n process.exit(0);\\n}\\n","scripts/fclt-runtime.cjs":"\\"use strict\\";\\n\\nconst { spawn } = require(\\"node:child_process\\");\\nconst crypto = require(\\"node:crypto\\");\\nconst fs = require(\\"node:fs\\");\\nconst fsp = require(\\"node:fs/promises\\");\\nconst https = require(\\"node:https\\");\\nconst os = require(\\"node:os\\");\\nconst path = require(\\"node:path\\");\\n\\nconst PLUGIN_PROTOCOL_VERSION = 1;\\nconst STATE_SCHEMA_VERSION = 1;\\nconst REPOSITORY = \\"hack-dance/fclt\\";\\nconst MAX_BINARY_BYTES = 256 * 1024 * 1024;\\nconst MAX_METADATA_BYTES = 2 * 1024 * 1024;\\nconst DOWNLOAD_TIMEOUT_MS = 30_000;\\nconst COMMAND_TIMEOUT_MS = 15_000;\\nconst ALLOWED_DOWNLOAD_HOSTS = new Set([\\n \\"api.github.com\\",\\n \\"github.com\\",\\n \\"objects.githubusercontent.com\\",\\n \\"release-assets.githubusercontent.com\\",\\n]);\\nconst SEMVER_RE = /^\\\\d+\\\\.\\\\d+\\\\.\\\\d+(?:[-+][0-9A-Za-z.-]+)?$/;\\nconst SHA256_RE = /^[a-f0-9]{64}$/;\\nconst NEWLINE_RE = /\\\\r?\\\\n/;\\nconst CHECKSUM_LINE_RE = /^([a-fA-F0-9]{64})\\\\s+\\\\*?(.+)$/;\\nconst WINDOWS_SHIM_RE = /\\\\.(?:bat|cmd)$/i;\\n\\nfunction isPlainObject(value) {\\n return Boolean(value) && typeof value === \\"object\\" && !Array.isArray(value);\\n}\\n\\nfunction pluginVersion() {\\n try {\\n const manifest = JSON.parse(\\n fs.readFileSync(\\n path.resolve(__dirname, \\"..\\", \\".codex-plugin\\", \\"plugin.json\\"),\\n \\"utf8\\"\\n )\\n );\\n return typeof manifest.version === \\"string\\" ? manifest.version : \\"unknown\\";\\n } catch {\\n return \\"unknown\\";\\n }\\n}\\n\\nfunction runtimeStateRoot(env = process.env, platform = process.platform) {\\n if (env.FCLT_PLUGIN_RUNTIME_DIR) {\\n return path.resolve(env.FCLT_PLUGIN_RUNTIME_DIR);\\n }\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n if (platform === \\"darwin\\") {\\n return path.join(\\n home,\\n \\"Library\\",\\n \\"Application Support\\",\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n }\\n if (platform === \\"win32\\") {\\n return path.join(\\n env.LOCALAPPDATA || path.join(home, \\"AppData\\", \\"Local\\"),\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n }\\n return path.join(\\n env.XDG_STATE_HOME || path.join(home, \\".local\\", \\"state\\"),\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n}\\n\\nfunction installStatePaths(env = process.env, platform = process.platform) {\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n const override = env.FACULT_LOCAL_STATE_DIR?.trim();\\n const portableRoot = override\\n ? path.resolve(override)\\n : platform === \\"darwin\\"\\n ? path.join(home, \\"Library\\", \\"Application Support\\", \\"fclt\\")\\n : path.join(\\n env.XDG_STATE_HOME\\n ? path.resolve(env.XDG_STATE_HOME)\\n : path.join(home, \\".local\\", \\"state\\"),\\n \\"fclt\\"\\n );\\n const candidates = [\\n path.join(portableRoot, \\"install.json\\"),\\n ...(platform === \\"win32\\"\\n ? [\\n path.join(\\n env.LOCALAPPDATA || path.join(home, \\"AppData\\", \\"Local\\"),\\n \\"fclt\\",\\n \\"install.json\\"\\n ),\\n ]\\n : []),\\n path.join(home, \\".ai\\", \\".facult\\", \\"install.json\\"),\\n path.join(home, \\".facult\\", \\"install.json\\"),\\n path.join(home, \\".local\\", \\"share\\", \\"fclt\\", \\"install.json\\"),\\n ];\\n return [...new Set(candidates.map((candidate) => path.resolve(candidate)))];\\n}\\n\\nfunction isSubpath(child, parent) {\\n const relative = path.relative(parent, child);\\n return (\\n relative === \\"\\" || !(relative.startsWith(\\"..\\") || path.isAbsolute(relative))\\n );\\n}\\n\\nasync function assertManagedPath(target, root) {\\n const resolvedRoot = path.resolve(root);\\n const resolvedTarget = path.resolve(target);\\n if (!isSubpath(resolvedTarget, resolvedRoot)) {\\n throw new Error(\\"Runtime path escapes the managed runtime root.\\");\\n }\\n\\n const relative = path.relative(resolvedRoot, path.dirname(resolvedTarget));\\n const segments = relative ? relative.split(path.sep) : [];\\n let cursor = resolvedRoot;\\n for (const segment of segments) {\\n cursor = path.join(cursor, segment);\\n try {\\n if ((await fsp.lstat(cursor)).isSymbolicLink()) {\\n throw new Error(\\"Runtime path traverses a symbolic link.\\");\\n }\\n } catch (error) {\\n if (error && error.code === \\"ENOENT\\") {\\n continue;\\n }\\n throw error;\\n }\\n }\\n return resolvedTarget;\\n}\\n\\nasync function readJson(pathValue) {\\n try {\\n const value = JSON.parse(await fsp.readFile(pathValue, \\"utf8\\"));\\n return isPlainObject(value) ? value : null;\\n } catch {\\n return null;\\n }\\n}\\n\\nasync function runtimePolicy(options = {}) {\\n const root = runtimeStateRoot(options.env, options.platform);\\n const persisted = await readJson(path.join(root, \\"policy.json\\"));\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n updateChecksEnabled: persisted?.updateChecksEnabled !== false,\\n pinnedVersion:\\n typeof persisted?.pinnedVersion === \\"string\\" &&\\n persisted.pinnedVersion.trim()\\n ? normalizeVersion(persisted.pinnedVersion)\\n : null,\\n };\\n}\\n\\nfunction commandNames(platform = process.platform) {\\n return platform === \\"win32\\"\\n ? [\\"fclt.exe\\", \\"fclt.cmd\\", \\"facult.exe\\", \\"facult.cmd\\"]\\n : [\\"fclt\\", \\"facult\\"];\\n}\\n\\nfunction pathCandidates(env = process.env, platform = process.platform) {\\n const values = [];\\n for (const directory of (env.PATH || \\"\\").split(path.delimiter)) {\\n if (!directory) {\\n continue;\\n }\\n for (const name of commandNames(platform)) {\\n values.push(path.join(directory, name));\\n }\\n }\\n return values;\\n}\\n\\nfunction systemPathCandidates(env = process.env, platform = process.platform) {\\n if (Object.hasOwn(env, \\"FCLT_SYSTEM_PATHS\\")) {\\n return (env.FCLT_SYSTEM_PATHS || \\"\\").split(path.delimiter).filter(Boolean);\\n }\\n if (platform === \\"darwin\\") {\\n return [\\"/opt/homebrew/bin/fclt\\", \\"/usr/local/bin/fclt\\"];\\n }\\n if (platform === \\"win32\\") {\\n return [];\\n }\\n return [\\"/usr/local/bin/fclt\\", \\"/usr/bin/fclt\\"];\\n}\\n\\nfunction candidateSource(candidate) {\\n const normalized = candidate.split(\\"\\\\\\\\\\").join(\\"/\\");\\n if (normalized.includes(\\"/plugin-runtime/versions/\\")) {\\n return \\"plugin_runtime\\";\\n }\\n if (normalized.includes(\\"/mise/\\") || normalized.includes(\\"/mise/installs/\\")) {\\n return \\"mise\\";\\n }\\n if (\\n normalized.includes(\\"/Cellar/\\") ||\\n normalized.startsWith(\\"/opt/homebrew/\\")\\n ) {\\n return \\"homebrew\\";\\n }\\n if (normalized.includes(\\"/node_modules/\\") || normalized.includes(\\"/npm/\\")) {\\n return \\"npm\\";\\n }\\n if (normalized.includes(\\"/.ai/.facult/bin/\\")) {\\n return \\"canonical_install\\";\\n }\\n return \\"path\\";\\n}\\n\\nasync function activeRuntimeCandidate(root) {\\n const active = await readJson(path.join(root, \\"active.json\\"));\\n if (typeof active?.executable !== \\"string\\" || !active.executable.trim()) {\\n return null;\\n }\\n const executable = path.resolve(active.executable);\\n if (!isSubpath(executable, path.join(root, \\"versions\\"))) {\\n return null;\\n }\\n return {\\n executable,\\n source: \\"plugin_runtime\\",\\n expectedSha256: active.sha256,\\n active,\\n };\\n}\\n\\nasync function persistedInstallCandidates(\\n env = process.env,\\n platform = process.platform\\n) {\\n const candidates = [];\\n for (const statePath of installStatePaths(env, platform)) {\\n const state = await readJson(statePath);\\n if (typeof state?.binaryPath === \\"string\\" && state.binaryPath.trim()) {\\n candidates.push({\\n executable: path.resolve(state.binaryPath),\\n source:\\n typeof state.source === \\"string\\" ? state.source : \\"install_metadata\\",\\n installStatePath: statePath,\\n });\\n }\\n }\\n return candidates;\\n}\\n\\nasync function runtimeCandidates(options = {}) {\\n const env = options.env || process.env;\\n const platform = options.platform || process.platform;\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n const root = runtimeStateRoot(env, platform);\\n const candidates = [];\\n let configuredPathCandidate = null;\\n\\n if (env.FCLT_BIN?.trim()) {\\n const explicit = env.FCLT_BIN.trim();\\n if (path.isAbsolute(explicit) || explicit.includes(path.sep)) {\\n candidates.push({\\n executable: path.resolve(explicit),\\n source: \\"explicit\\",\\n });\\n } else {\\n const resolved = pathCandidates(env, platform).find(\\n (candidate) =>\\n path.basename(candidate) === explicit && fs.existsSync(candidate)\\n );\\n configuredPathCandidate = {\\n executable: resolved || explicit,\\n source: \\"configured_path\\",\\n };\\n }\\n }\\n\\n const active = await activeRuntimeCandidate(root);\\n if (active) {\\n candidates.push(active);\\n }\\n if (configuredPathCandidate) {\\n candidates.push(configuredPathCandidate);\\n }\\n candidates.push(...(await persistedInstallCandidates(env, platform)));\\n candidates.push(\\n ...pathCandidates(env, platform).map((executable) => ({\\n executable,\\n source: candidateSource(executable),\\n }))\\n );\\n\\n for (const executable of [\\n path.join(home, \\".ai\\", \\".facult\\", \\"bin\\", commandNames(platform)[0]),\\n ...systemPathCandidates(env, platform),\\n ]) {\\n candidates.push({ executable, source: candidateSource(executable) });\\n }\\n\\n const unique = [];\\n const seen = new Set();\\n for (const candidate of candidates) {\\n const key = path.resolve(candidate.executable);\\n if (!seen.has(key)) {\\n seen.add(key);\\n unique.push(candidate);\\n }\\n }\\n return unique;\\n}\\n\\nfunction runCommand(executable, args, options = {}) {\\n return new Promise((resolve) => {\\n let child;\\n try {\\n const platform = options.platform || process.platform;\\n const windowsShim =\\n platform === \\"win32\\" && WINDOWS_SHIM_RE.test(executable);\\n const command = windowsShim\\n ? options.env?.ComSpec || process.env.ComSpec || \\"cmd.exe\\"\\n : executable;\\n const commandArgs = windowsShim\\n ? [\\n \\"/d\\",\\n \\"/v:off\\",\\n \\"/s\\",\\n \\"/c\\",\\n [executable, ...args]\\n .map(\\n (value) =>\\n `\\"${String(value)\\n .replaceAll(\\"%\\", \\"%%\\")\\n .replace(/[\\\\^&|<>()!\\"]/g, \\"^$&\\")}\\"`\\n )\\n .join(\\" \\"),\\n ]\\n : args;\\n child = spawn(command, commandArgs, {\\n cwd: options.cwd || process.cwd(),\\n env: options.env || process.env,\\n stdio: [\\"ignore\\", \\"pipe\\", \\"pipe\\"],\\n });\\n } catch (error) {\\n resolve({ code: 1, stdout: \\"\\", stderr: error.message });\\n return;\\n }\\n let stdout = \\"\\";\\n let stderr = \\"\\";\\n let settled = false;\\n const timer = setTimeout(\\n () => child.kill(\\"SIGTERM\\"),\\n options.timeoutMs || COMMAND_TIMEOUT_MS\\n );\\n const finish = (code, error) => {\\n if (settled) {\\n return;\\n }\\n settled = true;\\n clearTimeout(timer);\\n resolve({\\n code,\\n stdout,\\n stderr: [stderr.trim(), error].filter(Boolean).join(\\"\\\\n\\"),\\n });\\n };\\n child.stdout.on(\\"data\\", (chunk) => {\\n stdout += chunk.toString();\\n if (stdout.length > MAX_METADATA_BYTES) {\\n child.kill(\\"SIGTERM\\");\\n }\\n });\\n child.stderr.on(\\"data\\", (chunk) => {\\n stderr += chunk.toString();\\n if (stderr.length > MAX_METADATA_BYTES) {\\n child.kill(\\"SIGTERM\\");\\n }\\n });\\n child.on(\\"error\\", (error) => finish(1, error.message));\\n child.on(\\"close\\", (code) => finish(code ?? 1));\\n });\\n}\\n\\nfunction parseProtocolReport(raw) {\\n try {\\n const report = JSON.parse(raw);\\n if (\\n !isPlainObject(report) ||\\n report.schemaVersion !== 1 ||\\n typeof report.packageVersion !== \\"string\\" ||\\n !isPlainObject(report.protocol) ||\\n !Number.isInteger(report.protocol.version) ||\\n !Number.isInteger(report.protocol.minimumPluginVersion) ||\\n !Number.isInteger(report.protocol.maximumPluginVersion) ||\\n (report.capabilities !== undefined &&\\n (!Array.isArray(report.capabilities) ||\\n report.capabilities.some((value) => typeof value !== \\"string\\")))\\n ) {\\n return null;\\n }\\n return report;\\n } catch {\\n return null;\\n }\\n}\\n\\nfunction protocolCompatibility(report) {\\n if (!report) {\\n return { compatible: false, reason: \\"missing_protocol_handshake\\" };\\n }\\n const compatible =\\n report.protocol.minimumPluginVersion <= PLUGIN_PROTOCOL_VERSION &&\\n report.protocol.maximumPluginVersion >= PLUGIN_PROTOCOL_VERSION;\\n return {\\n compatible,\\n reason: compatible ? \\"compatible\\" : \\"protocol_version_skew\\",\\n };\\n}\\n\\nasync function inspectCandidate(candidate, options = {}) {\\n const executable = path.resolve(candidate.executable);\\n try {\\n const stat = await fsp.stat(executable);\\n if (!stat.isFile()) {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"not_a_file\\",\\n };\\n }\\n } catch {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"not_found\\",\\n };\\n }\\n\\n if (candidate.source === \\"plugin_runtime\\") {\\n if (\\n typeof candidate.expectedSha256 !== \\"string\\" ||\\n !SHA256_RE.test(candidate.expectedSha256)\\n ) {\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: false,\\n reason: \\"missing_checksum\\",\\n };\\n }\\n try {\\n const actualSha256 = sha256(await fsp.readFile(executable));\\n if (actualSha256 !== candidate.expectedSha256) {\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: false,\\n reason: \\"checksum_mismatch\\",\\n };\\n }\\n } catch {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"checksum_unreadable\\",\\n };\\n }\\n }\\n\\n const result = await runCommand(executable, [\\"protocol\\", \\"--json\\"], options);\\n const report =\\n result.code === 0 ? parseProtocolReport(result.stdout.trim()) : null;\\n const compatibility = protocolCompatibility(report);\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: compatibility.compatible,\\n reason: compatibility.reason,\\n packageVersion: report?.packageVersion,\\n protocol: report?.protocol,\\n platform: report?.runtime?.platform,\\n architecture: report?.runtime?.architecture,\\n capabilities: report?.capabilities || [],\\n };\\n}\\n\\nasync function discoverRuntime(options = {}) {\\n const policy = await runtimePolicy(options);\\n const inspected = [];\\n for (const candidate of await runtimeCandidates(options)) {\\n const result = await inspectCandidate(candidate, options);\\n inspected.push(result);\\n if (result.compatible) {\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n plugin: {\\n version: pluginVersion(),\\n protocolVersion: PLUGIN_PROTOCOL_VERSION,\\n },\\n policy,\\n selected: result,\\n compatible: true,\\n requiresFreshSession: false,\\n candidates: inspected,\\n };\\n }\\n }\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n plugin: {\\n version: pluginVersion(),\\n protocolVersion: PLUGIN_PROTOCOL_VERSION,\\n },\\n policy,\\n selected: null,\\n compatible: false,\\n requiresFreshSession: false,\\n candidates: inspected,\\n };\\n}\\n\\nfunction releaseTarget(\\n platform = process.platform,\\n architecture = process.arch\\n) {\\n if (\\n platform === \\"darwin\\" &&\\n (architecture === \\"arm64\\" || architecture === \\"x64\\")\\n ) {\\n return { platform: \\"darwin\\", architecture, extension: \\"\\" };\\n }\\n if (platform === \\"linux\\" && architecture === \\"x64\\") {\\n return { platform: \\"linux\\", architecture, extension: \\"\\" };\\n }\\n if (platform === \\"win32\\" && architecture === \\"x64\\") {\\n return { platform: \\"windows\\", architecture, extension: \\".exe\\" };\\n }\\n throw new Error(\\n `Unsupported plugin runtime target: ${platform}/${architecture}`\\n );\\n}\\n\\nfunction normalizeVersion(version) {\\n const normalized = version?.startsWith(\\"v\\") ? version.slice(1) : version;\\n if (!(normalized && SEMVER_RE.test(normalized))) {\\n throw new Error(\\"Runtime version must be an explicit semantic version.\\");\\n }\\n return normalized;\\n}\\n\\nfunction assertAllowedUrl(urlValue) {\\n const url = new URL(urlValue);\\n if (url.protocol !== \\"https:\\" || !ALLOWED_DOWNLOAD_HOSTS.has(url.hostname)) {\\n throw new Error(\\n \\"Runtime downloads are restricted to approved HTTPS release hosts.\\"\\n );\\n }\\n return url;\\n}\\n\\nfunction downloadBuffer(urlValue, options = {}) {\\n const maxBytes = options.maxBytes || MAX_METADATA_BYTES;\\n const redirectsRemaining = options.redirectsRemaining ?? 5;\\n const url = assertAllowedUrl(urlValue);\\n return new Promise((resolve, reject) => {\\n const request = https.get(\\n url,\\n {\\n headers: {\\n accept: options.accept || \\"application/octet-stream\\",\\n \\"user-agent\\": \\"fclt-codex-plugin\\",\\n },\\n },\\n (response) => {\\n if (\\n response.statusCode &&\\n response.statusCode >= 300 &&\\n response.statusCode < 400 &&\\n response.headers.location\\n ) {\\n response.resume();\\n if (redirectsRemaining <= 0) {\\n reject(new Error(\\"Runtime download exceeded the redirect limit.\\"));\\n return;\\n }\\n const redirected = new URL(response.headers.location, url).toString();\\n downloadBuffer(redirected, {\\n ...options,\\n redirectsRemaining: redirectsRemaining - 1,\\n }).then(resolve, reject);\\n return;\\n }\\n if (response.statusCode !== 200) {\\n response.resume();\\n reject(\\n new Error(\\n `Runtime download failed with HTTP ${response.statusCode}.`\\n )\\n );\\n return;\\n }\\n const declaredLength = Number(response.headers[\\"content-length\\"] || 0);\\n if (declaredLength > maxBytes) {\\n response.resume();\\n reject(new Error(\\"Runtime download exceeds the allowed size.\\"));\\n return;\\n }\\n const chunks = [];\\n let total = 0;\\n response.on(\\"data\\", (chunk) => {\\n total += chunk.length;\\n if (total > maxBytes) {\\n request.destroy(\\n new Error(\\"Runtime download exceeds the allowed size.\\")\\n );\\n return;\\n }\\n chunks.push(chunk);\\n });\\n response.on(\\"end\\", () => resolve(Buffer.concat(chunks)));\\n }\\n );\\n request.setTimeout(options.timeoutMs || DOWNLOAD_TIMEOUT_MS, () => {\\n request.destroy(new Error(\\"Runtime download timed out.\\"));\\n });\\n request.on(\\"error\\", reject);\\n });\\n}\\n\\nfunction sha256(bytes) {\\n return crypto.createHash(\\"sha256\\").update(bytes).digest(\\"hex\\");\\n}\\n\\nfunction checksumForAsset(checksums, assetName) {\\n for (const line of checksums.split(NEWLINE_RE)) {\\n const match = CHECKSUM_LINE_RE.exec(line.trim());\\n if (match?.[2] === assetName) {\\n return match[1].toLowerCase();\\n }\\n }\\n throw new Error(`Published checksums do not include ${assetName}.`);\\n}\\n\\nasync function withMutationLock(root, action) {\\n await fsp.mkdir(root, { recursive: true, mode: 0o700 });\\n const lockPath = await assertManagedPath(\\n path.join(root, \\"mutation.lock\\"),\\n root\\n );\\n let handle;\\n try {\\n handle = await fsp.open(lockPath, \\"wx\\", 0o600);\\n } catch (error) {\\n if (error?.code === \\"EEXIST\\") {\\n throw new Error(\\n \\"Another fclt plugin runtime mutation is already in progress.\\"\\n );\\n }\\n throw error;\\n }\\n try {\\n return await action();\\n } finally {\\n await handle.close();\\n await fsp.rm(lockPath, { force: true });\\n }\\n}\\n\\nasync function writeJsonAtomic(pathValue, value, root) {\\n const target = await assertManagedPath(pathValue, root);\\n await fsp.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });\\n const temporary = `${target}.tmp-${crypto.randomUUID()}`;\\n await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\\\\n`, {\\n mode: 0o600,\\n });\\n await fsp.rename(temporary, target);\\n}\\n\\nfunction releaseUrls(version, target) {\\n const tag = `v${version}`;\\n const assetName = `fclt-${version}-${target.platform}-${target.architecture}${target.extension}`;\\n const base = `https://github.com/${REPOSITORY}/releases/download/${tag}`;\\n return {\\n tag,\\n assetName,\\n binaryUrl: `${base}/${assetName}`,\\n checksumUrl: `${base}/SHA256SUMS`,\\n };\\n}\\n\\nfunction releaseMetadataUrl(version) {\\n return `https://api.github.com/repos/${REPOSITORY}/releases/tags/v${version}`;\\n}\\n\\nfunction releaseAssets(metadata, version, target) {\\n if (\\n !isPlainObject(metadata) ||\\n metadata.tag_name !== `v${version}` ||\\n !Array.isArray(metadata.assets)\\n ) {\\n throw new Error(\\n \\"Release metadata does not match the requested immutable tag.\\"\\n );\\n }\\n const expected = releaseUrls(version, target);\\n const findAsset = (name) =>\\n metadata.assets.find(\\n (asset) =>\\n isPlainObject(asset) &&\\n asset.name === name &&\\n typeof asset.browser_download_url === \\"string\\"\\n );\\n const binary = findAsset(expected.assetName);\\n const checksums = findAsset(\\"SHA256SUMS\\");\\n if (!(binary && checksums)) {\\n throw new Error(\\n \\"Release metadata is missing the required runtime or checksum asset.\\"\\n );\\n }\\n assertAllowedUrl(binary.browser_download_url);\\n assertAllowedUrl(checksums.browser_download_url);\\n return { binary, checksums, expected };\\n}\\n\\nfunction verifyPublishedDigest(asset, bytes) {\\n if (typeof asset.digest !== \\"string\\" || !asset.digest.trim()) {\\n return null;\\n }\\n const [algorithm, expected] = asset.digest.toLowerCase().split(\\":\\");\\n if (algorithm !== \\"sha256\\" || !SHA256_RE.test(expected || \\"\\")) {\\n throw new Error(`Release asset ${asset.name} has an unsupported digest.`);\\n }\\n const actual = sha256(bytes);\\n if (actual !== expected) {\\n throw new Error(\\n `Release asset ${asset.name} does not match its published digest.`\\n );\\n }\\n return asset.digest.toLowerCase();\\n}\\n\\nasync function resolveLatestVersion(fetchBuffer = downloadBuffer) {\\n const bytes = await fetchBuffer(\\n `https://api.github.com/repos/${REPOSITORY}/releases/latest`,\\n {\\n maxBytes: MAX_METADATA_BYTES,\\n accept: \\"application/vnd.github+json\\",\\n }\\n );\\n const metadata = JSON.parse(bytes.toString(\\"utf8\\"));\\n if (!isPlainObject(metadata) || typeof metadata.tag_name !== \\"string\\") {\\n throw new Error(\\"Latest release metadata did not include a tag.\\");\\n }\\n return normalizeVersion(metadata.tag_name);\\n}\\n\\nasync function checkRuntimeUpdate(options = {}) {\\n const discovery = await discoverRuntime(options);\\n if (!discovery.policy.updateChecksEnabled) {\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n action: \\"check\\",\\n skipped: true,\\n reason: \\"update_checks_disabled\\",\\n currentVersion: discovery.selected?.packageVersion || null,\\n pinnedVersion: discovery.policy.pinnedVersion,\\n mutates: false,\\n };\\n }\\n const latestVersion =\\n discovery.policy.pinnedVersion ||\\n (await resolveLatestVersion(options.fetchBuffer || downloadBuffer));\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n action: \\"check\\",\\n currentVersion: discovery.selected?.packageVersion || null,\\n latestVersion,\\n channel: discovery.policy.pinnedVersion ? \\"pinned\\" : \\"latest\\",\\n updateAvailable: discovery.selected?.packageVersion !== latestVersion,\\n selected: discovery.selected,\\n mutates: false,\\n };\\n}\\n\\nasync function stageRuntime(options) {\\n if (options.approve !== true) {\\n throw new Error(\\"Staging a runtime download requires approve=true.\\");\\n }\\n const version = normalizeVersion(options.version);\\n const target = releaseTarget(options.platform, options.architecture);\\n const root = runtimeStateRoot(options.env, options.platform);\\n const fetchBuffer = options.fetchBuffer || downloadBuffer;\\n const urls = releaseUrls(version, target);\\n const policy = await runtimePolicy(options);\\n if (policy.pinnedVersion && policy.pinnedVersion !== version) {\\n throw new Error(`Runtime policy is pinned to ${policy.pinnedVersion}.`);\\n }\\n\\n return await withMutationLock(root, async () => {\\n const metadataBytes = await fetchBuffer(releaseMetadataUrl(version), {\\n maxBytes: MAX_METADATA_BYTES,\\n accept: \\"application/vnd.github+json\\",\\n });\\n const metadata = JSON.parse(metadataBytes.toString(\\"utf8\\"));\\n const assets = releaseAssets(metadata, version, target);\\n const [checksumBytes, binaryBytes] = await Promise.all([\\n fetchBuffer(assets.checksums.browser_download_url, {\\n maxBytes: MAX_METADATA_BYTES,\\n }),\\n fetchBuffer(assets.binary.browser_download_url, {\\n maxBytes: MAX_BINARY_BYTES,\\n }),\\n ]);\\n const checksumDigest = verifyPublishedDigest(\\n assets.checksums,\\n checksumBytes\\n );\\n const binaryDigest = verifyPublishedDigest(assets.binary, binaryBytes);\\n const expectedSha256 = checksumForAsset(\\n checksumBytes.toString(\\"utf8\\"),\\n urls.assetName\\n );\\n const actualSha256 = sha256(binaryBytes);\\n if (expectedSha256 !== actualSha256) {\\n throw new Error(\\n \\"Downloaded runtime checksum does not match the published SHA256SUMS entry.\\"\\n );\\n }\\n\\n const stageDir = await assertManagedPath(\\n path.join(root, \\"staged\\", version),\\n root\\n );\\n await fsp.rm(stageDir, { recursive: true, force: true });\\n await fsp.mkdir(stageDir, { recursive: true, mode: 0o700 });\\n const executable = await assertManagedPath(\\n path.join(stageDir, target.platform === \\"windows\\" ? \\"fclt.exe\\" : \\"fclt\\"),\\n root\\n );\\n await fsp.writeFile(executable, binaryBytes, { mode: 0o700 });\\n if (target.platform !== \\"windows\\") {\\n await fsp.chmod(executable, 0o700);\\n }\\n\\n const inspected = await inspectCandidate(\\n { executable, source: \\"staged_plugin_runtime\\" },\\n { env: options.env, timeoutMs: options.timeoutMs }\\n );\\n if (!inspected.compatible || inspected.packageVersion !== version) {\\n await fsp.rm(stageDir, { recursive: true, force: true });\\n throw new Error(\\n \\"Staged runtime failed version or protocol verification.\\"\\n );\\n }\\n\\n const manifest = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n version,\\n tag: urls.tag,\\n assetName: urls.assetName,\\n executable,\\n sha256: actualSha256,\\n source: {\\n repository: REPOSITORY,\\n releaseMetadataUrl: releaseMetadataUrl(version),\\n binaryUrl: assets.binary.browser_download_url,\\n binaryAssetId: assets.binary.id ?? null,\\n binaryDigest,\\n checksumUrl: assets.checksums.browser_download_url,\\n checksumAssetId: assets.checksums.id ?? null,\\n checksumDigest,\\n },\\n protocol: inspected.protocol,\\n platform: target.platform,\\n architecture: target.architecture,\\n stagedAt: new Date().toISOString(),\\n };\\n await writeJsonAtomic(path.join(stageDir, \\"manifest.json\\"), manifest, root);\\n return { action: \\"stage\\", mutatesActiveRuntime: false, manifest };\\n });\\n}\\n\\nasync function verifyManifestExecutable(manifest, root, expectedParent) {\\n if (\\n !isPlainObject(manifest) ||\\n manifest.schemaVersion !== STATE_SCHEMA_VERSION ||\\n typeof manifest.version !== \\"string\\" ||\\n typeof manifest.executable !== \\"string\\" ||\\n typeof manifest.sha256 !== \\"string\\" ||\\n !SHA256_RE.test(manifest.sha256)\\n ) {\\n throw new Error(\\"Runtime manifest is missing required verification data.\\");\\n }\\n const executable = path.resolve(manifest.executable);\\n if (!isSubpath(executable, expectedParent)) {\\n throw new Error(\\n \\"Runtime manifest executable escapes its expected directory.\\"\\n );\\n }\\n await assertManagedPath(executable, root);\\n const bytes = await fsp.readFile(executable);\\n if (sha256(bytes) !== manifest.sha256) {\\n throw new Error(\\"Runtime manifest checksum does not match its executable.\\");\\n }\\n const inspected = await inspectCandidate({\\n executable,\\n source: \\"plugin_runtime\\",\\n expectedSha256: manifest.sha256,\\n });\\n if (!inspected.compatible || inspected.packageVersion !== manifest.version) {\\n throw new Error(\\n \\"Runtime manifest executable failed protocol verification.\\"\\n );\\n }\\n return inspected;\\n}\\n\\nasync function setRuntimePolicy(options = {}) {\\n if (options.approve !== true) {\\n throw new Error(\\"Changing runtime update policy requires approve=true.\\");\\n }\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const current = await runtimePolicy(options);\\n const next = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n updateChecksEnabled:\\n typeof options.updateChecksEnabled === \\"boolean\\"\\n ? options.updateChecksEnabled\\n : current.updateChecksEnabled,\\n pinnedVersion: options.clearPin\\n ? null\\n : options.pinnedVersion\\n ? normalizeVersion(options.pinnedVersion)\\n : current.pinnedVersion,\\n updatedAt: new Date().toISOString(),\\n };\\n await writeJsonAtomic(path.join(root, \\"policy.json\\"), next, root);\\n return { action: \\"policy\\", previous: current, policy: next };\\n });\\n}\\n\\nasync function applyStagedRuntime(options) {\\n if (options.approve !== true) {\\n throw new Error(\\"Applying a runtime requires approve=true.\\");\\n }\\n const version = normalizeVersion(options.version);\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const stageDir = path.join(root, \\"staged\\", version);\\n const manifest = await readJson(path.join(stageDir, \\"manifest.json\\"));\\n if (!manifest) {\\n throw new Error(`No staged runtime exists for ${version}.`);\\n }\\n if (options.expectedSha256 !== manifest.sha256) {\\n throw new Error(\\n \\"Staged runtime precondition failed: expected checksum changed.\\"\\n );\\n }\\n await verifyManifestExecutable(manifest, root, stageDir);\\n\\n const activePath = path.join(root, \\"active.json\\");\\n const previous = await readJson(activePath);\\n const versionDir = await assertManagedPath(\\n path.join(root, \\"versions\\", version),\\n root\\n );\\n await fsp.mkdir(versionDir, { recursive: true, mode: 0o700 });\\n const executable = await assertManagedPath(\\n path.join(versionDir, path.basename(manifest.executable)),\\n root\\n );\\n const temporary = `${executable}.tmp-${crypto.randomUUID()}`;\\n await fsp.copyFile(manifest.executable, temporary);\\n if (process.platform !== \\"win32\\") {\\n await fsp.chmod(temporary, 0o700);\\n }\\n await fsp.rename(temporary, executable);\\n const activeManifest = {\\n ...manifest,\\n executable,\\n activatedAt: new Date().toISOString(),\\n previous:\\n typeof previous?.version === \\"string\\" &&\\n typeof previous?.executable === \\"string\\"\\n ? {\\n version: previous.version,\\n executable: previous.executable,\\n sha256: previous.sha256,\\n }\\n : null,\\n };\\n await writeJsonAtomic(activePath, activeManifest, root);\\n const inspected = await verifyManifestExecutable(\\n activeManifest,\\n root,\\n versionDir\\n );\\n return {\\n action: \\"apply\\",\\n active: inspected,\\n previous: activeManifest.previous,\\n rollbackAvailable: Boolean(activeManifest.previous),\\n requiresFreshSession: false,\\n };\\n });\\n}\\n\\nasync function rollbackRuntime(options = {}) {\\n if (options.approve !== true) {\\n throw new Error(\\"Rolling back a runtime requires approve=true.\\");\\n }\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const activePath = path.join(root, \\"active.json\\");\\n const active = await readJson(activePath);\\n if (!isPlainObject(active?.previous)) {\\n throw new Error(\\n \\"The active plugin runtime does not have a retained rollback target.\\"\\n );\\n }\\n if (\\n options.expectedActiveVersion &&\\n options.expectedActiveVersion !== active.version\\n ) {\\n throw new Error(\\n \\"Runtime rollback precondition failed: active version changed.\\"\\n );\\n }\\n const previous = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n version: active.previous.version,\\n executable: active.previous.executable,\\n sha256: active.previous.sha256,\\n previous: {\\n version: active.version,\\n executable: active.executable,\\n sha256: active.sha256,\\n },\\n rolledBackAt: new Date().toISOString(),\\n };\\n const previousDir = path.dirname(path.resolve(previous.executable));\\n const inspected = await verifyManifestExecutable(\\n previous,\\n root,\\n previousDir\\n );\\n await writeJsonAtomic(activePath, previous, root);\\n return {\\n action: \\"rollback\\",\\n active: inspected,\\n rolledBackFrom: active.version,\\n rollbackAvailable: true,\\n requiresFreshSession: false,\\n };\\n });\\n}\\n\\nmodule.exports = {\\n PLUGIN_PROTOCOL_VERSION,\\n applyStagedRuntime,\\n assertManagedPath,\\n checkRuntimeUpdate,\\n checksumForAsset,\\n discoverRuntime,\\n downloadBuffer,\\n normalizeVersion,\\n parseProtocolReport,\\n pluginVersion,\\n protocolCompatibility,\\n releaseTarget,\\n rollbackRuntime,\\n runCommand,\\n runtimeCandidates,\\n runtimePolicy,\\n runtimeStateRoot,\\n setRuntimePolicy,\\n sha256,\\n stageRuntime,\\n};\\n","skills/fclt-capability-review/SKILL.md":"---\\nname: fclt-capability-review\\ndescription: Inspect fclt capability roots, docs, snippets, skills, agents, MCP, and automations.\\ntags: [fclt, capability, review, inventory]\\n---\\n\\n# fclt-capability-review\\n\\n## When To Use\\nUse this skill when Codex needs to understand what capability exists before changing it.\\n\\nUse it for:\\n\\n- checking global and project `.ai` roots\\n- finding relevant skills, snippets, instructions, agents, MCP servers, or automations\\n- deciding whether a change belongs in global or project scope\\n- checking whether managed rendering is enabled or needed\\n- reviewing public/private boundaries before publishing docs or pack assets\\n\\n## Workflow\\n\\n```bash\\nfclt status --json\\nfclt inventory --json\\nfclt list skills\\nfclt list instructions\\nfclt list snippets\\nfclt graph AGENTS.global.md\\n```\\n\\nFor project work:\\n\\n```bash\\nfclt status --project --json\\nfclt inventory --project --json\\n```\\n\\n## Rules\\n\\n- Read existing repo guidance before proposing project capability.\\n- Use project scope for repo-specific commands, tests, architecture, or team workflow.\\n- Use global scope only for broadly reusable behavior.\\n- Keep generated state and review artifacts out of repo-local `.ai`.\\n- Prefer adding or updating the smallest unit: instruction, snippet, skill, agent, MCP config, or automation.\\n- Treat engineering implementation as task work. Do not force product changes\\n through capability evolution merely because evolution tools exist.\\n- Before recommending mutation, state the observed problem, evidence, why the\\n proposed capability is the correct target, risk class, expected outcome,\\n verification plan, assumptions, and recovery route.\\n- Never silently overwrite authored capability or confuse generated/rendered\\n output with canonical source.\\n\\n## Output\\n\\n- capability roots found\\n- relevant assets\\n- scope recommendation\\n- missing or stale capability\\n- safe next command\\n- deliberately withheld operations or missing safer API\\n- verification and undo path for any proposed mutation\\n","skills/fclt-evolution/SKILL.md":"---\\nname: fclt-evolution\\ndescription: Turn repeated fclt writebacks into reviewed capability changes.\\ntags: [fclt, evolution, proposals, capability]\\n---\\n\\n# fclt-evolution\\n\\n## When To Use\\nUse this skill when repeated writebacks, stale canonical assets, or a clearly missing capability should become a concrete proposal.\\n\\nDo not use it for a single weak preference or speculative idea.\\n\\n## Workflow\\n\\n1. Review signal:\\n\\n```bash\\nfclt ai review reconcile --since --until --json\\nfclt ai writeback group --by asset\\nfclt ai writeback summarize --by domain\\nfclt ai evolve list\\n```\\n\\nFor an enabled scheduled loop, inspect its durable queue and observed scheduler\\nhealth with `fclt ai loop status --json`. Use `fclt ai loop run --dry-run\\n--json` for a fresh incremental scan that does not advance cursors or write\\nreconciliation or loop state. The full queue is authoritative; the notification\\ndelta intentionally suppresses unchanged items.\\n\\n2. Assess proposal readiness before mutating state:\\n\\n```bash\\nfclt ai evolve assess --asset --json\\n```\\n\\nUse the assessment recommendation as the decision checkpoint:\\n\\n- `reconcile_sources`: run the bounded read-only source review; writebacks alone cannot prove the window is empty.\\n- `review_reconciled_signals`: review correlated dispositions and linked work without creating one proposal per ticket.\\n- `no_mutation`: do not change capability state; ask for a target or evidence.\\n- `record_more_writeback`: explain what recurrence would justify evolution and record a new writeback only if there is fresh concrete evidence.\\n- `propose`: ask before running the proposal command, then create the smallest target-specific proposal.\\n- `review_existing_proposal`: inspect or revise the existing proposal instead of creating a duplicate.\\n\\n3. Propose only when evidence is strong enough:\\n\\n```bash\\nfclt ai evolve propose\\n```\\n\\n4. Draft and inspect:\\n\\n```bash\\nfclt ai evolve draft EV-00001\\nfclt ai evolve review EV-00001\\n```\\n\\n5. Accept/apply only when scope, target, and evidence are correct:\\n\\n```bash\\nfclt ai evolve accept EV-00001\\nfclt ai evolve apply EV-00001\\n```\\n\\n6. Verify the outcome after the producing loop has had a real chance to run:\\n\\n```bash\\nfclt ai writeback link WB-00001 --issue TEAM-123\\nfclt ai writeback disposition WB-00001 --type task --target TEAM-123\\nfclt ai evolve verify EV-00001 --effectiveness improved --evidence test:post-apply\\n```\\n\\nApply is not completion. Do not resolve source writebacks until post-apply evidence shows the\\nintended behavior improved. Treat recurrence as unchanged or regressed evidence linked to the same\\nevolution, not as an unrelated singleton.\\n\\n## Proposal Kinds\\n\\n- `update_asset`\\n- `create_asset`\\n- `extract_snippet`\\n- `add_skill`\\n- `promote_asset`\\n\\n## Rules\\n\\n- Prefer the smallest valid proposal kind.\\n- Keep project-specific behavior project-scoped until reuse is proven.\\n- Ask for approval before applying global instructions, global skills, plugin behavior, or other broad shared surfaces.\\n- Reject or park proposals that are stale, duplicated, vague, or unsupported.\\n- Use the operator\'s task system for executable implementation work that needs owner, priority, or state.\\n- A no-op answer must still be useful: include the evidence grade, missing signal, next writeback target, and exact approval boundary.\\n- State the problem, source evidence, reason for the selected target, risk,\\n expected outcome, verification plan, assumptions, and undo path before any\\n review-producing or mutating action.\\n- Preview and verify the exact scope. Never overwrite authored capability\\n silently or treat generated/rendered output as the canonical target.\\n- Canonical apply and cross-scope promotion are unavailable through the plugin\\n until a transaction-safe API can return tested rollback data. Do not bypass\\n that boundary with shell or arbitrary CLI arguments.\\n- The plugin exposes only closed-schema loop status and preview actions.\\n Scheduler enable/disable/run and external tracker mutation remain outside the\\n MCP surface.\\n\\n## Output\\n\\n- proposals reviewed\\n- repeated signal\\n- assessment recommendation\\n- proposal created or updated\\n- approvals needed\\n- apply/reject/no-op rationale\\n- actual changed records/artifacts, verification result, and recovery route\\n","skills/fclt-setup/SKILL.md":"---\\nname: fclt-setup\\ndescription: Install, update, inspect, and initialize fclt from Codex.\\ntags: [fclt, setup, codex, onboarding]\\n---\\n\\n# fclt-setup\\n\\n## When To Use\\nUse this skill when a user wants Codex to install, update, configure, inspect, or repair fclt.\\n\\nUse it for:\\n\\n- checking whether `fclt` is installed and current\\n- initializing global `~/.ai`\\n- discovering repositories and enrolling a reviewed minimal project layer\\n- installing or refreshing the built-in operating-model pack\\n- checking setup health with `doctor`\\n- finding canonical, generated, runtime, and review paths\\n\\n## Workflow\\n\\n1. Bootstrap the global loop with one idempotent command:\\n\\n```bash\\nfclt setup\\n```\\n\\nThis initializes or safely updates global capability, prepares writeback/evolution review state,\\nand installs the Codex plugin when Codex is available. It does not initialize the current\\nrepository.\\n\\n2. Inspect runtime selection and compatibility with `fclt_runtime` action\\n `status`. Report the selected executable, version, source, protocol\\n compatibility, and fresh-session state.\\n\\nIf no compatible runtime is available, use the staged lifecycle:\\n\\n- `check` is read-only\\n- `stage` requires an explicit version and approval, but does not activate it\\n- `apply` requires approval plus the staged checksum precondition\\n- `rollback` verifies and restores the retained prior runtime\\n\\nNever curl-pipe code, use an unverified mutable URL, or replace an existing\\nglobal installation silently.\\n\\n3. Check current setup state and exact repair actions:\\n\\n```bash\\nfclt --version\\nfclt paths --json\\nfclt doctor --json\\n```\\n\\nThrough MCP, call `fclt_setup` with an explicit `global` or\\n`global_and_project` scope. Project setup also requires the exact `cwd`.\\nPreview is the default; apply requires both `dryRun: false` and\\n`approve: true`. `global_and_project` returns a no-write project enrollment\\nplan; project application still uses the typed CLI plan-hash contract below.\\n\\n4. For advanced manual recovery, initialize global capability when missing:\\n\\n```bash\\nfclt templates init operating-model --global\\n```\\n\\n5. Discover candidate repositories only beneath explicit roots:\\n\\n```bash\\nfclt projects discover --root ~/dev --since 30d --json\\n```\\n\\nDiscovery is bounded and read-only. Review duplicate clone/worktree groups,\\ndirty state, existing guidance, and repository identity before selecting a\\nproject. Never bulk-enroll the discovery result.\\n\\n6. Preview the exact minimal project enrollment plan:\\n\\n```bash\\nfclt project init --project-root /path/to/repo --json\\n```\\n\\nThe minimal layer is `.ai/.gitignore` plus `.ai/config.toml`. It does not\\ninstall the operating pack, enable managed rendering, schedule a loop, or copy\\n`AGENTS.md`/`CLAUDE.md`. Review every canonical, generated, and machine-local\\nwrite plus the rollback command.\\n\\nIf project guidance should be adopted, name each canonical file explicitly:\\n\\n```bash\\nfclt project init --project-root /path/to/repo \\\\\\n --guidance AGENTS.md --json\\n```\\n\\nGuidance adoption is reference-only. fclt previews the full content and hash,\\nand refuses untracked, modified, secret-shaped, or machine-path-bearing input.\\n\\n7. Apply only the unchanged reviewed plan:\\n\\n```bash\\nfclt project init --project-root /path/to/repo \\\\\\n --apply --plan-sha --json\\n```\\n\\nIf options, source files, or preconditions change, discard the old hash and\\npreview again.\\n\\n8. Inspect health, coverage, and lifecycle:\\n\\n```bash\\nfclt projects status --root /path/to/repo --json\\nfclt project disable --project-root /path/to/repo --json\\nfclt project rollback --receipt --json\\n```\\n\\nDisable and remove decisions preserve canonical files, receipts, and review\\nhistory. Rollback previews by default and refuses drift.\\n\\n9. Install the full operating pack only when explicitly requested:\\n\\n```bash\\nfclt templates init operating-model --project --dry-run\\nfclt templates init operating-model --project\\n```\\n\\nThis is distinct from minimal enrollment and does not seed project\\n`AGENTS.global.md` from repository guidance.\\n\\n10. Refresh global pack defaults non-destructively:\\n\\n```bash\\nfclt templates init operating-model --global --update --dry-run\\nfclt templates init operating-model --global --update\\n```\\n\\n11. Use `--force` only when the user explicitly wants to replace local edits.\\n\\n## Rules\\n\\n- Preserve existing `AGENTS.md`, `CLAUDE.md`, and `AGENTS.global.md` guidance.\\n- Never infer guidance adoption from filenames or copy repository guidance into\\n `.ai/AGENTS.global.md`.\\n- Treat root `AGENTS.md` or `CLAUDE.md` as canonical repository guidance unless\\n the user explicitly chooses another tracked, clean source.\\n- Write the protective `.ai/.gitignore` before generated state.\\n- Keep canonical project files separate from machine-local generated indexes,\\n registries, receipts, and scheduling state.\\n- Keep minimal enrollment separate from the full operating pack and managed\\n rendering.\\n- Treat `doctor --json` issues as setup facts, not user-facing blame.\\n- Treat Codex plugin registration as weaker evidence than fresh-session tool discovery.\\n- Treat external trackers as separate integrations. Core readiness depends only on configured local evidence coverage, not a vendor plugin or token.\\n- Prefer temp-root smoke tests for install/update behavior.\\n- Do not enable managed rendering unless the user wants fclt to write tool homes.\\n- Preview before mutation and state the exact global/project/plugin target.\\n- Do not report a staged runtime or installed plugin as active until the active\\n handshake and fresh-session discovery have been verified.\\n\\n## Output\\n\\n- current installed version\\n- setup health\\n- paths that matter\\n- commands run\\n- what changed\\n- problem, evidence, reason, target, risk, and expected outcome\\n- verification performed and its actual result\\n- assumptions and fresh-session state\\n- exact undo or rollback path\\n- what still needs approval\\n","skills/fclt-writeback/SKILL.md":"---\\nname: fclt-writeback\\ndescription: Record and review fclt writebacks from real agent work.\\ntags: [fclt, writeback, learning, feedback-loop]\\n---\\n\\n# fclt-writeback\\n\\n## When To Use\\nUse this skill when work reveals durable friction, missing context, weak verification, stale guidance, repeated success, or a capability gap.\\n\\nWriteback is for preserving signal. It is not for every preference or one-off annoyance.\\n\\n## Workflow\\n\\n1. Decide scope:\\n\\n- `project` when the learning depends on a repo, test harness, architecture, or workflow.\\n- `global` when the learning applies across projects or shared tool behavior.\\n\\n2. Choose the smallest target:\\n\\n- instruction\\n- snippet\\n- skill\\n- agent\\n- MCP/tool config\\n- automation\\n\\n3. Record writeback when the target and evidence are clear:\\n\\n```bash\\nfclt ai writeback add \\\\\\n --kind missing_context \\\\\\n --category opportunity \\\\\\n --summary \\"Project verification guidance was not discoverable\\" \\\\\\n --details \\"The task had to reconstruct the command from CI configuration\\" \\\\\\n --impact \\"Verification took longer and could have selected the wrong harness\\" \\\\\\n --attempted-workaround \\"Inspected package scripts and CI\\" \\\\\\n --desired-outcome \\"The supported verification command is available at task start\\" \\\\\\n --sensitivity internal \\\\\\n --evidence session: \\\\\\n --asset @project/instructions/TESTING.md\\n```\\n\\n4. Review current signal:\\n\\n```bash\\nfclt ai writeback list\\nfclt ai writeback group --by asset\\nfclt ai writeback summarize --by domain\\nfclt ai loop activity --project\\n```\\n\\n## Rules\\n\\n- Prefer one high-signal writeback over several weak ones.\\n- Include concrete evidence when possible.\\n- Capture concise context, impact, attempted workaround, desired outcome, and\\n sensitivity when they improve review quality.\\n- Never capture hidden chain-of-thought, raw transcripts, unbounded logs,\\n secrets, tokens, or credential-bearing payloads. Reference the smallest\\n redacted external evidence identifier instead.\\n- Do not copy private project detail into global writebacks.\\n- Use task tracking for executable product/tooling work; use writeback for reusable operating-layer learning.\\n- If the same signal repeats and the target is clear, hand off to `fclt-evolution`.\\n- State the observed problem, evidence, target, reason, expected outcome, and\\n assumptions before recording.\\n- Do not capture secrets, private tokens, or raw sensitive payloads as evidence.\\n- For lifecycle mutations, use an explicit scope and expected prior state.\\n Report the journal/review evidence and the available undo transition.\\n\\n## Output\\n\\n- writeback id or no-op rationale\\n- scope\\n- target asset\\n- evidence summary\\n- whether this is ready for evolution\\n- risk class and approval boundary\\n- actual changed records/artifacts\\n- verification result and recovery route\\n"}' + '{".codex-plugin/plugin.json":"{\\n \\"name\\": \\"fclt\\",\\n \\"version\\": \\"0.1.3\\",\\n \\"description\\": \\"Codex workflows and MCP tools for evidence reconciliation, writeback, evolution, and outcome review.\\",\\n \\"author\\": {\\n \\"name\\": \\"Hack Dance\\",\\n \\"url\\": \\"https://hack.dance\\"\\n },\\n \\"license\\": \\"MIT\\",\\n \\"keywords\\": [\\n \\"fclt\\",\\n \\"facult\\",\\n \\"codex\\",\\n \\"skills\\",\\n \\"mcp\\",\\n \\"writeback\\",\\n \\"evolution\\"\\n ],\\n \\"skills\\": \\"./skills/\\",\\n \\"mcpServers\\": \\"./.mcp.json\\",\\n \\"interface\\": {\\n \\"displayName\\": \\"fclt\\",\\n \\"shortDescription\\": \\"Feedback loops for AI work\\",\\n \\"longDescription\\": \\"Capture signal from real agent work, reconcile configured evidence, turn repeated findings into reviewed capability changes, and verify whether those changes improved the work that produced them.\\",\\n \\"developerName\\": \\"Hack Dance\\",\\n \\"category\\": \\"Productivity\\",\\n \\"capabilities\\": [\\"Read\\", \\"Write\\", \\"MCP\\"],\\n \\"defaultPrompt\\": [\\n \\"Use fclt to check this repo\'s AI capability setup.\\",\\n \\"Reconcile the configured evidence window and explain its coverage.\\",\\n \\"Record useful writeback from this work and review any repeated signal.\\"\\n ],\\n \\"brandColor\\": \\"#166534\\",\\n \\"composerIcon\\": \\"./assets/fclt-mark.png\\",\\n \\"logo\\": \\"./assets/fclt-mark.png\\"\\n }\\n}\\n",".mcp.json":"{\\n \\"mcpServers\\": {\\n \\"fclt\\": {\\n \\"command\\": \\"node\\",\\n \\"args\\": [\\"./scripts/fclt-mcp.cjs\\"],\\n \\"env\\": {\\n \\"FCLT_BIN\\": \\"fclt\\"\\n },\\n \\"cwd\\": \\".\\"\\n }\\n }\\n}\\n","scripts/fclt-mcp.cjs":"#!/usr/bin/env node\\n\\"use strict\\";\\n\\nconst fs = require(\\"node:fs\\");\\nconst os = require(\\"node:os\\");\\nconst path = require(\\"node:path\\");\\nconst runtime = require(\\"./fclt-runtime.cjs\\");\\n\\nconst DEFAULT_TIMEOUT_MS = Number(process.env.FCLT_MCP_TIMEOUT_MS || 60_000);\\nconst ACTIVITY_ACTION_RESOLVE_CAPABILITY = \\"activity-action-resolve-v1\\";\\nconst AUDIT_READ_ONLY_CAPABILITY = \\"audit-read-only-v1\\";\\nconst CONTENT_LENGTH_RE = /Content-Length:\\\\s*(\\\\d+)/i;\\nconst PLUGIN_ROOT = path.resolve(__dirname, \\"..\\");\\n\\nconst tools = [\\n {\\n name: \\"fclt_setup\\",\\n description:\\n \\"Bootstrap or repair the global fclt loop and optionally return an exact no-write project enrollment plan.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: {\\n type: \\"string\\",\\n enum: [\\"global\\", \\"global_and_project\\"],\\n },\\n cwd: { type: \\"string\\" },\\n dryRun: { type: \\"boolean\\" },\\n installCodexPlugin: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_runtime\\",\\n description:\\n \\"Discover, bootstrap, update, or roll back the verified fclt runtime used by this plugin.\\",\\n inputSchema: {\\n type: \\"object\\",\\n additionalProperties: false,\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\"status\\", \\"check\\", \\"policy\\", \\"stage\\", \\"apply\\", \\"rollback\\"],\\n },\\n version: { type: \\"string\\" },\\n expectedSha256: { type: \\"string\\", pattern: \\"^[a-f0-9]{64}$\\" },\\n expectedActiveVersion: { type: \\"string\\" },\\n pinnedVersion: { type: \\"string\\" },\\n clearPin: { type: \\"boolean\\" },\\n updateChecksEnabled: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_capability\\",\\n description:\\n \\"Inspect fclt capability, provenance, templates, snippets, adapters, and managed status without exposing secrets.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"scan\\",\\n \\"inventory\\",\\n \\"list\\",\\n \\"show\\",\\n \\"find\\",\\n \\"graph\\",\\n \\"adapters\\",\\n \\"managed_status\\",\\n \\"templates_list\\",\\n \\"snippet_list\\",\\n \\"snippet_show\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n kind: {\\n type: \\"string\\",\\n enum: [\\n \\"skills\\",\\n \\"mcp\\",\\n \\"agents\\",\\n \\"automations\\",\\n \\"snippets\\",\\n \\"instructions\\",\\n ],\\n },\\n query: { type: \\"string\\" },\\n selector: { type: \\"string\\" },\\n graphMode: { type: \\"string\\", enum: [\\"show\\", \\"deps\\", \\"dependents\\"] },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_workflow\\",\\n description:\\n \\"Run typed writeback and evolution review operations. Canonical apply and cross-scope promotion are deliberately withheld.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"writeback_list\\",\\n \\"writeback_show\\",\\n \\"writeback_group\\",\\n \\"writeback_summarize\\",\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"evolve_assess\\",\\n \\"evolve_list\\",\\n \\"evolve_show\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_verify\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n id: { type: \\"string\\" },\\n kind: { type: \\"string\\" },\\n category: {\\n type: \\"string\\",\\n enum: [\\"friction\\", \\"opportunity\\", \\"reusable-success\\"],\\n },\\n summary: { type: \\"string\\" },\\n details: { type: \\"string\\", maxLength: 2000 },\\n impact: { type: \\"string\\", maxLength: 1000 },\\n attemptedWorkaround: { type: \\"string\\", maxLength: 1000 },\\n desiredOutcome: { type: \\"string\\", maxLength: 1000 },\\n sensitivity: {\\n type: \\"string\\",\\n enum: [\\"public\\", \\"internal\\", \\"private\\"],\\n },\\n asset: { type: \\"string\\" },\\n evidence: { type: \\"array\\", items: { type: \\"string\\" } },\\n confidence: { type: \\"string\\", enum: [\\"low\\", \\"medium\\", \\"high\\"] },\\n by: { type: \\"string\\", enum: [\\"asset\\", \\"kind\\", \\"domain\\"] },\\n issue: { type: \\"string\\" },\\n disposition: {\\n type: \\"string\\",\\n enum: [\\"propose\\", \\"apply-local\\", \\"task\\", \\"resolve-watch\\", \\"defer\\"],\\n },\\n target: { type: \\"string\\" },\\n nextTrigger: { type: \\"string\\" },\\n expectedOutcome: { type: \\"string\\" },\\n append: { type: \\"string\\" },\\n reason: { type: \\"string\\" },\\n byProposal: { type: \\"string\\" },\\n effectiveness: {\\n type: \\"string\\",\\n enum: [\\"improved\\", \\"unchanged\\", \\"regressed\\", \\"inconclusive\\"],\\n },\\n note: { type: \\"string\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_sync\\",\\n description:\\n \\"Inspect managed state or preview a scoped tool sync. Apply and live adoption remain withheld pending transaction-safe APIs.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: { type: \\"string\\", enum: [\\"status\\", \\"preview\\"] },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n tool: { type: \\"string\\" },\\n },\\n required: [\\"action\\", \\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_registry\\",\\n description:\\n \\"Search and verify remote capability, preview installs and updates, run typed source reconciliation reviews, or resolve one opaque activity action locator without mutation. Registry mutation remains withheld.\\",\\n inputSchema: {\\n oneOf: [\\n {\\n type: \\"object\\",\\n properties: {\\n action: { const: \\"activity_resolve\\" },\\n locator: {\\n type: \\"string\\",\\n pattern: \\"^fclt-act-v[0-9]+\\\\\\\\.[a-f0-9]{64}\\\\\\\\.[a-f0-9]{64}$\\",\\n },\\n },\\n required: [\\"action\\", \\"locator\\"],\\n additionalProperties: false,\\n },\\n {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"search\\",\\n \\"verify_source\\",\\n \\"source_list\\",\\n \\"install_preview\\",\\n \\"update_check\\",\\n \\"reconcile_status\\",\\n \\"reconcile\\",\\n ],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n query: { type: \\"string\\" },\\n source: { type: \\"string\\" },\\n item: { type: \\"string\\" },\\n as: { type: \\"string\\" },\\n since: {\\n type: \\"string\\",\\n pattern:\\n \\"^\\\\\\\\d{4}-\\\\\\\\d{2}-\\\\\\\\d{2}(?:T\\\\\\\\d{2}:\\\\\\\\d{2}:\\\\\\\\d{2}(?:\\\\\\\\.\\\\\\\\d+)?(?:Z|[+-]\\\\\\\\d{2}:\\\\\\\\d{2}))?$\\",\\n },\\n until: {\\n type: \\"string\\",\\n pattern:\\n \\"^\\\\\\\\d{4}-\\\\\\\\d{2}-\\\\\\\\d{2}(?:T\\\\\\\\d{2}:\\\\\\\\d{2}:\\\\\\\\d{2}(?:\\\\\\\\.\\\\\\\\d+)?(?:Z|[+-]\\\\\\\\d{2}:\\\\\\\\d{2}))?$\\",\\n },\\n sourceIds: {\\n type: \\"array\\",\\n items: {\\n type: \\"string\\",\\n pattern: \\"^[A-Za-z0-9][A-Za-z0-9._-]*$\\",\\n },\\n },\\n incremental: { type: \\"boolean\\" },\\n },\\n required: [\\"action\\"],\\n additionalProperties: false,\\n },\\n ],\\n },\\n },\\n {\\n name: \\"fclt_audit\\",\\n description:\\n \\"Run a structured, redacted, non-interactive fclt security audit with no report or index writes.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: { type: \\"string\\", enum: [\\"scan\\"] },\\n cwd: { type: \\"string\\" },\\n target: { type: \\"string\\" },\\n severity: {\\n type: \\"string\\",\\n enum: [\\"critical\\", \\"high\\", \\"medium\\", \\"low\\"],\\n },\\n },\\n required: [\\"action\\"],\\n },\\n },\\n {\\n name: \\"fclt_automation\\",\\n description:\\n \\"Read one aggregate activity set across all configured loops by default, or inspect one explicit loop scope. Schedule and canonical mutation remain CLI-only.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n action: {\\n type: \\"string\\",\\n enum: [\\n \\"autosync_status\\",\\n \\"loop_status\\",\\n \\"loop_activity\\",\\n \\"loop_preview\\",\\n ],\\n },\\n scope: {\\n type: \\"string\\",\\n enum: [\\"all\\", \\"global\\", \\"project\\"],\\n },\\n cwd: { type: \\"string\\" },\\n tool: { type: \\"string\\" },\\n },\\n required: [\\"action\\"],\\n oneOf: [\\n {\\n properties: {\\n action: { const: \\"loop_activity\\" },\\n scope: {\\n type: \\"string\\",\\n enum: [\\"all\\", \\"global\\", \\"project\\"],\\n default: \\"all\\",\\n },\\n },\\n required: [\\"action\\"],\\n },\\n {\\n properties: {\\n action: {\\n enum: [\\"autosync_status\\", \\"loop_status\\", \\"loop_preview\\"],\\n },\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n },\\n required: [\\"action\\", \\"scope\\"],\\n },\\n ],\\n },\\n },\\n {\\n name: \\"fclt_status\\",\\n description:\\n \\"Return fclt status for the current, global, or project scope.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_doctor\\",\\n description: \\"Run read-only fclt doctor checks and return JSON output.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_paths\\",\\n description: \\"Return canonical, generated, review, and runtime fclt paths.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_init_operating_model\\",\\n description: \\"Install or update the built-in operating-model pack.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n update: { type: \\"boolean\\" },\\n dryRun: { type: \\"boolean\\" },\\n force: { type: \\"boolean\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\"],\\n },\\n },\\n {\\n name: \\"fclt_writeback_add\\",\\n description: \\"Record a durable fclt writeback with evidence.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n kind: { type: \\"string\\" },\\n category: {\\n type: \\"string\\",\\n enum: [\\"friction\\", \\"opportunity\\", \\"reusable-success\\"],\\n },\\n summary: { type: \\"string\\" },\\n details: { type: \\"string\\", maxLength: 2000 },\\n impact: { type: \\"string\\", maxLength: 1000 },\\n attemptedWorkaround: { type: \\"string\\", maxLength: 1000 },\\n desiredOutcome: { type: \\"string\\", maxLength: 1000 },\\n sensitivity: {\\n type: \\"string\\",\\n enum: [\\"public\\", \\"internal\\", \\"private\\"],\\n },\\n asset: { type: \\"string\\" },\\n evidence: { type: \\"string\\" },\\n confidence: {\\n type: \\"string\\",\\n enum: [\\"low\\", \\"medium\\", \\"high\\"],\\n },\\n approve: { type: \\"boolean\\" },\\n },\\n required: [\\"scope\\", \\"kind\\", \\"summary\\", \\"evidence\\", \\"approve\\"],\\n },\\n },\\n {\\n name: \\"fclt_writeback_review\\",\\n description: \\"List, group, or summarize current fclt writebacks.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n mode: { type: \\"string\\", enum: [\\"list\\", \\"group\\", \\"summarize\\"] },\\n by: { type: \\"string\\" },\\n },\\n },\\n },\\n {\\n name: \\"fclt_evolve\\",\\n description:\\n \\"Assess, list, propose, draft, or review fclt evolution proposals.\\",\\n inputSchema: {\\n type: \\"object\\",\\n properties: {\\n scope: { type: \\"string\\", enum: [\\"auto\\", \\"global\\", \\"project\\"] },\\n cwd: { type: \\"string\\" },\\n action: {\\n type: \\"string\\",\\n enum: [\\"assess\\", \\"list\\", \\"propose\\", \\"draft\\", \\"review\\", \\"show\\"],\\n },\\n id: { type: \\"string\\" },\\n asset: { type: \\"string\\" },\\n approve: { type: \\"boolean\\" },\\n },\\n },\\n },\\n];\\n\\nfor (const tool of tools) {\\n if (!tool.inputSchema.oneOf || tool.inputSchema.properties) {\\n tool.inputSchema.additionalProperties = false;\\n }\\n}\\n\\nfunction isPlainObject(value) {\\n return Boolean(value) && typeof value === \\"object\\" && !Array.isArray(value);\\n}\\n\\nfunction validateToolArguments(name, args) {\\n if (!isPlainObject(args)) {\\n throw new Error(`${name} arguments must be an object`);\\n }\\n const tool = tools.find((entry) => entry.name === name);\\n if (!tool) {\\n throw new Error(`Unknown tool: ${name}`);\\n }\\n const rootSchema = tool.inputSchema;\\n const schema =\\n rootSchema.oneOf && !rootSchema.properties\\n ? rootSchema.oneOf.find((branch) => {\\n const action = branch.properties?.action;\\n return (\\n action?.const === args.action || action?.enum?.includes(args.action)\\n );\\n })\\n : rootSchema;\\n if (!schema) {\\n throw new Error(`${name}.action is not an allowed value`);\\n }\\n const properties = schema.properties || {};\\n const unknown = Object.keys(args).filter((key) => !(key in properties));\\n if (unknown.length > 0) {\\n throw new Error(\\n `${name} received unknown argument fields: ${unknown.join(\\", \\")}`\\n );\\n }\\n for (const required of schema.required || []) {\\n if (!(required in args)) {\\n throw new Error(`${name} requires ${required}`);\\n }\\n }\\n for (const [key, value] of Object.entries(args)) {\\n const property = properties[key];\\n if (property.const !== undefined && value !== property.const) {\\n throw new Error(`${name}.${key} is not an allowed value`);\\n }\\n if (!property.type) {\\n continue;\\n }\\n const validType =\\n property.type === \\"array\\"\\n ? Array.isArray(value)\\n : property.type === \\"object\\"\\n ? isPlainObject(value)\\n : typeof value === property.type;\\n if (!validType) {\\n throw new Error(`${name}.${key} must be ${property.type}`);\\n }\\n if (property.enum && !property.enum.includes(value)) {\\n throw new Error(`${name}.${key} is not an allowed value`);\\n }\\n if (property.pattern && !new RegExp(property.pattern).test(value)) {\\n throw new Error(`${name}.${key} has an invalid format`);\\n }\\n if (\\n property.type === \\"array\\" &&\\n property.items?.type &&\\n value.some((item) => typeof item !== property.items.type)\\n ) {\\n throw new Error(`${name}.${key} contains an invalid item`);\\n }\\n if (\\n property.type === \\"array\\" &&\\n property.items?.pattern &&\\n value.some((item) => !new RegExp(property.items.pattern).test(item))\\n ) {\\n throw new Error(`${name}.${key} contains an invalid item`);\\n }\\n }\\n}\\n\\nfunction scopeArgs(scope) {\\n if (scope === \\"global\\") {\\n return [\\"--global\\"];\\n }\\n if (scope === \\"project\\") {\\n return [\\"--project\\"];\\n }\\n return [];\\n}\\n\\nfunction boolFlag(name, value) {\\n return value ? [name] : [];\\n}\\n\\nfunction stringFlag(name, value) {\\n return typeof value === \\"string\\" && value.trim() ? [name, value] : [];\\n}\\n\\nfunction repeatedStringFlag(name, values) {\\n return Array.isArray(values)\\n ? values.flatMap((value) => stringFlag(name, value))\\n : [];\\n}\\n\\nfunction requireString(name, value) {\\n if (typeof value !== \\"string\\" || !value.trim()) {\\n throw new Error(`${name} is required`);\\n }\\n return value;\\n}\\n\\nfunction requireMutationApproval(name, args) {\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(`${name} requires an explicit global or project scope`);\\n }\\n if (args.approve !== true) {\\n throw new Error(`${name} requires approve=true`);\\n }\\n}\\n\\nfunction capabilityCommand(args) {\\n const action = args.action;\\n if (action === \\"scan\\") {\\n return [\\"scan\\", \\"--json\\"];\\n }\\n if (action === \\"inventory\\") {\\n return [\\"inventory\\", ...scopeArgs(args.scope), \\"--json\\"];\\n }\\n if (action === \\"list\\") {\\n return [\\"list\\", args.kind || \\"skills\\", ...scopeArgs(args.scope), \\"--json\\"];\\n }\\n if (action === \\"show\\") {\\n return [\\n \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n ...scopeArgs(args.scope),\\n ];\\n }\\n if (action === \\"find\\") {\\n return [\\n \\"find\\",\\n requireString(\\"query\\", args.query),\\n ...scopeArgs(args.scope),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"graph\\") {\\n return [\\n \\"graph\\",\\n args.graphMode || \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n ...scopeArgs(args.scope),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"adapters\\") {\\n return [\\"adapters\\", \\"--json\\"];\\n }\\n if (action === \\"managed_status\\") {\\n return [\\"managed\\", ...scopeArgs(args.scope)];\\n }\\n if (action === \\"templates_list\\") {\\n return [\\"templates\\", \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"snippet_list\\") {\\n return [\\"snippets\\", \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"snippet_show\\") {\\n return [\\n \\"snippets\\",\\n \\"show\\",\\n requireString(\\"selector\\", args.selector),\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported capability action: ${action}`);\\n}\\n\\nconst WORKFLOW_MUTATIONS = new Set([\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"writeback_dismiss\\",\\n \\"writeback_promote\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_accept\\",\\n \\"evolve_reject\\",\\n \\"evolve_supersede\\",\\n \\"evolve_verify\\",\\n]);\\n\\nconst WORKFLOW_ACTION_FIELDS = {\\n writeback_list: [],\\n writeback_show: [\\"id\\"],\\n writeback_group: [\\"by\\"],\\n writeback_summarize: [\\"by\\"],\\n writeback_add: [\\n \\"kind\\",\\n \\"category\\",\\n \\"summary\\",\\n \\"details\\",\\n \\"impact\\",\\n \\"attemptedWorkaround\\",\\n \\"desiredOutcome\\",\\n \\"sensitivity\\",\\n \\"asset\\",\\n \\"evidence\\",\\n \\"confidence\\",\\n \\"approve\\",\\n ],\\n writeback_link: [\\"id\\", \\"issue\\", \\"approve\\"],\\n writeback_disposition: [\\n \\"id\\",\\n \\"disposition\\",\\n \\"target\\",\\n \\"nextTrigger\\",\\n \\"expectedOutcome\\",\\n \\"approve\\",\\n ],\\n evolve_assess: [\\"asset\\"],\\n evolve_list: [],\\n evolve_show: [\\"id\\"],\\n evolve_propose: [\\"asset\\", \\"approve\\"],\\n evolve_draft: [\\"id\\", \\"append\\", \\"approve\\"],\\n evolve_review: [\\"id\\", \\"approve\\"],\\n evolve_verify: [\\"id\\", \\"effectiveness\\", \\"evidence\\", \\"note\\", \\"approve\\"],\\n};\\n\\nfunction requireOnlyWorkflowFields(args) {\\n const fields = WORKFLOW_ACTION_FIELDS[args.action];\\n if (!fields) {\\n return;\\n }\\n const allowed = new Set([\\"action\\", \\"scope\\", \\"cwd\\", ...fields]);\\n const unexpected = Object.keys(args).filter((key) => !allowed.has(key));\\n if (unexpected.length > 0) {\\n throw new Error(\\n `${args.action} received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n}\\n\\nfunction workflowCommand(args) {\\n const action = args.action;\\n requireOnlyWorkflowFields(args);\\n if (WORKFLOW_MUTATIONS.has(action)) {\\n requireMutationApproval(action, args);\\n }\\n const scope = scopeArgs(args.scope);\\n if (action === \\"writeback_list\\") {\\n return [\\"ai\\", \\"writeback\\", ...scope, \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"writeback_show\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"show\\",\\n requireString(\\"id\\", args.id),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_group\\" || action === \\"writeback_summarize\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n action === \\"writeback_group\\" ? \\"group\\" : \\"summarize\\",\\n ...stringFlag(\\"--by\\", args.by),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_add\\") {\\n if (!Array.isArray(args.evidence) || args.evidence.length === 0) {\\n throw new Error(\\"writeback_add requires at least one evidence reference\\");\\n }\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"add\\",\\n \\"--kind\\",\\n requireString(\\"kind\\", args.kind),\\n \\"--summary\\",\\n requireString(\\"summary\\", args.summary),\\n ...stringFlag(\\"--asset\\", args.asset),\\n ...stringFlag(\\"--category\\", args.category),\\n ...stringFlag(\\"--details\\", args.details),\\n ...stringFlag(\\"--impact\\", args.impact),\\n ...stringFlag(\\"--attempted-workaround\\", args.attemptedWorkaround),\\n ...stringFlag(\\"--desired-outcome\\", args.desiredOutcome),\\n ...stringFlag(\\"--sensitivity\\", args.sensitivity),\\n ...repeatedStringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--confidence\\", args.confidence),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"writeback_link\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"link\\",\\n requireString(\\"id\\", args.id),\\n \\"--issue\\",\\n requireString(\\"issue\\", args.issue),\\n ];\\n }\\n if (action === \\"writeback_disposition\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n \\"disposition\\",\\n requireString(\\"id\\", args.id),\\n \\"--type\\",\\n requireString(\\"disposition\\", args.disposition),\\n ...stringFlag(\\"--target\\", args.target),\\n ...stringFlag(\\"--next-trigger\\", args.nextTrigger),\\n ...stringFlag(\\"--expected-outcome\\", args.expectedOutcome),\\n ];\\n }\\n if (action === \\"writeback_dismiss\\" || action === \\"writeback_promote\\") {\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scope,\\n action === \\"writeback_dismiss\\" ? \\"dismiss\\" : \\"promote\\",\\n requireString(\\"id\\", args.id),\\n ];\\n }\\n if (action === \\"evolve_assess\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"assess\\",\\n ...stringFlag(\\"--asset\\", args.asset),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_list\\") {\\n return [\\"ai\\", \\"evolve\\", ...scope, \\"list\\", \\"--json\\"];\\n }\\n if (action === \\"evolve_show\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"show\\",\\n requireString(\\"id\\", args.id),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_propose\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"propose\\",\\n \\"--asset\\",\\n requireString(\\"asset\\", args.asset),\\n \\"--json\\",\\n ];\\n }\\n if (action === \\"evolve_draft\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"draft\\",\\n requireString(\\"id\\", args.id),\\n ...stringFlag(\\"--append\\", args.append),\\n ];\\n }\\n if (action === \\"evolve_review\\" || action === \\"evolve_accept\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n action === \\"evolve_review\\" ? \\"review\\" : \\"accept\\",\\n requireString(\\"id\\", args.id),\\n ];\\n }\\n if (action === \\"evolve_reject\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"reject\\",\\n requireString(\\"id\\", args.id),\\n \\"--reason\\",\\n requireString(\\"reason\\", args.reason),\\n ];\\n }\\n if (action === \\"evolve_supersede\\") {\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"supersede\\",\\n requireString(\\"id\\", args.id),\\n \\"--by\\",\\n requireString(\\"byProposal\\", args.byProposal),\\n ];\\n }\\n if (action === \\"evolve_verify\\") {\\n if (!Array.isArray(args.evidence) || args.evidence.length === 0) {\\n throw new Error(\\"evolve_verify requires at least one evidence reference\\");\\n }\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scope,\\n \\"verify\\",\\n requireString(\\"id\\", args.id),\\n \\"--effectiveness\\",\\n requireString(\\"effectiveness\\", args.effectiveness),\\n ...repeatedStringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--note\\", args.note),\\n ];\\n }\\n throw new Error(`Unsupported workflow action: ${action}`);\\n}\\n\\nfunction syncCommand(args) {\\n if (args.action === \\"status\\") {\\n return [\\"managed\\", ...scopeArgs(args.scope)];\\n }\\n if (args.action === \\"preview\\") {\\n return [\\n \\"sync\\",\\n ...(args.tool ? [args.tool] : []),\\n \\"--dry-run\\",\\n ...scopeArgs(args.scope),\\n ];\\n }\\n throw new Error(`Unsupported sync action: ${args.action}`);\\n}\\n\\nfunction requireOnlyRegistryFields(args, fields) {\\n const allowed = new Set([\\"action\\", \\"scope\\", \\"cwd\\", ...fields]);\\n const unexpected = Object.keys(args).filter((key) => !allowed.has(key));\\n if (unexpected.length > 0) {\\n throw new Error(\\n `${args.action} received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n}\\n\\nfunction registryCommand(args) {\\n if (args.action === \\"activity_resolve\\") {\\n const unexpected = Object.keys(args).filter(\\n (key) => key !== \\"action\\" && key !== \\"locator\\"\\n );\\n if (unexpected.length > 0) {\\n throw new Error(\\n `activity_resolve received unsupported fields: ${unexpected.join(\\", \\")}`\\n );\\n }\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n \\"resolve\\",\\n requireString(\\"locator\\", args.locator),\\n \\"--json\\",\\n ];\\n }\\n if (\\n args.scope === \\"project\\" &&\\n args.action !== \\"reconcile_status\\" &&\\n args.action !== \\"reconcile\\"\\n ) {\\n throw new Error(`${args.action} only supports global scope`);\\n }\\n if (args.action === \\"search\\") {\\n requireOnlyRegistryFields(args, [\\"query\\"]);\\n return [\\"search\\", requireString(\\"query\\", args.query), \\"--json\\"];\\n }\\n if (args.action === \\"verify_source\\") {\\n requireOnlyRegistryFields(args, [\\"source\\"]);\\n return [\\"verify-source\\", requireString(\\"source\\", args.source), \\"--json\\"];\\n }\\n if (args.action === \\"source_list\\") {\\n requireOnlyRegistryFields(args, []);\\n return [\\"sources\\", \\"list\\", \\"--json\\"];\\n }\\n if (args.action === \\"install_preview\\") {\\n requireOnlyRegistryFields(args, [\\"item\\", \\"as\\"]);\\n return [\\n \\"install\\",\\n requireString(\\"item\\", args.item),\\n ...stringFlag(\\"--as\\", args.as),\\n \\"--dry-run\\",\\n \\"--strict-source-trust\\",\\n \\"--json\\",\\n ];\\n }\\n if (args.action === \\"update_check\\") {\\n requireOnlyRegistryFields(args, []);\\n return [\\"update\\", \\"--strict-source-trust\\", \\"--json\\"];\\n }\\n if (args.action === \\"reconcile_status\\") {\\n requireOnlyRegistryFields(args, []);\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(\\n \\"reconcile_status requires an explicit global or project scope\\"\\n );\\n }\\n return [\\"ai\\", \\"review\\", ...scopeArgs(args.scope), \\"status\\", \\"--json\\"];\\n }\\n if (args.action === \\"reconcile\\") {\\n requireOnlyRegistryFields(args, [\\n \\"since\\",\\n \\"until\\",\\n \\"sourceIds\\",\\n \\"incremental\\",\\n ]);\\n if (args.scope !== \\"global\\" && args.scope !== \\"project\\") {\\n throw new Error(\\"reconcile requires an explicit global or project scope\\");\\n }\\n return [\\n \\"ai\\",\\n \\"review\\",\\n ...scopeArgs(args.scope),\\n \\"reconcile\\",\\n \\"--since\\",\\n requireString(\\"since\\", args.since),\\n ...stringFlag(\\"--until\\", args.until),\\n ...repeatedStringFlag(\\"--source\\", args.sourceIds),\\n ...boolFlag(\\"--incremental\\", args.incremental),\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported registry action: ${args.action}`);\\n}\\n\\nfunction isSubpath(child, parent) {\\n const relative = path.relative(parent, child);\\n return (\\n relative === \\"\\" || !(relative.startsWith(\\"..\\") || path.isAbsolute(relative))\\n );\\n}\\n\\nfunction isDirectory(candidate) {\\n try {\\n return fs.statSync(candidate).isDirectory();\\n } catch {\\n return false;\\n }\\n}\\n\\nfunction resolveWorkspaceCwd({ allowHomeFallback = true } = {}) {\\n const candidates = [\\n process.env.FCLT_MCP_WORKSPACE_CWD,\\n process.env.INIT_CWD,\\n process.env.PWD,\\n ];\\n for (const candidate of candidates) {\\n if (typeof candidate !== \\"string\\" || !candidate.trim()) {\\n continue;\\n }\\n const resolved = path.resolve(candidate);\\n const isHome = resolved === path.resolve(os.homedir());\\n if (\\n (allowHomeFallback || !isHome) &&\\n !isSubpath(resolved, PLUGIN_ROOT) &&\\n isDirectory(resolved)\\n ) {\\n return resolved;\\n }\\n }\\n if (allowHomeFallback && isDirectory(os.homedir())) {\\n return os.homedir();\\n }\\n return undefined;\\n}\\n\\nfunction resolveToolCwd(name, args = {}) {\\n if (typeof args.cwd === \\"string\\" && args.cwd.trim()) {\\n return args.cwd;\\n }\\n const inferred = resolveWorkspaceCwd({\\n allowHomeFallback: args.scope !== \\"project\\",\\n });\\n if (inferred) {\\n return inferred;\\n }\\n if (args.scope === \\"project\\") {\\n throw new Error(\\n `${name} with project scope requires a cwd for the target workspace`\\n );\\n }\\n return process.cwd();\\n}\\n\\nfunction commandForTool(name, args = {}) {\\n switch (name) {\\n case \\"fclt_setup\\": {\\n const apply = args.dryRun === false;\\n if (apply && args.approve !== true) {\\n throw new Error(\\"fclt_setup apply requires approve=true\\");\\n }\\n if (\\n args.scope === \\"global_and_project\\" &&\\n (typeof args.cwd !== \\"string\\" || !args.cwd.trim())\\n ) {\\n throw new Error(\\n \\"fclt_setup global_and_project scope requires an explicit cwd\\"\\n );\\n }\\n return [\\n \\"setup\\",\\n \\"--json\\",\\n ...(args.scope === \\"global_and_project\\" ? [\\"--include-project\\"] : []),\\n ...(apply ? [] : [\\"--dry-run\\"]),\\n ...(args.installCodexPlugin === false ? [\\"--no-codex-plugin\\"] : []),\\n ];\\n }\\n case \\"fclt_capability\\":\\n return capabilityCommand(args);\\n case \\"fclt_workflow\\":\\n return workflowCommand(args);\\n case \\"fclt_sync\\":\\n return syncCommand(args);\\n case \\"fclt_registry\\":\\n return registryCommand(args);\\n case \\"fclt_audit\\":\\n return [\\n \\"audit\\",\\n \\"--non-interactive\\",\\n ...(args.target ? [args.target] : []),\\n ...stringFlag(\\"--severity\\", args.severity),\\n \\"--json\\",\\n ];\\n case \\"fclt_automation\\":\\n if (\\n args.action !== \\"loop_activity\\" &&\\n args.scope !== \\"global\\" &&\\n args.scope !== \\"project\\"\\n ) {\\n throw new Error(`${args.action} requires global or project scope`);\\n }\\n if (args.action === \\"autosync_status\\") {\\n return [\\n \\"autosync\\",\\n \\"status\\",\\n ...(args.tool ? [args.tool] : []),\\n ...scopeArgs(args.scope),\\n ];\\n }\\n if (args.tool) {\\n throw new Error(`${args.action} does not accept tool`);\\n }\\n if (args.action === \\"loop_status\\") {\\n return [\\"ai\\", \\"loop\\", ...scopeArgs(args.scope), \\"status\\", \\"--json\\"];\\n }\\n if (args.action === \\"loop_activity\\") {\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n ...scopeArgs(args.scope),\\n \\"activity\\",\\n ...(args.scope === \\"global\\" || args.scope === \\"project\\"\\n ? []\\n : [\\"--all\\"]),\\n \\"--json\\",\\n ];\\n }\\n if (args.action === \\"loop_preview\\") {\\n return [\\n \\"ai\\",\\n \\"loop\\",\\n ...scopeArgs(args.scope),\\n \\"run\\",\\n \\"--dry-run\\",\\n \\"--json\\",\\n ];\\n }\\n throw new Error(`Unsupported automation action: ${args.action}`);\\n case \\"fclt_status\\":\\n return [\\"status\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_doctor\\":\\n return [\\"doctor\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_paths\\":\\n return [\\"paths\\", ...scopeArgs(args.scope), \\"--json\\"];\\n case \\"fclt_init_operating_model\\":\\n if (args.dryRun === false && args.approve !== true) {\\n throw new Error(\\n \\"fclt_init_operating_model apply requires approve=true\\"\\n );\\n }\\n if (args.force === true && args.approve !== true) {\\n throw new Error(\\n \\"fclt_init_operating_model force requires approve=true\\"\\n );\\n }\\n return [\\n \\"templates\\",\\n \\"init\\",\\n \\"operating-model\\",\\n ...scopeArgs(args.scope),\\n ...boolFlag(\\"--update\\", args.update),\\n ...(args.dryRun === false ? [] : [\\"--dry-run\\"]),\\n ...boolFlag(\\"--force\\", args.force),\\n \\"--json\\",\\n ];\\n case \\"fclt_writeback_add\\":\\n requireMutationApproval(name, args);\\n requireString(\\"evidence\\", args.evidence);\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scopeArgs(args.scope),\\n \\"add\\",\\n \\"--kind\\",\\n args.kind,\\n \\"--summary\\",\\n args.summary,\\n ...stringFlag(\\"--asset\\", args.asset),\\n ...stringFlag(\\"--category\\", args.category),\\n ...stringFlag(\\"--details\\", args.details),\\n ...stringFlag(\\"--impact\\", args.impact),\\n ...stringFlag(\\"--attempted-workaround\\", args.attemptedWorkaround),\\n ...stringFlag(\\"--desired-outcome\\", args.desiredOutcome),\\n ...stringFlag(\\"--sensitivity\\", args.sensitivity),\\n ...stringFlag(\\"--evidence\\", args.evidence),\\n ...stringFlag(\\"--confidence\\", args.confidence),\\n \\"--json\\",\\n ];\\n case \\"fclt_writeback_review\\": {\\n const mode = args.mode || \\"list\\";\\n return [\\n \\"ai\\",\\n \\"writeback\\",\\n ...scopeArgs(args.scope),\\n mode,\\n ...stringFlag(\\"--by\\", args.by),\\n ];\\n }\\n case \\"fclt_evolve\\": {\\n const action = args.action || \\"list\\";\\n if ([\\"propose\\", \\"draft\\", \\"review\\"].includes(action)) {\\n requireMutationApproval(`fclt_evolve ${action}`, args);\\n }\\n if (action === \\"propose\\") {\\n requireString(\\"asset\\", args.asset);\\n }\\n if ([\\"draft\\", \\"review\\", \\"show\\"].includes(action)) {\\n requireString(\\"id\\", args.id);\\n }\\n return [\\n \\"ai\\",\\n \\"evolve\\",\\n ...scopeArgs(args.scope),\\n action,\\n ...(action === \\"assess\\" || action === \\"propose\\"\\n ? stringFlag(\\"--asset\\", args.asset)\\n : []),\\n ...(args.id ? [args.id] : []),\\n ...(action === \\"assess\\" ? [\\"--json\\"] : []),\\n ];\\n }\\n default:\\n throw new Error(`Unknown tool: ${name}`);\\n }\\n}\\n\\nfunction operationMetadata(name, args, command) {\\n const action =\\n name === \\"fclt_writeback_add\\"\\n ? \\"writeback_add\\"\\n : name === \\"fclt_evolve\\"\\n ? `evolve_${args.action || \\"list\\"}`\\n : args.action || name;\\n const reviewActions = new Set([\\n \\"writeback_add\\",\\n \\"writeback_link\\",\\n \\"writeback_disposition\\",\\n \\"evolve_propose\\",\\n \\"evolve_draft\\",\\n \\"evolve_review\\",\\n \\"evolve_verify\\",\\n \\"reconcile\\",\\n ]);\\n const preview = command.includes(\\"--dry-run\\");\\n const risk = reviewActions.has(action)\\n ? \\"review_producing\\"\\n : !preview && name === \\"fclt_setup\\"\\n ? \\"reversible_mutation\\"\\n : !preview && name === \\"fclt_init_operating_model\\"\\n ? \\"high_risk_destructive\\"\\n : \\"read_only\\";\\n return {\\n tool: name,\\n action,\\n risk,\\n scope:\\n args.scope ||\\n (name === \\"fclt_automation\\" && action === \\"loop_activity\\"\\n ? \\"all\\"\\n : \\"auto\\"),\\n target:\\n args.id ||\\n args.selector ||\\n args.asset ||\\n args.item ||\\n args.source ||\\n args.tool ||\\n null,\\n preview,\\n };\\n}\\n\\nfunction recoveryForOperation(operation, stdout) {\\n if (operation.risk === \\"review_producing\\") {\\n return {\\n canonicalCapabilityChanged: false,\\n audit: \\"native fclt review artifacts and append-only journal\\",\\n };\\n }\\n if (\\n operation.risk !== \\"reversible_mutation\\" &&\\n operation.risk !== \\"high_risk_destructive\\"\\n ) {\\n return null;\\n }\\n const report = isPlainObject(stdout) ? stdout : {};\\n return {\\n rollbackAvailable: false,\\n changedPaths: Array.isArray(report.changedPaths) ? report.changedPaths : [],\\n skippedPaths: Array.isArray(report.skippedPaths) ? report.skippedPaths : [],\\n repairActions: Array.isArray(report.repairActions)\\n ? report.repairActions\\n : [],\\n verification:\\n operation.tool === \\"fclt_setup\\"\\n ? \\"rerun fclt_setup in preview mode and verify doctor readiness\\"\\n : \\"verify doctor, generated state, authored-file hashes, and exact target paths\\",\\n };\\n}\\n\\nasync function runFclt(args, cwd, operation) {\\n const discovery = await runtime.discoverRuntime();\\n if (!discovery.selected) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"no_compatible_runtime\\",\\n message:\\n \\"No compatible fclt runtime is available. Check, stage, and apply an explicit verified version with fclt_runtime.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n if (\\n operation.tool === \\"fclt_audit\\" &&\\n !discovery.selected.capabilities?.includes(AUDIT_READ_ONLY_CAPABILITY)\\n ) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"missing_runtime_capability\\",\\n message:\\n \\"The selected fclt runtime does not advertise audit-read-only-v1; typed audit fails closed.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n if (\\n operation.action === \\"activity_resolve\\" &&\\n !discovery.selected.capabilities?.includes(\\n ACTIVITY_ACTION_RESOLVE_CAPABILITY\\n )\\n ) {\\n return {\\n code: 1,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n error: \\"missing_runtime_capability\\",\\n message:\\n \\"The selected fclt runtime does not advertise activity-action-resolve-v1; typed activity resolution fails closed.\\",\\n runtime: discovery,\\n },\\n null,\\n 2\\n ),\\n };\\n }\\n\\n const result = await runtime.runCommand(discovery.selected.executable, args, {\\n cwd: cwd || process.cwd(),\\n env: process.env,\\n timeoutMs: DEFAULT_TIMEOUT_MS,\\n });\\n const parsedStdout = parseJsonOrText(result.stdout.trim());\\n return {\\n code: result.code,\\n text: JSON.stringify(\\n {\\n schemaVersion: 1,\\n operation,\\n runtime: discovery.selected,\\n result: {\\n exitCode: result.code,\\n stdout: parsedStdout,\\n stderr: result.stderr,\\n },\\n verification: {\\n status: result.code === 0 ? \\"passed\\" : \\"failed\\",\\n exitCode: result.code,\\n },\\n recovery: recoveryForOperation(operation, parsedStdout),\\n },\\n null,\\n 2\\n ),\\n };\\n}\\n\\nfunction parseJsonOrText(value) {\\n if (!value) {\\n return \\"\\";\\n }\\n try {\\n return JSON.parse(value);\\n } catch {\\n return value;\\n }\\n}\\n\\nasync function handleRuntimeTool(args = {}) {\\n const action = args.action || \\"status\\";\\n if (action === \\"status\\") {\\n return await runtime.discoverRuntime();\\n }\\n if (action === \\"check\\") {\\n return await runtime.checkRuntimeUpdate();\\n }\\n if (action === \\"policy\\") {\\n return await runtime.setRuntimePolicy({\\n approve: args.approve,\\n pinnedVersion: args.pinnedVersion,\\n clearPin: args.clearPin,\\n updateChecksEnabled: args.updateChecksEnabled,\\n });\\n }\\n if (action === \\"stage\\") {\\n return await runtime.stageRuntime({\\n approve: args.approve,\\n version: args.version,\\n });\\n }\\n if (action === \\"apply\\") {\\n return await runtime.applyStagedRuntime({\\n approve: args.approve,\\n expectedSha256: args.expectedSha256,\\n version: args.version,\\n });\\n }\\n if (action === \\"rollback\\") {\\n return await runtime.rollbackRuntime({\\n approve: args.approve,\\n expectedActiveVersion: args.expectedActiveVersion,\\n });\\n }\\n throw new Error(`Unknown runtime action: ${action}`);\\n}\\n\\nfunction runtimeOperationMetadata(args, result) {\\n const action = args.action || \\"status\\";\\n const risk =\\n action === \\"status\\" || action === \\"check\\"\\n ? \\"read_only\\"\\n : action === \\"stage\\"\\n ? \\"review_producing\\"\\n : \\"high_risk_destructive\\";\\n return {\\n operation: {\\n tool: \\"fclt_runtime\\",\\n action,\\n risk,\\n scope: \\"plugin_runtime\\",\\n target:\\n args.version ||\\n args.pinnedVersion ||\\n args.expectedActiveVersion ||\\n null,\\n approved: args.approve === true,\\n },\\n verification: {\\n status: \\"passed\\",\\n activeVersion:\\n result.active?.packageVersion ||\\n result.selected?.packageVersion ||\\n null,\\n },\\n recovery:\\n action === \\"apply\\" || action === \\"rollback\\"\\n ? {\\n rollbackAvailable: result.rollbackAvailable === true,\\n previous: result.previous || null,\\n }\\n : action === \\"policy\\"\\n ? { previousPolicy: result.previous || null }\\n : null,\\n };\\n}\\n\\nlet transportFraming = \\"content-length\\";\\n\\nfunction send(message) {\\n const body = JSON.stringify(message);\\n if (transportFraming === \\"newline\\") {\\n process.stdout.write(`${body}\\\\n`);\\n return;\\n }\\n process.stdout.write(\\n `Content-Length: ${Buffer.byteLength(body)}\\\\r\\\\n\\\\r\\\\n${body}`\\n );\\n}\\n\\nasync function handle(message) {\\n if (!message || message.id == null) {\\n return;\\n }\\n\\n try {\\n if (message.method === \\"initialize\\") {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n protocolVersion: \\"2025-06-18\\",\\n capabilities: { tools: {} },\\n serverInfo: { name: \\"fclt\\", version: runtime.pluginVersion() },\\n },\\n });\\n return;\\n }\\n if (message.method === \\"tools/list\\") {\\n send({ jsonrpc: \\"2.0\\", id: message.id, result: { tools } });\\n return;\\n }\\n if (message.method === \\"tools/call\\") {\\n const { name, arguments: args = {} } = message.params || {};\\n validateToolArguments(name, args);\\n if (name === \\"fclt_runtime\\") {\\n const result = await handleRuntimeTool(args);\\n const metadata = runtimeOperationMetadata(args, result);\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n isError: false,\\n content: [\\n {\\n type: \\"text\\",\\n text: JSON.stringify({ ...result, ...metadata }, null, 2),\\n },\\n ],\\n },\\n });\\n return;\\n }\\n const command = commandForTool(name, args);\\n const result = await runFclt(\\n command,\\n resolveToolCwd(name, args),\\n operationMetadata(name, args, command)\\n );\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n result: {\\n isError: result.code !== 0,\\n content: [{ type: \\"text\\", text: result.text }],\\n },\\n });\\n return;\\n }\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n error: { code: -32_601, message: `Method not found: ${message.method}` },\\n });\\n } catch (error) {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: message.id,\\n error: {\\n code: -32_000,\\n message: error instanceof Error ? error.message : String(error),\\n },\\n });\\n }\\n}\\n\\nlet buffer = Buffer.alloc(0);\\n\\nfunction dispatch(body, framing) {\\n transportFraming = framing;\\n handle(JSON.parse(body)).catch((error) => {\\n send({\\n jsonrpc: \\"2.0\\",\\n id: null,\\n error: {\\n code: -32_000,\\n message: error instanceof Error ? error.message : String(error),\\n },\\n });\\n });\\n}\\n\\nprocess.stdin.on(\\"data\\", (chunk) => {\\n buffer = Buffer.concat([buffer, chunk]);\\n while (true) {\\n while (buffer[0] === 10 || buffer[0] === 13) {\\n buffer = buffer.subarray(1);\\n }\\n if (buffer.length === 0) {\\n return;\\n }\\n if (buffer[0] === 123 || buffer[0] === 91) {\\n const lineEnd = buffer.indexOf(\\"\\\\n\\");\\n if (lineEnd === -1) {\\n return;\\n }\\n const body = buffer.subarray(0, lineEnd).toString(\\"utf8\\").trim();\\n buffer = buffer.subarray(lineEnd + 1);\\n if (body) {\\n dispatch(body, \\"newline\\");\\n }\\n continue;\\n }\\n const headerEnd = buffer.indexOf(\\"\\\\r\\\\n\\\\r\\\\n\\");\\n if (headerEnd === -1) {\\n return;\\n }\\n const header = buffer.slice(0, headerEnd).toString(\\"utf8\\");\\n const match = CONTENT_LENGTH_RE.exec(header);\\n if (!match) {\\n buffer = Buffer.alloc(0);\\n return;\\n }\\n const length = Number(match[1]);\\n const frameEnd = headerEnd + 4 + length;\\n if (buffer.length < frameEnd) {\\n return;\\n }\\n const body = buffer.slice(headerEnd + 4, frameEnd).toString(\\"utf8\\");\\n buffer = buffer.slice(frameEnd);\\n dispatch(body, \\"content-length\\");\\n }\\n});\\n\\nif (process.argv.includes(\\"--self-test\\")) {\\n console.log(\\n JSON.stringify(\\n {\\n pluginVersion: runtime.pluginVersion(),\\n protocolVersion: runtime.PLUGIN_PROTOCOL_VERSION,\\n tools: tools.map((tool) => tool.name),\\n },\\n null,\\n 2\\n )\\n );\\n process.exit(0);\\n}\\n","scripts/fclt-runtime.cjs":"\\"use strict\\";\\n\\nconst { spawn } = require(\\"node:child_process\\");\\nconst crypto = require(\\"node:crypto\\");\\nconst fs = require(\\"node:fs\\");\\nconst fsp = require(\\"node:fs/promises\\");\\nconst https = require(\\"node:https\\");\\nconst os = require(\\"node:os\\");\\nconst path = require(\\"node:path\\");\\n\\nconst PLUGIN_PROTOCOL_VERSION = 1;\\nconst STATE_SCHEMA_VERSION = 1;\\nconst REPOSITORY = \\"hack-dance/fclt\\";\\nconst MAX_BINARY_BYTES = 256 * 1024 * 1024;\\nconst MAX_METADATA_BYTES = 2 * 1024 * 1024;\\nconst DOWNLOAD_TIMEOUT_MS = 30_000;\\nconst COMMAND_TIMEOUT_MS = 15_000;\\nconst ALLOWED_DOWNLOAD_HOSTS = new Set([\\n \\"api.github.com\\",\\n \\"github.com\\",\\n \\"objects.githubusercontent.com\\",\\n \\"release-assets.githubusercontent.com\\",\\n]);\\nconst SEMVER_RE = /^\\\\d+\\\\.\\\\d+\\\\.\\\\d+(?:[-+][0-9A-Za-z.-]+)?$/;\\nconst SHA256_RE = /^[a-f0-9]{64}$/;\\nconst NEWLINE_RE = /\\\\r?\\\\n/;\\nconst CHECKSUM_LINE_RE = /^([a-fA-F0-9]{64})\\\\s+\\\\*?(.+)$/;\\nconst WINDOWS_SHIM_RE = /\\\\.(?:bat|cmd)$/i;\\n\\nfunction isPlainObject(value) {\\n return Boolean(value) && typeof value === \\"object\\" && !Array.isArray(value);\\n}\\n\\nfunction pluginVersion() {\\n try {\\n const manifest = JSON.parse(\\n fs.readFileSync(\\n path.resolve(__dirname, \\"..\\", \\".codex-plugin\\", \\"plugin.json\\"),\\n \\"utf8\\"\\n )\\n );\\n return typeof manifest.version === \\"string\\" ? manifest.version : \\"unknown\\";\\n } catch {\\n return \\"unknown\\";\\n }\\n}\\n\\nfunction runtimeStateRoot(env = process.env, platform = process.platform) {\\n if (env.FCLT_PLUGIN_RUNTIME_DIR) {\\n return path.resolve(env.FCLT_PLUGIN_RUNTIME_DIR);\\n }\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n if (platform === \\"darwin\\") {\\n return path.join(\\n home,\\n \\"Library\\",\\n \\"Application Support\\",\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n }\\n if (platform === \\"win32\\") {\\n return path.join(\\n env.LOCALAPPDATA || path.join(home, \\"AppData\\", \\"Local\\"),\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n }\\n return path.join(\\n env.XDG_STATE_HOME || path.join(home, \\".local\\", \\"state\\"),\\n \\"fclt\\",\\n \\"plugin-runtime\\"\\n );\\n}\\n\\nfunction installStatePaths(env = process.env, platform = process.platform) {\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n const override = env.FACULT_LOCAL_STATE_DIR?.trim();\\n const portableRoot = override\\n ? path.resolve(override)\\n : platform === \\"darwin\\"\\n ? path.join(home, \\"Library\\", \\"Application Support\\", \\"fclt\\")\\n : path.join(\\n env.XDG_STATE_HOME\\n ? path.resolve(env.XDG_STATE_HOME)\\n : path.join(home, \\".local\\", \\"state\\"),\\n \\"fclt\\"\\n );\\n const candidates = [\\n path.join(portableRoot, \\"install.json\\"),\\n ...(platform === \\"win32\\"\\n ? [\\n path.join(\\n env.LOCALAPPDATA || path.join(home, \\"AppData\\", \\"Local\\"),\\n \\"fclt\\",\\n \\"install.json\\"\\n ),\\n ]\\n : []),\\n path.join(home, \\".ai\\", \\".facult\\", \\"install.json\\"),\\n path.join(home, \\".facult\\", \\"install.json\\"),\\n path.join(home, \\".local\\", \\"share\\", \\"fclt\\", \\"install.json\\"),\\n ];\\n return [...new Set(candidates.map((candidate) => path.resolve(candidate)))];\\n}\\n\\nfunction isSubpath(child, parent) {\\n const relative = path.relative(parent, child);\\n return (\\n relative === \\"\\" || !(relative.startsWith(\\"..\\") || path.isAbsolute(relative))\\n );\\n}\\n\\nasync function assertManagedPath(target, root) {\\n const resolvedRoot = path.resolve(root);\\n const resolvedTarget = path.resolve(target);\\n if (!isSubpath(resolvedTarget, resolvedRoot)) {\\n throw new Error(\\"Runtime path escapes the managed runtime root.\\");\\n }\\n\\n const relative = path.relative(resolvedRoot, path.dirname(resolvedTarget));\\n const segments = relative ? relative.split(path.sep) : [];\\n let cursor = resolvedRoot;\\n for (const segment of segments) {\\n cursor = path.join(cursor, segment);\\n try {\\n if ((await fsp.lstat(cursor)).isSymbolicLink()) {\\n throw new Error(\\"Runtime path traverses a symbolic link.\\");\\n }\\n } catch (error) {\\n if (error && error.code === \\"ENOENT\\") {\\n continue;\\n }\\n throw error;\\n }\\n }\\n return resolvedTarget;\\n}\\n\\nasync function readJson(pathValue) {\\n try {\\n const value = JSON.parse(await fsp.readFile(pathValue, \\"utf8\\"));\\n return isPlainObject(value) ? value : null;\\n } catch {\\n return null;\\n }\\n}\\n\\nasync function runtimePolicy(options = {}) {\\n const root = runtimeStateRoot(options.env, options.platform);\\n const persisted = await readJson(path.join(root, \\"policy.json\\"));\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n updateChecksEnabled: persisted?.updateChecksEnabled !== false,\\n pinnedVersion:\\n typeof persisted?.pinnedVersion === \\"string\\" &&\\n persisted.pinnedVersion.trim()\\n ? normalizeVersion(persisted.pinnedVersion)\\n : null,\\n };\\n}\\n\\nfunction commandNames(platform = process.platform) {\\n return platform === \\"win32\\"\\n ? [\\"fclt.exe\\", \\"fclt.cmd\\", \\"facult.exe\\", \\"facult.cmd\\"]\\n : [\\"fclt\\", \\"facult\\"];\\n}\\n\\nfunction pathCandidates(env = process.env, platform = process.platform) {\\n const values = [];\\n for (const directory of (env.PATH || \\"\\").split(path.delimiter)) {\\n if (!directory) {\\n continue;\\n }\\n for (const name of commandNames(platform)) {\\n values.push(path.join(directory, name));\\n }\\n }\\n return values;\\n}\\n\\nfunction systemPathCandidates(env = process.env, platform = process.platform) {\\n if (Object.hasOwn(env, \\"FCLT_SYSTEM_PATHS\\")) {\\n return (env.FCLT_SYSTEM_PATHS || \\"\\").split(path.delimiter).filter(Boolean);\\n }\\n if (platform === \\"darwin\\") {\\n return [\\"/opt/homebrew/bin/fclt\\", \\"/usr/local/bin/fclt\\"];\\n }\\n if (platform === \\"win32\\") {\\n return [];\\n }\\n return [\\"/usr/local/bin/fclt\\", \\"/usr/bin/fclt\\"];\\n}\\n\\nfunction candidateSource(candidate) {\\n const normalized = candidate.split(\\"\\\\\\\\\\").join(\\"/\\");\\n if (normalized.includes(\\"/plugin-runtime/versions/\\")) {\\n return \\"plugin_runtime\\";\\n }\\n if (normalized.includes(\\"/mise/\\") || normalized.includes(\\"/mise/installs/\\")) {\\n return \\"mise\\";\\n }\\n if (\\n normalized.includes(\\"/Cellar/\\") ||\\n normalized.startsWith(\\"/opt/homebrew/\\")\\n ) {\\n return \\"homebrew\\";\\n }\\n if (normalized.includes(\\"/node_modules/\\") || normalized.includes(\\"/npm/\\")) {\\n return \\"npm\\";\\n }\\n if (normalized.includes(\\"/.ai/.facult/bin/\\")) {\\n return \\"canonical_install\\";\\n }\\n return \\"path\\";\\n}\\n\\nasync function activeRuntimeCandidate(root) {\\n const active = await readJson(path.join(root, \\"active.json\\"));\\n if (typeof active?.executable !== \\"string\\" || !active.executable.trim()) {\\n return null;\\n }\\n const executable = path.resolve(active.executable);\\n if (!isSubpath(executable, path.join(root, \\"versions\\"))) {\\n return null;\\n }\\n return {\\n executable,\\n source: \\"plugin_runtime\\",\\n expectedSha256: active.sha256,\\n active,\\n };\\n}\\n\\nasync function persistedInstallCandidates(\\n env = process.env,\\n platform = process.platform\\n) {\\n const candidates = [];\\n for (const statePath of installStatePaths(env, platform)) {\\n const state = await readJson(statePath);\\n if (typeof state?.binaryPath === \\"string\\" && state.binaryPath.trim()) {\\n candidates.push({\\n executable: path.resolve(state.binaryPath),\\n source:\\n typeof state.source === \\"string\\" ? state.source : \\"install_metadata\\",\\n installStatePath: statePath,\\n });\\n }\\n }\\n return candidates;\\n}\\n\\nasync function runtimeCandidates(options = {}) {\\n const env = options.env || process.env;\\n const platform = options.platform || process.platform;\\n const home = env.HOME || env.USERPROFILE || os.homedir();\\n const root = runtimeStateRoot(env, platform);\\n const candidates = [];\\n let configuredPathCandidate = null;\\n\\n if (env.FCLT_BIN?.trim()) {\\n const explicit = env.FCLT_BIN.trim();\\n if (path.isAbsolute(explicit) || explicit.includes(path.sep)) {\\n candidates.push({\\n executable: path.resolve(explicit),\\n source: \\"explicit\\",\\n });\\n } else {\\n const resolved = pathCandidates(env, platform).find(\\n (candidate) =>\\n path.basename(candidate) === explicit && fs.existsSync(candidate)\\n );\\n configuredPathCandidate = {\\n executable: resolved || explicit,\\n source: \\"configured_path\\",\\n };\\n }\\n }\\n\\n const active = await activeRuntimeCandidate(root);\\n if (active) {\\n candidates.push(active);\\n }\\n if (configuredPathCandidate) {\\n candidates.push(configuredPathCandidate);\\n }\\n candidates.push(...(await persistedInstallCandidates(env, platform)));\\n candidates.push(\\n ...pathCandidates(env, platform).map((executable) => ({\\n executable,\\n source: candidateSource(executable),\\n }))\\n );\\n\\n for (const executable of [\\n path.join(home, \\".ai\\", \\".facult\\", \\"bin\\", commandNames(platform)[0]),\\n ...systemPathCandidates(env, platform),\\n ]) {\\n candidates.push({ executable, source: candidateSource(executable) });\\n }\\n\\n const unique = [];\\n const seen = new Set();\\n for (const candidate of candidates) {\\n const key = path.resolve(candidate.executable);\\n if (!seen.has(key)) {\\n seen.add(key);\\n unique.push(candidate);\\n }\\n }\\n return unique;\\n}\\n\\nfunction runCommand(executable, args, options = {}) {\\n return new Promise((resolve) => {\\n let child;\\n try {\\n const platform = options.platform || process.platform;\\n const windowsShim =\\n platform === \\"win32\\" && WINDOWS_SHIM_RE.test(executable);\\n const command = windowsShim\\n ? options.env?.ComSpec || process.env.ComSpec || \\"cmd.exe\\"\\n : executable;\\n const commandArgs = windowsShim\\n ? [\\n \\"/d\\",\\n \\"/v:off\\",\\n \\"/s\\",\\n \\"/c\\",\\n [executable, ...args]\\n .map(\\n (value) =>\\n `\\"${String(value)\\n .replaceAll(\\"%\\", \\"%%\\")\\n .replace(/[\\\\^&|<>()!\\"]/g, \\"^$&\\")}\\"`\\n )\\n .join(\\" \\"),\\n ]\\n : args;\\n child = spawn(command, commandArgs, {\\n cwd: options.cwd || process.cwd(),\\n env: options.env || process.env,\\n stdio: [\\"ignore\\", \\"pipe\\", \\"pipe\\"],\\n });\\n } catch (error) {\\n resolve({ code: 1, stdout: \\"\\", stderr: error.message });\\n return;\\n }\\n let stdout = \\"\\";\\n let stderr = \\"\\";\\n let settled = false;\\n const timer = setTimeout(\\n () => child.kill(\\"SIGTERM\\"),\\n options.timeoutMs || COMMAND_TIMEOUT_MS\\n );\\n const finish = (code, error) => {\\n if (settled) {\\n return;\\n }\\n settled = true;\\n clearTimeout(timer);\\n resolve({\\n code,\\n stdout,\\n stderr: [stderr.trim(), error].filter(Boolean).join(\\"\\\\n\\"),\\n });\\n };\\n child.stdout.on(\\"data\\", (chunk) => {\\n stdout += chunk.toString();\\n if (stdout.length > MAX_METADATA_BYTES) {\\n child.kill(\\"SIGTERM\\");\\n }\\n });\\n child.stderr.on(\\"data\\", (chunk) => {\\n stderr += chunk.toString();\\n if (stderr.length > MAX_METADATA_BYTES) {\\n child.kill(\\"SIGTERM\\");\\n }\\n });\\n child.on(\\"error\\", (error) => finish(1, error.message));\\n child.on(\\"close\\", (code) => finish(code ?? 1));\\n });\\n}\\n\\nfunction parseProtocolReport(raw) {\\n try {\\n const report = JSON.parse(raw);\\n if (\\n !isPlainObject(report) ||\\n report.schemaVersion !== 1 ||\\n typeof report.packageVersion !== \\"string\\" ||\\n !isPlainObject(report.protocol) ||\\n !Number.isInteger(report.protocol.version) ||\\n !Number.isInteger(report.protocol.minimumPluginVersion) ||\\n !Number.isInteger(report.protocol.maximumPluginVersion) ||\\n (report.capabilities !== undefined &&\\n (!Array.isArray(report.capabilities) ||\\n report.capabilities.some((value) => typeof value !== \\"string\\")))\\n ) {\\n return null;\\n }\\n return report;\\n } catch {\\n return null;\\n }\\n}\\n\\nfunction protocolCompatibility(report) {\\n if (!report) {\\n return { compatible: false, reason: \\"missing_protocol_handshake\\" };\\n }\\n const compatible =\\n report.protocol.minimumPluginVersion <= PLUGIN_PROTOCOL_VERSION &&\\n report.protocol.maximumPluginVersion >= PLUGIN_PROTOCOL_VERSION;\\n return {\\n compatible,\\n reason: compatible ? \\"compatible\\" : \\"protocol_version_skew\\",\\n };\\n}\\n\\nasync function inspectCandidate(candidate, options = {}) {\\n const executable = path.resolve(candidate.executable);\\n try {\\n const stat = await fsp.stat(executable);\\n if (!stat.isFile()) {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"not_a_file\\",\\n };\\n }\\n } catch {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"not_found\\",\\n };\\n }\\n\\n if (candidate.source === \\"plugin_runtime\\") {\\n if (\\n typeof candidate.expectedSha256 !== \\"string\\" ||\\n !SHA256_RE.test(candidate.expectedSha256)\\n ) {\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: false,\\n reason: \\"missing_checksum\\",\\n };\\n }\\n try {\\n const actualSha256 = sha256(await fsp.readFile(executable));\\n if (actualSha256 !== candidate.expectedSha256) {\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: false,\\n reason: \\"checksum_mismatch\\",\\n };\\n }\\n } catch {\\n return {\\n ...candidate,\\n executable,\\n available: false,\\n compatible: false,\\n reason: \\"checksum_unreadable\\",\\n };\\n }\\n }\\n\\n const result = await runCommand(executable, [\\"protocol\\", \\"--json\\"], options);\\n const report =\\n result.code === 0 ? parseProtocolReport(result.stdout.trim()) : null;\\n const compatibility = protocolCompatibility(report);\\n return {\\n ...candidate,\\n executable,\\n available: true,\\n compatible: compatibility.compatible,\\n reason: compatibility.reason,\\n packageVersion: report?.packageVersion,\\n protocol: report?.protocol,\\n platform: report?.runtime?.platform,\\n architecture: report?.runtime?.architecture,\\n capabilities: report?.capabilities || [],\\n };\\n}\\n\\nasync function discoverRuntime(options = {}) {\\n const policy = await runtimePolicy(options);\\n const inspected = [];\\n for (const candidate of await runtimeCandidates(options)) {\\n const result = await inspectCandidate(candidate, options);\\n inspected.push(result);\\n if (result.compatible) {\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n plugin: {\\n version: pluginVersion(),\\n protocolVersion: PLUGIN_PROTOCOL_VERSION,\\n },\\n policy,\\n selected: result,\\n compatible: true,\\n requiresFreshSession: false,\\n candidates: inspected,\\n };\\n }\\n }\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n plugin: {\\n version: pluginVersion(),\\n protocolVersion: PLUGIN_PROTOCOL_VERSION,\\n },\\n policy,\\n selected: null,\\n compatible: false,\\n requiresFreshSession: false,\\n candidates: inspected,\\n };\\n}\\n\\nfunction releaseTarget(\\n platform = process.platform,\\n architecture = process.arch\\n) {\\n if (\\n platform === \\"darwin\\" &&\\n (architecture === \\"arm64\\" || architecture === \\"x64\\")\\n ) {\\n return { platform: \\"darwin\\", architecture, extension: \\"\\" };\\n }\\n if (platform === \\"linux\\" && architecture === \\"x64\\") {\\n return { platform: \\"linux\\", architecture, extension: \\"\\" };\\n }\\n if (platform === \\"win32\\" && architecture === \\"x64\\") {\\n return { platform: \\"windows\\", architecture, extension: \\".exe\\" };\\n }\\n throw new Error(\\n `Unsupported plugin runtime target: ${platform}/${architecture}`\\n );\\n}\\n\\nfunction normalizeVersion(version) {\\n const normalized = version?.startsWith(\\"v\\") ? version.slice(1) : version;\\n if (!(normalized && SEMVER_RE.test(normalized))) {\\n throw new Error(\\"Runtime version must be an explicit semantic version.\\");\\n }\\n return normalized;\\n}\\n\\nfunction assertAllowedUrl(urlValue) {\\n const url = new URL(urlValue);\\n if (url.protocol !== \\"https:\\" || !ALLOWED_DOWNLOAD_HOSTS.has(url.hostname)) {\\n throw new Error(\\n \\"Runtime downloads are restricted to approved HTTPS release hosts.\\"\\n );\\n }\\n return url;\\n}\\n\\nfunction downloadBuffer(urlValue, options = {}) {\\n const maxBytes = options.maxBytes || MAX_METADATA_BYTES;\\n const redirectsRemaining = options.redirectsRemaining ?? 5;\\n const url = assertAllowedUrl(urlValue);\\n return new Promise((resolve, reject) => {\\n const request = https.get(\\n url,\\n {\\n headers: {\\n accept: options.accept || \\"application/octet-stream\\",\\n \\"user-agent\\": \\"fclt-codex-plugin\\",\\n },\\n },\\n (response) => {\\n if (\\n response.statusCode &&\\n response.statusCode >= 300 &&\\n response.statusCode < 400 &&\\n response.headers.location\\n ) {\\n response.resume();\\n if (redirectsRemaining <= 0) {\\n reject(new Error(\\"Runtime download exceeded the redirect limit.\\"));\\n return;\\n }\\n const redirected = new URL(response.headers.location, url).toString();\\n downloadBuffer(redirected, {\\n ...options,\\n redirectsRemaining: redirectsRemaining - 1,\\n }).then(resolve, reject);\\n return;\\n }\\n if (response.statusCode !== 200) {\\n response.resume();\\n reject(\\n new Error(\\n `Runtime download failed with HTTP ${response.statusCode}.`\\n )\\n );\\n return;\\n }\\n const declaredLength = Number(response.headers[\\"content-length\\"] || 0);\\n if (declaredLength > maxBytes) {\\n response.resume();\\n reject(new Error(\\"Runtime download exceeds the allowed size.\\"));\\n return;\\n }\\n const chunks = [];\\n let total = 0;\\n response.on(\\"data\\", (chunk) => {\\n total += chunk.length;\\n if (total > maxBytes) {\\n request.destroy(\\n new Error(\\"Runtime download exceeds the allowed size.\\")\\n );\\n return;\\n }\\n chunks.push(chunk);\\n });\\n response.on(\\"end\\", () => resolve(Buffer.concat(chunks)));\\n }\\n );\\n request.setTimeout(options.timeoutMs || DOWNLOAD_TIMEOUT_MS, () => {\\n request.destroy(new Error(\\"Runtime download timed out.\\"));\\n });\\n request.on(\\"error\\", reject);\\n });\\n}\\n\\nfunction sha256(bytes) {\\n return crypto.createHash(\\"sha256\\").update(bytes).digest(\\"hex\\");\\n}\\n\\nfunction checksumForAsset(checksums, assetName) {\\n for (const line of checksums.split(NEWLINE_RE)) {\\n const match = CHECKSUM_LINE_RE.exec(line.trim());\\n if (match?.[2] === assetName) {\\n return match[1].toLowerCase();\\n }\\n }\\n throw new Error(`Published checksums do not include ${assetName}.`);\\n}\\n\\nasync function withMutationLock(root, action) {\\n await fsp.mkdir(root, { recursive: true, mode: 0o700 });\\n const lockPath = await assertManagedPath(\\n path.join(root, \\"mutation.lock\\"),\\n root\\n );\\n let handle;\\n try {\\n handle = await fsp.open(lockPath, \\"wx\\", 0o600);\\n } catch (error) {\\n if (error?.code === \\"EEXIST\\") {\\n throw new Error(\\n \\"Another fclt plugin runtime mutation is already in progress.\\"\\n );\\n }\\n throw error;\\n }\\n try {\\n return await action();\\n } finally {\\n await handle.close();\\n await fsp.rm(lockPath, { force: true });\\n }\\n}\\n\\nasync function writeJsonAtomic(pathValue, value, root) {\\n const target = await assertManagedPath(pathValue, root);\\n await fsp.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });\\n const temporary = `${target}.tmp-${crypto.randomUUID()}`;\\n await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\\\\n`, {\\n mode: 0o600,\\n });\\n await fsp.rename(temporary, target);\\n}\\n\\nfunction releaseUrls(version, target) {\\n const tag = `v${version}`;\\n const assetName = `fclt-${version}-${target.platform}-${target.architecture}${target.extension}`;\\n const base = `https://github.com/${REPOSITORY}/releases/download/${tag}`;\\n return {\\n tag,\\n assetName,\\n binaryUrl: `${base}/${assetName}`,\\n checksumUrl: `${base}/SHA256SUMS`,\\n };\\n}\\n\\nfunction releaseMetadataUrl(version) {\\n return `https://api.github.com/repos/${REPOSITORY}/releases/tags/v${version}`;\\n}\\n\\nfunction releaseAssets(metadata, version, target) {\\n if (\\n !isPlainObject(metadata) ||\\n metadata.tag_name !== `v${version}` ||\\n !Array.isArray(metadata.assets)\\n ) {\\n throw new Error(\\n \\"Release metadata does not match the requested immutable tag.\\"\\n );\\n }\\n const expected = releaseUrls(version, target);\\n const findAsset = (name) =>\\n metadata.assets.find(\\n (asset) =>\\n isPlainObject(asset) &&\\n asset.name === name &&\\n typeof asset.browser_download_url === \\"string\\"\\n );\\n const binary = findAsset(expected.assetName);\\n const checksums = findAsset(\\"SHA256SUMS\\");\\n if (!(binary && checksums)) {\\n throw new Error(\\n \\"Release metadata is missing the required runtime or checksum asset.\\"\\n );\\n }\\n assertAllowedUrl(binary.browser_download_url);\\n assertAllowedUrl(checksums.browser_download_url);\\n return { binary, checksums, expected };\\n}\\n\\nfunction verifyPublishedDigest(asset, bytes) {\\n if (typeof asset.digest !== \\"string\\" || !asset.digest.trim()) {\\n return null;\\n }\\n const [algorithm, expected] = asset.digest.toLowerCase().split(\\":\\");\\n if (algorithm !== \\"sha256\\" || !SHA256_RE.test(expected || \\"\\")) {\\n throw new Error(`Release asset ${asset.name} has an unsupported digest.`);\\n }\\n const actual = sha256(bytes);\\n if (actual !== expected) {\\n throw new Error(\\n `Release asset ${asset.name} does not match its published digest.`\\n );\\n }\\n return asset.digest.toLowerCase();\\n}\\n\\nasync function resolveLatestVersion(fetchBuffer = downloadBuffer) {\\n const bytes = await fetchBuffer(\\n `https://api.github.com/repos/${REPOSITORY}/releases/latest`,\\n {\\n maxBytes: MAX_METADATA_BYTES,\\n accept: \\"application/vnd.github+json\\",\\n }\\n );\\n const metadata = JSON.parse(bytes.toString(\\"utf8\\"));\\n if (!isPlainObject(metadata) || typeof metadata.tag_name !== \\"string\\") {\\n throw new Error(\\"Latest release metadata did not include a tag.\\");\\n }\\n return normalizeVersion(metadata.tag_name);\\n}\\n\\nasync function checkRuntimeUpdate(options = {}) {\\n const discovery = await discoverRuntime(options);\\n if (!discovery.policy.updateChecksEnabled) {\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n action: \\"check\\",\\n skipped: true,\\n reason: \\"update_checks_disabled\\",\\n currentVersion: discovery.selected?.packageVersion || null,\\n pinnedVersion: discovery.policy.pinnedVersion,\\n mutates: false,\\n };\\n }\\n const latestVersion =\\n discovery.policy.pinnedVersion ||\\n (await resolveLatestVersion(options.fetchBuffer || downloadBuffer));\\n return {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n action: \\"check\\",\\n currentVersion: discovery.selected?.packageVersion || null,\\n latestVersion,\\n channel: discovery.policy.pinnedVersion ? \\"pinned\\" : \\"latest\\",\\n updateAvailable: discovery.selected?.packageVersion !== latestVersion,\\n selected: discovery.selected,\\n mutates: false,\\n };\\n}\\n\\nasync function stageRuntime(options) {\\n if (options.approve !== true) {\\n throw new Error(\\"Staging a runtime download requires approve=true.\\");\\n }\\n const version = normalizeVersion(options.version);\\n const target = releaseTarget(options.platform, options.architecture);\\n const root = runtimeStateRoot(options.env, options.platform);\\n const fetchBuffer = options.fetchBuffer || downloadBuffer;\\n const urls = releaseUrls(version, target);\\n const policy = await runtimePolicy(options);\\n if (policy.pinnedVersion && policy.pinnedVersion !== version) {\\n throw new Error(`Runtime policy is pinned to ${policy.pinnedVersion}.`);\\n }\\n\\n return await withMutationLock(root, async () => {\\n const metadataBytes = await fetchBuffer(releaseMetadataUrl(version), {\\n maxBytes: MAX_METADATA_BYTES,\\n accept: \\"application/vnd.github+json\\",\\n });\\n const metadata = JSON.parse(metadataBytes.toString(\\"utf8\\"));\\n const assets = releaseAssets(metadata, version, target);\\n const [checksumBytes, binaryBytes] = await Promise.all([\\n fetchBuffer(assets.checksums.browser_download_url, {\\n maxBytes: MAX_METADATA_BYTES,\\n }),\\n fetchBuffer(assets.binary.browser_download_url, {\\n maxBytes: MAX_BINARY_BYTES,\\n }),\\n ]);\\n const checksumDigest = verifyPublishedDigest(\\n assets.checksums,\\n checksumBytes\\n );\\n const binaryDigest = verifyPublishedDigest(assets.binary, binaryBytes);\\n const expectedSha256 = checksumForAsset(\\n checksumBytes.toString(\\"utf8\\"),\\n urls.assetName\\n );\\n const actualSha256 = sha256(binaryBytes);\\n if (expectedSha256 !== actualSha256) {\\n throw new Error(\\n \\"Downloaded runtime checksum does not match the published SHA256SUMS entry.\\"\\n );\\n }\\n\\n const stageDir = await assertManagedPath(\\n path.join(root, \\"staged\\", version),\\n root\\n );\\n await fsp.rm(stageDir, { recursive: true, force: true });\\n await fsp.mkdir(stageDir, { recursive: true, mode: 0o700 });\\n const executable = await assertManagedPath(\\n path.join(stageDir, target.platform === \\"windows\\" ? \\"fclt.exe\\" : \\"fclt\\"),\\n root\\n );\\n await fsp.writeFile(executable, binaryBytes, { mode: 0o700 });\\n if (target.platform !== \\"windows\\") {\\n await fsp.chmod(executable, 0o700);\\n }\\n\\n const inspected = await inspectCandidate(\\n { executable, source: \\"staged_plugin_runtime\\" },\\n { env: options.env, timeoutMs: options.timeoutMs }\\n );\\n if (!inspected.compatible || inspected.packageVersion !== version) {\\n await fsp.rm(stageDir, { recursive: true, force: true });\\n throw new Error(\\n \\"Staged runtime failed version or protocol verification.\\"\\n );\\n }\\n\\n const manifest = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n version,\\n tag: urls.tag,\\n assetName: urls.assetName,\\n executable,\\n sha256: actualSha256,\\n source: {\\n repository: REPOSITORY,\\n releaseMetadataUrl: releaseMetadataUrl(version),\\n binaryUrl: assets.binary.browser_download_url,\\n binaryAssetId: assets.binary.id ?? null,\\n binaryDigest,\\n checksumUrl: assets.checksums.browser_download_url,\\n checksumAssetId: assets.checksums.id ?? null,\\n checksumDigest,\\n },\\n protocol: inspected.protocol,\\n platform: target.platform,\\n architecture: target.architecture,\\n stagedAt: new Date().toISOString(),\\n };\\n await writeJsonAtomic(path.join(stageDir, \\"manifest.json\\"), manifest, root);\\n return { action: \\"stage\\", mutatesActiveRuntime: false, manifest };\\n });\\n}\\n\\nasync function verifyManifestExecutable(manifest, root, expectedParent) {\\n if (\\n !isPlainObject(manifest) ||\\n manifest.schemaVersion !== STATE_SCHEMA_VERSION ||\\n typeof manifest.version !== \\"string\\" ||\\n typeof manifest.executable !== \\"string\\" ||\\n typeof manifest.sha256 !== \\"string\\" ||\\n !SHA256_RE.test(manifest.sha256)\\n ) {\\n throw new Error(\\"Runtime manifest is missing required verification data.\\");\\n }\\n const executable = path.resolve(manifest.executable);\\n if (!isSubpath(executable, expectedParent)) {\\n throw new Error(\\n \\"Runtime manifest executable escapes its expected directory.\\"\\n );\\n }\\n await assertManagedPath(executable, root);\\n const bytes = await fsp.readFile(executable);\\n if (sha256(bytes) !== manifest.sha256) {\\n throw new Error(\\"Runtime manifest checksum does not match its executable.\\");\\n }\\n const inspected = await inspectCandidate({\\n executable,\\n source: \\"plugin_runtime\\",\\n expectedSha256: manifest.sha256,\\n });\\n if (!inspected.compatible || inspected.packageVersion !== manifest.version) {\\n throw new Error(\\n \\"Runtime manifest executable failed protocol verification.\\"\\n );\\n }\\n return inspected;\\n}\\n\\nasync function setRuntimePolicy(options = {}) {\\n if (options.approve !== true) {\\n throw new Error(\\"Changing runtime update policy requires approve=true.\\");\\n }\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const current = await runtimePolicy(options);\\n const next = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n updateChecksEnabled:\\n typeof options.updateChecksEnabled === \\"boolean\\"\\n ? options.updateChecksEnabled\\n : current.updateChecksEnabled,\\n pinnedVersion: options.clearPin\\n ? null\\n : options.pinnedVersion\\n ? normalizeVersion(options.pinnedVersion)\\n : current.pinnedVersion,\\n updatedAt: new Date().toISOString(),\\n };\\n await writeJsonAtomic(path.join(root, \\"policy.json\\"), next, root);\\n return { action: \\"policy\\", previous: current, policy: next };\\n });\\n}\\n\\nasync function applyStagedRuntime(options) {\\n if (options.approve !== true) {\\n throw new Error(\\"Applying a runtime requires approve=true.\\");\\n }\\n const version = normalizeVersion(options.version);\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const stageDir = path.join(root, \\"staged\\", version);\\n const manifest = await readJson(path.join(stageDir, \\"manifest.json\\"));\\n if (!manifest) {\\n throw new Error(`No staged runtime exists for ${version}.`);\\n }\\n if (options.expectedSha256 !== manifest.sha256) {\\n throw new Error(\\n \\"Staged runtime precondition failed: expected checksum changed.\\"\\n );\\n }\\n await verifyManifestExecutable(manifest, root, stageDir);\\n\\n const activePath = path.join(root, \\"active.json\\");\\n const previous = await readJson(activePath);\\n const versionDir = await assertManagedPath(\\n path.join(root, \\"versions\\", version),\\n root\\n );\\n await fsp.mkdir(versionDir, { recursive: true, mode: 0o700 });\\n const executable = await assertManagedPath(\\n path.join(versionDir, path.basename(manifest.executable)),\\n root\\n );\\n const temporary = `${executable}.tmp-${crypto.randomUUID()}`;\\n await fsp.copyFile(manifest.executable, temporary);\\n if (process.platform !== \\"win32\\") {\\n await fsp.chmod(temporary, 0o700);\\n }\\n await fsp.rename(temporary, executable);\\n const activeManifest = {\\n ...manifest,\\n executable,\\n activatedAt: new Date().toISOString(),\\n previous:\\n typeof previous?.version === \\"string\\" &&\\n typeof previous?.executable === \\"string\\"\\n ? {\\n version: previous.version,\\n executable: previous.executable,\\n sha256: previous.sha256,\\n }\\n : null,\\n };\\n await writeJsonAtomic(activePath, activeManifest, root);\\n const inspected = await verifyManifestExecutable(\\n activeManifest,\\n root,\\n versionDir\\n );\\n return {\\n action: \\"apply\\",\\n active: inspected,\\n previous: activeManifest.previous,\\n rollbackAvailable: Boolean(activeManifest.previous),\\n requiresFreshSession: false,\\n };\\n });\\n}\\n\\nasync function rollbackRuntime(options = {}) {\\n if (options.approve !== true) {\\n throw new Error(\\"Rolling back a runtime requires approve=true.\\");\\n }\\n const root = runtimeStateRoot(options.env, options.platform);\\n return await withMutationLock(root, async () => {\\n const activePath = path.join(root, \\"active.json\\");\\n const active = await readJson(activePath);\\n if (!isPlainObject(active?.previous)) {\\n throw new Error(\\n \\"The active plugin runtime does not have a retained rollback target.\\"\\n );\\n }\\n if (\\n options.expectedActiveVersion &&\\n options.expectedActiveVersion !== active.version\\n ) {\\n throw new Error(\\n \\"Runtime rollback precondition failed: active version changed.\\"\\n );\\n }\\n const previous = {\\n schemaVersion: STATE_SCHEMA_VERSION,\\n version: active.previous.version,\\n executable: active.previous.executable,\\n sha256: active.previous.sha256,\\n previous: {\\n version: active.version,\\n executable: active.executable,\\n sha256: active.sha256,\\n },\\n rolledBackAt: new Date().toISOString(),\\n };\\n const previousDir = path.dirname(path.resolve(previous.executable));\\n const inspected = await verifyManifestExecutable(\\n previous,\\n root,\\n previousDir\\n );\\n await writeJsonAtomic(activePath, previous, root);\\n return {\\n action: \\"rollback\\",\\n active: inspected,\\n rolledBackFrom: active.version,\\n rollbackAvailable: true,\\n requiresFreshSession: false,\\n };\\n });\\n}\\n\\nmodule.exports = {\\n PLUGIN_PROTOCOL_VERSION,\\n applyStagedRuntime,\\n assertManagedPath,\\n checkRuntimeUpdate,\\n checksumForAsset,\\n discoverRuntime,\\n downloadBuffer,\\n normalizeVersion,\\n parseProtocolReport,\\n pluginVersion,\\n protocolCompatibility,\\n releaseTarget,\\n rollbackRuntime,\\n runCommand,\\n runtimeCandidates,\\n runtimePolicy,\\n runtimeStateRoot,\\n setRuntimePolicy,\\n sha256,\\n stageRuntime,\\n};\\n","skills/fclt-capability-review/SKILL.md":"---\\nname: fclt-capability-review\\ndescription: Inspect fclt capability roots, docs, snippets, skills, agents, MCP, and automations.\\ntags: [fclt, capability, review, inventory]\\n---\\n\\n# fclt-capability-review\\n\\n## When To Use\\nUse this skill when Codex needs to understand what capability exists before changing it.\\n\\nUse it for:\\n\\n- checking global and project `.ai` roots\\n- finding relevant skills, snippets, instructions, agents, MCP servers, or automations\\n- deciding whether a change belongs in global or project scope\\n- checking whether managed rendering is enabled or needed\\n- reviewing public/private boundaries before publishing docs or pack assets\\n\\n## Workflow\\n\\n```bash\\nfclt status --json\\nfclt inventory --json\\nfclt list skills\\nfclt list instructions\\nfclt list snippets\\nfclt graph AGENTS.global.md\\n```\\n\\nFor project work:\\n\\n```bash\\nfclt status --project --json\\nfclt inventory --project --json\\n```\\n\\n## Rules\\n\\n- Read existing repo guidance before proposing project capability.\\n- Use project scope for repo-specific commands, tests, architecture, or team workflow.\\n- Use global scope only for broadly reusable behavior.\\n- Keep generated state and review artifacts out of repo-local `.ai`.\\n- Prefer adding or updating the smallest unit: instruction, snippet, skill, agent, MCP config, or automation.\\n- Treat engineering implementation as task work. Do not force product changes\\n through capability evolution merely because evolution tools exist.\\n- Before recommending mutation, state the observed problem, evidence, why the\\n proposed capability is the correct target, risk class, expected outcome,\\n verification plan, assumptions, and recovery route.\\n- Never silently overwrite authored capability or confuse generated/rendered\\n output with canonical source.\\n\\n## Output\\n\\n- capability roots found\\n- relevant assets\\n- scope recommendation\\n- missing or stale capability\\n- safe next command\\n- deliberately withheld operations or missing safer API\\n- verification and undo path for any proposed mutation\\n","skills/fclt-evolution/SKILL.md":"---\\nname: fclt-evolution\\ndescription: Turn repeated fclt writebacks into reviewed capability changes.\\ntags: [fclt, evolution, proposals, capability]\\n---\\n\\n# fclt-evolution\\n\\n## When To Use\\nUse this skill when repeated writebacks, stale canonical assets, or a clearly missing capability should become a concrete proposal.\\n\\nDo not use it for a single weak preference or speculative idea.\\n\\n## Workflow\\n\\n1. Review signal:\\n\\n```bash\\nfclt ai review reconcile --since --until --json\\nfclt ai writeback group --by asset\\nfclt ai writeback summarize --by domain\\nfclt ai evolve list\\n```\\n\\nFor an enabled scheduled loop, inspect its durable queue and observed scheduler\\nhealth with `fclt ai loop status --json`. Use `fclt ai loop run --dry-run\\n--json` for a fresh incremental scan that does not advance cursors or write\\nreconciliation or loop state. The full queue is authoritative; the notification\\ndelta intentionally suppresses unchanged items.\\n\\n2. Assess proposal readiness before mutating state:\\n\\n```bash\\nfclt ai evolve assess --asset --json\\n```\\n\\nUse the assessment recommendation as the decision checkpoint:\\n\\n- `reconcile_sources`: run the bounded read-only source review; writebacks alone cannot prove the window is empty.\\n- `review_reconciled_signals`: review correlated dispositions and linked work without creating one proposal per ticket.\\n- `no_mutation`: do not change capability state; ask for a target or evidence.\\n- `record_more_writeback`: explain what recurrence would justify evolution and record a new writeback only if there is fresh concrete evidence.\\n- `propose`: ask before running the proposal command, then create the smallest target-specific proposal.\\n- `review_existing_proposal`: inspect or revise the existing proposal instead of creating a duplicate.\\n\\n3. Propose only when evidence is strong enough:\\n\\n```bash\\nfclt ai evolve propose\\n```\\n\\n4. Draft and inspect:\\n\\n```bash\\nfclt ai evolve draft EV-00001\\nfclt ai evolve review EV-00001\\n```\\n\\n5. Accept/apply only when scope, target, and evidence are correct:\\n\\n```bash\\nfclt ai evolve accept EV-00001\\nfclt ai evolve apply EV-00001\\n```\\n\\n6. Verify the outcome after the producing loop has had a real chance to run:\\n\\n```bash\\nfclt ai writeback link WB-00001 --issue TEAM-123\\nfclt ai writeback disposition WB-00001 --type task --target TEAM-123\\nfclt ai evolve verify EV-00001 --effectiveness improved --evidence test:post-apply\\n```\\n\\nApply is not completion. Do not resolve source writebacks until post-apply evidence shows the\\nintended behavior improved. Treat recurrence as unchanged or regressed evidence linked to the same\\nevolution, not as an unrelated singleton.\\n\\n## Proposal Kinds\\n\\n- `update_asset`\\n- `create_asset`\\n- `extract_snippet`\\n- `add_skill`\\n- `promote_asset`\\n\\n## Rules\\n\\n- Prefer the smallest valid proposal kind.\\n- Keep project-specific behavior project-scoped until reuse is proven.\\n- Ask for approval before applying global instructions, global skills, plugin behavior, or other broad shared surfaces.\\n- Reject or park proposals that are stale, duplicated, vague, or unsupported.\\n- Use the operator\'s task system for executable implementation work that needs owner, priority, or state.\\n- A no-op answer must still be useful: include the evidence grade, missing signal, next writeback target, and exact approval boundary.\\n- State the problem, source evidence, reason for the selected target, risk,\\n expected outcome, verification plan, assumptions, and undo path before any\\n review-producing or mutating action.\\n- Preview and verify the exact scope. Never overwrite authored capability\\n silently or treat generated/rendered output as the canonical target.\\n- Canonical apply and cross-scope promotion are unavailable through the plugin\\n until a transaction-safe API can return tested rollback data. Do not bypass\\n that boundary with shell or arbitrary CLI arguments.\\n- The plugin exposes only closed-schema loop status and preview actions.\\n Scheduler enable/disable/run and external tracker mutation remain outside the\\n MCP surface.\\n\\n## Output\\n\\n- proposals reviewed\\n- repeated signal\\n- assessment recommendation\\n- proposal created or updated\\n- approvals needed\\n- apply/reject/no-op rationale\\n- actual changed records/artifacts, verification result, and recovery route\\n","skills/fclt-setup/SKILL.md":"---\\nname: fclt-setup\\ndescription: Install, update, inspect, and initialize fclt from Codex.\\ntags: [fclt, setup, codex, onboarding]\\n---\\n\\n# fclt-setup\\n\\n## When To Use\\nUse this skill when a user wants Codex to install, update, configure, inspect, or repair fclt.\\n\\nUse it for:\\n\\n- checking whether `fclt` is installed and current\\n- initializing global `~/.ai`\\n- discovering repositories and enrolling a reviewed minimal project layer\\n- installing or refreshing the built-in operating-model pack\\n- checking setup health with `doctor`\\n- finding canonical, generated, runtime, and review paths\\n\\n## Workflow\\n\\n1. Bootstrap the global loop with one idempotent command:\\n\\n```bash\\nfclt setup\\n```\\n\\nThis initializes or safely updates global capability, prepares writeback/evolution review state,\\nand installs the Codex plugin when Codex is available. It does not initialize the current\\nrepository.\\n\\n2. Inspect runtime selection and compatibility with `fclt_runtime` action\\n `status`. Report the selected executable, version, source, protocol\\n compatibility, and fresh-session state.\\n\\nIf no compatible runtime is available, use the staged lifecycle:\\n\\n- `check` is read-only\\n- `stage` requires an explicit version and approval, but does not activate it\\n- `apply` requires approval plus the staged checksum precondition\\n- `rollback` verifies and restores the retained prior runtime\\n\\nNever curl-pipe code, use an unverified mutable URL, or replace an existing\\nglobal installation silently.\\n\\n3. Check current setup state and exact repair actions:\\n\\n```bash\\nfclt --version\\nfclt paths --json\\nfclt doctor --json\\n```\\n\\nThrough MCP, call `fclt_setup` with an explicit `global` or\\n`global_and_project` scope. Project setup also requires the exact `cwd`.\\nPreview is the default; apply requires both `dryRun: false` and\\n`approve: true`. `global_and_project` returns a no-write project enrollment\\nplan; project application still uses the typed CLI plan-hash contract below.\\n\\n4. For advanced manual recovery, initialize global capability when missing:\\n\\n```bash\\nfclt templates init operating-model --global\\n```\\n\\n5. Discover candidate repositories only beneath explicit roots:\\n\\n```bash\\nfclt projects discover --root ~/dev --since 30d --json\\n```\\n\\nDiscovery is bounded and read-only. Review duplicate clone/worktree groups,\\ndirty state, existing guidance, stable portfolio identity, and the separate\\ncheckout/worktree execution identity before selecting a project. Never\\nbulk-enroll the discovery result.\\n\\n6. Preview the exact minimal project enrollment plan:\\n\\n```bash\\nfclt project init --project-root /path/to/repo --json\\n```\\n\\nThe minimal layer is `.ai/.gitignore` plus `.ai/config.toml`. It does not\\ninstall the operating pack, enable managed rendering, schedule a loop, or copy\\n`AGENTS.md`/`CLAUDE.md`. Review every canonical, generated, and machine-local\\nwrite plus the rollback command.\\n\\nIf project guidance should be adopted, name each canonical file explicitly:\\n\\n```bash\\nfclt project init --project-root /path/to/repo \\\\\\n --guidance AGENTS.md --json\\n```\\n\\nGuidance adoption is reference-only. fclt previews the full content and hash,\\nand refuses untracked, modified, `assume-unchanged`, `skip-worktree`,\\nsecret-shaped, or machine-path-bearing input. Cleanliness requires identical\\nworktree, index, and `HEAD` blobs.\\n\\n7. Apply only the unchanged reviewed plan:\\n\\n```bash\\nfclt project init --project-root /path/to/repo \\\\\\n --apply --plan-sha --json\\n```\\n\\nIf options, source files, or preconditions change, discard the old hash and\\npreview again. Apply serializes portfolio registry mutations, isolates\\nlocation-bearing generated state per checkout/worktree, refuses symlinked\\ngenerated targets, and publishes the receipt only after the registry commit.\\n\\n8. Inspect health, coverage, and lifecycle:\\n\\n```bash\\nfclt projects status --root /path/to/repo --json\\nfclt project disable --project-root /path/to/repo --json\\nfclt project rollback --receipt --json\\n```\\n\\nDisable and remove decisions preserve canonical files, receipts, and review\\nhistory. Rollback previews by default and refuses drift.\\n\\n9. Install the full operating pack only when explicitly requested:\\n\\n```bash\\nfclt templates init operating-model --project --dry-run\\nfclt templates init operating-model --project\\n```\\n\\nThis is distinct from minimal enrollment and does not seed project\\n`AGENTS.global.md` from repository guidance.\\n\\n10. Refresh global pack defaults non-destructively:\\n\\n```bash\\nfclt templates init operating-model --global --update --dry-run\\nfclt templates init operating-model --global --update\\n```\\n\\n11. Use `--force` only when the user explicitly wants to replace local edits.\\n\\n## Rules\\n\\n- Preserve existing `AGENTS.md`, `CLAUDE.md`, and `AGENTS.global.md` guidance.\\n- Never infer guidance adoption from filenames or copy repository guidance into\\n `.ai/AGENTS.global.md`.\\n- Treat root `AGENTS.md` or `CLAUDE.md` as canonical repository guidance unless\\n the user explicitly chooses another tracked, clean source.\\n- Write the protective `.ai/.gitignore` before generated state.\\n- Keep canonical project files separate from machine-local generated indexes,\\n registries, receipts, and scheduling state.\\n- Keep minimal enrollment separate from the full operating pack and managed\\n rendering.\\n- Treat `doctor --json` issues as setup facts, not user-facing blame.\\n- Treat Codex plugin registration as weaker evidence than fresh-session tool discovery.\\n- Treat external trackers as separate integrations. Core readiness depends only on configured local evidence coverage, not a vendor plugin or token.\\n- Prefer temp-root smoke tests for install/update behavior.\\n- Do not enable managed rendering unless the user wants fclt to write tool homes.\\n- Preview before mutation and state the exact global/project/plugin target.\\n- Do not report a staged runtime or installed plugin as active until the active\\n handshake and fresh-session discovery have been verified.\\n\\n## Output\\n\\n- current installed version\\n- setup health\\n- paths that matter\\n- commands run\\n- what changed\\n- problem, evidence, reason, target, risk, and expected outcome\\n- verification performed and its actual result\\n- assumptions and fresh-session state\\n- exact undo or rollback path\\n- what still needs approval\\n","skills/fclt-writeback/SKILL.md":"---\\nname: fclt-writeback\\ndescription: Record and review fclt writebacks from real agent work.\\ntags: [fclt, writeback, learning, feedback-loop]\\n---\\n\\n# fclt-writeback\\n\\n## When To Use\\nUse this skill when work reveals durable friction, missing context, weak verification, stale guidance, repeated success, or a capability gap.\\n\\nWriteback is for preserving signal. It is not for every preference or one-off annoyance.\\n\\n## Workflow\\n\\n1. Decide scope:\\n\\n- `project` when the learning depends on a repo, test harness, architecture, or workflow.\\n- `global` when the learning applies across projects or shared tool behavior.\\n\\n2. Choose the smallest target:\\n\\n- instruction\\n- snippet\\n- skill\\n- agent\\n- MCP/tool config\\n- automation\\n\\n3. Record writeback when the target and evidence are clear:\\n\\n```bash\\nfclt ai writeback add \\\\\\n --kind missing_context \\\\\\n --category opportunity \\\\\\n --summary \\"Project verification guidance was not discoverable\\" \\\\\\n --details \\"The task had to reconstruct the command from CI configuration\\" \\\\\\n --impact \\"Verification took longer and could have selected the wrong harness\\" \\\\\\n --attempted-workaround \\"Inspected package scripts and CI\\" \\\\\\n --desired-outcome \\"The supported verification command is available at task start\\" \\\\\\n --sensitivity internal \\\\\\n --evidence session: \\\\\\n --asset @project/instructions/TESTING.md\\n```\\n\\n4. Review current signal:\\n\\n```bash\\nfclt ai writeback list\\nfclt ai writeback group --by asset\\nfclt ai writeback summarize --by domain\\nfclt ai loop activity --project\\n```\\n\\n## Rules\\n\\n- Prefer one high-signal writeback over several weak ones.\\n- Include concrete evidence when possible.\\n- Capture concise context, impact, attempted workaround, desired outcome, and\\n sensitivity when they improve review quality.\\n- Never capture hidden chain-of-thought, raw transcripts, unbounded logs,\\n secrets, tokens, or credential-bearing payloads. Reference the smallest\\n redacted external evidence identifier instead.\\n- Do not copy private project detail into global writebacks.\\n- Use task tracking for executable product/tooling work; use writeback for reusable operating-layer learning.\\n- If the same signal repeats and the target is clear, hand off to `fclt-evolution`.\\n- State the observed problem, evidence, target, reason, expected outcome, and\\n assumptions before recording.\\n- Do not capture secrets, private tokens, or raw sensitive payloads as evidence.\\n- For lifecycle mutations, use an explicit scope and expected prior state.\\n Report the journal/review evidence and the available undo transition.\\n\\n## Output\\n\\n- writeback id or no-op rationale\\n- scope\\n- target asset\\n- evidence summary\\n- whether this is ready for evolution\\n- risk class and approval boundary\\n- actual changed records/artifacts\\n- verification result and recovery route\\n"}' ) as Record; export const BUILTIN_FCLT_CODEX_PLUGIN_BINARY_FILES = JSON.parse( diff --git a/src/doctor.test.ts b/src/doctor.test.ts index a677cb80..99cd7018 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -1858,7 +1858,7 @@ test("doctor --json flags invalid canonical global guidance", async () => { canonicalTemplateRefsIssuePaths: string[]; }; issues: Array<{ code: string }>; - actions: Array<{ id: string; command: string }>; + actions: Array<{ id: string; command: string; risk: string }>; }; expect(report.health.state).toBe("canonical_source_attention"); expect(report.health.ok).toBe(false); @@ -2348,6 +2348,7 @@ test("doctor --json flags generated-only project ai roots without exiting nonzer expect.objectContaining({ id: "init-project-ai", command: `fclt project init --project-root '${projectRoot}'`, + risk: "read_only", }) ); } finally { diff --git a/src/doctor.ts b/src/doctor.ts index ede1cf52..d10d70a0 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -852,7 +852,7 @@ const UNRESOLVED_REFS_TEMPLATE_RE = /\$\{refs\.([A-Za-z0-9_.-]+)\}/g; const FCLTY_BLOCK_RE = /([\s\S]*?)/g; -function shellQuote(value: string): string { +export function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } @@ -1656,7 +1656,7 @@ export async function buildDoctorReport(opts?: { id: "init-project-ai", label: "Preview minimal project enrollment", command: projectAiInitCommand(rootDir), - risk: "canonical_write", + risk: "read_only", }); } diff --git a/src/index-builder.ts b/src/index-builder.ts index ffdda6b1..ec2c8bc6 100644 --- a/src/index-builder.ts +++ b/src/index-builder.ts @@ -1191,10 +1191,11 @@ function renderedTargetNodeName( async function readManagedState( homeDir: string, - rootDir: string + rootDir: string, + machineStateDir?: string ): Promise { const statePath = join( - facultMachineStateDir(homeDir, rootDir), + machineStateDir ?? facultMachineStateDir(homeDir, rootDir), "managed.json" ); try { @@ -1575,6 +1576,8 @@ interface BuildIndexOptions { rootDir?: string; /** Override home directory for generated state placement (useful for tests). */ homeDir?: string; + /** Use an enrollment-validated machine state destination. */ + machineStateDir?: string; } interface BuildIndexResult { @@ -1592,8 +1595,12 @@ async function buildIndexInternal( const homeDir = opts?.homeDir ?? process.env.HOME ?? ""; const rootDir = opts?.rootDir ?? (homeDir ? facultRootDir(homeDir) : facultRootDir()); - const outputPath = facultAiIndexPath(homeDir, rootDir); - const graphPath = facultAiGraphPath(homeDir, rootDir); + const outputPath = opts?.machineStateDir + ? join(opts.machineStateDir, "ai", "index.json") + : facultAiIndexPath(homeDir, rootDir); + const graphPath = opts?.machineStateDir + ? join(opts.machineStateDir, "ai", "graph.json") + : facultAiGraphPath(homeDir, rootDir); const projectRoot = projectRootFromAiRoot(rootDir, homeDir); const projectSlug = projectSlugFromAiRoot(rootDir, homeDir); const currentScope: IndexedSource = projectRoot @@ -1609,7 +1616,11 @@ async function buildIndexInternal( scope: "global", rootDir, }; - const managedState = await readManagedState(homeDir, rootDir); + const managedState = await readManagedState( + homeDir, + rootDir, + opts?.machineStateDir + ); let previousIndex: Record | null = null; if (!force) { diff --git a/src/manage.test.ts b/src/manage.test.ts index cedd988d..499e5604 100644 --- a/src/manage.test.ts +++ b/src/manage.test.ts @@ -156,7 +156,7 @@ async function setupMutatedCodexPlugin(mutation: InstalledPluginMutation) { codexBin, home, installedMutation: mutation, - selectedVersion: "0.1.2", + selectedVersion: "0.1.3", }); return { home, @@ -3118,7 +3118,7 @@ describe("syncManagedTools", () => { await writeCodexPluginStub({ codexBin, home, - selectedVersion: "0.1.2", + selectedVersion: "0.1.3", }); await writeJson(join(home, ".agents", "plugins", "marketplace.json"), { name: "local", @@ -3162,7 +3162,7 @@ describe("syncManagedTools", () => { "cache", "local", "fclt", - "0.1.1" + "0.1.2" ); await mkdir(oldCache, { recursive: true }); await Bun.write(join(oldCache, "selected.txt"), "legacy-wrapper\n"); @@ -3175,15 +3175,15 @@ describe("syncManagedTools", () => { await writeCodexPluginStub({ codexBin, home, - listedVersion: "0.1.1", - selectedVersion: "0.1.2", + listedVersion: "0.1.2", + selectedVersion: "0.1.3", }); const result = await setupCodexPlugin({ homeDir: home, codexBin }); expect(result.codexInstall.status).toBe("failed"); expect(result.codexInstall.stderr).toContain( - "expected fclt@local version 0.1.2 to be installed and enabled" + "expected fclt@local version 0.1.3 to be installed and enabled" ); expect(result.codexInstall.verificationCommand).toEqual([ codexBin, @@ -3205,14 +3205,14 @@ describe("syncManagedTools", () => { "cache", "local", "fclt", - "0.1.2", + "0.1.3", "selected.txt" ) ).exists() ).toBe(false); }); - it("verifies the current selected plugin payload with an old cache present", async () => { + it("upgrades to plugin 0.1.3 while preserving a cached 0.1.2 payload", async () => { const home = await createTempDir(); const oldCache = join( home, @@ -3221,7 +3221,7 @@ describe("syncManagedTools", () => { "cache", "local", "fclt", - "0.1.1" + "0.1.2" ); await mkdir(oldCache, { recursive: true }); await Bun.write(join(oldCache, "selected.txt"), "legacy-wrapper\n"); @@ -3234,7 +3234,7 @@ describe("syncManagedTools", () => { await writeCodexPluginStub({ codexBin, home, - selectedVersion: "0.1.2", + selectedVersion: "0.1.3", }); const result = await setupCodexPlugin({ homeDir: home, codexBin }); @@ -3245,7 +3245,7 @@ describe("syncManagedTools", () => { "cache", "local", "fclt", - "0.1.2" + "0.1.3" ); expect(result.codexInstall.status).toBe("succeeded"); @@ -3253,7 +3253,7 @@ describe("syncManagedTools", () => { (await Bun.file( join(installedPath, ".codex-plugin", "plugin.json") ).json()) as { version: string } - ).toMatchObject({ version: "0.1.2" }); + ).toMatchObject({ version: "0.1.3" }); expect( await Bun.file(join(installedPath, "scripts", "fclt-mcp.cjs")).text() ).toContain("audit-read-only-v1"); @@ -3274,7 +3274,7 @@ describe("syncManagedTools", () => { codexBin, home, installedMutation: "symlink", - selectedVersion: "0.1.2", + selectedVersion: "0.1.3", }); const result = await setupCodexPlugin({ homeDir: home, codexBin }); @@ -3312,7 +3312,7 @@ describe("syncManagedTools", () => { codexBin, home, installedMutation: "unreadable-subtree", - selectedVersion: "0.1.2", + selectedVersion: "0.1.3", }); const result = await setupCodexPlugin({ homeDir: home, codexBin }); @@ -3333,7 +3333,7 @@ describe("syncManagedTools", () => { "cache", "local", "fclt", - "0.1.2", + "0.1.3", "unexpected-private" ), 0o700 diff --git a/src/paths.ts b/src/paths.ts index 52107762..01b97b0c 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,20 +1,27 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; -import { readdirSync, readFileSync, statSync } from "node:fs"; +import { + lstatSync, + readdirSync, + readFileSync, + realpathSync, + statSync, +} from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, + posix, relative, resolve, win32, } from "node:path"; +import { resolveRepositoryExecutionIdentitySync } from "./repository-identity"; import { parseJsonLenient } from "./util/json"; const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/; -const PROJECT_REPOSITORY_ID_RE = /^repo_[a-f0-9]{24}$/; export interface FacultConfig { /** @@ -76,6 +83,69 @@ function resolvePath(p: string, home: string): string { return expanded.startsWith("/") ? expanded : resolve(expanded); } +function physicalPathComparisonKey( + pathValue: string, + platform: NodeJS.Platform +): string { + const pathApi = platform === "win32" ? win32 : posix; + const resolved = pathApi.resolve(pathValue); + + // Cross-platform callers use this only to exercise the platform's lexical + // comparison rule. Native callers additionally resolve every existing + // ancestor so symlink spellings converge. + if (platform !== process.platform) { + return platform === "win32" ? resolved.toLowerCase() : resolved; + } + + const missingParts: string[] = []; + let existingAncestor = resolved; + while (true) { + try { + const physicalAncestor = realpathSync.native(existingAncestor); + const comparisonPath = missingParts.reduce( + (current, part) => pathApi.join(current, part), + physicalAncestor + ); + return platform === "win32" + ? comparisonPath.toLowerCase() + : comparisonPath; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + const parent = pathApi.dirname(existingAncestor); + if (parent === existingAncestor) { + return platform === "win32" ? resolved.toLowerCase() : resolved; + } + missingParts.unshift(pathApi.basename(existingAncestor)); + existingAncestor = parent; + } + } +} + +export function pathsPhysicallyEquivalent( + left: string, + right: string, + platform: NodeJS.Platform = process.platform +): boolean { + return ( + physicalPathComparisonKey(left, platform) === + physicalPathComparisonKey(right, platform) + ); +} + +export function pathsMayCollide( + left: string, + right: string, + platform: NodeJS.Platform = process.platform +): boolean { + const leftKey = physicalPathComparisonKey(left, platform); + const rightKey = physicalPathComparisonKey(right, platform); + return ( + leftKey === rightKey || leftKey.toLowerCase() === rightKey.toLowerCase() + ); +} + function dirExists(p: string): boolean { try { return statSync(p).isDirectory(); @@ -84,6 +154,43 @@ function dirExists(p: string): boolean { } } +function safeMachineStateDirExists(pathValue: string): boolean { + try { + const metadata = lstatSync(pathValue); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error( + `Refusing unsafe machine-local project state directory: ${pathValue}` + ); + } + return true; + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : null; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} + +function safePathEntryExists(pathValue: string): boolean { + try { + lstatSync(pathValue); + return true; + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : null; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} + function fileExists(p: string): boolean { try { return statSync(p).isFile(); @@ -292,33 +399,56 @@ export function machineStateProjectKey( rootDir: string, home: string = defaultHomeDir(), config?: FacultConfig | null +): string { + const executionKey = executionMachineStateProjectKey(rootDir, home, config); + const legacyKey = legacyMachineStateProjectKey(rootDir, home, config); + const projectsRoot = join(facultLocalStateRoot(home), "projects"); + if (executionKey === legacyKey) { + safeMachineStateDirExists(join(projectsRoot, executionKey)); + return executionKey; + } + const legacyExists = safeMachineStateDirExists(join(projectsRoot, legacyKey)); + const executionExists = safeMachineStateDirExists( + join(projectsRoot, executionKey) + ); + if (legacyExists && executionExists) { + throw new Error( + `Conflicting legacy and execution machine-local project state directories require enrollment reconciliation: ${join(projectsRoot, legacyKey)} and ${join(projectsRoot, executionKey)}` + ); + } + if (legacyExists) { + return legacyKey; + } + return executionKey; +} + +export function executionMachineStateProjectKey( + rootDir: string, + home: string = defaultHomeDir(), + config?: FacultConfig | null ): string { const projectRoot = projectRootFromAiRoot(rootDir, home, config); if (projectRoot) { - const projectConfigPath = join(rootDir, "config.toml"); - try { - const parsed = Bun.TOML.parse(readFileSync(projectConfigPath, "utf8")); - if ( - parsed && - typeof parsed === "object" && - !Array.isArray(parsed) && - "project" in parsed - ) { - const project = (parsed as Record).project; - if (isPlainObject(project)) { - const repositoryId = project.repository_id; - if ( - typeof repositoryId === "string" && - PROJECT_REPOSITORY_ID_RE.test(repositoryId) - ) { - return repositoryId; - } - } - } - } catch { - // Fall back to the legacy path-derived key for unenrolled projects. + const executionIdentity = + resolveRepositoryExecutionIdentitySync(projectRoot); + if (executionIdentity) { + return executionIdentity.id; + } + if (safePathEntryExists(join(projectRoot, ".git"))) { + throw new Error( + `Unable to resolve machine-local execution identity for project state: ${projectRoot}` + ); } } + return legacyMachineStateProjectKey(rootDir, home, config); +} + +export function legacyMachineStateProjectKey( + rootDir: string, + home: string = defaultHomeDir(), + config?: FacultConfig | null +): string { + const projectRoot = projectRootFromAiRoot(rootDir, home, config); const labelSource = projectRoot ?? rootDir; const label = basename(labelSource).trim().toLowerCase(); const slug = label.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); diff --git a/src/projects.test.ts b/src/projects.test.ts index f9095ad6..783083c6 100644 --- a/src/projects.test.ts +++ b/src/projects.test.ts @@ -1,27 +1,51 @@ import { afterEach, describe, expect, it } from "bun:test"; import { + chmod, + lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, + rm, + stat, symlink, + truncate, + utimes, writeFile, } from "node:fs/promises"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { runFixtureGit } from "../test/git-fixture"; -import { facultAiIndexPath, facultMachineStateDir } from "./paths"; +import { + facultAiEvolutionReviewDir, + facultAiGraphPath, + facultAiIndexPath, + facultAiReconciliationReviewDir, + facultAiWritebackReviewDir, + facultConfigPath, + facultLocalStateRoot, + facultMachineStateDir, + legacyMachineStateProjectKey, + pathsPhysicallyEquivalent, +} from "./paths"; import { applyProjectEnrollment, buildProjectsStatus, discoverProjects, planProjectEnrollment, projectCommand, + resolveRepositoryExecutionIdentity, resolveRepositoryIdentity, rollbackProjectEnrollment, } from "./projects"; +import { + normalizeRepositoryRemote, + repositoryPathComparisonKey, +} from "./repository-identity"; +import { gitEnvironmentForRepository } from "./util/git-environment"; const originalCwd = process.cwd(); const originalExitCode = process.exitCode; @@ -100,6 +124,36 @@ async function listTree(root: string): Promise { return out.sort(); } +async function pathEntryExists(pathValue: string): Promise { + try { + await lstat(pathValue); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +async function gitCheckIgnoreExitCode(args: { + homeDir: string; + path: string; + repoDir: string; +}): Promise { + const process = Bun.spawn({ + cmd: ["git", "check-ignore", "--no-index", args.path], + cwd: args.repoDir, + env: gitEnvironmentForRepository({ + isolatedHome: args.homeDir, + repoDir: args.repoDir, + }), + stderr: "pipe", + stdout: "pipe", + }); + return await process.exited; +} + describe("project discovery", () => { it("requires explicit roots and performs no writes", async () => { const { root, home } = await makeFixture(); @@ -123,18 +177,123 @@ describe("project discovery", () => { expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); }); + it("skips an abandoned Git candidate without losing healthy discovery results", async () => { + const { root, home } = await makeFixture(); + const portfolio = join(root, "portfolio"); + const healthy = join(portfolio, "healthy"); + const abandoned = join(portfolio, "abandoned"); + await createRepository({ path: healthy, home }); + await mkdir(abandoned, { recursive: true }); + await writeFile( + join(abandoned, ".git"), + "gitdir: ../missing-worktree-git-dir\n", + "utf8" + ); + + const discovery = await discoverProjects({ + roots: [portfolio], + homeDir: home, + maxVisits: 100, + maxResults: 10, + }); + + expect(discovery.projects.map((project) => project.root)).toEqual([ + healthy, + ]); + expect(discovery.groups).toHaveLength(1); + expect(discovery.bounds.truncated).toBe(false); + }); + + it("rejects missing explicit roots instead of reporting complete empty discovery", async () => { + const { root, home } = await makeFixture(); + await expect( + discoverProjects({ + roots: [join(root, "missing")], + homeDir: home, + }) + ).rejects.toThrow(); + }); + + it("propagates repository inspection failures instead of reporting an empty inventory", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const registryPath = plan.machineLocalWrites[0]?.path; + if (!registryPath) { + throw new Error("Expected a machine-local project registry path"); + } + await mkdir(dirname(registryPath), { recursive: true }); + for (const content of ["{", ""]) { + await writeFile(registryPath, content, "utf8"); + await expect( + discoverProjects({ + roots: [repo], + homeDir: home, + }) + ).rejects.toThrow("Project registry is invalid"); + expect(await readFile(registryPath, "utf8")).toBe(content); + } + }); + + it("reports worktree cleanliness as unknown when Git status fails", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + await rm(join(repo, ".git", "index")); + await mkdir(join(repo, ".git", "index")); + + const discovery = await discoverProjects({ + roots: [repo], + homeDir: home, + }); + expect(discovery.projects).toHaveLength(1); + expect(discovery.projects[0]?.dirty).toBeNull(); + + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + expect(plan.worktree.dirty).toBeNull(); + }); + it("correlates duplicate clones and worktrees by portable identity", async () => { const { root, home } = await makeFixture(); const source = join(root, "source"); const clone = join(root, "clone"); const worktree = join(root, "worktree"); await createRepository({ path: source, home }); + await runFixtureGit({ + argv: [ + "remote", + "add", + "origin", + "https://github.com/example/portable-project.git", + ], + repoDir: source, + homeDir: home, + cwd: source, + }); await runFixtureGit({ argv: ["clone", source, clone], repoDir: clone, homeDir: home, cwd: root, }); + await runFixtureGit({ + argv: [ + "remote", + "set-url", + "origin", + "https://github.com/example/portable-project.git", + ], + repoDir: clone, + homeDir: home, + cwd: clone, + }); await runFixtureGit({ argv: ["worktree", "add", "-b", "fixture-worktree", worktree], repoDir: source, @@ -165,14 +324,228 @@ describe("project discovery", () => { const initial = join(root, "before"); const renamed = join(root, "after"); await createRepository({ path: initial, home }); - const before = await resolveRepositoryIdentity(initial); + const [before, executionBefore] = await Promise.all([ + resolveRepositoryIdentity(initial), + resolveRepositoryExecutionIdentity(initial), + ]); await rename(initial, renamed); - const after = await resolveRepositoryIdentity(renamed); + const [after, executionAfter] = await Promise.all([ + resolveRepositoryIdentity(renamed), + resolveRepositoryExecutionIdentity(renamed), + ]); expect(after.id).toBe(before.id); - expect(after.kind).toBe("root-commit"); - expect(after.stability).toBe("portable"); + expect(executionAfter).toEqual(executionBefore); + expect(after.kind).toBe("git-common-dir"); + expect(after.stability).toBe("machine-local"); + }); + + it("preserves a linked worktree execution identity when Git moves it", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const initial = join(root, "worktree-before"); + const moved = join(root, "worktree-after"); + await createRepository({ path: repo, home }); + await runFixtureGit({ + argv: ["worktree", "add", "-b", "move-worktree", initial], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const [repoExecution, before] = await Promise.all([ + resolveRepositoryExecutionIdentity(repo), + resolveRepositoryExecutionIdentity(initial), + ]); + + await runFixtureGit({ + argv: ["worktree", "move", initial, moved], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const after = await resolveRepositoryExecutionIdentity(moved); + + expect(after).toEqual(before); + expect(after.id).not.toBe(repoExecution.id); + }); + + it("preserves execution identity when a separate-Git-directory checkout moves", async () => { + const { root, home } = await makeFixture(); + const gitDir = join(root, "repository.git"); + const initial = join(root, "checkout-before"); + const moved = join(root, "checkout-after"); + await runFixtureGit({ + argv: ["init", "-b", "main", `--separate-git-dir=${gitDir}`, initial], + repoDir: initial, + homeDir: home, + cwd: root, + }); + await writeFile(join(initial, "README.md"), "# Fixture\n", "utf8"); + await runFixtureGit({ + argv: ["add", "README.md"], + repoDir: initial, + homeDir: home, + cwd: initial, + }); + await runFixtureGit({ + argv: [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-m", + "fixture", + ], + repoDir: initial, + homeDir: home, + cwd: initial, + }); + const before = await resolveRepositoryExecutionIdentity(initial); + + await rename(initial, moved); + const after = await resolveRepositoryExecutionIdentity(moved); + + expect(after).toEqual(before); + }); + + it("isolates a checkout that points at another repository's Git directory", async () => { + const { root, home } = await makeFixture(); + const victim = join(root, "victim"); + const alias = join(root, "alias"); + await createRepository({ path: victim, home }); + await mkdir(alias, { recursive: true }); + await writeFile( + join(alias, ".git"), + `gitdir: ${join(victim, ".git")}\n`, + "utf8" + ); + + const [victimExecution, aliasExecution] = await Promise.all([ + resolveRepositoryExecutionIdentity(victim), + resolveRepositoryExecutionIdentity(alias), + ]); + + expect(aliasExecution.id).not.toBe(victimExecution.id); + }); + + it("changes execution identity when a Git pointer is retargeted in place", async () => { + const { root, home } = await makeFixture(); + const first = join(root, "first"); + const second = join(root, "second"); + const alias = join(root, "alias"); + await createRepository({ path: first, home }); + await createRepository({ path: second, home }); + await mkdir(alias, { recursive: true }); + const pointerPath = join(alias, ".git"); + await writeFile(pointerPath, `gitdir: ${join(first, ".git")}\n`, "utf8"); + const before = await resolveRepositoryExecutionIdentity(alias); + + await writeFile(pointerPath, `gitdir: ${join(second, ".git")}\n`, "utf8"); + const after = await resolveRepositoryExecutionIdentity(alias); + + expect(after.id).not.toBe(before.id); + }); + + it("accepts a submodule Git directory bound through core.worktree", async () => { + const { root, home } = await makeFixture(); + const child = join(root, "child"); + const parent = join(root, "parent"); + const submodule = join(parent, "submodule"); + await createRepository({ path: child, home }); + await createRepository({ path: parent, home }); + await runFixtureGit({ + argv: [ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + child, + "submodule", + ], + repoDir: parent, + homeDir: home, + cwd: parent, + }); + + const [parentExecution, submoduleExecution] = await Promise.all([ + resolveRepositoryExecutionIdentity(parent), + resolveRepositoryExecutionIdentity(submodule), + ]); + + expect(submoduleExecution.id).not.toBe(parentExecution.id); + }); + + it("preserves repository and execution identity across normal Git mutations", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const [repositoryBefore, executionBefore] = await Promise.all([ + resolveRepositoryIdentity(repo, home), + resolveRepositoryExecutionIdentity(repo), + ]); + + await writeFile(join(repo, "CHANGELOG.md"), "# Change\n", "utf8"); + await runFixtureGit({ + argv: ["add", "CHANGELOG.md"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await runFixtureGit({ + argv: [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-m", + "normal mutation", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + + expect(await resolveRepositoryIdentity(repo, home)).toEqual( + repositoryBefore + ); + expect(await resolveRepositoryExecutionIdentity(repo)).toEqual( + executionBefore + ); + }); + + it("distinguishes no-remote repositories with identical root history", async () => { + const { root, home } = await makeFixture(); + const parent = join(root, "parent"); + const fork = join(root, "fork"); + await createRepository({ path: parent, home }); + await runFixtureGit({ + argv: ["clone", parent, fork], + repoDir: fork, + homeDir: home, + cwd: root, + }); + await runFixtureGit({ + argv: ["remote", "remove", "origin"], + repoDir: fork, + homeDir: home, + cwd: fork, + }); + + const [parentIdentity, forkIdentity] = await Promise.all([ + resolveRepositoryIdentity(parent, home), + resolveRepositoryIdentity(fork, home), + ]); + expect(parentIdentity.kind).toBe("git-common-dir"); + expect(forkIdentity.kind).toBe("git-common-dir"); + expect(parentIdentity.id).not.toBe(forkIdentity.id); + expect( + parentIdentity.aliases.find((alias) => alias.kind === "root-commit")?.id + ).toBe( + forkIdentity.aliases.find((alias) => alias.kind === "root-commit")?.id + ); }); it("normalizes HTTPS and SSH URLs for stable clone identity", async () => { @@ -205,274 +578,5125 @@ describe("project discovery", () => { const sshIdentity = await resolveRepositoryIdentity(repo); expect(sshIdentity.id).toBe(httpsIdentity.id); - expect(sshIdentity.fingerprint).toBe("github.com/example/project"); - }); - - it("applies the since filter without mutating repositories", async () => { - const { root, home } = await makeFixture(); - const repo = join(root, "repo"); - await createRepository({ path: repo, home }); - - const discovery = await discoverProjects({ - roots: [root], - since: "1h", - now: new Date("2100-01-01T00:00:00.000Z"), + expect(sshIdentity).toMatchObject({ + kind: "remote", + fingerprint: "github.com/example/project", }); + }); - expect(discovery.projects).toEqual([]); - expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + it("rejects Windows drive and UNC local paths as repository remotes", () => { + expect( + [ + String.raw`C:\work\project.git`, + "C:/work/project.git", + "C:repo.git", + String.raw`\\server\share\project.git`, + "//server/share/project.git", + ].map(normalizeRepositoryRemote) + ).toEqual([null, null, null, null, null]); + expect( + normalizeRepositoryRemote("git@github.com:example/project.git") + ).toBe("github.com/example/project"); }); -}); -describe("project enrollment planning", () => { - it("is minimal, exact, no-write, and does not duplicate root guidance", async () => { + it("canonicalizes equivalent checkout paths for planning and state selection", async () => { const { root, home } = await makeFixture(); const repo = join(root, "repo"); - const guidance = - "# Canonical repository rules\n\n- Run the project checks.\n"; - await createRepository({ - path: repo, - home, - files: { - "AGENTS.md": guidance, - "README.md": "# Public fixture\n", - }, - }); - const before = await listTree(repo); + const alias = join(root, "repo-alias"); + await createRepository({ path: repo, home }); + await symlink(repo, alias, "dir"); - const plan = await planProjectEnrollment({ + const directPlan = await planProjectEnrollment({ projectRoot: repo, homeDir: home, - guidance: ["AGENTS.md"], + }); + const aliasPlan = await planProjectEnrollment({ + projectRoot: alias, + homeDir: home, }); - expect(plan.guidancePreview).toEqual([ - { - path: "AGENTS.md", - sha256: expect.any(String), - content: guidance, - gitState: "clean-tracked", - adoption: "reference", - }, - ]); - expect(plan.canonicalWrites.map((write) => write.path)).toEqual([ - join(repo, ".ai", ".gitignore"), - join(repo, ".ai", "config.toml"), - ]); - expect( - plan.canonicalWrites.some((write) => - write.path.endsWith("AGENTS.global.md") - ) - ).toBe(false); - expect(plan.canonicalWrites[1]?.content).toContain( - 'guidance = ["AGENTS.md"]' + expect(aliasPlan.projectRoot).toBe(directPlan.projectRoot); + expect(aliasPlan.executionIdentity).toEqual(directPlan.executionIdentity); + expect(facultMachineStateDir(home, join(alias, ".ai"))).toBe( + facultMachineStateDir(home, join(repo, ".ai")) + ); + expect(repositoryPathComparisonKey("C:\\Work\\Project", "win32")).toBe( + repositoryPathComparisonKey("c:\\work\\project", "win32") ); - expect(plan.protections).toEqual({ - ignoreWrittenFirst: true, - managedRendering: false, - automaticGuidanceCopy: false, - privacyFindings: [], - }); - expect(await listTree(repo)).toEqual(before); - expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); }); - it("does not adopt guidance unless explicitly selected", async () => { + it("keeps forks with shared root history in separate portfolio identities", async () => { const { root, home } = await makeFixture(); - const repo = join(root, "repo"); - await createRepository({ - path: repo, + const source = join(root, "source"); + const upstream = join(root, "upstream"); + const fork = join(root, "fork"); + await createRepository({ path: source, home }); + await runFixtureGit({ + argv: ["clone", source, upstream], + repoDir: upstream, + homeDir: home, + cwd: root, + }); + await runFixtureGit({ + argv: ["clone", source, fork], + repoDir: fork, + homeDir: home, + cwd: root, + }); + await runFixtureGit({ + argv: [ + "remote", + "set-url", + "origin", + "https://github.com/example/project.git", + ], + repoDir: upstream, + homeDir: home, + cwd: upstream, + }); + await runFixtureGit({ + argv: [ + "remote", + "set-url", + "origin", + "https://github.com/example/project-fork.git", + ], + repoDir: fork, + homeDir: home, + cwd: fork, + }); + await runFixtureGit({ + argv: [ + "remote", + "add", + "upstream", + "https://github.com/example/project.git", + ], + repoDir: fork, + homeDir: home, + cwd: fork, + }); + + const discovery = await discoverProjects({ + roots: [upstream, fork], + homeDir: home, + maxVisits: 20, + maxResults: 10, + }); + expect(discovery.projects).toHaveLength(2); + expect(discovery.groups).toHaveLength(2); + expect( + new Set(discovery.projects.map((project) => project.identity.id)).size + ).toBe(2); + + const upstreamPlan = await planProjectEnrollment({ + projectRoot: upstream, + homeDir: home, + }); + const forkIdentityBeforeEnrollment = await resolveRepositoryIdentity( + fork, + home + ); + expect( + forkIdentityBeforeEnrollment.aliases.some( + (alias) => + alias.kind === "remote" && + alias.fingerprint === "github.com/example/project" + ) + ).toBe(false); + await applyProjectEnrollment({ + plan: upstreamPlan, + expectedPlanSha256: upstreamPlan.planSha256, + homeDir: home, + }); + const registryPath = upstreamPlan.machineLocalWrites[0]?.path ?? ""; + const legacyRegistry = (await Bun.file(registryPath).json()) as { + projects: Record; + }; + legacyRegistry.projects[upstreamPlan.identity.id]?.aliases.push( + forkIdentityBeforeEnrollment.id + ); + await writeFile( + registryPath, + `${JSON.stringify(legacyRegistry, null, 2)}\n`, + "utf8" + ); + const forkPlan = await planProjectEnrollment({ + projectRoot: fork, + homeDir: home, + }); + expect(forkPlan.identity.id).toBe(forkIdentityBeforeEnrollment.id); + expect(forkPlan.identity.id).not.toBe(upstreamPlan.identity.id); + await applyProjectEnrollment({ + plan: forkPlan, + expectedPlanSha256: forkPlan.planSha256, + homeDir: home, + }); + + const registry = (await Bun.file(registryPath).json()) as { + projects: Record; + }; + expect(Object.keys(registry.projects).sort()).toEqual( + [upstreamPlan.identity.id, forkPlan.identity.id].sort() + ); + }); + + it("keeps primary identity stable when origin is added, renamed, removed, or joined by other remotes", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const initial = await resolveRepositoryIdentity(repo, home); + await runFixtureGit({ + argv: [ + "remote", + "add", + "origin", + "https://github.com/example/project.git", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const withOrigin = await resolveRepositoryIdentity(repo, home); + + await runFixtureGit({ + argv: [ + "remote", + "add", + "aaa", + "https://github.com/example/unrelated.git", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const withMultipleRemotes = await resolveRepositoryIdentity(repo, home); + await runFixtureGit({ + argv: ["remote", "rename", "origin", "upstream"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const renamed = await resolveRepositoryIdentity(repo, home); + await runFixtureGit({ + argv: ["remote", "remove", "upstream"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const removed = await resolveRepositoryIdentity(repo, home); + + expect( + [withOrigin, withMultipleRemotes, renamed, removed].map( + (identity) => identity.id + ) + ).toEqual([initial.id, initial.id, initial.id, initial.id]); + expect(withOrigin.aliases.some((alias) => alias.kind === "remote")).toBe( + true + ); + }); + + it("preserves an enrolled remote primary when origin is removed", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + await runFixtureGit({ + argv: [ + "remote", + "add", + "origin", + "https://github.com/example/remote-first.git", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + await runFixtureGit({ + argv: ["remote", "remove", "origin"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + + const withoutOrigin = await resolveRepositoryIdentity(repo, home); + expect(plan.identity.kind).toBe("remote"); + expect(withoutOrigin.id).toBe(plan.identity.id); + expect(withoutOrigin.kind).toBe("remote"); + expect( + withoutOrigin.aliases.some((alias) => alias.kind === "git-common-dir") + ).toBe(true); + }); + + it("keeps an uncommitted repository on its machine-local primary when origin changes", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "empty"); + await runFixtureGit({ + argv: ["init", "-b", "main", repo], + repoDir: repo, + homeDir: home, + cwd: root, + }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const initial = await resolveRepositoryIdentity(repo, home); + await runFixtureGit({ + argv: ["remote", "add", "origin", "https://github.com/example/empty.git"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const added = await resolveRepositoryIdentity(repo, home); + await runFixtureGit({ + argv: ["remote", "rename", "origin", "upstream"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const renamed = await resolveRepositoryIdentity(repo, home); + await runFixtureGit({ + argv: ["remote", "remove", "upstream"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const removed = await resolveRepositoryIdentity(repo, home); + + expect([added.id, renamed.id, removed.id]).toEqual([ + initial.id, + initial.id, + initial.id, + ]); + expect(initial.stability).toBe("machine-local"); + }); + + it("does not inherit enrollment when Git metadata is replaced at the same path", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const original = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan: original, + expectedPlanSha256: original.planSha256, + homeDir: home, + }); + const originalState = facultMachineStateDir(home, join(repo, ".ai")); + await writeFile(join(originalState, "runtime-marker"), "original\n"); + + await rm(join(repo, ".git"), { recursive: true }); + await runFixtureGit({ + argv: ["init", "-b", "main"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await runFixtureGit({ + argv: ["config", "user.name", "Fixture"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await runFixtureGit({ + argv: ["config", "user.email", "fixture@example.test"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await writeFile(join(repo, "README.md"), "# Replacement\n", "utf8"); + await runFixtureGit({ + argv: ["add", "README.md"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await runFixtureGit({ + argv: ["commit", "-m", "replacement"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + + const replacement = await resolveRepositoryIdentity(repo, home); + const replacementState = facultMachineStateDir(home, join(repo, ".ai")); + expect(replacement.id).not.toBe(original.identity.id); + expect(replacementState).not.toBe(originalState); + expect( + await Bun.file(join(replacementState, "runtime-marker")).exists() + ).toBe(false); + }); + + it("does not stabilize a same-path fork from shared root history alone", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const fork = join(root, "fork"); + await createRepository({ path: repo, home }); + await runFixtureGit({ + argv: [ + "remote", + "add", + "origin", + "https://github.com/example/parent.git", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await runFixtureGit({ + argv: ["clone", repo, fork], + repoDir: fork, + homeDir: home, + cwd: root, + }); + await runFixtureGit({ + argv: [ + "remote", + "set-url", + "origin", + "https://github.com/example/fork.git", + ], + repoDir: fork, + homeDir: home, + cwd: fork, + }); + const parentPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan: parentPlan, + expectedPlanSha256: parentPlan.planSha256, + homeDir: home, + }); + const forkIdentity = await resolveRepositoryIdentity( + fork, + join(root, "unregistered-home") + ); + const registryPath = parentPlan.machineLocalWrites[0]?.path ?? ""; + const registry = (await Bun.file(registryPath).json()) as { + projects: Record; + }; + registry.projects[parentPlan.identity.id]?.aliases.push(forkIdentity.id); + await writeFile( + registryPath, + `${JSON.stringify(registry, null, 2)}\n`, + "utf8" + ); + + await rename(join(repo, ".git"), join(root, "parent.git")); + await rename(join(fork, ".git"), join(repo, ".git")); + + const replacement = await resolveRepositoryIdentity(repo, home); + expect(replacement.id).toBe(forkIdentity.id); + expect(replacement.id).not.toBe(parentPlan.identity.id); + expect( + replacement.aliases.some( + (alias) => + alias.kind === "root-commit" && + parentPlan.identity.aliases.some( + (parentAlias) => + parentAlias.kind === "root-commit" && parentAlias.id === alias.id + ) + ) + ).toBe(true); + }); + + it("distinguishes no-origin forks by their normalized remote sets", async () => { + const { root, home } = await makeFixture(); + const parent = join(root, "parent"); + const fork = join(root, "fork"); + await createRepository({ path: parent, home }); + await runFixtureGit({ + argv: ["clone", parent, fork], + repoDir: fork, + homeDir: home, + cwd: root, + }); + await runFixtureGit({ + argv: ["remote", "remove", "origin"], + repoDir: fork, + homeDir: home, + cwd: fork, + }); + for (const [repo, own] of [ + [parent, "parent"], + [fork, "fork"], + ] as const) { + await runFixtureGit({ + argv: [ + "remote", + "add", + "upstream", + `https://github.com/example/${own}.git`, + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await runFixtureGit({ + argv: [ + "remote", + "add", + "mirror", + "https://github.com/example/shared.git", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + } + const discovery = await discoverProjects({ + roots: [parent, fork], + homeDir: home, + }); + expect( + new Set(discovery.projects.map((project) => project.identity.id)).size + ).toBe(2); + }); + + it("does not refresh the Git index during discovery", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const indexPath = join(repo, ".git", "index"); + const trackedPath = join(repo, "README.md"); + await utimes( + trackedPath, + new Date("2040-01-01T00:00:00.000Z"), + new Date("2040-01-01T00:00:00.000Z") + ); + const beforeBytes = await readFile(indexPath); + const beforeStat = await stat(indexPath); + + await discoverProjects({ + roots: [repo], + maxVisits: 10, + maxResults: 10, + }); + + const afterBytes = await readFile(indexPath); + const afterStat = await stat(indexPath); + expect(afterBytes.equals(beforeBytes)).toBe(true); + expect(afterStat.mtimeMs).toBe(beforeStat.mtimeMs); + }); + + it("applies the since filter without mutating repositories", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + + const discovery = await discoverProjects({ + roots: [root], + since: "1h", + now: new Date("2100-01-01T00:00:00.000Z"), + }); + + expect(discovery.projects).toEqual([]); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + }); +}); + +describe("project enrollment planning", () => { + it("refuses direct and CLI planning when the project AI root is global without writes", async () => { + const { root, home } = await makeFixture(); + await createRepository({ path: home, home: join(root, "git-home") }); + const before = await listTree(home); + + await expect( + planProjectEnrollment({ projectRoot: home, homeDir: home }) + ).rejects.toThrow("collides with the configured global AI root"); + expect(await listTree(home)).toEqual(before); + expect(await pathEntryExists(join(home, ".ai"))).toBe(false); + + const logs: string[] = []; + const errors: string[] = []; + const previousLog = console.log; + const previousError = console.error; + let cliExitCode: number | string | null | undefined; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + console.error = (...args: unknown[]) => errors.push(args.join(" ")); + process.exitCode = 0; + try { + await projectCommand(["init", "--project-root", home, "--json"], { + cwd: home, + homeDir: home, + }); + cliExitCode = process.exitCode; + } finally { + console.log = previousLog; + console.error = previousError; + process.exitCode = 0; + } + + expect(cliExitCode).toBe(1); + expect(logs).toEqual([]); + expect(errors.join("\n")).toContain( + "collides with the configured global AI root" + ); + expect(await listTree(home)).toEqual(before); + expect(await pathEntryExists(join(home, ".ai"))).toBe(false); + }); + + it("refuses a physically equivalent configured global root and preserves normal planning", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const otherRepo = join(root, "other-repo"); + const globalAlias = join(root, "configured-global"); + await createRepository({ path: repo, home }); + await createRepository({ path: otherRepo, home }); + await mkdir(join(repo, ".ai"), { recursive: true }); + await symlink( + join(repo, ".ai"), + globalAlias, + process.platform === "win32" ? "junction" : "dir" + ); + await mkdir(dirname(facultConfigPath(home)), { recursive: true }); + await writeFile( + facultConfigPath(home), + `${JSON.stringify({ rootDir: globalAlias })}\n`, + "utf8" + ); + const before = await listTree(repo); + + await expect( + planProjectEnrollment({ projectRoot: repo, homeDir: home }) + ).rejects.toThrow("collides with the configured global AI root"); + expect(await listTree(repo)).toEqual(before); + + const plan = await planProjectEnrollment({ + projectRoot: otherRepo, + homeDir: home, + }); + expect(plan.projectRoot).toBe(otherRepo); + expect(plan.aiRoot).toBe(join(otherRepo, ".ai")); + expect(await pathEntryExists(join(otherRepo, ".ai"))).toBe(false); + expect( + pathsPhysicallyEquivalent( + "C:\\Users\\Dimitri\\.ai", + "c:\\users\\dimitri\\.AI", + "win32" + ) + ).toBe(true); + expect( + pathsPhysicallyEquivalent( + "C:\\Users\\Dimitri\\.ai", + "C:\\Users\\Dimitri\\project\\.ai", + "win32" + ) + ).toBe(false); + }); + + it("conservatively refuses missing global and project roots that differ only by case", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "case-collision-repo"); + await createRepository({ path: repo, home }); + const projectAiRoot = join(repo, ".ai"); + const configuredGlobalRoot = join(repo, ".AI"); + if (process.platform === "win32") { + return; + } + await mkdir(dirname(facultConfigPath(home)), { recursive: true }); + await writeFile( + facultConfigPath(home), + `${JSON.stringify({ rootDir: configuredGlobalRoot })}\n`, + "utf8" + ); + const before = await listTree(repo); + + await expect( + planProjectEnrollment({ projectRoot: repo, homeDir: home }) + ).rejects.toThrow("collides with the configured global AI root"); + expect(await listTree(repo)).toEqual(before); + expect(await pathEntryExists(projectAiRoot)).toBe(false); + expect(await pathEntryExists(configuredGlobalRoot)).toBe(false); + }); + + it("rejects unsupported Windows enrollment during planning without writes", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const before = await listTree(repo); + + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + platform: "win32", + }) + ).rejects.toThrow("planning is unsupported on win32"); + expect(await listTree(repo)).toEqual(before); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + + const reviewedPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + platform: "linux", + }); + await expect( + applyProjectEnrollment({ + plan: reviewedPlan, + expectedPlanSha256: reviewedPlan.planSha256, + homeDir: home, + platform: "win32", + }) + ).rejects.toThrow("registry mutation is unsupported on win32"); + expect(await listTree(repo)).toEqual(before); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + expect( + await Bun.file(reviewedPlan.machineLocalWrites[0]?.path ?? "").exists() + ).toBe(false); + }); + + it("rejects malformed registry containers and entries before enrollment writes", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const registryPath = plan.machineLocalWrites[0]?.path; + if (!registryPath) { + throw new Error("Expected a project registry path"); + } + await mkdir(dirname(registryPath), { recursive: true }); + const key = "repo_000000000000000000000000"; + const malformedContents = [ + "", + ...[[], null, 42, { [key]: null }, { [key]: { repositoryId: key } }].map( + (projects) => + `${JSON.stringify({ + version: 1, + updatedAt: "2026-07-29T12:00:00.000Z", + projects, + })}\n` + ), + ]; + + for (const content of malformedContents) { + await writeFile(registryPath, content, "utf8"); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }) + ).rejects.toThrow("Project registry is invalid"); + expect(await readFile(registryPath, "utf8")).toBe(content); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + expect( + await readdir(join(dirname(registryPath), "receipts")).catch(() => []) + ).toEqual([]); + } + }); + + it("fails when an existing canonical file cannot be read", async () => { + if (process.platform === "win32") { + return; + } + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const ignorePath = join(repo, ".ai", ".gitignore"); + await mkdir(dirname(ignorePath), { recursive: true }); + await writeFile(ignorePath, "/user-rule\n", "utf8"); + await chmod(ignorePath, 0o000); + try { + await expect( + planProjectEnrollment({ projectRoot: repo, homeDir: home }) + ).rejects.toThrow(); + } finally { + await chmod(ignorePath, 0o600); + } + }); + + it("rejects a legacy state migration that appeared after preview", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + expect(plan.stateMigrations).toEqual([]); + const legacyDir = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + await mkdir(legacyDir, { recursive: true }); + await writeFile(join(legacyDir, "journal.jsonl"), "preserve\n", "utf8"); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }) + ).rejects.toThrow("state migrations changed"); + expect(await readFile(join(legacyDir, "journal.jsonl"), "utf8")).toBe( + "preserve\n" + ); + expect(await Bun.file(facultMachineStateDir(home, aiRoot)).exists()).toBe( + false + ); + expect(await Bun.file(aiRoot).exists()).toBe(false); + }); + + it("is minimal, exact, no-write, and does not duplicate root guidance", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const guidance = + "# Canonical repository rules\n\n- Run the project checks.\n"; + await createRepository({ + path: repo, + home, + files: { + "AGENTS.md": guidance, + "README.md": "# Public fixture\n", + }, + }); + const before = await listTree(repo); + + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["AGENTS.md"], + }); + + expect(plan.guidancePreview).toEqual([ + { + path: "AGENTS.md", + sha256: expect.any(String), + content: guidance, + gitState: "clean-tracked", + adoption: "reference", + }, + ]); + expect(plan.canonicalWrites.map((write) => write.path)).toEqual([ + join(repo, ".ai", ".gitignore"), + join(repo, ".ai", "config.toml"), + ]); + expect( + plan.generatedWrites.every((write) => + write.path.includes(plan.executionIdentity.id) + ) + ).toBe(true); + expect( + plan.canonicalWrites.some((write) => + write.path.endsWith("AGENTS.global.md") + ) + ).toBe(false); + expect(plan.canonicalWrites[1]?.content).toContain( + 'guidance = ["AGENTS.md"]' + ); + expect(plan.protections).toEqual({ + ignoreWrittenFirst: true, + managedRendering: false, + automaticGuidanceCopy: false, + privacyFindings: [], + }); + expect(await listTree(repo)).toEqual(before); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + }); + + it("rejects guidance replaced after Git verification before reading", async () => { + if (process.platform === "win32") { + return; + } + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const outside = join(root, "outside.md"); + const guidance = "# Reviewed guidance\n"; + await createRepository({ + path: repo, + home, + files: { "AGENTS.md": guidance }, + }); + await writeFile(outside, guidance, "utf8"); + + await expect( + planProjectEnrollment({ + beforeGuidanceRead: async () => { + await rename( + join(repo, "AGENTS.md"), + join(repo, "AGENTS.original.md") + ); + await symlink(outside, join(repo, "AGENTS.md")); + }, + projectRoot: repo, + homeDir: home, + guidance: ["AGENTS.md"], + }) + ).rejects.toThrow("source changed before read"); + expect(await readFile(outside, "utf8")).toBe(guidance); + expect((await lstat(join(repo, "AGENTS.md"))).isSymbolicLink()).toBe(true); + }); + + it("rejects canonical files replaced after target verification before preview", async () => { + if (process.platform === "win32") { + return; + } + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const outside = join(root, "outside.toml"); + await createRepository({ path: repo, home }); + await mkdir(join(repo, ".ai"), { recursive: true }); + await writeFile( + join(repo, ".ai", "config.toml"), + "version = 1\n\n[custom]\nowned = true\n", + "utf8" + ); + await writeFile( + outside, + "version = 1\n\n[private]\nowned = false\n", + "utf8" + ); + + await expect( + planProjectEnrollment({ + beforeCanonicalPreviewRead: async () => { + await rename( + join(repo, ".ai", "config.toml"), + join(repo, ".ai", "config.original.toml") + ); + await symlink(outside, join(repo, ".ai", "config.toml")); + }, + projectRoot: repo, + homeDir: home, + }) + ).rejects.toThrow("unsafe canonical file"); + expect(await readFile(outside, "utf8")).toBe( + "version = 1\n\n[private]\nowned = false\n" + ); + expect( + (await lstat(join(repo, ".ai", "config.toml"))).isSymbolicLink() + ).toBe(true); + }); + + it("does not adopt guidance unless explicitly selected", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ + path: repo, + home, + files: { "AGENTS.md": "# Existing\n" }, + }); + + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + expect(plan.guidancePreview).toEqual([]); + expect(plan.options.guidance).toEqual([]); + expect(plan.warnings.join("\n")).toContain("not copied or adopted"); + }); + + it("keeps scheduling outside minimal enrollment", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + cadence: "weekly", + scheduling: true, + }) + ).rejects.toThrow("does not install scheduling"); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + }); + + it("refuses dirty or untracked guidance without writing", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ + path: repo, + home, + files: { "AGENTS.md": "# Reviewed\n" }, + }); + await writeFile( + join(repo, "AGENTS.md"), + "# Dirty local guidance\n", + "utf8" + ); + + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["AGENTS.md"], + }) + ).rejects.toThrow("byte-for-byte clean"); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + + await writeFile(join(repo, "CLAUDE.md"), "# Untracked\n", "utf8"); + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["CLAUDE.md"], + }) + ).rejects.toThrow("byte-for-byte clean"); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + }); + + it("refuses assume-unchanged and skip-worktree guidance whose worktree bytes drift", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const reviewed = "# Reviewed guidance\n"; + await createRepository({ + path: repo, + home, + files: { "AGENTS.md": reviewed }, + }); + + await runFixtureGit({ + argv: ["update-index", "--assume-unchanged", "AGENTS.md"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await writeFile(join(repo, "AGENTS.md"), "# Hidden local edit\n", "utf8"); + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["AGENTS.md"], + }) + ).rejects.toThrow("byte-for-byte clean"); + + await runFixtureGit({ + argv: ["update-index", "--no-assume-unchanged", "AGENTS.md"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await writeFile(join(repo, "AGENTS.md"), reviewed, "utf8"); + await runFixtureGit({ + argv: ["update-index", "--skip-worktree", "AGENTS.md"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await writeFile( + join(repo, "AGENTS.md"), + "# Hidden worktree edit\n", + "utf8" + ); + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["AGENTS.md"], + }) + ).rejects.toThrow("byte-for-byte clean"); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + }); + + it("refuses secret-shaped and machine-local guidance in public fixtures", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "public-repo"); + await createRepository({ + path: repo, + home, + files: { + "docs/safe.md": + '# Safe public guidance\n\nUse https://example.com/home/docs, https://example.com/search?next=(/tmp/private), [Docs](/tmp/help "Guide"), [Reference][docs], Guide, , option C: recommended, and docs/config.toml.\n\n[docs]: /home/help "Home"\n', + "docs/url-paths.md": + "# Safe public guidance\n\nUse https://example.com/?next=/tmp/private#fallback=/home/alice/private and https://example.com/docs#source=/opt/company/internal.\n", + "docs/local.md": + "# Local\n\nRead /Users/example/private/config.toml.\n", + "docs/linux-home.md": "# Local\n\nRead /home/alice/private.toml.\n", + "docs/root-home.md": "# Local\n\nRead /root/.ssh/config.\n", + "docs/workspace-local.md": + "# Local\n\nRead /workspace/alice/private.md.\n", + "docs/tmp-local.md": "# Local\n\nRead /tmp/acme/token.\n", + "docs/opt-local.md": "# Local\n\nRead /opt/company/internal.md.\n", + "docs/colon-local.md": + "# Local\n\nLocal path:/opt/company/internal.md.\n", + "docs/boot-local.md": "# Local\n\nRead /boot/loader/private.\n", + "docs/lib-local.md": "# Local\n\nRead /lib/private-config.\n", + "docs/media-local.md": "# Local\n\nRead /media/alice/private.\n", + "docs/data-local.md": "# Local\n\nRead /data/alice/private.md.\n", + "docs/net-local.md": "# Local\n\nRead /net/company/internal.\n", + "docs/nix-local.md": "# Local\n\nRead /nix/store/private-config.\n", + "docs/html-data-local.md": + "# Local\n\n/data/alice/private.md\n", + "docs/html-net-local.md": + "# Local\n\n/net/company/internal\n", + "docs/html-nix-local.md": + "# Local\n\n
/nix/store/private-config
\n", + "docs/trailing-root-local.md": '# Local\n\nRead "/tmp/".\n', + "docs/windows-backslash.md": String.raw`# Local + +Read C:\Users\Alice\private.toml. +`, + "docs/windows-forward.md": + "# Local\n\nRead C:/Users/Alice/private.toml.\n", + "docs/windows-drive-relative.md": String.raw`# Local + +Read D:private\config.toml. +`, + "docs/windows-unc.md": String.raw`# Local + +Read \\server\share\private.toml. +`, + "docs/windows-forward-unc.md": + "# Local\n\nRead //server/share/private.toml.\n", + "docs/secret.md": "# Secret\n\napi_key = abcdefghijklmnop\n", + }, + }); + await mkdir(join(repo, ".ai"), { recursive: true }); + await writeFile( + join(repo, ".ai", ".gitignore"), + "# https://example.com/?next=/tmp/private\n//cache/\n", + "utf8" + ); + + const safe = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["docs/safe.md", "docs/url-paths.md"], + }); + expect(safe.guidancePreview[0]?.path).toBe("docs/safe.md"); + expect(safe.guidancePreview[1]?.path).toBe("docs/url-paths.md"); + for (const guidance of [ + "docs/local.md", + "docs/linux-home.md", + "docs/root-home.md", + "docs/workspace-local.md", + "docs/tmp-local.md", + "docs/opt-local.md", + "docs/colon-local.md", + "docs/boot-local.md", + "docs/lib-local.md", + "docs/media-local.md", + "docs/data-local.md", + "docs/net-local.md", + "docs/nix-local.md", + "docs/html-data-local.md", + "docs/html-net-local.md", + "docs/html-nix-local.md", + "docs/trailing-root-local.md", + "docs/windows-backslash.md", + "docs/windows-forward.md", + "docs/windows-drive-relative.md", + "docs/windows-unc.md", + "docs/windows-forward-unc.md", + ]) { + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: [guidance], + }) + ).rejects.toThrow("machine-local absolute path"); + } + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["docs/secret.md"], + }) + ).rejects.toThrow("secret-shaped content"); + }); + + it("preserves existing ignore rules and versioned canonical config", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + await mkdir(join(repo, ".ai"), { recursive: true }); + await writeFile( + join(repo, ".ai", ".gitignore"), + "/private.local\n", + "utf8" + ); + + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + expect(plan.canonicalWrites[0]?.content).toContain("/private.local"); + expect(plan.canonicalWrites[0]?.content).toContain("/.facult/"); + + await writeFile( + join(repo, ".ai", "config.toml"), + "version = 1\n\n[custom]\nowned = true\n", + "utf8" + ); + const merged = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + expect(merged.canonicalWrites[1]?.content).toContain("[custom]"); + expect(merged.canonicalWrites[1]?.content).toContain("owned = true"); + expect(merged.canonicalWrites[1]?.content).toContain("[project]"); + + await writeFile( + join(repo, ".ai", "config.toml"), + 'version = 1\n\n[project]\nrepository_id = "repo_conflict"\n', + "utf8" + ); + await expect( + planProjectEnrollment({ projectRoot: repo, homeDir: home }) + ).rejects.toThrow( + "Refusing to update an invalid canonical project enrollment config" + ); + }); + + it("updates a valid enrollment and rollback restores the prior project config", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ + path: repo, + home, + files: { "AGENTS.md": "# Reviewed project guidance\n" }, + }); + const firstPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + now: new Date("2026-07-29T10:00:00.000Z"), + }); + const configPath = join(repo, ".ai", "config.toml"); + const firstConfig = await readFile(configPath, "utf8"); + await writeFile( + configPath, + `${firstConfig.trimEnd()}\n\n[custom]\nowned = true\n`, + "utf8" + ); + + const secondPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + cadence: "weekly", + guidance: ["AGENTS.md"], + sources: ["git", "writebacks"], + }); + expect(secondPlan.canonicalWrites[1]?.content).toContain( + 'cadence = "weekly"' + ); + expect(secondPlan.canonicalWrites[1]?.content).toContain( + 'guidance = ["AGENTS.md"]' + ); + expect(secondPlan.canonicalWrites[1]?.content).toContain( + "[custom]\nowned = true" + ); + const updatedConfig = secondPlan.canonicalWrites[1]?.content; + if (!updatedConfig) { + throw new Error("Expected a canonical project config write"); + } + const beforeSecondApply = await readFile(configPath, "utf8"); + const second = await applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: home, + now: new Date("2026-07-29T11:00:00.000Z"), + }); + expect(await readFile(configPath, "utf8")).toBe(updatedConfig); + + await rollbackProjectEnrollment({ + receiptId: second.receiptId, + homeDir: home, + apply: true, + now: new Date("2026-07-29T12:00:00.000Z"), + }); + expect(await readFile(configPath, "utf8")).toBe(beforeSecondApply); + }); + + it("updates the owned project table without matching multiline TOML content", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const firstPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + }); + const configPath = join(repo, ".ai", "config.toml"); + const firstConfig = await readFile(configPath, "utf8"); + const authoredPrefix = [ + "version = 1", + "", + "[custom]", + 'description = """', + "[project]", + 'this is authored text, not a table header"""', + "", + ].join("\n"); + const existing = `${authoredPrefix}${firstConfig.slice( + firstConfig.indexOf("[project]") + )}\n[ "after]quoted" ] # preserved table\nowned = true\n`; + await writeFile(configPath, existing, "utf8"); + + const nextPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + cadence: "weekly", + }); + const updated = nextPlan.canonicalWrites[1]?.content ?? ""; + expect(Bun.TOML.parse(updated)).toMatchObject({ + "after]quoted": { owned: true }, + custom: { + description: "\n[project]\nthis is authored text, not a table header", + }, + project: { cadence: "weekly" }, + }); + expect(updated).toContain(authoredPrefix); + expect(updated.match(/^\[project\]$/gm)).toHaveLength(2); + }); + + it("updates a parser-equivalent quoted project table header", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const firstPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + }); + const configPath = join(repo, ".ai", "config.toml"); + const firstConfig = await readFile(configPath, "utf8"); + await writeFile( + configPath, + `${firstConfig.replace("[project]", '["project"]')}\n[custom]\nowned = true\n`, + "utf8" + ); + + const nextPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + cadence: "weekly", + }); + const updated = nextPlan.canonicalWrites[1]?.content ?? ""; + expect(Bun.TOML.parse(updated)).toMatchObject({ + custom: { owned: true }, + project: { cadence: "weekly" }, + }); + expect(updated).not.toContain('["project"]'); + expect(updated).toContain("[project]"); + }); + + it("reasserts protective ignores after conflicting negations", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + await mkdir(join(repo, ".ai"), { recursive: true }); + await writeFile( + join(repo, ".ai", ".gitignore"), + [ + "/.facult/", + "!/.facult/", + "/config.local.toml", + "!/config.local.toml", + "", + ].join("\n"), + "utf8" + ); + + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const ignore = plan.canonicalWrites[0]?.content ?? ""; + + expect(ignore.lastIndexOf("/.facult/")).toBeGreaterThan( + ignore.lastIndexOf("!/.facult/") + ); + expect(ignore.lastIndexOf("/config.local.toml")).toBeGreaterThan( + ignore.lastIndexOf("!/config.local.toml") + ); + }); + + it("refuses symlinked project state and guidance", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const outside = join(root, "outside"); + await createRepository({ path: repo, home }); + await mkdir(outside, { recursive: true }); + await mkdir(join(repo, "docs"), { recursive: true }); + await writeFile(join(outside, "guidance.md"), "# Private\n", "utf8"); + await symlink(join(outside, "guidance.md"), join(repo, "docs", "link.md")); + await runFixtureGit({ + argv: ["add", "docs/link.md"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await runFixtureGit({ + argv: [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "-m", + "track symlink", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["docs/link.md"], + }) + ).rejects.toThrow("source must be a regular file"); + + await symlink(outside, join(repo, ".ai")); + await expect( + planProjectEnrollment({ projectRoot: repo, homeDir: home }) + ).rejects.toThrow("unsafe project AI root"); + }); +}); + +describe("project enrollment lifecycle", () => { + it("uses shared canonical modes, private local modes, and restores prior modes", async () => { + if (process.platform === "win32") { + return; + } + const { root, home } = await makeFixture(); + const freshRepo = join(root, "fresh"); + await createRepository({ path: freshRepo, home }); + const freshPlan = await planProjectEnrollment({ + projectRoot: freshRepo, + homeDir: home, + }); + const freshResult = await applyProjectEnrollment({ + plan: freshPlan, + expectedPlanSha256: freshPlan.planSha256, + homeDir: home, + }); + for (const pathValue of freshResult.changedPaths) { + expect((await stat(pathValue)).mode % 0o1000).toBe(0o644); + } + for (const pathValue of [ + ...freshResult.generatedPaths, + freshResult.registryPath, + join( + facultLocalStateRoot(home), + "projects", + "receipts", + `${freshResult.receiptId}.json` + ), + ]) { + expect((await stat(pathValue)).mode % 0o1000).toBe(0o600); + } + + const existingRepo = join(root, "existing"); + const existingAi = join(existingRepo, ".ai"); + const ignorePath = join(existingAi, ".gitignore"); + const configPath = join(existingAi, "config.toml"); + await createRepository({ path: existingRepo, home }); + await mkdir(existingAi, { recursive: true }); + await writeFile(ignorePath, "# existing\n", "utf8"); + await writeFile( + configPath, + 'version = 1\n\n[custom]\nvalue = "preserve"\n', + "utf8" + ); + await chmod(ignorePath, 0o640); + await chmod(configPath, 0o664); + const existingPlan = await planProjectEnrollment({ + projectRoot: existingRepo, + homeDir: home, + }); + expect( + existingPlan.canonicalWrites.map((write) => write.precondition.mode) + ).toEqual([0o640, 0o664]); + const existingResult = await applyProjectEnrollment({ + plan: existingPlan, + expectedPlanSha256: existingPlan.planSha256, + homeDir: home, + }); + expect((await stat(ignorePath)).mode % 0o1000).toBe(0o640); + expect((await stat(configPath)).mode % 0o1000).toBe(0o664); + + await rollbackProjectEnrollment({ + receiptId: existingResult.receiptId, + homeDir: home, + apply: true, + }); + expect(await readFile(ignorePath, "utf8")).toBe("# existing\n"); + expect(await readFile(configPath, "utf8")).toBe( + 'version = 1\n\n[custom]\nvalue = "preserve"\n' + ); + expect((await stat(ignorePath)).mode % 0o1000).toBe(0o640); + expect((await stat(configPath)).mode % 0o1000).toBe(0o664); + }); + + it("atomically migrates legacy path-keyed machine state before enrollment", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyDir = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + const preserved = { + "journal/events.jsonl": '{"event":"preserve"}\n', + "ai/project/writeback/queue.jsonl": '{"writeback":"preserve"}\n', + "ai/project/evolution/proposals/review.json": '{"proposal":"preserve"}\n', + "managed.json": '{"managed":"preserve"}\n', + "autosync/state.json": '{"autosync":"preserve"}\n', + }; + for (const [relativePath, content] of Object.entries(preserved)) { + const pathValue = join(legacyDir, relativePath); + await mkdir(dirname(pathValue), { recursive: true }); + await writeFile(pathValue, content, "utf8"); + } + expect(facultMachineStateDir(home, aiRoot)).toBe(legacyDir); + const legacyKey = legacyMachineStateProjectKey(aiRoot, home); + const executionId = (await resolveRepositoryExecutionIdentity(repo)).id; + const reviewMirrors = [ + { + source: join( + home, + ".ai", + "writebacks", + "projects", + legacyKey, + "review.md" + ), + destination: join( + home, + ".ai", + "writebacks", + "projects", + executionId, + "review.md" + ), + }, + { + source: join( + home, + ".ai", + "evolution", + "projects", + legacyKey, + "review.md" + ), + destination: join( + home, + ".ai", + "evolution", + "projects", + executionId, + "review.md" + ), + }, + { + source: join( + home, + ".ai", + "reconciliation", + "projects", + legacyKey, + "review.md" + ), + destination: join( + home, + ".ai", + "reconciliation", + "projects", + executionId, + "review.md" + ), + }, + ]; + for (const mirror of reviewMirrors) { + await mkdir(dirname(mirror.source), { recursive: true }); + await writeFile(mirror.source, "# Preserve review\n", "utf8"); + } + + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + expect(plan.stateMigrations).toHaveLength(4); + expect(plan.stateMigrations.map((migration) => migration.source)).toEqual([ + legacyDir, + ...reviewMirrors.map((mirror) => dirname(mirror.source)), + ]); + expect( + plan.stateMigrations.every( + (migration) => + migration.reason.includes("Preserve legacy path-keyed") && + migration.destination.includes(plan.executionIdentity.id) + ) + ).toBe(true); + const output: string[] = []; + const previousLog = console.log; + console.log = (value?: unknown) => output.push(String(value)); + try { + await projectCommand(["init", "--project-root", repo], { + homeDir: home, + }); + } finally { + console.log = previousLog; + } + expect( + (JSON.parse(output.join("\n")) as { stateMigrations: unknown[] }) + .stateMigrations + ).toEqual(plan.stateMigrations); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + + const selectedDir = facultMachineStateDir(home, aiRoot); + expect(selectedDir).not.toBe(legacyDir); + expect(await Bun.file(legacyDir).exists()).toBe(false); + for (const [relativePath, content] of Object.entries(preserved)) { + expect(await readFile(join(selectedDir, relativePath), "utf8")).toBe( + content + ); + } + for (const mirror of reviewMirrors) { + expect(await Bun.file(mirror.source).exists()).toBe(false); + expect(await readFile(mirror.destination, "utf8")).toBe( + "# Preserve review\n" + ); + } + }); + + it("migrates a legacy key created through an equivalent symlink spelling", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const alias = join(root, "repo-alias"); + await createRepository({ path: repo, home }); + await symlink( + repo, + alias, + process.platform === "win32" ? "junction" : "dir" + ); + const aliasAiRoot = join(alias, ".ai"); + const legacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aliasAiRoot, home) + ); + const unrelatedLegacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(join(root, "unrelated-alias", ".ai"), home) + ); + const journal = join(legacyState, "journal", "events.jsonl"); + await mkdir(dirname(journal), { recursive: true }); + await writeFile(journal, "symlink invocation\n", "utf8"); + await mkdir(unrelatedLegacyState, { recursive: true }); + await writeFile( + join(unrelatedLegacyState, "unrelated.jsonl"), + "do not adopt\n", + "utf8" + ); + + const plan = await planProjectEnrollment({ + projectRoot: alias, + homeDir: home, + }); + expect(plan.projectRoot).toBe(repo); + expect(plan.legacyStateRoots).toContain(aliasAiRoot); + expect( + plan.stateMigrations.some( + (migration) => migration.source === unrelatedLegacyState + ) + ).toBe(false); + expect(plan.stateMigrations).toEqual([ + expect.objectContaining({ + source: legacyState, + destination: facultMachineStateDir(home, join(repo, ".ai")), + }), + ]); + + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + + expect(await pathEntryExists(legacyState)).toBe(false); + expect( + await readFile(join(unrelatedLegacyState, "unrelated.jsonl"), "utf8") + ).toBe("do not adopt\n"); + expect( + await readFile( + join( + facultMachineStateDir(home, join(repo, ".ai")), + "journal", + "events.jsonl" + ), + "utf8" + ) + ).toBe("symlink invocation\n"); + }); + + it("uses an identity-matched persisted location as a legacy key candidate", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const alias = join(root, "persisted-alias"); + await createRepository({ path: repo, home }); + await symlink( + repo, + alias, + process.platform === "win32" ? "junction" : "dir" + ); + const firstPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const first = await applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + }); + const registry = (await Bun.file(first.registryPath).json()) as { + projects: Record< + string, + { + locations: Array<{ + firstSeenAt: string; + lastSeenAt: string; + path: string; + }>; + } + >; + }; + registry.projects[firstPlan.identity.id]?.locations.push({ + path: alias, + firstSeenAt: "2026-07-01T00:00:00.000Z", + lastSeenAt: "2026-07-01T00:00:00.000Z", + }); + await writeFile( + first.registryPath, + `${JSON.stringify(registry, null, 2)}\n`, + "utf8" + ); + const aliasAiRoot = join(alias, ".ai"); + const legacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aliasAiRoot, home) + ); + const legacyJournal = join(legacyState, "journal", "events.jsonl"); + await mkdir(dirname(legacyJournal), { recursive: true }); + await writeFile(legacyJournal, "persisted alias\n", "utf8"); + + const secondPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + expect(secondPlan.legacyStateRoots).toContain(aliasAiRoot); + expect(secondPlan.stateMigrations).toEqual([ + expect.objectContaining({ source: legacyState }), + ]); + + await applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: home, + }); + + expect(await pathEntryExists(legacyState)).toBe(false); + expect( + await readFile( + join( + facultMachineStateDir(home, join(repo, ".ai")), + "journal", + "events.jsonl" + ), + "utf8" + ) + ).toBe("persisted alias\n"); + }); + + it("deterministically merges disjoint legacy and selected machine state", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + const selectedState = facultMachineStateDir(home, aiRoot); + const legacyJournal = join(legacyState, "journal", "events.jsonl"); + const selectedQueue = join( + selectedState, + "ai", + "project", + "writeback", + "queue.jsonl" + ); + await mkdir(dirname(legacyJournal), { recursive: true }); + await writeFile(legacyJournal, '{"legacy":true}\n', "utf8"); + await mkdir(dirname(selectedQueue), { recursive: true }); + await writeFile(selectedQueue, '{"selected":true}\n', "utf8"); + expect(() => facultMachineStateDir(home, aiRoot)).toThrow( + "require enrollment reconciliation" + ); + + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const migration = plan.stateMigrations.find( + (candidate) => candidate.source === legacyState + ); + expect(migration?.destination).toBe(selectedState); + expect(migration?.strategy).toBe("merge-disjoint"); + expect(typeof migration?.sourceTreeSha256).toBe("string"); + expect(typeof migration?.destinationTreeSha256).toBe("string"); + + let quarantinePath: string | null = null; + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterLegacyStateQuarantine: async ({ quarantine, source }) => { + quarantinePath = quarantine; + expect(await pathEntryExists(source)).toBe(false); + expect((await lstat(quarantine)).isDirectory()).toBe(true); + }, + }); + + expect(await pathEntryExists(legacyState)).toBe(false); + expect(quarantinePath).not.toBeNull(); + if (!quarantinePath) { + throw new Error("Expected legacy migration quarantine observation"); + } + expect(await pathEntryExists(quarantinePath)).toBe(false); + expect( + await readFile(join(selectedState, "journal", "events.jsonl"), "utf8") + ).toBe('{"legacy":true}\n'); + expect(await readFile(selectedQueue, "utf8")).toBe('{"selected":true}\n'); + expect(facultMachineStateDir(home, aiRoot)).toBe(selectedState); + }); + + it("fails closed when an enrolled checkout execution identity is temporarily unavailable", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const aiRoot = join(repo, ".ai"); + const executionState = facultMachineStateDir(home, aiRoot); + const legacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + const originalPath = process.env.PATH; + process.env.PATH = join(root, "missing-bin"); + try { + expect(() => facultMachineStateDir(home, aiRoot)).toThrow( + "Unable to resolve machine-local execution identity" + ); + } finally { + process.env.PATH = originalPath; + } + expect(await pathEntryExists(executionState)).toBe(true); + expect(await pathEntryExists(legacyState)).toBe(false); + }); + + it("preserves legacy state selection for non-Git project config roots", async () => { + const { root, home } = await makeFixture(); + const aiRoot = join(root, "non-git", ".ai"); + await mkdir(aiRoot, { recursive: true }); + await writeFile( + join(aiRoot, "config.toml"), + 'version = 1\n\n[workspace]\nname = "local"\n', + "utf8" + ); + + expect(facultMachineStateDir(home, aiRoot)).toBe( + join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ) + ); + }); + + it("rejects unsafe or unreadable legacy state during path selection", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const outside = join(root, "outside"); + await createRepository({ path: repo, home }); + await mkdir(outside, { recursive: true }); + const aiRoot = join(repo, ".ai"); + const projectsRoot = join(facultLocalStateRoot(home), "projects"); + const legacyState = join( + projectsRoot, + legacyMachineStateProjectKey(aiRoot, home) + ); + await mkdir(projectsRoot, { recursive: true }); + await symlink( + outside, + legacyState, + process.platform === "win32" ? "junction" : "dir" + ); + expect(() => facultMachineStateDir(home, aiRoot)).toThrow( + "unsafe machine-local project state directory" + ); + await rm(legacyState); + + await mkdir(legacyState, { recursive: true }); + if (process.platform !== "win32") { + await chmod(projectsRoot, 0o000); + try { + let accessDenied = false; + try { + await lstat(legacyState); + } catch (error) { + accessDenied = Boolean( + error && + typeof error === "object" && + "code" in error && + error.code === "EACCES" + ); + } + if (accessDenied) { + expect(() => facultMachineStateDir(home, aiRoot)).toThrow(); + } + } finally { + await chmod(projectsRoot, 0o700); + } + } + await rm(legacyState, { recursive: true }); + const nonGitRoot = join(root, "non-git"); + const nonGitAiRoot = join(nonGitRoot, ".ai"); + await mkdir(join(nonGitAiRoot, "instructions"), { recursive: true }); + const nonGitState = join( + projectsRoot, + legacyMachineStateProjectKey(nonGitAiRoot, home) + ); + await symlink( + outside, + nonGitState, + process.platform === "win32" ? "junction" : "dir" + ); + expect(() => facultMachineStateDir(home, nonGitAiRoot)).toThrow( + "unsafe machine-local project state directory" + ); + }); + + it("preserves a late legacy writer detected before quarantine", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + const selectedState = facultMachineStateDir(home, aiRoot); + const legacyJournal = join(legacyState, "journal", "events.jsonl"); + const lateQueue = join(legacyState, "writeback", "late.jsonl"); + const selectedQueue = join(selectedState, "review", "queue.jsonl"); + await mkdir(dirname(legacyJournal), { recursive: true }); + await writeFile(legacyJournal, "legacy journal\n", "utf8"); + await mkdir(dirname(selectedQueue), { recursive: true }); + await writeFile(selectedQueue, "selected queue\n", "utf8"); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + beforeLegacyStateQuarantine: async () => { + await mkdir(dirname(lateQueue), { recursive: true }); + await writeFile(lateQueue, "late writer\n", "utf8"); + }, + }) + ).rejects.toThrow("changed before quarantine"); + + expect(await readFile(legacyJournal, "utf8")).toBe("legacy journal\n"); + expect(await readFile(lateQueue, "utf8")).toBe("late writer\n"); + expect(await readFile(selectedQueue, "utf8")).toBe("selected queue\n"); + expect( + await Bun.file(join(selectedState, "journal", "events.jsonl")).exists() + ).toBe(false); + expect(await Bun.file(aiRoot).exists()).toBe(false); + }); + + it("preserves a late legacy writer detected before rename commit", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + const executionState = join( + facultLocalStateRoot(home), + "projects", + (await resolveRepositoryExecutionIdentity(repo)).id + ); + const legacyJournal = join(legacyState, "journal", "events.jsonl"); + const lateQueue = join(legacyState, "writeback", "late.jsonl"); + await mkdir(dirname(legacyJournal), { recursive: true }); + await writeFile(legacyJournal, "legacy journal\n", "utf8"); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + expect(plan.stateMigrations).toEqual([ + expect.objectContaining({ + source: legacyState, + destination: executionState, + strategy: "rename", + }), + ]); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterReceiptWrite: async () => { + await mkdir(dirname(lateQueue), { recursive: true }); + await writeFile(lateQueue, "late writer\n", "utf8"); + }, + }) + ).rejects.toThrow("changed before commit"); + + expect(await readFile(legacyJournal, "utf8")).toBe("legacy journal\n"); + expect(await readFile(lateQueue, "utf8")).toBe("late writer\n"); + expect(await pathEntryExists(executionState)).toBe(false); + expect(await Bun.file(aiRoot).exists()).toBe(false); + expect( + await Bun.file(plan.machineLocalWrites[0]?.path ?? "").exists() + ).toBe(false); + }); + + it("compensates a legacy source reappearance after quarantine", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + const selectedState = facultMachineStateDir(home, aiRoot); + const legacyJournal = join(legacyState, "journal", "events.jsonl"); + const lateReview = join(legacyState, "review", "late.md"); + const selectedQueue = join(selectedState, "review", "queue.jsonl"); + await mkdir(dirname(legacyJournal), { recursive: true }); + await writeFile(legacyJournal, "legacy journal\n", "utf8"); + await mkdir(dirname(selectedQueue), { recursive: true }); + await writeFile(selectedQueue, "selected queue\n", "utf8"); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterLegacyStateQuarantine: async ({ source }) => { + await mkdir(dirname(lateReview), { recursive: true }); + await writeFile(lateReview, "late review\n", "utf8"); + expect(source).toBe(legacyState); + }, + }) + ).rejects.toThrow("changed after quarantine"); + + expect(await readFile(legacyJournal, "utf8")).toBe("legacy journal\n"); + expect(await readFile(lateReview, "utf8")).toBe("late review\n"); + expect(await readFile(selectedQueue, "utf8")).toBe("selected queue\n"); + expect( + await Bun.file(join(selectedState, "journal", "events.jsonl")).exists() + ).toBe(false); + expect( + (await readdir(dirname(legacyState))).some((name) => + name.includes(".fclt-quarantine-") + ) + ).toBe(false); + expect(await Bun.file(aiRoot).exists()).toBe(false); + }); + + it("rebuilds selected generated overlaps while preserving disjoint state", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + const selectedState = facultMachineStateDir(home, aiRoot); + const legacyIndex = join(legacyState, "ai", "index.json"); + const legacyGraph = join(legacyState, "ai", "graph.json"); + const selectedIndex = facultAiIndexPath(home, aiRoot); + const selectedGraph = facultAiGraphPath(home, aiRoot); + const legacyJournal = join(legacyState, "journal", "events.jsonl"); + const selectedQueue = join(selectedState, "review", "queue.jsonl"); + for (const [pathValue, content] of [ + [legacyIndex, '{"legacy":"index"}\n'], + [legacyGraph, '{"legacy":"graph"}\n'], + [legacyJournal, "legacy journal\n"], + [selectedIndex, '{"selected":"index"}\n'], + [selectedGraph, '{"selected":"graph"}\n'], + [selectedQueue, "selected queue\n"], + ] as const) { + await mkdir(dirname(pathValue), { recursive: true }); + await writeFile(pathValue, content, "utf8"); + } + + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const migration = plan.stateMigrations.find( + (candidate) => candidate.source === legacyState + ); + expect(migration?.rebuildableOverlaps).toEqual([ + "ai/graph.json", + "ai/index.json", + ]); + + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + + expect(await Bun.file(legacyState).exists()).toBe(false); + expect(JSON.parse(await readFile(selectedIndex, "utf8")).version).toBe(1); + expect(JSON.parse(await readFile(selectedGraph, "utf8")).version).toBe(1); + expect( + await readFile(join(selectedState, "journal", "events.jsonl"), "utf8") + ).toBe("legacy journal\n"); + expect(await readFile(selectedQueue, "utf8")).toBe("selected queue\n"); + }); + + it("restores generated overlaps when a later enrollment step fails", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + const legacyIndex = join(legacyState, "ai", "index.json"); + const selectedIndex = facultAiIndexPath(home, aiRoot); + await mkdir(dirname(legacyIndex), { recursive: true }); + await writeFile(legacyIndex, "legacy generated\n", "utf8"); + await mkdir(dirname(selectedIndex), { recursive: true }); + await writeFile(selectedIndex, "selected generated\n", "utf8"); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterGeneratedWrites: () => + Promise.reject(new Error("injected generated-overlap failure")), + }) + ).rejects.toThrow("injected generated-overlap failure"); + + expect(await readFile(legacyIndex, "utf8")).toBe("legacy generated\n"); + expect(await readFile(selectedIndex, "utf8")).toBe("selected generated\n"); + expect(await Bun.file(aiRoot).exists()).toBe(false); + }); + + it("compensates a disjoint merge when later enrollment fails", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + const selectedState = facultMachineStateDir(home, aiRoot); + const legacyFile = join(legacyState, "journal", "events.jsonl"); + const selectedFile = join(selectedState, "review", "queue.jsonl"); + await mkdir(dirname(legacyFile), { recursive: true }); + await writeFile(legacyFile, "legacy\n", "utf8"); + await mkdir(dirname(selectedFile), { recursive: true }); + await writeFile(selectedFile, "selected\n", "utf8"); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterGeneratedWrites: () => + Promise.reject(new Error("injected post-merge failure")), + }) + ).rejects.toThrow("injected post-merge failure"); + + expect(await readFile(legacyFile, "utf8")).toBe("legacy\n"); + expect(await readFile(selectedFile, "utf8")).toBe("selected\n"); + expect( + await Bun.file(join(selectedState, "journal", "events.jsonl")).exists() + ).toBe(false); + expect(await Bun.file(aiRoot).exists()).toBe(false); + }); + + it("compensates completed legacy moves when later enrollment fails", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyKey = legacyMachineStateProjectKey(aiRoot, home); + const legacyState = join(facultLocalStateRoot(home), "projects", legacyKey); + const legacyReviews = ["writebacks", "evolution", "reconciliation"].map( + (artifact) => join(home, ".ai", artifact, "projects", legacyKey) + ); + for (const pathValue of [legacyState, ...legacyReviews]) { + await mkdir(pathValue, { recursive: true }); + await writeFile(join(pathValue, "preserve.txt"), pathValue, "utf8"); + } + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterGeneratedWrites: () => + Promise.reject(new Error("injected later enrollment failure")), + }) + ).rejects.toThrow("injected later enrollment failure"); + + const selectedPaths = [ + facultMachineStateDir(home, aiRoot), + facultAiWritebackReviewDir(home, aiRoot), + facultAiEvolutionReviewDir(home, aiRoot), + facultAiReconciliationReviewDir(home, aiRoot), + ]; + for (const pathValue of [legacyState, ...legacyReviews]) { + expect(await readFile(join(pathValue, "preserve.txt"), "utf8")).toBe( + pathValue + ); + } + for (const pathValue of selectedPaths) { + expect(await Bun.file(pathValue).exists()).toBe(false); + } + expect(await Bun.file(aiRoot).exists()).toBe(false); + }); + + it("compensates earlier legacy moves when a later rename fails", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyKey = legacyMachineStateProjectKey(aiRoot, home); + const legacyState = join(facultLocalStateRoot(home), "projects", legacyKey); + const legacyReview = join(home, ".ai", "writebacks", "projects", legacyKey); + for (const pathValue of [legacyState, legacyReview]) { + await mkdir(pathValue, { recursive: true }); + await writeFile(join(pathValue, "preserve.txt"), pathValue, "utf8"); + } + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + beforeLegacyStateRename: ({ index }) => + index === 1 + ? Promise.reject(new Error("injected migration rename failure")) + : Promise.resolve(), + }) + ).rejects.toThrow("injected migration rename failure"); + for (const pathValue of [legacyState, legacyReview]) { + expect(await readFile(join(pathValue, "preserve.txt"), "utf8")).toBe( + pathValue + ); + } + expect(await Bun.file(facultMachineStateDir(home, aiRoot)).exists()).toBe( + false + ); + expect( + await Bun.file(facultAiWritebackReviewDir(home, aiRoot)).exists() + ).toBe(false); + }); + + it("fails closed when a moved destination is replaced before restore", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyState = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + await mkdir(legacyState, { recursive: true }); + await writeFile(join(legacyState, "preserve.txt"), "legacy\n", "utf8"); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const selectedState = + plan.stateMigrations.find((migration) => migration.source === legacyState) + ?.destination ?? ""; + expect(selectedState).not.toBe(""); + const displacedState = `${selectedState}.displaced`; + let replaced = false; + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterGeneratedWrites: () => + Promise.reject(new Error("injected later failure")), + beforeLegacyStateRestore: async ({ destination }) => { + if (!replaced && destination === selectedState) { + replaced = true; + await rename(destination, displacedState); + await mkdir(destination); + await writeFile( + join(destination, "replacement.txt"), + "replacement\n", + "utf8" + ); + } + }, + }) + ).rejects.toThrow("transaction cleanup was incomplete"); + expect(await Bun.file(legacyState).exists()).toBe(false); + expect(await readFile(join(selectedState, "replacement.txt"), "utf8")).toBe( + "replacement\n" + ); + expect(await readFile(join(displacedState, "preserve.txt"), "utf8")).toBe( + "legacy\n" + ); + }); + + it("fails closed when legacy and selected machine state both exist", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyDir = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + const selectedDir = facultMachineStateDir(home, aiRoot); + await mkdir(legacyDir, { recursive: true }); + await mkdir(selectedDir, { recursive: true }); + await writeFile(join(legacyDir, "managed.json"), "legacy\n", "utf8"); + await writeFile(join(selectedDir, "managed.json"), "selected\n", "utf8"); + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }) + ).rejects.toThrow("conflicting legacy and selected"); + expect(await readFile(join(legacyDir, "managed.json"), "utf8")).toBe( + "legacy\n" + ); + expect(await readFile(join(selectedDir, "managed.json"), "utf8")).toBe( + "selected\n" + ); + expect(await Bun.file(aiRoot).exists()).toBe(false); + }); + + it("refuses generated index and graph symlinks without touching their targets", async () => { + for (const generatedIndex of [0, 1]) { + const { root, home } = await makeFixture(); + const repo = join(root, `repo-${generatedIndex}`); + const victim = join(root, `victim-${generatedIndex}.json`); + await createRepository({ path: repo, home }); + await writeFile(victim, "user-owned\n", "utf8"); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const generatedPath = plan.generatedWrites[generatedIndex]?.path; + expect(generatedPath).toBeDefined(); + await mkdir(dirname(generatedPath ?? ""), { recursive: true }); + await symlink(victim, generatedPath ?? ""); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }) + ).rejects.toThrow("symlinked machine-local state path"); + + expect(await readFile(victim, "utf8")).toBe("user-owned\n"); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + } + }); + + it("requires the reviewed hash and writes protection before generated state", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: "wrong", + homeDir: home, + }) + ).rejects.toThrow("exact plan SHA"); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + + const result = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + now: new Date("2026-07-28T12:00:00.000Z"), + }); + + expect(result.changedPaths).toEqual([ + join(repo, ".ai", ".gitignore"), + join(repo, ".ai", "config.toml"), + ]); + expect(await readFile(join(repo, ".ai", ".gitignore"), "utf8")).toContain( + "/.facult/" + ); + expect(await Bun.file(join(repo, ".ai", ".facult")).exists()).toBe(false); + expect(await Bun.file(join(repo, ".ai", "AGENTS.global.md")).exists()).toBe( + false + ); + expect( + await Bun.file(facultAiIndexPath(home, join(repo, ".ai"))).exists() + ).toBe(true); + expect( + facultMachineStateDir(home, join(repo, ".ai")).endsWith( + plan.executionIdentity.id + ) + ).toBe(true); + expect(await Bun.file(result.registryPath).exists()).toBe(true); + const status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(status.projects[0]?.generated).toEqual({ + index: true, + graph: true, + health: "ready", + }); + }); + + it("reports unenrolled discovered repositories without writing", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + + const status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + + expect(status.projects).toHaveLength(1); + expect(status.projects[0]).toMatchObject({ + decision: "inactive", + coverage: "inactive", + health: "degraded", + canonical: { + exists: false, + config: false, + protectiveIgnore: false, + }, + generated: { index: false, graph: false, health: "missing" }, + }); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + expect(await Bun.file(status.registryPath).exists()).toBe(false); + }); + + it("refuses a stale plan before any write", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await mkdir(join(repo, ".ai"), { recursive: true }); + await writeFile(join(repo, ".ai", ".gitignore"), "/user-change\n", "utf8"); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }) + ).rejects.toThrow("plan is stale"); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + false + ); + expect(await readFile(join(repo, ".ai", ".gitignore"), "utf8")).toBe( + "/user-change\n" + ); + }); + + it("revalidates every canonical destination at its write boundary", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const userEdit = "version = 1\n\n[user]\nowned = true\n"; + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + beforeCanonicalWrite: async ({ index }) => { + if (index === 1) { + await writeFile(join(repo, ".ai", "config.toml"), userEdit, "utf8"); + } + }, + }) + ).rejects.toThrow("no-replace commit boundary"); + + expect(await readFile(join(repo, ".ai", "config.toml"), "utf8")).toBe( + userEdit + ); + expect(await Bun.file(join(repo, ".ai", ".gitignore")).exists()).toBe( + false + ); + }); + + it("compensates an existing canonical file swapped at the enrollment exchange boundary", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const configPath = join(aiRoot, "config.toml"); + const reviewedPath = join(aiRoot, "config.reviewed.toml"); + const reviewed = "version = 1\n\n[custom]\nowned = true\n"; + const concurrent = "version = 1\n\n[user]\nowned = true\n"; + await mkdir(aiRoot); + await writeFile(configPath, reviewed, "utf8"); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + beforeCanonicalWrite: async ({ path }) => { + if (path !== configPath) { + return; + } + await rename(configPath, reviewedPath); + await writeFile(configPath, concurrent, "utf8"); + }, + }) + ).rejects.toThrow("commit boundary"); + + expect(await readFile(configPath, "utf8")).toBe(concurrent); + expect(await readFile(reviewedPath, "utf8")).toBe(reviewed); + expect(await Bun.file(join(aiRoot, ".gitignore")).exists()).toBe(false); + expect( + (await readdir(aiRoot)).filter((name) => name.endsWith(".tmp")) + ).toEqual([]); + }); + + it("preserves a concurrent canonical edit when a later apply stage fails", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const concurrentEdit = "version = 1\n\n[user]\nowned = true\n"; + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterGeneratedWrites: async () => { + await writeFile( + join(repo, ".ai", "config.toml"), + concurrentEdit, + "utf8" + ); + throw new Error("injected late failure"); + }, + }) + ).rejects.toThrow("injected late failure"); + + expect(await readFile(join(repo, ".ai", "config.toml"), "utf8")).toBe( + concurrentEdit + ); + expect(await Bun.file(join(repo, ".ai", ".gitignore")).exists()).toBe( + false + ); + for (const generated of plan.generatedWrites) { + expect(await Bun.file(generated.path).exists()).toBe(false); + } + }); + + it("revalidates ownership at the cleanup restore commit boundary", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + await mkdir(join(repo, ".ai"), { recursive: true }); + const before = "version = 1\n\n[custom]\nowned = true\n"; + const concurrent = `${before}\n[user]\nlate = true\n`; + const configPath = join(repo, ".ai", "config.toml"); + await writeFile(configPath, before, "utf8"); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + let edited = false; + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterGeneratedWrites: () => Promise.reject(new Error("late failure")), + beforeCleanupRestore: async ({ path }) => { + if (!edited && path === configPath) { + edited = true; + await writeFile(configPath, concurrent, "utf8"); + } + }, + }) + ).rejects.toThrow("cleanup was incomplete"); + expect(await readFile(configPath, "utf8")).toBe(concurrent); + }); + + it("preserves a fresh canonical leaf swapped during failed-apply cleanup", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const configPath = join(repo, ".ai", "config.toml"); + const displacedPath = join(repo, ".ai", "config.enrolled.toml"); + const replacement = "version = 1\n\n[user]\nowned = true\n"; + const enrolled = plan.canonicalWrites.find( + (write) => write.path === configPath + )?.content; + if (!enrolled) { + throw new Error("Expected a planned project config"); + } + let swapped = false; + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterGeneratedWrites: () => + Promise.reject(new Error("injected late apply failure")), + beforeCleanupRestore: async ({ path }) => { + if (swapped || path !== configPath) { + return; + } + swapped = true; + await rename(configPath, displacedPath); + await writeFile(configPath, replacement, "utf8"); + }, + }) + ).rejects.toThrow("cleanup was incomplete"); + + expect(swapped).toBe(true); + expect(await readFile(configPath, "utf8")).toBe(replacement); + expect(await readFile(displacedPath, "utf8")).toBe(enrolled); + expect( + (await readdir(join(repo, ".ai"))).filter((name) => + name.endsWith(".rollback") + ) + ).toEqual([]); + }); + + it("fails closed when a failed-apply cleanup safe root is replaced", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const displacedRepo = join(root, "repo-enrolled"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const configPath = join(repo, ".ai", "config.toml"); + const replacement = "version = 1\n\n[user]\nowned = true\n"; + const enrolled = plan.canonicalWrites.find( + (write) => write.path === configPath + )?.content; + if (!enrolled) { + throw new Error("Expected a planned project config"); + } + let swapped = false; + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterGeneratedWrites: () => + Promise.reject(new Error("injected late apply failure")), + beforeCleanupRestore: async ({ path }) => { + if (swapped || path !== configPath) { + return; + } + swapped = true; + await rename(repo, displacedRepo); + await mkdir(join(repo, ".ai"), { recursive: true }); + await writeFile(configPath, replacement, "utf8"); + }, + }) + ).rejects.toThrow("cleanup was incomplete"); + + expect(swapped).toBe(true); + expect(await readFile(configPath, "utf8")).toBe(replacement); + expect( + await readFile(join(displacedRepo, ".ai", "config.toml"), "utf8") + ).toBe(enrolled); + }); + + it("cleans generated state, registry, and receipt after a final verification failure", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterReceiptWrite: () => + Promise.reject(new Error("injected final verification failure")), + }) + ).rejects.toThrow("injected final verification failure"); + + expect(await Bun.file(join(repo, ".ai", ".gitignore")).exists()).toBe( + false + ); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + false + ); + for (const generated of plan.generatedWrites) { + expect(await Bun.file(generated.path).exists()).toBe(false); + } + const registryPath = plan.machineLocalWrites[0]?.path ?? ""; + expect(await Bun.file(registryPath).exists()).toBe(false); + const receiptFiles = await readdir( + join(dirname(registryPath), "receipts") + ).catch(() => []); + expect(receiptFiles.filter((name) => name.endsWith(".json"))).toEqual([]); + }); + + it("propagates non-ENOENT metadata errors during transaction snapshots", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const aiRoot = join(repo, ".ai"); + const displacedAiRoot = join(repo, ".ai-enrolled"); + let thrown: unknown; + + try { + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + afterGeneratedWrites: async () => { + await rename(aiRoot, displacedAiRoot); + await writeFile(aiRoot, "not a directory\n", "utf8"); + throw new Error("injected metadata failure"); + }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(AggregateError); + const errors = (thrown as AggregateError).errors; + expect( + errors.some( + (error) => (error as NodeJS.ErrnoException).code === "ENOTDIR" + ) + ).toBe(true); + expect(await readFile(aiRoot, "utf8")).toBe("not a directory\n"); + expect(await readFile(join(displacedAiRoot, "config.toml"), "utf8")).toBe( + plan.canonicalWrites[1]?.content ?? "" + ); + }); + + it("preserves a non-cooperating registry edit at the final commit boundary", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const registryPath = plan.machineLocalWrites[0]?.path ?? ""; + const concurrentRegistry = `${JSON.stringify( + { + version: 1, + updatedAt: "2026-07-28T12:30:00.000Z", + projects: {}, + }, + null, + 2 + )}\n`; + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + beforeRegistryWrite: async () => { + await writeFile(registryPath, concurrentRegistry, "utf8"); + }, + }) + ).rejects.toThrow("commit boundary"); + + expect(await readFile(registryPath, "utf8")).toBe(concurrentRegistry); + expect(await Bun.file(join(repo, ".ai", ".gitignore")).exists()).toBe( + false + ); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + false + ); + for (const generated of plan.generatedWrites) { + expect(await Bun.file(generated.path).exists()).toBe(false); + } + const receiptFiles = await readdir( + join(dirname(registryPath), "receipts") + ).catch(() => []); + expect(receiptFiles.filter((name) => name.endsWith(".json"))).toEqual([]); + }); + + it("preserves a registry replacement at the conditional exchange boundary", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const registryPath = plan.machineLocalWrites[0]?.path ?? ""; + const initialRegistry = `${JSON.stringify( + { + version: 1, + updatedAt: "2026-07-28T12:00:00.000Z", + projects: {}, + }, + null, + 2 + )}\n`; + const concurrentRegistry = initialRegistry.replace( + "2026-07-28T12:00:00.000Z", + "2026-07-28T12:30:00.000Z" + ); + await mkdir(dirname(registryPath), { recursive: true }); + await writeFile(registryPath, initialRegistry, "utf8"); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + beforeRegistryExchange: async () => { + await writeFile(registryPath, concurrentRegistry, "utf8"); + }, + }) + ).rejects.toThrow("conditional commit boundary"); + + expect(await readFile(registryPath, "utf8")).toBe(concurrentRegistry); + expect(await Bun.file(join(repo, ".ai", ".gitignore")).exists()).toBe( + false + ); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + false + ); + for (const generated of plan.generatedWrites) { + expect(await Bun.file(generated.path).exists()).toBe(false); + } + const receiptFiles = await readdir( + join(dirname(registryPath), "receipts") + ).catch(() => []); + expect(receiptFiles.filter((name) => name.endsWith(".json"))).toEqual([]); + }); + + it("serializes duplicate exact plans so one transaction wins without losing registry history", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const firstPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const secondPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + const results = await Promise.allSettled([ + applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + }), + applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: home, + }), + ]); + + expect( + results.filter((result) => result.status === "fulfilled") + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected") + ).toHaveLength(1); + const applied = results.find((result) => result.status === "fulfilled"); + if (applied?.status !== "fulfilled") { + throw new Error("Expected one enrollment transaction to succeed"); + } + const registry = (await Bun.file(applied.value.registryPath).json()) as { + projects: Record }>; + }; + expect( + registry.projects[firstPlan.identity.id]?.history.filter( + (event) => event.action === "enrolled" + ) + ).toHaveLength(1); + }); + + it("refuses an older receipt after a newer enrollment of the same checkout", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const firstPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const first = await applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + now: new Date("2026-07-28T12:00:00.000Z"), + }); + const secondPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const second = await applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: home, + now: new Date("2026-07-28T13:00:00.000Z"), + }); + + await expect( + rollbackProjectEnrollment({ + receiptId: first.receiptId, + homeDir: home, + apply: true, + }) + ).rejects.toThrow("not the active enrollment"); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + true + ); + expect(second.receiptId).not.toBe(first.receiptId); + }); + + it("serializes apply against rollback so only one receipt transition wins", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const initialPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const initial = await applyProjectEnrollment({ + plan: initialPlan, + expectedPlanSha256: initialPlan.planSha256, + homeDir: home, + now: new Date("2026-07-28T12:00:00.000Z"), + }); + const nextPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + + const results = await Promise.allSettled([ + applyProjectEnrollment({ + plan: nextPlan, + expectedPlanSha256: nextPlan.planSha256, + homeDir: home, + now: new Date("2026-07-28T13:00:00.000Z"), + }), + rollbackProjectEnrollment({ + receiptId: initial.receiptId, + homeDir: home, + apply: true, + now: new Date("2026-07-28T13:00:00.000Z"), + }), + ]); + + expect( + results.filter((result) => result.status === "fulfilled") + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected") + ).toHaveLength(1); + const registry = (await Bun.file(initial.registryPath).json()) as { + projects: Record }>; + }; + expect(registry.projects[initialPlan.identity.id]?.history).toHaveLength(2); + }); + + it("reclaims an abandoned mutation lock while excluding a live owner", async () => { + const { root, home } = await makeFixture(); + const firstRepo = join(root, "first"); + const secondRepo = join(root, "second"); + await createRepository({ path: firstRepo, home }); + await createRepository({ path: secondRepo, home }); + const firstPlan = await planProjectEnrollment({ + projectRoot: firstRepo, + homeDir: home, + }); + const secondPlan = await planProjectEnrollment({ + projectRoot: secondRepo, + homeDir: home, + }); + const lockPath = + firstPlan.machineLocalWrites.find((write) => + write.path.endsWith("mutation.lock") + )?.path ?? ""; + await mkdir(lockPath, { recursive: true }); + await writeFile( + join(lockPath, "owner.json"), + `${JSON.stringify({ + version: 2, + endpoint: join(tmpdir(), "fclt-abandoned-owner.sock"), + ownerId: "abandoned-owner", + pid: 999_999, + acquiredAt: "2026-07-28T00:00:00.000Z", + transport: "ipc-socket", + })}\n`, + "utf8" + ); + + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const firstApply = applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + afterGeneratedWrites: async () => { + entered.resolve(); + await release.promise; + }, + }); + await entered.promise; + const liveOwner = (await Bun.file(join(lockPath, "owner.json")).json()) as { + pid: number; + transport: string; + }; + expect(liveOwner).toMatchObject({ + pid: process.pid, + transport: "ipc-socket", + }); + + let secondSettled = false; + const secondApply = applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: home, + }).finally(() => { + secondSettled = true; + }); + await Bun.sleep(50); + expect(secondSettled).toBe(false); + release.resolve(); + + await Promise.all([firstApply, secondApply]); + expect(await Bun.file(lockPath).exists()).toBe(false); + }); + + it("fails safe when a live owner is IPC-unresponsive or its PID was reused", async () => { + if (process.platform === "win32") { + return; + } + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const lockPath = + plan.machineLocalWrites.find((write) => + write.path.endsWith("mutation.lock") + )?.path ?? ""; + await mkdir(lockPath, { recursive: true }); + await writeFile( + join(lockPath, "owner.json"), + `${JSON.stringify({ + version: 2, + endpoint: join(tmpdir(), "fclt-recycled-pid-owner.sock"), + ownerId: "recycled-pid-owner", + pid: process.pid, + acquiredAt: "2026-07-28T00:00:00.000Z", + transport: "ipc-socket", + })}\n`, + "utf8" + ); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + mutationLockAttempts: 3, + }) + ).rejects.toThrow("still in progress"); + }); + + it("does not reclaim an ownerless mutation lock after initialization delay", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const lockPath = + plan.machineLocalWrites.find((write) => + write.path.endsWith("mutation.lock") + )?.path ?? ""; + await mkdir(lockPath, { recursive: true }); + const old = new Date("2026-07-28T00:00:00.000Z"); + await utimes(lockPath, old, old); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + mutationLockAttempts: 3, + }) + ).rejects.toThrow("still in progress"); + expect((await lstat(lockPath)).isDirectory()).toBe(true); + }); + + it("refuses a symlinked mutation-lock owner without reclaiming the lock", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const outsideOwner = join(root, "outside-owner.json"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const lockPath = + plan.machineLocalWrites.find((write) => + write.path.endsWith("mutation.lock") + )?.path ?? ""; + await mkdir(lockPath, { recursive: true }); + await writeFile(outsideOwner, '{"version":2}\n', "utf8"); + await symlink(outsideOwner, join(lockPath, "owner.json")); + + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + mutationLockAttempts: 1, + }) + ).rejects.toThrow("Refusing unsafe canonical file"); + expect((await lstat(lockPath)).isDirectory()).toBe(true); + expect(await readFile(outsideOwner, "utf8")).toBe('{"version":2}\n'); + }); + + it("releases the mutation lock before sequential operations resolve", async () => { + const { root, home } = await makeFixture(); + for (let index = 0; index < 4; index += 1) { + const repo = join(root, `sequential-${index}`); + await createRepository({ + path: repo, + home, + files: { "README.md": `# Sequential ${index}\n` }, + }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const lockPath = + plan.machineLocalWrites.find((write) => + write.path.endsWith("mutation.lock") + )?.path ?? ""; + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + expect(await Bun.file(lockPath).exists()).toBe(false); + + const rolledBack = await rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + apply: true, + }); + expect(rolledBack.applied).toBe(true); + expect(await Bun.file(lockPath).exists()).toBe(false); + } + }); + + it("rolls back a fresh enrollment through the Windows-safe removal path", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "windows-rollback"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const configPath = join(repo, ".ai", "config.toml"); + const ignorePath = join(repo, ".ai", ".gitignore"); + + const rolledBack = await rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + apply: true, + removalPlatform: "win32", + }); + + expect(rolledBack.applied).toBe(true); + expect(await Bun.file(configPath).exists()).toBe(false); + expect(await Bun.file(ignorePath).exists()).toBe(false); + expect( + (await readdir(join(repo, ".ai"))).filter( + (name) => name.endsWith(".rollback") || name.endsWith(".preserved") + ) + ).toEqual([]); + }); + + it("ignores occupied TCP ports and isolates distinct state roots", async () => { + const { root, home } = await makeFixture(); + const otherHome = join(root, "other-home"); + const firstRepo = join(root, "first-repo"); + const secondRepo = join(root, "second-repo"); + await mkdir(otherHome, { recursive: true }); + await createRepository({ path: firstRepo, home }); + await createRepository({ path: secondRepo, home: otherHome }); + const firstPlan = await planProjectEnrollment({ + projectRoot: firstRepo, + homeDir: home, + }); + const secondPlan = await planProjectEnrollment({ + projectRoot: secondRepo, + homeDir: otherHome, + }); + const unrelated = createServer((socket) => socket.end("unrelated\n")); + await new Promise((resolvePromise, rejectPromise) => { + unrelated.once("error", rejectPromise); + unrelated.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => + resolvePromise() + ); + }); + try { + const [first, second] = await Promise.all([ + applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + }), + applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: otherHome, + }), + ]); + expect(first.applied).toBe(true); + expect(second.applied).toBe(true); + for (const plan of [firstPlan, secondPlan]) { + expect( + await Bun.file( + plan.machineLocalWrites.find((write) => + write.path.endsWith("mutation.lock") + )?.path ?? "" + ).exists() + ).toBe(false); + } + } finally { + await new Promise((resolvePromise, rejectPromise) => { + unrelated.close((error) => { + if (error) { + rejectPromise(error); + return; + } + resolvePromise(); + }); + }); + } + }); + + it("preserves a legacy root primary with common-dir proof while adding the remote alias", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + await runFixtureGit({ + argv: [ + "remote", + "add", + "origin", + "https://github.com/example/migrated.git", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const rootAlias = plan.identity.aliases.find( + (alias) => alias.kind === "root-commit" + ); + if (!rootAlias) { + throw new Error("Expected a root-commit migration alias"); + } + const commonAlias = plan.identity.aliases.find( + (alias) => alias.kind === "git-common-dir" + ); + if (!commonAlias) { + throw new Error("Expected a git-common-dir migration alias"); + } + const registryPath = plan.machineLocalWrites[0]?.path ?? ""; + await mkdir(dirname(registryPath), { recursive: true }); + await writeFile( + registryPath, + `${JSON.stringify( + { + version: 1, + updatedAt: "2026-07-27T12:00:00.000Z", + projects: { + [rootAlias.id]: { + repositoryId: rootAlias.id, + aliases: [commonAlias.id], + identityKind: "root-commit", + identityFingerprint: rootAlias.fingerprint, + decision: "disabled", + sources: ["git"], + cadence: "on-demand", + scheduling: false, + guidance: [], + locations: [ + { + path: repo, + firstSeenAt: "2026-07-27T12:00:00.000Z", + lastSeenAt: "2026-07-27T12:00:00.000Z", + }, + ], + lastSuccessfulRun: null, + pendingApprovals: [], + history: [ + { + at: "2026-07-27T12:00:00.000Z", + action: "disabled", + root: repo, + }, + ], + }, + }, + }, + null, + 2 + )}\n`, + "utf8" + ); + + const migratedPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const applied = await applyProjectEnrollment({ + plan: migratedPlan, + expectedPlanSha256: migratedPlan.planSha256, + homeDir: home, + now: new Date("2026-07-28T12:00:00.000Z"), + }); + const registry = (await Bun.file(applied.registryPath).json()) as { + projects: Record< + string, + { aliases: string[]; history: Array<{ action: string }> } + >; + }; + + expect(migratedPlan.identity.id).toBe(rootAlias.id); + expect(Object.keys(registry.projects)).toEqual([rootAlias.id]); + expect(registry.projects[rootAlias.id]?.aliases).toContain( + plan.identity.id + ); + expect( + registry.projects[rootAlias.id]?.history.map((event) => event.action) + ).toEqual(["disabled", "enrolled"]); + }); + + it("matches a recorded portable alias when enrolling a separate clone", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const clone = join(root, "clone"); + const origin = "https://github.com/example/portable-alias.git"; + await createRepository({ path: repo, home }); + const initialPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const initial = await applyProjectEnrollment({ + plan: initialPlan, + expectedPlanSha256: initialPlan.planSha256, + homeDir: home, + }); + await runFixtureGit({ + argv: ["remote", "add", "origin", origin], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + const remotePlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan: remotePlan, + expectedPlanSha256: remotePlan.planSha256, + homeDir: home, + }); + await runFixtureGit({ + argv: ["clone", repo, clone], + repoDir: clone, + homeDir: home, + cwd: root, + }); + await runFixtureGit({ + argv: ["remote", "set-url", "origin", origin], + repoDir: clone, + homeDir: home, + cwd: clone, + }); + + const clonePlan = await planProjectEnrollment({ + projectRoot: clone, + homeDir: home, + }); + const cloneExecutionIdentity = + await resolveRepositoryExecutionIdentity(clone); + expect(remotePlan.identity.id).toBe(initialPlan.identity.id); + expect(clonePlan.identity.id).toBe(initialPlan.identity.id); + expect(clonePlan.executionIdentity.id).toBe(cloneExecutionIdentity.id); + expect(clonePlan.executionIdentity.id).not.toBe( + initialPlan.executionIdentity.id + ); + await applyProjectEnrollment({ + plan: clonePlan, + expectedPlanSha256: clonePlan.planSha256, + homeDir: home, + }); + const registry = (await Bun.file(initial.registryPath).json()) as { + projects: Record }>; + }; + expect(Object.keys(registry.projects)).toEqual([initialPlan.identity.id]); + expect( + registry.projects[initialPlan.identity.id]?.locations.map( + (location) => location.path + ) + ).toEqual([clone, repo].sort()); + }); + + it("uses distinct execution state and preserves registry history for concurrent clone and worktree enrollment", async () => { + const { root, home } = await makeFixture(); + const source = join(root, "source"); + const clone = join(root, "clone"); + const worktree = join(root, "worktree"); + await createRepository({ path: source, home }); + await runFixtureGit({ + argv: [ + "remote", + "add", + "origin", + "https://github.com/example/enrollment-project.git", + ], + repoDir: source, + homeDir: home, + cwd: source, + }); + await runFixtureGit({ + argv: ["clone", source, clone], + repoDir: clone, + homeDir: home, + cwd: root, + }); + await runFixtureGit({ + argv: [ + "remote", + "set-url", + "origin", + "https://github.com/example/enrollment-project.git", + ], + repoDir: clone, + homeDir: home, + cwd: clone, + }); + await runFixtureGit({ + argv: ["worktree", "add", "-b", "enrollment-worktree", worktree], + repoDir: source, + homeDir: home, + cwd: source, + }); + const clonePlan = await planProjectEnrollment({ + projectRoot: clone, + homeDir: home, + }); + const worktreePlan = await planProjectEnrollment({ + projectRoot: worktree, + homeDir: home, + }); + + expect(clonePlan.identity.id).toBe(worktreePlan.identity.id); + expect(clonePlan.executionIdentity.id).not.toBe( + worktreePlan.executionIdentity.id + ); + expect(clonePlan.generatedWrites.map((write) => write.path)).not.toEqual( + worktreePlan.generatedWrites.map((write) => write.path) + ); + const [cloneResult, worktreeResult] = await Promise.all([ + applyProjectEnrollment({ + plan: clonePlan, + expectedPlanSha256: clonePlan.planSha256, + homeDir: home, + }), + applyProjectEnrollment({ + plan: worktreePlan, + expectedPlanSha256: worktreePlan.planSha256, + homeDir: home, + }), + ]); + const registry = (await Bun.file(cloneResult.registryPath).json()) as { + projects: Record< + string, + { + activeReceipts: Record; + history: Array<{ action: string }>; + locations: Array<{ path: string }>; + } + >; + }; + const entry = registry.projects[clonePlan.identity.id]; + expect(entry?.locations.map((location) => location.path).sort()).toEqual( + [clone, worktree].sort() + ); + expect( + entry?.history.filter((event) => event.action === "enrolled") + ).toHaveLength(2); + expect(worktreeResult.registryPath).toBe(cloneResult.registryPath); + + let status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [clone, worktree], + }); + expect(status.projects[0]).toMatchObject({ + coverage: "covered", + health: "healthy", + }); + const nonPrimaryRoot = + status.projects[0]?.canonicalRoot === join(clone, ".ai") + ? worktree + : clone; + const nonPrimaryAiRoot = join(nonPrimaryRoot, ".ai"); + const configPath = join(nonPrimaryAiRoot, "config.toml"); + const ignorePath = join(nonPrimaryAiRoot, ".gitignore"); + const indexPath = facultAiIndexPath(home, nonPrimaryAiRoot); + const graphPath = facultAiGraphPath(home, nonPrimaryAiRoot); + const [configContent, ignoreContent, indexContent, graphContent] = + await Promise.all([ + readFile(configPath), + readFile(ignorePath), + readFile(indexPath), + readFile(graphPath), + ]); + + await rm(configPath); + status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [clone, worktree], + }); + expect(status.projects[0]).toMatchObject({ + coverage: "partial", + health: "degraded", + canonical: { config: false }, + }); + await writeFile(configPath, configContent); + + await writeFile(ignorePath, "", "utf8"); + status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [clone, worktree], + }); + expect(status.projects[0]).toMatchObject({ + coverage: "partial", + health: "degraded", + canonical: { protectiveIgnore: false }, + }); + await writeFile(ignorePath, ignoreContent); + + await writeFile(indexPath, "{}", "utf8"); + status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [clone, worktree], + }); + expect(status.projects[0]).toMatchObject({ + health: "degraded", + generated: { index: false, graph: true, health: "missing" }, + }); + await writeFile(indexPath, indexContent); + + await writeFile(graphPath, "{}", "utf8"); + status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [clone, worktree], + }); + expect(status.projects[0]).toMatchObject({ + health: "degraded", + generated: { index: true, graph: false, health: "missing" }, + }); + await writeFile(graphPath, graphContent); + + const missingRoot = join(root, "missing-active-checkout"); + if (!entry) { + throw new Error("Expected the shared registry entry"); + } + entry.activeReceipts[missingRoot] = cloneResult.receiptId; + await writeFile( + cloneResult.registryPath, + `${JSON.stringify(registry, null, 2)}\n`, + "utf8" + ); + status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [clone, worktree], + }); + expect(status.projects[0]).toMatchObject({ + coverage: "partial", + health: "degraded", + locations: expect.arrayContaining([ + expect.objectContaining({ path: missingRoot, exists: false }), + ]), + }); + delete entry.activeReceipts[missingRoot]; + await writeFile( + cloneResult.registryPath, + `${JSON.stringify(registry, null, 2)}\n`, + "utf8" + ); + + const primaryRoot = nonPrimaryRoot === clone ? worktree : clone; + await rm(nonPrimaryRoot, { recursive: true }); + status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [primaryRoot], + }); + expect(status.projects[0]).toMatchObject({ + coverage: "partial", + health: "degraded", + locations: expect.arrayContaining([ + expect.objectContaining({ path: nonPrimaryRoot, exists: false }), + ]), + }); + }); + + it("retires a stale active receipt when an enrolled checkout moves", async () => { + const { root, home } = await makeFixture(); + const initialRoot = join(root, "before"); + const renamedRoot = join(root, "after"); + await createRepository({ path: initialRoot, home }); + await runFixtureGit({ + argv: [ + "remote", + "add", + "origin", + "https://github.com/example/moved-enrollment.git", + ], + repoDir: initialRoot, + homeDir: home, + cwd: initialRoot, + }); + const initialPlan = await planProjectEnrollment({ + projectRoot: initialRoot, + homeDir: home, + }); + const initial = await applyProjectEnrollment({ + plan: initialPlan, + expectedPlanSha256: initialPlan.planSha256, + homeDir: home, + }); + const initialState = join( + facultLocalStateRoot(home), + "projects", + initialPlan.executionIdentity.id + ); + const journalPath = join(initialState, "journal", "events.jsonl"); + await mkdir(dirname(journalPath), { recursive: true }); + await writeFile(journalPath, "preserve-after-move\n", "utf8"); + + await rename(initialRoot, renamedRoot); + await symlink( + renamedRoot, + initialRoot, + process.platform === "win32" ? "junction" : "dir" + ); + const renamedPlan = await planProjectEnrollment({ + projectRoot: renamedRoot, + homeDir: home, + }); + const renamedState = join( + facultLocalStateRoot(home), + "projects", + renamedPlan.executionIdentity.id + ); + expect(renamedPlan.executionIdentity.id).toBe( + initialPlan.executionIdentity.id + ); + expect(renamedState).toBe(initialState); + const renamed = await applyProjectEnrollment({ + plan: renamedPlan, + expectedPlanSha256: renamedPlan.planSha256, + homeDir: home, + }); + expect( + await readFile(join(renamedState, "journal", "events.jsonl"), "utf8") + ).toBe("preserve-after-move\n"); + const readActiveReceipts = async () => { + const registry = (await Bun.file(renamed.registryPath).json()) as { + projects: Record }>; + }; + return registry.projects[renamedPlan.identity.id]?.activeReceipts; + }; + expect(await readActiveReceipts()).toEqual({ + [renamedRoot]: renamed.receiptId, + }); + expect( + ( + await buildProjectsStatus({ + homeDir: home, + }) + ).projects[0] + ).toMatchObject({ coverage: "covered", health: "healthy" }); + + await rollbackProjectEnrollment({ + receiptId: renamed.receiptId, + homeDir: home, + apply: true, + }); + expect(await readActiveReceipts()).toEqual({}); + expect( + ( + await buildProjectsStatus({ + homeDir: home, + }) + ).projects[0] + ).toMatchObject({ coverage: "covered", health: "healthy" }); + expect(initial.receiptId).not.toBe(renamed.receiptId); + }); + + it("retires a moved receipt when its old path is reused by a sibling clone", async () => { + const { root, home } = await makeFixture(); + const initialRoot = join(root, "before"); + const renamedRoot = join(root, "after"); + const origin = "https://github.com/example/reused-moved-path.git"; + await createRepository({ path: initialRoot, home }); + await runFixtureGit({ + argv: ["remote", "add", "origin", origin], + repoDir: initialRoot, + homeDir: home, + cwd: initialRoot, + }); + const initialPlan = await planProjectEnrollment({ + projectRoot: initialRoot, + homeDir: home, + }); + await applyProjectEnrollment({ + plan: initialPlan, + expectedPlanSha256: initialPlan.planSha256, + homeDir: home, + }); + + await rename(initialRoot, renamedRoot); + await runFixtureGit({ + argv: ["clone", renamedRoot, initialRoot], + repoDir: initialRoot, + homeDir: home, + cwd: root, + }); + await runFixtureGit({ + argv: ["remote", "set-url", "origin", origin], + repoDir: initialRoot, + homeDir: home, + cwd: initialRoot, + }); + const [renamedExecution, siblingExecution] = await Promise.all([ + resolveRepositoryExecutionIdentity(renamedRoot), + resolveRepositoryExecutionIdentity(initialRoot), + ]); + expect(siblingExecution.id).not.toBe(renamedExecution.id); + + const renamedPlan = await planProjectEnrollment({ + projectRoot: renamedRoot, + homeDir: home, + }); + const renamed = await applyProjectEnrollment({ + plan: renamedPlan, + expectedPlanSha256: renamedPlan.planSha256, + homeDir: home, + }); + const registry = (await Bun.file(renamed.registryPath).json()) as { + projects: Record }>; + }; + + expect(registry.projects[renamedPlan.identity.id]?.activeReceipts).toEqual({ + [renamedRoot]: renamed.receiptId, + }); + }); + + it("preserves another checkout receipt when repository inspection is inconclusive", async () => { + const { root, home } = await makeFixture(); + const source = join(root, "source"); + const firstClone = join(root, "first"); + const secondClone = join(root, "second"); + const origin = "https://github.com/example/inconclusive-receipt.git"; + await createRepository({ path: source, home }); + await runFixtureGit({ + argv: ["remote", "add", "origin", origin], + repoDir: source, + homeDir: home, + cwd: source, + }); + for (const clone of [firstClone, secondClone]) { + await runFixtureGit({ + argv: ["clone", source, clone], + repoDir: clone, + homeDir: home, + cwd: root, + }); + await runFixtureGit({ + argv: ["remote", "set-url", "origin", origin], + repoDir: clone, + homeDir: home, + cwd: clone, + }); + } + const firstPlan = await planProjectEnrollment({ + projectRoot: firstClone, + homeDir: home, + }); + const first = await applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + }); + const secondPlan = await planProjectEnrollment({ + projectRoot: secondClone, + homeDir: home, + }); + await applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: home, + }); + const registryBefore = await readFile(first.registryPath, "utf8"); + const gitDir = join(firstClone, ".git"); + const savedGitDir = join(firstClone, ".git.saved"); + await rename(gitDir, savedGitDir); + await writeFile(gitDir, "gitdir: /definitely/missing/git-dir\n", "utf8"); + try { + await expect(buildProjectsStatus({ homeDir: home })).rejects.toThrow( + "Not a Git repository" + ); + const reEnrollment = await planProjectEnrollment({ + projectRoot: secondClone, + homeDir: home, + cadence: "weekly", + }); + await expect( + applyProjectEnrollment({ + plan: reEnrollment, + expectedPlanSha256: reEnrollment.planSha256, + homeDir: home, + }) + ).rejects.toThrow("Not a Git repository"); + expect(await readFile(first.registryPath, "utf8")).toBe(registryBefore); + } finally { + await rm(gitDir); + await rename(savedGitDir, gitDir); + } + }); + + it("does not trust a checked-in repository id for machine-state isolation", async () => { + const { root, home } = await makeFixture(); + const victim = join(root, "victim"); + const attacker = join(root, "attacker"); + await createRepository({ path: victim, home }); + await createRepository({ + path: attacker, home, - files: { "AGENTS.md": "# Existing\n" }, + files: { "README.md": "# Different root commit\n" }, + }); + const victimIdentity = await resolveRepositoryIdentity(victim); + const attackerIdentity = await resolveRepositoryIdentity(attacker); + const attackerExecution = + await resolveRepositoryExecutionIdentity(attacker); + expect(attackerIdentity.id).not.toBe(victimIdentity.id); + await mkdir(join(attacker, ".ai"), { recursive: true }); + await writeFile( + join(attacker, ".ai", "config.toml"), + [ + "version = 1", + "", + "[project]", + `repository_id = "${victimIdentity.id}"`, + "", + ].join("\n"), + "utf8" + ); + + expect( + facultMachineStateDir(home, join(attacker, ".ai")).endsWith( + victimIdentity.id + ) + ).toBe(false); + + await writeFile( + join(attacker, ".ai", "config.toml"), + [ + "version = 1", + "", + "[project]", + `repository_id = "${attackerIdentity.id}"`, + "", + ].join("\n"), + "utf8" + ); + expect( + facultMachineStateDir(home, join(attacker, ".ai")).endsWith( + attackerExecution.id + ) + ).toBe(true); + }); + + it("previews and applies rollback while preserving receipts and history", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + now: new Date("2026-07-28T12:00:00.000Z"), + }); + + const preview = await rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + }); + expect(preview.applied).toBe(false); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + true + ); + + const rolledBack = await rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + apply: true, + now: new Date("2026-07-28T13:00:00.000Z"), + }); + expect(rolledBack.applied).toBe(true); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + false + ); + expect(await Bun.file(join(repo, ".ai", ".gitignore")).exists()).toBe( + false + ); + for (const preserved of rolledBack.preserved) { + expect(await Bun.file(preserved).exists()).toBe(true); + } + const status = await buildProjectsStatus({ homeDir: home }); + expect(status.projects[0]?.decision).toBe("disabled"); + expect(status.projects[0]?.coverage).toBe("inactive"); + }); + + it("rejects symlinked and oversized enrollment receipts before rollback", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const outsideReceipt = join(root, "outside-receipt.json"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const receiptPath = join( + dirname(applied.registryPath), + "receipts", + `${applied.receiptId}.json` + ); + const receipt = await readFile(receiptPath, "utf8"); + const configPath = join(repo, ".ai", "config.toml"); + const config = await readFile(configPath, "utf8"); + const registry = await readFile(applied.registryPath, "utf8"); + await rename(receiptPath, outsideReceipt); + await symlink(outsideReceipt, receiptPath); + + for (const apply of [false, true]) { + await expect( + rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + apply, + }) + ).rejects.toThrow("Refusing unsafe canonical file"); + } + expect(await readFile(configPath, "utf8")).toBe(config); + expect(await readFile(applied.registryPath, "utf8")).toBe(registry); + + await rm(receiptPath); + await writeFile(receiptPath, receipt, "utf8"); + await truncate(receiptPath, 1024 * 1024 + 1); + await expect( + rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + }) + ).rejects.toThrow("Refusing unsafe canonical file"); + expect(await readFile(configPath, "utf8")).toBe(config); + expect(await readFile(applied.registryPath, "utf8")).toBe(registry); + }); + + it("restores the previous registry entry and receipt linkage after re-enrollment rollback", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const firstPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + sources: ["git"], + cadence: "on-demand", + }); + const first = await applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + now: new Date("2026-07-28T12:00:00.000Z"), + }); + const secondPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + sources: ["git"], + cadence: "on-demand", + }); + const second = await applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: home, + now: new Date("2026-07-28T13:00:00.000Z"), + }); + + await rollbackProjectEnrollment({ + receiptId: second.receiptId, + homeDir: home, + apply: true, + now: new Date("2026-07-28T14:00:00.000Z"), + }); + + const registry = (await Bun.file(second.registryPath).json()) as { + projects: Record< + string, + { + activeReceipts: Record; + cadence: string; + decision: string; + history: Array<{ action: string; receiptId?: string }>; + locations: Array<{ + firstSeenAt: string; + lastSeenAt: string; + path: string; + }>; + sources: string[]; + } + >; + }; + const entry = registry.projects[firstPlan.identity.id]; + expect(entry).toMatchObject({ + activeReceipts: { [repo]: first.receiptId }, + cadence: "on-demand", + decision: "selected", + sources: ["git"], + }); + expect(entry?.locations).toEqual([ + { + path: repo, + firstSeenAt: "2026-07-28T12:00:00.000Z", + lastSeenAt: "2026-07-28T12:00:00.000Z", + }, + ]); + expect(entry?.history.map((event) => event.action)).toEqual([ + "enrolled", + "enrolled", + "rolled-back", + ]); + expect(entry?.history.map((event) => event.receiptId)).toEqual([ + first.receiptId, + second.receiptId, + second.receiptId, + ]); + + const firstRollback = await rollbackProjectEnrollment({ + receiptId: first.receiptId, + homeDir: home, + apply: true, + now: new Date("2026-07-28T15:00:00.000Z"), + }); + expect(firstRollback.applied).toBe(true); + expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( + false + ); + }); + + it("preserves later lifecycle decisions when rolling back re-enrollment", async () => { + const { root, home } = await makeFixture(); + const scenarios = [ + { action: "disabled", command: "disable", decision: "disabled" }, + { action: "ignored", command: "ignore", decision: "ignored" }, + { action: "inactive", command: "inactive", decision: "inactive" }, + { action: "removed", command: "remove", decision: "removed" }, + ] as const; + for (const [index, scenario] of scenarios.entries()) { + const repo = join(root, `repo-${scenario.command}`); + await createRepository({ path: repo, home }); + const firstPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const first = await applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + now: new Date(`2026-07-28T1${index}:00:00.000Z`), + }); + const configBefore = await readFile( + join(repo, ".ai", "config.toml"), + "utf8" + ); + const ignoreBefore = await readFile( + join(repo, ".ai", ".gitignore"), + "utf8" + ); + const secondPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + cadence: "weekly", + sources: ["git"], + }); + const second = await applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: home, + now: new Date(`2026-07-29T1${index}:00:00.000Z`), + }); + const previousLog = console.log; + console.log = () => undefined; + try { + await projectCommand( + [scenario.command, "--project-root", repo, "--json"], + { + homeDir: home, + now: () => new Date(`2026-07-30T1${index}:00:00.000Z`), + } + ); + } finally { + console.log = previousLog; + } + + await rollbackProjectEnrollment({ + receiptId: second.receiptId, + homeDir: home, + apply: true, + now: new Date(`2026-07-31T1${index}:00:00.000Z`), + }); + + const registry = (await Bun.file(second.registryPath).json()) as { + projects: Record< + string, + { + activeReceipts: Record; + cadence: string; + decision: string; + history: Array<{ action: string; receiptId?: string }>; + sources: string[]; + } + >; + }; + const entry = registry.projects[firstPlan.identity.id]; + expect(entry?.decision).toBe(scenario.decision); + expect(entry?.cadence).toBe("on-demand"); + expect(entry?.sources).toEqual(["git", "writebacks"]); + expect(entry?.activeReceipts).toEqual({ + [repo]: first.receiptId, + }); + expect(entry?.history.map((event) => event.action)).toEqual([ + "enrolled", + "enrolled", + scenario.action, + "rolled-back", + ]); + expect(await readFile(join(repo, ".ai", "config.toml"), "utf8")).toBe( + configBefore + ); + expect(await readFile(join(repo, ".ai", ".gitignore"), "utf8")).toBe( + ignoreBefore + ); + } + }); + + it("does not preserve a canceled re-enrollment as a later lifecycle decision", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const firstPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const first = await applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + now: new Date("2026-07-28T12:00:00.000Z"), + }); + const secondPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + cadence: "weekly", + }); + const second = await applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: home, + now: new Date("2026-07-28T13:00:00.000Z"), + }); + + await rollbackProjectEnrollment({ + receiptId: second.receiptId, + homeDir: home, + apply: true, + now: new Date("2026-07-28T14:00:00.000Z"), + }); + await rollbackProjectEnrollment({ + receiptId: first.receiptId, + homeDir: home, + apply: true, + now: new Date("2026-07-28T15:00:00.000Z"), + }); + + const registry = (await Bun.file(first.registryPath).json()) as { + projects: Record< + string, + { + activeReceipts: Record; + decision: string; + history: Array<{ action: string }>; + } + >; + }; + expect(registry.projects[firstPlan.identity.id]).toMatchObject({ + activeReceipts: {}, + decision: "disabled", + }); + expect( + registry.projects[firstPlan.identity.id]?.history.map( + (event) => event.action + ) + ).toEqual(["enrolled", "enrolled", "rolled-back", "rolled-back"]); + const status = await buildProjectsStatus({ homeDir: home }); + expect(status.projects[0]).toMatchObject({ + coverage: "inactive", + decision: "disabled", + }); + }); + + it("preserves a lifecycle decision made before a canceled re-enrollment", async () => { + const { root, home } = await makeFixture(); + const scenarios = [ + { command: "disable", decision: "disabled" }, + { command: "ignore", decision: "ignored" }, + { command: "inactive", decision: "inactive" }, + { command: "remove", decision: "removed" }, + ] as const; + for (const [index, scenario] of scenarios.entries()) { + const repo = join(root, `repo-${scenario.command}`); + await createRepository({ path: repo, home }); + const firstPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const first = await applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + now: new Date(`2026-07-28T1${index}:00:00.000Z`), + }); + const previousLog = console.log; + console.log = () => undefined; + try { + await projectCommand( + [scenario.command, "--project-root", repo, "--json"], + { + homeDir: home, + now: () => new Date(`2026-07-29T1${index}:00:00.000Z`), + } + ); + } finally { + console.log = previousLog; + } + const secondPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + cadence: "weekly", + }); + const second = await applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: home, + now: new Date(`2026-07-30T1${index}:00:00.000Z`), + }); + + await rollbackProjectEnrollment({ + receiptId: second.receiptId, + homeDir: home, + apply: true, + now: new Date(`2026-07-31T1${index}:00:00.000Z`), + }); + await rollbackProjectEnrollment({ + receiptId: first.receiptId, + homeDir: home, + apply: true, + now: new Date(`2026-08-01T1${index}:00:00.000Z`), + }); + + const registry = (await Bun.file(first.registryPath).json()) as { + projects: Record< + string, + { + activeReceipts: Record; + decision: string; + } + >; + }; + expect(registry.projects[firstPlan.identity.id]).toMatchObject({ + activeReceipts: {}, + decision: scenario.decision, + }); + } + }, 20_000); + + it("restores a later lifecycle decision when rollback compensation runs", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const firstPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan: firstPlan, + expectedPlanSha256: firstPlan.planSha256, + homeDir: home, + now: new Date("2026-07-28T12:00:00.000Z"), + }); + const secondPlan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const second = await applyProjectEnrollment({ + plan: secondPlan, + expectedPlanSha256: secondPlan.planSha256, + homeDir: home, + now: new Date("2026-07-28T13:00:00.000Z"), + }); + const previousLog = console.log; + console.log = () => undefined; + try { + await projectCommand(["disable", "--project-root", repo, "--json"], { + homeDir: home, + now: () => new Date("2026-07-28T14:00:00.000Z"), + }); + } finally { + console.log = previousLog; + } + const registryBefore = await readFile(second.registryPath, "utf8"); + const configBefore = await readFile( + join(repo, ".ai", "config.toml"), + "utf8" + ); + const ignoreBefore = await readFile( + join(repo, ".ai", ".gitignore"), + "utf8" + ); + + await expect( + rollbackProjectEnrollment({ + receiptId: second.receiptId, + homeDir: home, + apply: true, + beforeRegistryWrite: () => + Promise.reject(new Error("injected lifecycle rollback failure")), + }) + ).rejects.toThrow("injected lifecycle rollback failure"); + + expect(await readFile(second.registryPath, "utf8")).toBe(registryBefore); + expect(await readFile(join(repo, ".ai", "config.toml"), "utf8")).toBe( + configBefore + ); + expect(await readFile(join(repo, ".ai", ".gitignore"), "utf8")).toBe( + ignoreBefore + ); + const registry = JSON.parse(registryBefore) as { + projects: Record< + string, + { + activeReceipts: Record; + decision: string; + } + >; + }; + expect(registry.projects[firstPlan.identity.id]).toMatchObject({ + activeReceipts: { [repo]: second.receiptId }, + decision: "disabled", + }); + }); + + it("refuses rollback after the repository is replaced at the receipt path", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const configPath = join(repo, ".ai", "config.toml"); + const ignorePath = join(repo, ".ai", ".gitignore"); + const configBefore = await readFile(configPath, "utf8"); + const ignoreBefore = await readFile(ignorePath, "utf8"); + const registryBefore = await readFile(applied.registryPath, "utf8"); + + await rm(join(repo, ".git"), { recursive: true }); + await runFixtureGit({ + argv: ["init", "-b", "main"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await writeFile(join(repo, "README.md"), "# Replacement\n", "utf8"); + await runFixtureGit({ + argv: ["add", "README.md"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await runFixtureGit({ + argv: [ + "-c", + "user.name=Replacement", + "-c", + "user.email=replacement@example.test", + "commit", + "-m", + "replacement", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + + const replacementIdentity = await resolveRepositoryIdentity(repo, home); + expect(replacementIdentity.id).not.toBe(plan.identity.id); + expect(await readFile(configPath, "utf8")).toBe(configBefore); + expect(await readFile(ignorePath, "utf8")).toBe(ignoreBefore); + + await expect( + rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + apply: true, + }) + ).rejects.toThrow( + "receipt project root no longer identifies the enrolled repository" + ); + + expect(await readFile(configPath, "utf8")).toBe(configBefore); + expect(await readFile(ignorePath, "utf8")).toBe(ignoreBefore); + expect(await readFile(applied.registryPath, "utf8")).toBe(registryBefore); + }); + + it("fails closed when the canonical directory is swapped at the unlink boundary", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const aiRoot = join(repo, ".ai"); + const displacedAiRoot = join(repo, ".ai-enrolled"); + const configBefore = await readFile(join(aiRoot, "config.toml"), "utf8"); + const ignoreBefore = await readFile(join(aiRoot, ".gitignore"), "utf8"); + const registryBefore = await readFile(applied.registryPath, "utf8"); + let swapped = false; + + await expect( + rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + apply: true, + beforeCanonicalRemove: async ({ path }) => { + if (swapped || path !== join(aiRoot, "config.toml")) { + return; + } + swapped = true; + await rename(aiRoot, displacedAiRoot); + await mkdir(aiRoot); + await writeFile( + join(aiRoot, "config.toml"), + "replacement config\n", + "utf8" + ); + await writeFile( + join(aiRoot, ".gitignore"), + "replacement ignore\n", + "utf8" + ); + }, + }) + ).rejects.toThrow("canonical directory changed before unlink"); + + expect(swapped).toBe(true); + expect(await readFile(join(aiRoot, "config.toml"), "utf8")).toBe( + "replacement config\n" + ); + expect(await readFile(join(aiRoot, ".gitignore"), "utf8")).toBe( + "replacement ignore\n" + ); + expect(await readFile(join(displacedAiRoot, "config.toml"), "utf8")).toBe( + configBefore + ); + expect(await readFile(join(displacedAiRoot, ".gitignore"), "utf8")).toBe( + ignoreBefore + ); + expect(await readFile(applied.registryPath, "utf8")).toBe(registryBefore); + }); + + it("compensates a canonical leaf swap at the quarantine boundary", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, }); + const aiRoot = join(repo, ".ai"); + const configPath = join(aiRoot, "config.toml"); + const displacedConfigPath = join(aiRoot, "config.enrolled.toml"); + const configBefore = await readFile(configPath, "utf8"); + const ignoreBefore = await readFile(join(aiRoot, ".gitignore"), "utf8"); + const registryBefore = await readFile(applied.registryPath, "utf8"); + let swapped = false; + + await expect( + rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + apply: true, + beforeCanonicalRemove: async ({ path }) => { + if (swapped || path !== configPath) { + return; + } + swapped = true; + await rename(configPath, displacedConfigPath); + await writeFile(configPath, "replacement config\n", "utf8"); + }, + }) + ).rejects.toThrow("target changed at quarantine boundary"); + + expect(swapped).toBe(true); + expect(await readFile(configPath, "utf8")).toBe("replacement config\n"); + expect(await readFile(displacedConfigPath, "utf8")).toBe(configBefore); + expect(await readFile(join(aiRoot, ".gitignore"), "utf8")).toBe( + ignoreBefore + ); + expect( + (await readdir(aiRoot)).filter((name) => name.endsWith(".rollback")) + ).toEqual([]); + expect(await readFile(applied.registryPath, "utf8")).toBe(registryBefore); + }); + it("compensates an existing canonical file swapped at the rollback exchange boundary", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const configPath = join(aiRoot, "config.toml"); + const enrolledPath = join(aiRoot, "config.enrolled.toml"); + const original = "version = 1\n\n[custom]\nowned = true\n"; + const concurrent = "version = 1\n\n[user]\nowned = true\n"; + await mkdir(aiRoot); + await writeFile(configPath, original, "utf8"); + await writeFile(join(aiRoot, ".gitignore"), "/custom\n", "utf8"); const plan = await planProjectEnrollment({ projectRoot: repo, homeDir: home, }); + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const enrolled = await readFile(configPath, "utf8"); + const registryBefore = await readFile(applied.registryPath, "utf8"); + + await expect( + rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + apply: true, + beforeCanonicalRestore: async ({ path }) => { + if (path !== configPath) { + return; + } + await rename(configPath, enrolledPath); + await writeFile(configPath, concurrent, "utf8"); + }, + }) + ).rejects.toThrow("conditional commit boundary"); - expect(plan.guidancePreview).toEqual([]); - expect(plan.options.guidance).toEqual([]); - expect(plan.warnings.join("\n")).toContain("not copied or adopted"); + expect(await readFile(configPath, "utf8")).toBe(concurrent); + expect(await readFile(enrolledPath, "utf8")).toBe(enrolled); + expect(await readFile(applied.registryPath, "utf8")).toBe(registryBefore); + expect( + (await readdir(aiRoot)).filter((name) => name.endsWith(".tmp")) + ).toEqual([]); }); - it("keeps scheduling outside minimal enrollment", async () => { + it("restores canonical files and registry when rollback registry commit fails", async () => { const { root, home } = await makeFixture(); const repo = join(root, "repo"); await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const configPath = join(repo, ".ai", "config.toml"); + const ignorePath = join(repo, ".ai", ".gitignore"); + const configBefore = await readFile(configPath, "utf8"); + const ignoreBefore = await readFile(ignorePath, "utf8"); + const registryBefore = await readFile(applied.registryPath, "utf8"); await expect( - planProjectEnrollment({ - projectRoot: repo, + rollbackProjectEnrollment({ + receiptId: applied.receiptId, homeDir: home, - cadence: "weekly", - scheduling: true, + apply: true, + beforeRegistryWrite: () => + Promise.reject(new Error("injected rollback registry failure")), }) - ).rejects.toThrow("does not install scheduling"); - expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + ).rejects.toThrow("injected rollback registry failure"); + + expect(await readFile(configPath, "utf8")).toBe(configBefore); + expect(await readFile(ignorePath, "utf8")).toBe(ignoreBefore); + expect(await readFile(applied.registryPath, "utf8")).toBe(registryBefore); + + const retried = await rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + apply: true, + }); + expect(retried.applied).toBe(true); }); - it("refuses dirty or untracked guidance without writing", async () => { + it("keeps the portfolio selected while another checkout remains enrolled", async () => { const { root, home } = await makeFixture(); - const repo = join(root, "repo"); - await createRepository({ - path: repo, - home, - files: { "AGENTS.md": "# Reviewed\n" }, + const activeRepo = join(root, "z-active"); + const rolledBackRepo = join(root, "a-rolled-back"); + await createRepository({ path: activeRepo, home }); + await runFixtureGit({ + argv: [ + "remote", + "add", + "origin", + "https://github.com/example/multi-checkout.git", + ], + repoDir: activeRepo, + homeDir: home, + cwd: activeRepo, + }); + await runFixtureGit({ + argv: ["clone", activeRepo, rolledBackRepo], + repoDir: rolledBackRepo, + homeDir: home, + cwd: root, + }); + await runFixtureGit({ + argv: [ + "remote", + "set-url", + "origin", + "https://github.com/example/multi-checkout.git", + ], + repoDir: rolledBackRepo, + homeDir: home, + cwd: rolledBackRepo, + }); + const activePlan = await planProjectEnrollment({ + projectRoot: activeRepo, + homeDir: home, + }); + const rolledBackPlan = await planProjectEnrollment({ + projectRoot: rolledBackRepo, + homeDir: home, + }); + const active = await applyProjectEnrollment({ + plan: activePlan, + expectedPlanSha256: activePlan.planSha256, + homeDir: home, + }); + const rolledBack = await applyProjectEnrollment({ + plan: rolledBackPlan, + expectedPlanSha256: rolledBackPlan.planSha256, + homeDir: home, }); - await writeFile( - join(repo, "AGENTS.md"), - "# Dirty local guidance\n", - "utf8" - ); - await expect( - planProjectEnrollment({ - projectRoot: repo, - homeDir: home, - guidance: ["AGENTS.md"], - }) - ).rejects.toThrow("source must be tracked and clean"); - expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + await rollbackProjectEnrollment({ + receiptId: rolledBack.receiptId, + homeDir: home, + apply: true, + }); - await writeFile(join(repo, "CLAUDE.md"), "# Untracked\n", "utf8"); - await expect( - planProjectEnrollment({ - projectRoot: repo, - homeDir: home, - guidance: ["CLAUDE.md"], - }) - ).rejects.toThrow("source must be tracked and clean"); - expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + const registry = (await Bun.file(active.registryPath).json()) as { + projects: Record< + string, + { + activeReceipts: Record; + decision: string; + } + >; + }; + const entry = registry.projects[activePlan.identity.id]; + expect(entry?.decision).toBe("selected"); + expect(entry?.activeReceipts).toEqual({ + [activeRepo]: active.receiptId, + }); + const status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [activeRepo, rolledBackRepo], + }); + expect(status.projects[0]).toMatchObject({ + decision: "selected", + coverage: "covered", + canonicalRoot: join(activeRepo, ".ai"), + }); }); - it("refuses secret-shaped and machine-local guidance in public fixtures", async () => { + it("does not treat a stored same-path repository replacement as active", async () => { const { root, home } = await makeFixture(); - const repo = join(root, "public-repo"); - await createRepository({ - path: repo, - home, - files: { - "docs/safe.md": - "# Safe public guidance\n\nRun the documented checks.\n", - "docs/local.md": - "# Local\n\nRead /Users/example/private/config.toml.\n", - "docs/secret.md": "# Secret\n\napi_key = abcdefghijklmnop\n", - }, + const repo = join(root, "repo"); + const replacement = join(root, "replacement"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, }); - const safe = await planProjectEnrollment({ - projectRoot: repo, + await runFixtureGit({ + argv: ["clone", repo, replacement], + repoDir: replacement, homeDir: home, - guidance: ["docs/safe.md"], + cwd: root, + }); + await runFixtureGit({ + argv: [ + "remote", + "set-url", + "origin", + "https://github.com/example/replacement-fork.git", + ], + repoDir: replacement, + homeDir: home, + cwd: replacement, + }); + await rm(join(repo, ".git"), { recursive: true }); + await rename(join(replacement, ".git"), join(repo, ".git")); + + const status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(status.projects).toHaveLength(2); + expect( + status.projects.find( + (project) => project.repositoryId === plan.identity.id + ) + ).toMatchObject({ + repositoryId: plan.identity.id, + decision: "selected", + coverage: "partial", + health: "unavailable", + canonicalRoot: null, + locations: [ + { + path: repo, + exists: false, + dirty: null, + }, + ], + }); + expect( + status.projects.find( + (project) => project.repositoryId !== plan.identity.id + ) + ).toMatchObject({ + decision: "inactive", + coverage: "inactive", }); - expect(safe.guidancePreview[0]?.path).toBe("docs/safe.md"); - await expect( - planProjectEnrollment({ - projectRoot: repo, - homeDir: home, - guidance: ["docs/local.md"], - }) - ).rejects.toThrow("machine-local absolute path"); - await expect( - planProjectEnrollment({ - projectRoot: repo, - homeDir: home, - guidance: ["docs/secret.md"], - }) - ).rejects.toThrow("secret-shaped content"); }); - it("preserves existing ignore rules and versioned canonical config", async () => { + it("rejects a symlinked project registry from read-only discovery and status", async () => { const { root, home } = await makeFixture(); const repo = join(root, "repo"); + const outsideRegistry = join(root, "outside-registry.json"); await createRepository({ path: repo, home }); - await mkdir(join(repo, ".ai"), { recursive: true }); - await writeFile( - join(repo, ".ai", ".gitignore"), - "/private.local\n", - "utf8" + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + await rename(applied.registryPath, outsideRegistry); + await symlink(outsideRegistry, applied.registryPath); + + await expect(buildProjectsStatus({ homeDir: home })).rejects.toThrow( + "Refusing unsafe canonical file" ); + await expect( + discoverProjects({ roots: [repo], homeDir: home }) + ).rejects.toThrow("Refusing unsafe canonical file"); + }); + it("propagates non-absence metadata failures for registered checkout paths", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); const plan = await planProjectEnrollment({ projectRoot: repo, homeDir: home, }); - expect(plan.canonicalWrites[0]?.content).toContain("/private.local"); - expect(plan.canonicalWrites[0]?.content).toContain("/.facult/"); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + await rm(repo, { recursive: true }); + await symlink(repo, repo); + + await expect(buildProjectsStatus({ homeDir: home })).rejects.toMatchObject({ + code: "ELOOP", + }); + }); + it("reports protective ignores ineffective after later negations", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const ignorePath = join(repo, ".ai", ".gitignore"); await writeFile( - join(repo, ".ai", "config.toml"), - "version = 1\n\n[custom]\nowned = true\n", + ignorePath, + `${await readFile(ignorePath, "utf8")}!/*.toml \n`, "utf8" ); - const merged = await planProjectEnrollment({ + const gitCheck = await gitCheckIgnoreExitCode({ + path: ".ai/config.local.toml", + repoDir: repo, + homeDir: home, + }); + expect(gitCheck).toBe(1); + + const status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(status.projects[0]).toMatchObject({ + coverage: "partial", + health: "degraded", + canonical: { + protectiveIgnore: false, + }, + }); + }); + + it("requires protective ignore winners to come from project .ai/.gitignore", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + const externalExcludes = join(root, "global-excludes"); + const ignorePath = join(repo, ".ai", ".gitignore"); + const externalRules = "/.ai/.facult/\n/.ai/config.local.toml\n"; + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ projectRoot: repo, homeDir: home, }); - expect(merged.canonicalWrites[1]?.content).toContain("[custom]"); - expect(merged.canonicalWrites[1]?.content).toContain("owned = true"); - expect(merged.canonicalWrites[1]?.content).toContain("[project]"); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + await writeFile(ignorePath, "", "utf8"); + await writeFile(externalExcludes, externalRules, "utf8"); + await runFixtureGit({ + argv: ["config", "core.excludesFile", externalExcludes], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + expect( + await gitCheckIgnoreExitCode({ + path: ".ai/config.local.toml", + repoDir: repo, + homeDir: home, + }) + ).toBe(0); + let status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(status.projects[0]?.canonical.protectiveIgnore).toBe(false); - await writeFile( - join(repo, ".ai", "config.toml"), - 'version = 1\n\n[project]\nrepository_id = "repo_conflict"\n', - "utf8" - ); - await expect( - planProjectEnrollment({ projectRoot: repo, homeDir: home }) - ).rejects.toThrow( - "Refusing to replace existing canonical project enrollment config" - ); + await runFixtureGit({ + argv: ["config", "--unset", "core.excludesFile"], + repoDir: repo, + homeDir: home, + cwd: repo, + }); + await writeFile(join(repo, ".git", "info", "exclude"), externalRules); + status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(status.projects[0]?.canonical.protectiveIgnore).toBe(false); + + await writeFile(ignorePath, "/.facult/\n/config.local.toml\n", "utf8"); + status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(status.projects[0]).toMatchObject({ + coverage: "covered", + canonical: { + protectiveIgnore: true, + }, + }); }); - it("refuses symlinked project state and guidance", async () => { + it("uses Git directory-only ignore semantics for project health", async () => { const { root, home } = await makeFixture(); const repo = join(root, "repo"); - const outside = join(root, "outside"); await createRepository({ path: repo, home }); - await mkdir(outside, { recursive: true }); - await mkdir(join(repo, "docs"), { recursive: true }); - await writeFile(join(outside, "guidance.md"), "# Private\n", "utf8"); - await symlink(join(outside, "guidance.md"), join(repo, "docs", "link.md")); - await runFixtureGit({ - argv: ["add", "docs/link.md"], - repoDir: repo, + const plan = await planProjectEnrollment({ + projectRoot: repo, homeDir: home, - cwd: repo, }); - await runFixtureGit({ - argv: [ - "-c", - "user.name=Fixture", - "-c", - "user.email=fixture@example.test", - "commit", - "-m", - "track symlink", - ], - repoDir: repo, + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, homeDir: home, - cwd: repo, }); - - await expect( - planProjectEnrollment({ - projectRoot: repo, + await writeFile( + join(repo, ".ai", ".gitignore"), + "/.facult/\n/config*.toml\n", + "utf8" + ); + expect( + await gitCheckIgnoreExitCode({ + path: ".ai/.facult/nested/probe", + repoDir: repo, homeDir: home, - guidance: ["docs/link.md"], }) - ).rejects.toThrow("source must be a regular file"); - - await symlink(outside, join(repo, ".ai")); - await expect( - planProjectEnrollment({ projectRoot: repo, homeDir: home }) - ).rejects.toThrow("unsafe project AI root"); + ).toBe(0); + const status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(status.projects[0]?.coverage).toBe("covered"); }); -}); -describe("project enrollment lifecycle", () => { - it("requires the reviewed hash and writes protection before generated state", async () => { + it("accepts equivalent wildcard protections with Git whitespace semantics", async () => { const { root, home } = await makeFixture(); const repo = join(root, "repo"); await createRepository({ path: repo, home }); @@ -480,79 +5704,88 @@ describe("project enrollment lifecycle", () => { projectRoot: repo, homeDir: home, }); - - await expect( - applyProjectEnrollment({ - plan, - expectedPlanSha256: "wrong", - homeDir: home, - }) - ).rejects.toThrow("exact plan SHA"); - expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); - - const result = await applyProjectEnrollment({ + await applyProjectEnrollment({ plan, expectedPlanSha256: plan.planSha256, homeDir: home, - now: new Date("2026-07-28T12:00:00.000Z"), }); - - expect(result.changedPaths).toEqual([ + await writeFile( join(repo, ".ai", ".gitignore"), - join(repo, ".ai", "config.toml"), - ]); - expect(await readFile(join(repo, ".ai", ".gitignore"), "utf8")).toContain( - "/.facult/" - ); - expect(await Bun.file(join(repo, ".ai", ".facult")).exists()).toBe(false); - expect(await Bun.file(join(repo, ".ai", "AGENTS.global.md")).exists()).toBe( - false + "/.facult/** \n/config.local.* \n", + "utf8" ); - expect( - await Bun.file(facultAiIndexPath(home, join(repo, ".ai"))).exists() - ).toBe(true); - expect( - facultMachineStateDir(home, join(repo, ".ai")).endsWith(plan.identity.id) - ).toBe(true); - expect(await Bun.file(result.registryPath).exists()).toBe(true); + for (const pathValue of [ + ".ai/.facult/fclt-protective-probe", + ".ai/.facult/nested/fclt-protective-probe", + ".ai/config.local.toml", + ]) { + const gitCheck = await gitCheckIgnoreExitCode({ + path: pathValue, + repoDir: repo, + homeDir: home, + }); + expect(gitCheck).toBe(0); + } + const status = await buildProjectsStatus({ homeDir: home, discoveryRoots: [repo], }); - expect(status.projects[0]?.generated).toEqual({ - index: true, - graph: true, - health: "ready", + expect(status.projects[0]).toMatchObject({ + coverage: "covered", + canonical: { + protectiveIgnore: true, + }, }); }); - it("reports unenrolled discovered repositories without writing", async () => { + it("requires canonical config to be a readable regular non-symlink file", async () => { const { root, home } = await makeFixture(); const repo = join(root, "repo"); + const outsideConfig = join(root, "outside-config.toml"); await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const configPath = join(repo, ".ai", "config.toml"); + await rename(configPath, outsideConfig); + await symlink(outsideConfig, configPath); const status = await buildProjectsStatus({ homeDir: home, discoveryRoots: [repo], }); - - expect(status.projects).toHaveLength(1); expect(status.projects[0]).toMatchObject({ - decision: "inactive", - coverage: "inactive", + coverage: "partial", health: "degraded", canonical: { - exists: false, config: false, - protectiveIgnore: false, }, - generated: { index: false, graph: false, health: "missing" }, }); - expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); - expect(await Bun.file(status.registryPath).exists()).toBe(false); + + if (process.platform !== "win32") { + await rm(configPath); + await writeFile(configPath, await readFile(outsideConfig)); + await chmod(configPath, 0o000); + try { + const unreadableStatus = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(unreadableStatus.projects[0]?.canonical.config).toBe(false); + } finally { + await chmod(configPath, 0o600); + } + } }); - it("refuses a stale plan before any write", async () => { + it("requires canonical config to match the bounded enrollment TOML schema", async () => { const { root, home } = await makeFixture(); const repo = join(root, "repo"); await createRepository({ path: repo, home }); @@ -560,25 +5793,105 @@ describe("project enrollment lifecycle", () => { projectRoot: repo, homeDir: home, }); - await mkdir(join(repo, ".ai"), { recursive: true }); - await writeFile(join(repo, ".ai", ".gitignore"), "/user-change\n", "utf8"); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const configPath = join(repo, ".ai", "config.toml"); + const validConfig = await readFile(configPath, "utf8"); + const configIsHealthy = async () => + ( + await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }) + ).projects[0]?.canonical.config; - await expect( - applyProjectEnrollment({ - plan, - expectedPlanSha256: plan.planSha256, + for (const invalidConfig of [ + "not = [valid toml", + validConfig.replace("version = 1", "version = 2"), + validConfig.replace( + `repository_id = "${plan.identity.id}"`, + 'repository_id = "repo_000000000000000000000000"' + ), + validConfig.replace('cadence = "on-demand"', 'cadence = "sometimes"'), + validConfig.replace("guidance = []", 'guidance = ["../private.md"]'), + validConfig.replace( + "managed_rendering = false", + "managed_rendering = true" + ), + validConfig.replace( + "managed_rendering = false", + "managed_rendering = false\nextra = true" + ), + `${validConfig}\n[project.extra]\nenabled = true\n`, + ]) { + await writeFile(configPath, invalidConfig, "utf8"); + expect(await configIsHealthy()).toBe(false); + } + + await writeFile(configPath, validConfig, "utf8"); + expect(await configIsHealthy()).toBe(true); + await truncate(configPath, 1024 * 1024 + 1); + expect(await configIsHealthy()).toBe(false); + }); + + it("marks canonical config partial when enrollment options drift from the registry", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ + path: repo, + home, + files: { "README.md": "# Project\n" }, + }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + sources: ["git"], + guidance: ["README.md"], + cadence: "weekly", + }); + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const configPath = join(repo, ".ai", "config.toml"); + const validConfig = await readFile(configPath, "utf8"); + const driftedConfigs = [ + validConfig.replace('sources = ["git", "guidance"]', 'sources = ["git"]'), + validConfig.replace('guidance = ["README.md"]', "guidance = []"), + validConfig.replace('cadence = "weekly"', 'cadence = "daily"'), + validConfig.replace("scheduling = false", "scheduling = true"), + ]; + + for (const driftedConfig of driftedConfigs) { + expect(driftedConfig).not.toBe(validConfig); + await writeFile(configPath, driftedConfig, "utf8"); + const status = await buildProjectsStatus({ homeDir: home, - }) - ).rejects.toThrow("plan is stale"); - expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( - false - ); - expect(await readFile(join(repo, ".ai", ".gitignore"), "utf8")).toBe( - "/user-change\n" - ); + discoveryRoots: [repo], + }); + expect(status.projects[0]).toMatchObject({ + coverage: "partial", + health: "degraded", + canonical: { config: false }, + }); + } + + await writeFile(configPath, validConfig, "utf8"); + const restored = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(restored.projects[0]).toMatchObject({ + coverage: "covered", + canonical: { config: true }, + }); }); - it("previews and applies rollback while preserving receipts and history", async () => { + it("validates generated index and graph structure, bounds, and file safety", async () => { const { root, home } = await makeFixture(); const repo = join(root, "repo"); await createRepository({ path: repo, home }); @@ -590,37 +5903,83 @@ describe("project enrollment lifecycle", () => { plan, expectedPlanSha256: plan.planSha256, homeDir: home, - now: new Date("2026-07-28T12:00:00.000Z"), }); + const [indexPath, graphPath] = applied.generatedPaths; + if (!(indexPath && graphPath)) { + throw new Error("Expected generated project index and graph paths"); + } + const indexContent = await readFile(indexPath, "utf8"); + const graphContent = await readFile(graphPath, "utf8"); - const preview = await rollbackProjectEnrollment({ - receiptId: applied.receiptId, + const malformedIndex = JSON.parse(indexContent) as { + skills: Record; + }; + malformedIndex.skills = { broken: { name: "broken" } }; + await writeFile( + indexPath, + `${JSON.stringify(malformedIndex, null, 2)}\n`, + "utf8" + ); + let status = await buildProjectsStatus({ homeDir: home, + discoveryRoots: [repo], }); - expect(preview.applied).toBe(false); - expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( - true + expect(status.projects[0]).toMatchObject({ + health: "degraded", + generated: { index: false, graph: true, health: "missing" }, + }); + + await writeFile(indexPath, indexContent, "utf8"); + const malformedGraph = JSON.parse(graphContent) as { + edges: unknown[]; + }; + malformedGraph.edges = [{}]; + await writeFile( + graphPath, + `${JSON.stringify(malformedGraph, null, 2)}\n`, + "utf8" ); + status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(status.projects[0]?.generated.graph).toBe(false); + await writeFile(graphPath, graphContent, "utf8"); - const rolledBack = await rollbackProjectEnrollment({ - receiptId: applied.receiptId, + const outsideGraph = join(root, "outside-graph.json"); + await rename(graphPath, outsideGraph); + await symlink(outsideGraph, graphPath); + status = await buildProjectsStatus({ homeDir: home, - apply: true, - now: new Date("2026-07-28T13:00:00.000Z"), + discoveryRoots: [repo], }); - expect(rolledBack.applied).toBe(true); - expect(await Bun.file(join(repo, ".ai", "config.toml")).exists()).toBe( - false - ); - expect(await Bun.file(join(repo, ".ai", ".gitignore")).exists()).toBe( - false - ); - for (const preserved of rolledBack.preserved) { - expect(await Bun.file(preserved).exists()).toBe(true); + expect(status.projects[0]?.generated).toEqual({ + index: true, + graph: false, + health: "missing", + }); + await rm(graphPath); + await rename(outsideGraph, graphPath); + + if (process.platform !== "win32") { + await chmod(indexPath, 0o000); + try { + status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(status.projects[0]?.generated.index).toBe(false); + } finally { + await chmod(indexPath, 0o600); + } } - const status = await buildProjectsStatus({ homeDir: home }); - expect(status.projects[0]?.decision).toBe("disabled"); - expect(status.projects[0]?.coverage).toBe("inactive"); + + await truncate(indexPath, 64 * 1024 * 1024 + 1); + status = await buildProjectsStatus({ + homeDir: home, + discoveryRoots: [repo], + }); + expect(status.projects[0]?.generated.index).toBe(false); }); it("disable and remove preserve canonical files and review history", async () => { @@ -663,11 +6022,67 @@ describe("project enrollment lifecycle", () => { } }); - it("uses one machine-state key after cloning an enrolled repository", async () => { + it("rejects Windows lifecycle mutations before changing enrolled state", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const applied = await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }); + const registryBefore = await readFile(applied.registryPath, "utf8"); + const configPath = join(repo, ".ai", "config.toml"); + const configBefore = await readFile(configPath, "utf8"); + const previousError = console.error; + const previousExitCode = process.exitCode; + console.error = () => undefined; + try { + process.exitCode = 0; + await projectCommand(["disable", "--project-root", repo, "--json"], { + homeDir: home, + platform: "win32", + }); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode ?? 0; + console.error = previousError; + } + expect(await readFile(applied.registryPath, "utf8")).toBe(registryBefore); + expect(await readFile(configPath, "utf8")).toBe(configBefore); + + await expect( + rollbackProjectEnrollment({ + receiptId: applied.receiptId, + homeDir: home, + apply: true, + platform: "win32", + }) + ).rejects.toThrow("registry mutation is unsupported on win32"); + expect(await readFile(applied.registryPath, "utf8")).toBe(registryBefore); + expect(await readFile(configPath, "utf8")).toBe(configBefore); + }); + + it("shares portfolio identity while isolating clone execution state", async () => { const { root, home } = await makeFixture(); const repo = join(root, "repo"); const clone = join(root, "clone"); await createRepository({ path: repo, home }); + await runFixtureGit({ + argv: [ + "remote", + "add", + "origin", + "https://github.com/example/portfolio-project.git", + ], + repoDir: repo, + homeDir: home, + cwd: repo, + }); const plan = await planProjectEnrollment({ projectRoot: repo, homeDir: home, @@ -703,8 +6118,20 @@ describe("project enrollment lifecycle", () => { homeDir: home, cwd: root, }); + await runFixtureGit({ + argv: [ + "remote", + "set-url", + "origin", + "https://github.com/example/portfolio-project.git", + ], + repoDir: clone, + homeDir: home, + cwd: clone, + }); - expect(facultMachineStateDir(home, join(repo, ".ai"))).toBe( + expect((await resolveRepositoryIdentity(clone)).id).toBe(plan.identity.id); + expect(facultMachineStateDir(home, join(repo, ".ai"))).not.toBe( facultMachineStateDir(home, join(clone, ".ai")) ); }); diff --git a/src/projects.ts b/src/projects.ts index ca489eca..385a7b5a 100644 --- a/src/projects.ts +++ b/src/projects.ts @@ -1,16 +1,21 @@ import { createHash, randomUUID } from "node:crypto"; +import { constants, createReadStream, type Stats } from "node:fs"; import { + chmod, lstat, mkdir, + open, readdir, readFile, realpath, rename, rm, + rmdir, stat, writeFile, } from "node:fs/promises"; -import { homedir } from "node:os"; +import { createServer, type Server } from "node:net"; +import { homedir, tmpdir } from "node:os"; import { basename, dirname, @@ -19,12 +24,29 @@ import { relative, resolve, } from "node:path"; -import { buildIndex } from "./index-builder"; import { + replaceVerifiedFileAt, + unlinkVerifiedFileAt, +} from "./audit/safe-openat"; +import { resolveCliContextRoot } from "./cli-context"; +import { buildIndexSnapshot } from "./index-builder"; +import { + executionMachineStateProjectKey, facultAiGraphPath, facultAiIndexPath, facultLocalStateRoot, + legacyMachineStateProjectKey, + pathsMayCollide, + pathsPhysicallyEquivalent, + preferredGlobalAiRoot, } from "./paths"; +import { + type RepositoryExecutionIdentity, + type RepositoryIdentity, + repositoryExecutionIdentity, + repositoryIdentityAliasForPrimary, + repositoryIdentityFromGitFacts, +} from "./repository-identity"; const DEFAULT_MAX_VISITS = 10_000; const DEFAULT_MAX_RESULTS = 250; @@ -43,6 +65,29 @@ const DISCOVERY_IGNORES = new Set([ ]); const PROJECT_SOURCES = new Set(["git", "guidance", "writebacks"]); const PROJECT_CADENCES = new Set(["on-demand", "weekly", "daily"]); +const PROJECT_CONFIG_KEYS = [ + "cadence", + "guidance", + "managed_rendering", + "repository_id", + "scheduling", + "sources", +]; +const PROJECT_DECISIONS = new Set([ + "selected", + "inactive", + "ignored", + "disabled", + "removed", +]); +const PROJECT_HISTORY_ACTIONS = new Set([ + "enrolled", + "disabled", + "ignored", + "inactive", + "removed", + "rolled-back", +]); const PROTECTIVE_IGNORE_LINES = [ "# fclt machine-local and generated state", "/.facult/", @@ -51,19 +96,56 @@ const PROTECTIVE_IGNORE_LINES = [ const SECRET_SHAPE_RE = /(?:api[_-]?key|access[_-]?token|client[_-]?secret|password)\s*[:=]\s*["']?[^\s"'#]{8,}/i; const PRIVATE_KEY_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/; -const LOCAL_ABSOLUTE_PATH_RE = - /(?:^|[\s"'`(])(?:\/Users\/[^/\s]+|\/home\/[^/\s]+|[A-Za-z]:\\Users\\[^\\\s]+)/m; +const LOCAL_UNIX_ABSOLUTE_PATH_RE = + /(?:^|[\s"'`([{=>:])\/(?!\/)(?=[^\s"'`)\]}>])/m; +const LOCAL_WINDOWS_DRIVE_PATH_RE = + /(?:^|[\s"'`([{=])(?:[A-Za-z]:(?:[\\/][^\s"'`)\]}]*|[^\s"'`)\]}\\/][^\s"'`)\]}]*))/m; +const LOCAL_WINDOWS_UNC_PATH_RE = + /(?:^|[\s"'`([{=])(?:\\\\[^\\\s"'`)\]}]+\\[^\\\s"'`)\]}]+|\/\/[^/\s"'`)\]}]+\/[^/\s"'`)\]}]+)/m; +const LEADING_NEGATION_RE = /^!/; +const MARKDOWN_REFERENCE_ROOT_URL_RE = + /^\s*\[[^\]]+\]:\s*\/{1,2}\S+(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*$/gm; +const MARKDOWN_ROOT_URL_RE = + /\]\(\s*\/{1,2}[^\s)]+(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g; +const ROOT_URL_ATTRIBUTE_RE = + /\b(?:href|src)\s*=\s*(?:"\/{1,2}[^"]*"|'\/{1,2}[^']*'|\/{1,2}[^\s>]+)/gi; +const WEB_URL_TOKEN_RE = /\bhttps?:\/\/[^\s"'`<>]+/gi; const REPOSITORY_ID_RE = /^repo_[a-f0-9]{24}$/; -const SCP_REMOTE_RE = /^([^@/\s]+@)?([^:/\s]+):(.+)$/; -const GIT_PROTOCOL_PREFIX_RE = /^git\+/; -const GIT_SUFFIX_RE = /\.git\/?$/; -const TRAILING_SLASH_RE = /\/+$/; +const EXECUTION_ID_RE = /^worktree_[a-f0-9]{24}$/; +const GUIDANCE_INDEX_ENTRY_RE = /^H [0-7]{6} [0-9a-f]{40,64} 0\t/; const LINE_SPLIT_RE = /\r?\n/; +const WHITESPACE_RE = /\s+/; const SINCE_RE = /^(\d+)([dhw])$/; const PATH_PART_SPLIT_RE = /[\\/]/; const RECEIPT_ID_RE = /^enroll-[a-zA-Z0-9-]+$/; const NON_DIGIT_RE = /[^0-9]/g; const PLAN_SHA_RE = /^[a-f0-9]{64}$/; +const PROJECT_MUTATION_LOCK_ATTEMPTS = 500; +const PROJECT_MUTATION_LOCK_RETRY_MS = 10; +const PROJECT_CANONICAL_FILE_MAX_BYTES = 1024 * 1024; +const PROJECT_GENERATED_FILE_MAX_BYTES = 64 * 1024 * 1024; +const PROJECT_STATE_TREE_MAX_BYTES = 256 * 1024 * 1024; +const PROJECT_STATE_TREE_MAX_ENTRIES = 32_768; +const REBUILDABLE_PROJECT_STATE_PATHS = new Set([ + "ai/graph.json", + "ai/index.json", +]); +const CANONICAL_FILE_MODE = 0o644; +const MACHINE_LOCAL_FILE_MODE = 0o600; + +function permissionMode(mode: number): number { + return mode % 0o1000; +} + +function assertProjectRegistryMutationSupported( + platform: NodeJS.Platform +): void { + if (platform === "win32") { + throw new Error( + "Project registry mutation is unsupported on win32 because equivalent conditional replacement is unavailable" + ); + } +} export type ProjectDecision = | "selected" @@ -80,21 +162,15 @@ interface GitCommandResult { stderr: string; } -export interface RepositoryIdentity { - id: string; - kind: "remote" | "root-commit" | "git-common-dir"; - fingerprint: string; - stability: "portable" | "machine-local"; -} - export interface DiscoveredProject { root: string; name: string; identity: RepositoryIdentity; + executionIdentity: RepositoryExecutionIdentity; branch: string | null; head: string | null; lastCommitAt: string | null; - dirty: boolean; + dirty: boolean | null; canonicalAiRoot: string; canonicalAiExists: boolean; protectiveIgnore: boolean; @@ -130,6 +206,7 @@ interface FilePrecondition { path: string; existed: boolean; sha256: string | null; + mode: number | null; } export interface ProjectEnrollmentPlan { @@ -138,8 +215,9 @@ export interface ProjectEnrollmentPlan { projectRoot: string; aiRoot: string; identity: RepositoryIdentity; + executionIdentity: RepositoryExecutionIdentity; worktree: { - dirty: boolean; + dirty: boolean | null; branch: string | null; head: string | null; }; @@ -164,6 +242,8 @@ export interface ProjectEnrollmentPlan { path: string; reason: string; }>; + legacyStateRoots: string[]; + stateMigrations: ProjectStateMigrationPlanEntry[]; protections: { ignoreWrittenFirst: true; managedRendering: false; @@ -178,6 +258,16 @@ export interface ProjectEnrollmentPlan { planSha256: string; } +interface ProjectStateMigrationPlanEntry { + source: string; + destination: string; + reason: string; + strategy: "rename" | "merge-disjoint"; + sourceTreeSha256: string; + destinationTreeSha256: string | null; + rebuildableOverlaps: string[]; +} + interface ProjectRegistryLocation { path: string; firstSeenAt: string; @@ -199,6 +289,7 @@ interface ProjectRegistryHistory { interface ProjectRegistryEntry { repositoryId: string; + aliases?: string[]; identityKind: RepositoryIdentity["kind"]; identityFingerprint: string; decision: ProjectDecision; @@ -210,6 +301,7 @@ interface ProjectRegistryEntry { lastSuccessfulRun: string | null; pendingApprovals: string[]; history: ProjectRegistryHistory[]; + activeReceipts?: Record; } interface ProjectRegistry { @@ -223,19 +315,55 @@ interface EnrollmentReceipt { id: string; createdAt: string; repositoryId: string; + executionId: string; projectRoot: string; planSha256: string; + registryEntryBefore?: ProjectRegistryEntry | null; files: Array<{ path: string; before: string | null; + beforeMode: number | null; afterSha256: string; + afterMode: number; }>; } +interface TransactionArtifact { + path: string; + before: string | null; + beforeIdentity: { + dev: number; + ino: number; + } | null; + beforeMode: number | null; + afterContent: string | null; + afterSha256: string | null; + afterMode: number | null; + afterSize: number | null; + safeRoot?: string; + safeRootIdentity?: { + dev: number; + ino: number; + uid: number; + }; + written: boolean; +} + +interface ProjectMutationLockOwner { + version: 2; + endpoint: string; + ownerId: string; + pid: number; + acquiredAt: string; + transport: "ipc-socket"; +} + export interface ProjectCommandContext { cwd?: string; homeDir?: string; now?: () => Date; + /** @internal Platform branch override for cross-platform regression tests. */ + platform?: NodeJS.Platform; } interface ProjectStatusRow { @@ -291,14 +419,28 @@ function stableJson(value: unknown): string { async function runGit(args: { cwd: string; argv: string[]; + stdin?: string; }): Promise { const gitBinary = Bun.which("git") ?? "/usr/bin/git"; const proc = Bun.spawn({ cmd: [gitBinary, ...args.argv], cwd: args.cwd, - stdout: "pipe", + env: { + ...process.env, + GIT_OPTIONAL_LOCKS: "0", + }, + stdin: args.stdin === undefined ? "ignore" : "pipe", stderr: "pipe", + stdout: "pipe", }); + if (args.stdin !== undefined) { + const input = proc.stdin; + if (!input) { + throw new Error("Git stdin pipe is unavailable"); + } + input.write(args.stdin); + input.end(); + } const [exitCode, stdout, stderr] = await Promise.all([ proc.exited, new Response(proc.stdout).text(), @@ -312,59 +454,385 @@ async function runGit(args: { } async function pathExists(pathValue: string): Promise { - return await stat(pathValue) - .then(() => true) - .catch(() => false); + try { + await stat(pathValue); + return true; + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : null; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} + +async function validProjectEnrollmentConfig( + pathValue: string, + expected: Pick< + ProjectRegistryEntry, + "cadence" | "guidance" | "repositoryId" | "scheduling" | "sources" + > +): Promise { + try { + const before = await lstat(pathValue); + if ( + before.isSymbolicLink() || + !before.isFile() || + before.size > PROJECT_CANONICAL_FILE_MAX_BYTES + ) { + return false; + } + const content = await readFile(pathValue, "utf8"); + const after = await lstat(pathValue); + if ( + after.isSymbolicLink() || + !after.isFile() || + after.dev !== before.dev || + after.ino !== before.ino || + after.size !== before.size || + after.mtimeMs !== before.mtimeMs + ) { + return false; + } + const parsed = Bun.TOML.parse(content); + if (!isRecord(parsed) || parsed.version !== 1) { + return false; + } + const project = parsed.project; + return ( + isRecord(project) && + Object.keys(project).sort().join("\0") === + PROJECT_CONFIG_KEYS.join("\0") && + project.repository_id === expected.repositoryId && + Array.isArray(project.sources) && + project.sources.length === expected.sources.length && + project.sources.every( + (source, index) => + typeof source === "string" && + PROJECT_SOURCES.has(source) && + source === expected.sources[index] + ) && + Array.isArray(project.guidance) && + project.guidance.length === expected.guidance.length && + project.guidance.every((pathValue, index) => { + if (typeof pathValue !== "string") { + return false; + } + try { + ensureRepoRelativeMarkdown(pathValue); + return pathValue === expected.guidance[index]; + } catch { + return false; + } + }) && + project.cadence === expected.cadence && + project.scheduling === expected.scheduling && + project.managed_rendering === false + ); + } catch { + return false; + } +} + +async function validBoundedGeneratedJson( + pathValue: string, + validate: (value: unknown) => boolean +): Promise { + try { + const before = await lstat(pathValue); + if ( + before.isSymbolicLink() || + !before.isFile() || + before.size > PROJECT_GENERATED_FILE_MAX_BYTES + ) { + return false; + } + const content = await readFile(pathValue, "utf8"); + const after = await lstat(pathValue); + if ( + after.isSymbolicLink() || + !after.isFile() || + after.dev !== before.dev || + after.ino !== before.ino || + after.size !== before.size || + after.mtimeMs !== before.mtimeMs + ) { + return false; + } + return validate(JSON.parse(content)); + } catch { + return false; + } +} + +function isGeneratedIndex(value: unknown): boolean { + if (!isRecord(value) || value.version !== 1) { + return false; + } + const assetRecordIsValid = (record: Record) => + Object.values(record).every( + (entry) => + isRecord(entry) && + typeof entry.name === "string" && + typeof entry.path === "string" + ); + return ( + typeof value.updatedAt === "string" && + isRecord(value.skills) && + assetRecordIsValid(value.skills) && + isRecord(value.mcp) && + isRecord(value.mcp.servers) && + assetRecordIsValid(value.mcp.servers) && + isRecord(value.agents) && + assetRecordIsValid(value.agents) && + (value.automations === undefined || + (isRecord(value.automations) && assetRecordIsValid(value.automations))) && + isRecord(value.snippets) && + assetRecordIsValid(value.snippets) && + isRecord(value.instructions) && + assetRecordIsValid(value.instructions) + ); +} + +function isGeneratedGraph(value: unknown): boolean { + if ( + !isRecord(value) || + value.version !== 1 || + typeof value.generatedAt !== "string" || + !isRecord(value.nodes) || + !Array.isArray(value.edges) + ) { + return false; + } + const nodesAreValid = Object.values(value.nodes).every( + (node) => + isRecord(node) && + typeof node.id === "string" && + typeof node.kind === "string" && + typeof node.name === "string" && + typeof node.sourceKind === "string" && + typeof node.scope === "string" + ); + return ( + nodesAreValid && + value.edges.every( + (edge) => + isRecord(edge) && + typeof edge.from === "string" && + typeof edge.to === "string" && + typeof edge.kind === "string" && + typeof edge.locator === "string" + ) + ); } async function fileText(pathValue: string): Promise { - return await readFile(pathValue, "utf8").catch(() => null); + try { + return await readFile(pathValue, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } } -async function filePrecondition(pathValue: string): Promise { - const content = await fileText(pathValue); - return { - path: pathValue, - existed: content !== null, - sha256: content === null ? null : sha256(content), - }; +async function lstatIfExists(pathValue: string) { + try { + return await lstat(pathValue); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + +interface CanonicalFileSnapshot { + content: string; + metadata: Stats; +} + +function canonicalMetadataMatches(left: Stats, right: Stats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.nlink === right.nlink && + left.size === right.size && + left.ctimeMs === right.ctimeMs && + left.mtimeMs === right.mtimeMs + ); } -function normalizeRemote(raw: string): string | null { - const value = raw.trim(); - if (!value || value.startsWith("/") || value.startsWith("./")) { +async function canonicalFileSnapshot( + pathValue: string +): Promise { + const parentPath = dirname(pathValue); + const parentBefore = await lstatIfExists(parentPath); + if (!parentBefore) { + const file = await lstatIfExists(pathValue); + const parentAfter = await lstatIfExists(parentPath); + if (file || parentAfter) { + throw new Error( + `Canonical project file parent changed while planning: ${parentPath}` + ); + } + return null; + } + if (parentBefore.isSymbolicLink() || !parentBefore.isDirectory()) { + throw new Error(`Refusing unsafe canonical project root: ${parentPath}`); + } + + const pathMetadata = await lstatIfExists(pathValue); + if (!pathMetadata) { + const parentAfter = await lstatIfExists(parentPath); + const rebound = await lstatIfExists(pathValue); + if ( + !parentAfter || + parentAfter.isSymbolicLink() || + !parentAfter.isDirectory() || + !canonicalMetadataMatches(parentBefore, parentAfter) || + rebound + ) { + throw new Error( + `Canonical project file changed while planning: ${pathValue}` + ); + } return null; } - const scpMatch = value.includes("://") ? null : value.match(SCP_REMOTE_RE); - const asUrl = scpMatch - ? `ssh://${scpMatch[2]}/${scpMatch[3]}` - : value.replace(GIT_PROTOCOL_PREFIX_RE, ""); + if ( + pathMetadata.isSymbolicLink() || + !pathMetadata.isFile() || + pathMetadata.nlink !== 1 || + !Number.isSafeInteger(pathMetadata.size) || + pathMetadata.size < 0 || + pathMetadata.size > PROJECT_CANONICAL_FILE_MAX_BYTES + ) { + throw new Error(`Refusing unsafe canonical file: ${pathValue}`); + } + const handle = await open( + pathValue, + constants.O_RDONLY + + (constants.O_NOFOLLOW ?? 0) + + (constants.O_NONBLOCK ?? 0) + ).catch(() => { + throw new Error( + `Canonical project file changed before descriptor read: ${pathValue}` + ); + }); try { - const parsed = new URL(asUrl); - if (parsed.protocol === "file:") { - return null; + const opened = await handle.stat(); + if ( + opened.isSymbolicLink() || + !opened.isFile() || + opened.nlink !== 1 || + !canonicalMetadataMatches(pathMetadata, opened) + ) { + throw new Error( + `Canonical project file changed before descriptor read: ${pathValue}` + ); } - parsed.username = ""; - parsed.password = ""; - parsed.search = ""; - parsed.hash = ""; - parsed.hostname = parsed.hostname.toLowerCase(); - parsed.pathname = parsed.pathname - .replace(GIT_SUFFIX_RE, "") - .replace(TRAILING_SLASH_RE, ""); - const port = - parsed.port && - !( - (parsed.protocol === "https:" && parsed.port === "443") || - (parsed.protocol === "http:" && parsed.port === "80") || - (parsed.protocol === "ssh:" && parsed.port === "22") - ) - ? `:${parsed.port}` - : ""; - return `${parsed.hostname}${port}${parsed.pathname}`; - } catch { + const bytes = Buffer.alloc(opened.size); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await handle.read( + bytes, + offset, + bytes.length - offset, + offset + ); + if (bytesRead === 0) { + throw new Error( + `Canonical project file changed while reading: ${pathValue}` + ); + } + offset += bytesRead; + } + const trailing = Buffer.alloc(1); + if ((await handle.read(trailing, 0, 1, opened.size)).bytesRead !== 0) { + throw new Error( + `Canonical project file changed while reading: ${pathValue}` + ); + } + const [afterRead, rebound, parentAfter] = await Promise.all([ + handle.stat(), + lstatIfExists(pathValue), + lstatIfExists(parentPath), + ]); + if ( + !rebound || + rebound.isSymbolicLink() || + !rebound.isFile() || + !parentAfter || + parentAfter.isSymbolicLink() || + !parentAfter.isDirectory() || + !canonicalMetadataMatches(opened, afterRead) || + !canonicalMetadataMatches(afterRead, rebound) || + !canonicalMetadataMatches(parentBefore, parentAfter) + ) { + throw new Error( + `Canonical project file changed while reading: ${pathValue}` + ); + } + return { + content: bytes.toString("utf8"), + metadata: opened, + }; + } finally { + await handle.close(); + } +} + +async function regularFileText(pathValue: string): Promise { + const metadata = await lstatIfExists(pathValue); + if (!metadata) { return null; } + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`Refusing unsafe machine-local file: ${pathValue}`); + } + return await readFile(pathValue, "utf8"); +} + +async function filePrecondition(pathValue: string): Promise { + const snapshot = await canonicalFileSnapshot(pathValue); + if (!snapshot) { + return { + path: pathValue, + existed: false, + sha256: null, + mode: null, + }; + } + return { + path: pathValue, + existed: true, + sha256: sha256(snapshot.content), + mode: permissionMode(snapshot.metadata.mode), + }; +} + +function filePreconditionFromSnapshot( + pathValue: string, + snapshot: CanonicalFileSnapshot | null +): FilePrecondition { + return snapshot + ? { + path: pathValue, + existed: true, + sha256: sha256(snapshot.content), + mode: permissionMode(snapshot.metadata.mode), + } + : { + path: pathValue, + existed: false, + sha256: null, + mode: null, + }; } async function gitRoot(pathValue: string): Promise { @@ -379,86 +847,92 @@ async function gitRoot(pathValue: string): Promise { } export async function resolveRepositoryIdentity( - projectRoot: string + projectRoot: string, + homeDir?: string ): Promise { const root = await gitRoot(projectRoot); - const remotes = await runGit({ cwd: root, argv: ["remote"] }); - if (remotes.exitCode === 0) { - for (const name of remotes.stdout.split("\n").filter(Boolean).sort()) { - const remote = await runGit({ - cwd: root, - argv: ["remote", "get-url", name], - }); - const normalized = - remote.exitCode === 0 ? normalizeRemote(remote.stdout) : null; - if (normalized) { - return { - id: `repo_${sha256(`remote:${normalized}`).slice(0, 24)}`, - kind: "remote", - fingerprint: normalized, - stability: "portable", - }; - } - } - } - - const roots = await runGit({ - cwd: root, - argv: ["rev-list", "--max-parents=0", "HEAD"], + const identity = await resolveUnstabilizedRepositoryIdentity(root); + return await stabilizeRepositoryIdentity({ + identity, + projectRoot: root, + homeDir: resolve(homeDir ?? process.env.HOME ?? homedir()), }); - const rootCommit = roots.stdout.split("\n").filter(Boolean).sort()[0]; - if (roots.exitCode === 0 && rootCommit) { - return { - id: `repo_${sha256(`root-commit:${rootCommit}`).slice(0, 24)}`, - kind: "root-commit", - fingerprint: rootCommit, - stability: "portable", - }; - } +} - const commonDir = await runGit({ - cwd: root, - argv: ["rev-parse", "--git-common-dir"], +async function resolveUnstabilizedRepositoryIdentity( + projectRoot: string +): Promise { + const root = await gitRoot(projectRoot); + const [origin, remotes, roots, commonDir] = await Promise.all([ + runGit({ cwd: root, argv: ["remote", "get-url", "origin"] }), + runGit({ + cwd: root, + argv: ["config", "--get-regexp", "^remote\\..*\\.url$"], + }), + runGit({ cwd: root, argv: ["rev-list", "--max-parents=0", "HEAD"] }), + runGit({ cwd: root, argv: ["rev-parse", "--git-common-dir"] }), + ]); + return repositoryIdentityFromGitFacts({ + projectRoot: root, + originUrl: origin.exitCode === 0 ? origin.stdout : null, + remoteUrls: + remotes.exitCode === 0 + ? remotes.stdout + .split(LINE_SPLIT_RE) + .map((line) => line.trim().split(WHITESPACE_RE, 2)[1] ?? "") + .filter(Boolean) + : [], + rootCommit: roots.exitCode === 0 ? roots.stdout : null, + commonDir: commonDir.exitCode === 0 ? commonDir.stdout : null, }); - const commonPath = resolve(root, commonDir.stdout || ".git"); - return { - id: `repo_${sha256(`git-common-dir:${commonPath}`).slice(0, 24)}`, - kind: "git-common-dir", - fingerprint: sha256(commonPath), - stability: "machine-local", - }; +} + +export async function resolveRepositoryExecutionIdentity( + projectRoot: string +): Promise { + const root = await gitRoot(projectRoot); + return repositoryExecutionIdentity(root); } async function inspectRepository( - rootValue: string + rootValue: string, + homeDir?: string, + options?: { stabilizeIdentity?: boolean } ): Promise { const root = await gitRoot(rootValue); - const [identity, branch, head, lastCommit, statusResult] = await Promise.all([ - resolveRepositoryIdentity(root), - runGit({ cwd: root, argv: ["branch", "--show-current"] }), - runGit({ cwd: root, argv: ["rev-parse", "--verify", "HEAD"] }), - runGit({ cwd: root, argv: ["log", "-1", "--format=%cI"] }), - runGit({ - cwd: root, - argv: ["status", "--porcelain=v1", "--untracked-files=all"], - }), - ]); + const [rawIdentity, branch, head, lastCommit, statusResult] = + await Promise.all([ + resolveUnstabilizedRepositoryIdentity(root), + runGit({ cwd: root, argv: ["branch", "--show-current"] }), + runGit({ cwd: root, argv: ["rev-parse", "--verify", "HEAD"] }), + runGit({ cwd: root, argv: ["log", "-1", "--format=%cI"] }), + runGit({ + cwd: root, + argv: ["status", "--porcelain=v1", "--untracked-files=all"], + }), + ]); + const identity = + options?.stabilizeIdentity === false + ? rawIdentity + : await stabilizeRepositoryIdentity({ + identity: rawIdentity, + projectRoot: root, + homeDir: resolve(homeDir ?? process.env.HOME ?? homedir()), + }); const aiRoot = join(root, ".ai"); - const ignoreText = await fileText(join(aiRoot, ".gitignore")); return { root, name: basename(root), identity, + executionIdentity: repositoryExecutionIdentity(root), branch: branch.exitCode === 0 && branch.stdout ? branch.stdout : null, head: head.exitCode === 0 && head.stdout ? head.stdout : null, lastCommitAt: lastCommit.exitCode === 0 && lastCommit.stdout ? lastCommit.stdout : null, - dirty: statusResult.exitCode === 0 && Boolean(statusResult.stdout), + dirty: statusResult.exitCode === 0 ? Boolean(statusResult.stdout) : null, canonicalAiRoot: aiRoot, canonicalAiExists: await pathExists(aiRoot), - protectiveIgnore: PROTECTIVE_IGNORE_LINES.slice(1).every((line) => - ignoreText?.split(LINE_SPLIT_RE).includes(line) - ), + protectiveIgnore: await hasEffectiveProtectiveIgnore(root), duplicateLocations: 1, }; } @@ -506,9 +980,7 @@ async function discoverGitRoots(args: { truncated = true; break; } - const entries = await readdir(current, { withFileTypes: true }).catch( - () => [] - ); + const entries = await readdir(current, { withFileTypes: true }); if (entries.some((entry) => entry.name === ".git")) { found.add(await realpath(current).catch(() => current)); } @@ -533,6 +1005,7 @@ async function discoverGitRoots(args: { export async function discoverProjects(args: { roots: string[]; + homeDir?: string; since?: string; maxVisits?: number; maxResults?: number; @@ -552,9 +1025,22 @@ export async function discoverProjects(args: { const cutoff = parseSince(args.since, args.now ?? new Date()); const discovered = await discoverGitRoots({ roots, maxVisits, maxResults }); const inspected = await Promise.all( - discovered.roots.map(async (root) => await inspectRepository(root)) + discovered.roots.map(async (root) => { + try { + return await inspectRepository(root, args.homeDir); + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("Not a Git repository:") + ) { + return null; + } + throw error; + } + }) ); const projects = inspected + .filter((project): project is DiscoveredProject => project !== null) .filter((project) => { if (!cutoff) { return true; @@ -598,21 +1084,52 @@ export async function discoverProjects(args: { function appendProtectiveIgnore(existing: string | null): string { const lines = existing?.replace(/\r\n/g, "\n").split("\n") ?? []; - const out = [...lines]; + const protectedLines = new Set(PROTECTIVE_IGNORE_LINES); + const out = lines.filter((line) => !protectedLines.has(line)); while (out.at(-1) === "") { out.pop(); } - for (const line of PROTECTIVE_IGNORE_LINES) { - if (!out.includes(line)) { - if (line.startsWith("#") && out.length > 0 && out.at(-1) !== "") { - out.push(""); - } - out.push(line); - } + if (out.length > 0) { + out.push(""); } + out.push(...PROTECTIVE_IGNORE_LINES); return `${out.join("\n")}\n`; } +async function hasEffectiveProtectiveIgnore( + projectRoot: string +): Promise { + const expectedSource = join(projectRoot, ".ai", ".gitignore"); + for (const pathValue of [ + ".ai/.facult/fclt-protective-probe", + ".ai/.facult/nested/fclt-protective-probe", + ".ai/.facult/nested/fclt-protective-probe.toml", + ".ai/config.local.toml", + ]) { + const result = await runGit({ + cwd: projectRoot, + argv: ["check-ignore", "-v", "-z", "--no-index", "--stdin"], + stdin: `${pathValue}\0`, + }); + if (result.exitCode !== 0) { + return false; + } + const [source, line, pattern, matchedPath, ...extra] = + result.stdout.split("\0"); + if ( + !(source && line) || + pattern === undefined || + pattern.startsWith("!") || + matchedPath !== pathValue || + extra.some(Boolean) || + resolve(projectRoot, source) !== expectedSource + ) { + return false; + } + } + return true; +} + async function assertSafeCanonicalTargets( projectRoot: string, aiRoot: string @@ -660,51 +1177,304 @@ function renderProjectConfig(args: { return ["version = 1", "", projectTable].join("\n"); } -function mergeProjectConfig( - existing: string | null, - enrollmentConfig: string -): string { - if (existing === null || existing === enrollmentConfig) { - return enrollmentConfig; +type TomlMultilineString = "basic" | "literal" | null; + +function tomlTableHeader(line: string): { + array: boolean; + name: string; +} | null { + const content = line.trimStart(); + if (!content.startsWith("[")) { + return null; } - if (privacyFindings(existing).length > 0) { - throw new Error( - "Refusing to modify existing canonical project config with privacy findings" - ); + const array = content.startsWith("[["); + const nameStart = array ? 2 : 1; + let quote: "basic" | "literal" | null = null; + for (let index = nameStart; index < content.length; index += 1) { + const character = content[index]; + if (quote === "basic") { + if (character === "\\") { + index += 1; + } else if (character === '"') { + quote = null; + } + continue; + } + if (quote === "literal") { + if (character === "'") { + quote = null; + } + continue; + } + if (character === '"') { + quote = "basic"; + continue; + } + if (character === "'") { + quote = "literal"; + continue; + } + const closes = array + ? character === "]" && content[index + 1] === "]" + : character === "]"; + if (!closes) { + continue; + } + const trailing = content.slice(index + (array ? 2 : 1)).trimStart(); + if (trailing.length > 0 && !trailing.startsWith("#")) { + return null; + } + return { + array, + name: content.slice(nameStart, index).trim(), + }; } - let parsed: unknown; - try { - parsed = Bun.TOML.parse(existing); - } catch { - throw new Error("Refusing to modify invalid canonical project config"); + return null; +} + +function hasUnescapedDelimiter( + line: string, + index: number, + delimiter: '"""' | "'''" +): boolean { + if (!line.startsWith(delimiter, index)) { + return false; } - if ( - !parsed || - typeof parsed !== "object" || - Array.isArray(parsed) || - (parsed as Record).version !== 1 - ) { - throw new Error( - "Refusing to modify canonical project config without version = 1" - ); + if (delimiter === "'''") { + return true; } - if ("project" in (parsed as Record)) { - throw new Error( - "Refusing to replace existing canonical project enrollment config" - ); + let backslashes = 0; + for ( + let cursor = index - 1; + cursor >= 0 && line[cursor] === "\\"; + cursor -= 1 + ) { + backslashes += 1; } - const projectTable = enrollmentConfig.slice( - enrollmentConfig.indexOf("[project]") - ); - return `${existing.trimEnd()}\n\n${projectTable}`; + return backslashes % 2 === 0; } -function privacyFindings(content: string): string[] { - const findings: string[] = []; - if (SECRET_SHAPE_RE.test(content) || PRIVATE_KEY_RE.test(content)) { +function nextTomlMultilineStringState( + line: string, + initial: TomlMultilineString +): TomlMultilineString { + let state = initial; + let index = 0; + while (index < line.length) { + if (state) { + const delimiter = state === "basic" ? '"""' : "'''"; + if (hasUnescapedDelimiter(line, index, delimiter)) { + state = null; + index += delimiter.length; + } else { + index += 1; + } + continue; + } + const character = line[index]; + if (character === "#") { + break; + } + if (character === '"') { + if (line.startsWith('"""', index)) { + state = "basic"; + index += 3; + continue; + } + index += 1; + while (index < line.length) { + if (line[index] === "\\") { + index += 2; + } else if (line[index] === '"') { + index += 1; + break; + } else { + index += 1; + } + } + continue; + } + if (character === "'") { + if (line.startsWith("'''", index)) { + state = "literal"; + index += 3; + continue; + } + const closing = line.indexOf("'", index + 1); + index = closing < 0 ? line.length : closing + 1; + continue; + } + index += 1; + } + return state; +} + +function projectTableRange(lines: string[]): { + end: number; + start: number; +} | null { + const headers: Array<{ + array: boolean; + index: number; + name: string; + }> = []; + let multilineState: TomlMultilineString = null; + for (const [index, line] of lines.entries()) { + if (!multilineState) { + const header = tomlTableHeader(line); + if (header) { + headers.push({ ...header, index }); + } + } + multilineState = nextTomlMultilineStringState(line, multilineState); + } + const projectHeaderIndex = headers.findIndex((header) => + isOwnedProjectTomlTable(header) + ); + if (projectHeaderIndex < 0) { + return null; + } + const header = headers[projectHeaderIndex]; + if (!header) { + return null; + } + return { + start: header.index, + end: headers[projectHeaderIndex + 1]?.index ?? lines.length, + }; +} + +function isOwnedProjectTomlTable(header: { + array: boolean; + name: string; +}): boolean { + if (header.array) { + return false; + } + try { + const parsed = Bun.TOML.parse( + `[${header.name}]\n__fclt_owned_project_table__ = true\n` + ) as Record; + return ( + Object.keys(parsed).length === 1 && + isRecord(parsed.project) && + Object.keys(parsed.project).length === 1 && + parsed.project.__fclt_owned_project_table__ === true + ); + } catch { + return false; + } +} + +function mergeProjectConfig( + existing: string | null, + enrollmentConfig: string +): string { + if (existing === null || existing === enrollmentConfig) { + return enrollmentConfig; + } + if (privacyFindings(existing).length > 0) { + throw new Error( + "Refusing to modify existing canonical project config with privacy findings" + ); + } + let parsed: unknown; + try { + parsed = Bun.TOML.parse(existing); + } catch { + throw new Error("Refusing to modify invalid canonical project config"); + } + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + (parsed as Record).version !== 1 + ) { + throw new Error( + "Refusing to modify canonical project config without version = 1" + ); + } + const parsedRecord = parsed as Record; + if (!("project" in parsedRecord)) { + const projectTable = enrollmentConfig.slice( + enrollmentConfig.indexOf("[project]") + ); + return `${existing.trimEnd()}\n\n${projectTable}`; + } + const project = parsedRecord.project; + const enrollment = Bun.TOML.parse(enrollmentConfig) as Record< + string, + unknown + >; + const nextProject = enrollment.project; + if ( + !(isRecord(project) && isRecord(nextProject)) || + Object.keys(project).sort().join("\0") !== PROJECT_CONFIG_KEYS.join("\0") || + project.repository_id !== nextProject.repository_id || + !Array.isArray(project.sources) || + project.sources.some( + (source) => typeof source !== "string" || !PROJECT_SOURCES.has(source) + ) || + !Array.isArray(project.guidance) || + project.guidance.some( + (pathValue) => + typeof pathValue !== "string" || + ensureRepoRelativeMarkdown(pathValue) !== pathValue + ) || + typeof project.cadence !== "string" || + !PROJECT_CADENCES.has(project.cadence) || + typeof project.scheduling !== "boolean" || + project.managed_rendering !== false + ) { + throw new Error( + "Refusing to update an invalid canonical project enrollment config" + ); + } + const projectTable = enrollmentConfig.slice( + enrollmentConfig.indexOf("[project]") + ); + const lines = existing.split(LINE_SPLIT_RE); + const tableRange = projectTableRange(lines); + if (!tableRange) { + throw new Error( + "Refusing to update canonical project config without an owned [project] section" + ); + } + const before = lines.slice(0, tableRange.start).join("\n").trimEnd(); + const after = lines.slice(tableRange.end).join("\n").trim(); + return [before, projectTable.trimEnd(), after] + .filter((section) => section.length > 0) + .join("\n\n") + .concat("\n"); +} + +function privacyFindings( + content: string, + options?: { gitIgnorePatterns?: boolean } +): string[] { + const findings: string[] = []; + if (SECRET_SHAPE_RE.test(content) || PRIVATE_KEY_RE.test(content)) { findings.push("secret-shaped content"); } - if (LOCAL_ABSOLUTE_PATH_RE.test(content)) { + const filteredContent = options?.gitIgnorePatterns + ? content + .split(LINE_SPLIT_RE) + .filter((line) => { + const pattern = line.trim().replace(LEADING_NEGATION_RE, ""); + return pattern.length === 0 || pattern.startsWith("#"); + }) + .join("\n") + : content; + const pathContent = filteredContent + .replace(WEB_URL_TOKEN_RE, "") + .replace(MARKDOWN_REFERENCE_ROOT_URL_RE, "") + .replace(MARKDOWN_ROOT_URL_RE, "]()") + .replace(ROOT_URL_ATTRIBUTE_RE, ""); + if ( + LOCAL_UNIX_ABSOLUTE_PATH_RE.test(pathContent) || + LOCAL_WINDOWS_DRIVE_PATH_RE.test(pathContent) || + LOCAL_WINDOWS_UNC_PATH_RE.test(pathContent) + ) { findings.push("machine-local absolute path"); } return findings; @@ -721,123 +1491,2055 @@ function ensureRepoRelativeMarkdown(value: string): string { `Guidance must be a repository-relative Markdown path: ${value}` ); } - const normalized = value.split(PATH_PART_SPLIT_RE).join("/"); - if (normalized.startsWith(".ai/.facult/") || normalized.startsWith(".git/")) { + const normalized = value.split(PATH_PART_SPLIT_RE).join("/"); + if (normalized.startsWith(".ai/.facult/") || normalized.startsWith(".git/")) { + throw new Error( + `Generated or Git-internal guidance cannot be adopted: ${value}` + ); + } + return normalized; +} + +async function previewGuidance(args: { + beforeRead?: () => Promise; + projectRoot: string; + paths: string[]; +}): Promise { + const previews: GuidancePreview[] = []; + for (const rawPath of args.paths) { + const pathValue = ensureRepoRelativeMarkdown(rawPath); + const absolutePath = resolve(args.projectRoot, pathValue); + const rel = relative(args.projectRoot, absolutePath); + if (rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`Guidance is outside the repository: ${rawPath}`); + } + const guidanceStat = await lstatIfExists(absolutePath); + if (!guidanceStat?.isFile() || guidanceStat.isSymbolicLink()) { + throw new Error( + `Refusing guidance adoption from ${pathValue}: the source must be a regular file` + ); + } + const indexEntry = await runGit({ + cwd: args.projectRoot, + argv: ["ls-files", "--stage", "-v", "--", pathValue], + }); + const indexBlob = await runGit({ + cwd: args.projectRoot, + argv: ["rev-parse", "--verify", `:${pathValue}`], + }); + const headBlob = await runGit({ + cwd: args.projectRoot, + argv: ["rev-parse", "--verify", `HEAD:${pathValue}`], + }); + await args.beforeRead?.(); + const handle = await open( + absolutePath, + constants.O_RDONLY + + (constants.O_NOFOLLOW ?? 0) + + (constants.O_NONBLOCK ?? 0) + ).catch(() => { + throw new Error( + `Refusing guidance adoption from ${pathValue}: the source changed before read` + ); + }); + let content: string; + try { + const opened = await handle.stat(); + if ( + !opened.isFile() || + opened.isSymbolicLink() || + opened.nlink !== 1 || + opened.dev !== guidanceStat.dev || + opened.ino !== guidanceStat.ino + ) { + throw new Error( + `Refusing guidance adoption from ${pathValue}: the source changed before read` + ); + } + content = await handle.readFile("utf8"); + const afterRead = await handle.stat(); + const rebound = await lstatIfExists(absolutePath); + if ( + !rebound || + rebound.isSymbolicLink() || + !rebound.isFile() || + rebound.nlink !== 1 || + afterRead.dev !== opened.dev || + afterRead.ino !== opened.ino || + afterRead.mode !== opened.mode || + afterRead.size !== opened.size || + afterRead.ctimeMs !== opened.ctimeMs || + afterRead.mtimeMs !== opened.mtimeMs || + rebound.dev !== afterRead.dev || + rebound.ino !== afterRead.ino || + rebound.mode !== afterRead.mode || + rebound.size !== afterRead.size || + rebound.ctimeMs !== afterRead.ctimeMs || + rebound.mtimeMs !== afterRead.mtimeMs + ) { + throw new Error( + `Refusing guidance adoption from ${pathValue}: the source changed while reading` + ); + } + } finally { + await handle.close(); + } + const worktreeBlob = await runGit({ + cwd: args.projectRoot, + argv: ["hash-object", "--stdin"], + stdin: content, + }); + const indexLines = indexEntry.stdout.split(LINE_SPLIT_RE).filter(Boolean); + const indexIsOrdinary = + indexEntry.exitCode === 0 && + indexLines.length === 1 && + GUIDANCE_INDEX_ENTRY_RE.test(indexLines[0] ?? ""); + const blobsMatch = + indexBlob.exitCode === 0 && + headBlob.exitCode === 0 && + worktreeBlob.exitCode === 0 && + indexBlob.stdout === headBlob.stdout && + worktreeBlob.stdout === indexBlob.stdout; + if (!(indexIsOrdinary && blobsMatch)) { + throw new Error( + `Refusing guidance adoption from ${pathValue}: the source must be ordinarily tracked and byte-for-byte clean in HEAD, the index, and the worktree` + ); + } + const findings = privacyFindings(content); + if (findings.length > 0) { + throw new Error( + `Refusing guidance adoption from ${pathValue}: ${findings.join(", ")}` + ); + } + previews.push({ + path: pathValue, + sha256: sha256(content), + content, + gitState: "clean-tracked", + adoption: "reference", + }); + } + return previews; +} + +function uniqueSorted(values: T[]): T[] { + return [...new Set(values)].sort() as T[]; +} + +function projectRegistryPath(homeDir: string): string { + return join(facultLocalStateRoot(homeDir), "projects", "registry.json"); +} + +function projectReceiptsDir(homeDir: string): string { + return join(facultLocalStateRoot(homeDir), "projects", "receipts"); +} + +function executionMachineStateDir(homeDir: string, aiRoot: string): string { + return join( + facultLocalStateRoot(homeDir), + "projects", + executionMachineStateProjectKey(aiRoot, homeDir) + ); +} + +function projectMutationLockPath(homeDir: string): string { + return join(facultLocalStateRoot(homeDir), "projects", "mutation.lock"); +} + +interface ProjectStateMigration { + commit: () => Promise; + restore: () => Promise; +} + +interface ProjectStateTreeEntry { + path: string; + type: "directory" | "file"; + mode: number; + size?: number; + sha256?: string; +} + +interface ProjectStateTree { + entries: ProjectStateTreeEntry[]; + sha256: string; +} + +async function hashProjectStateFile( + pathValue: string, + expectedSize: number +): Promise { + const hash = createHash("sha256"); + let bytes = 0; + for await (const chunk of createReadStream(pathValue)) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.byteLength; + hash.update(buffer); + } + if (bytes !== expectedSize) { + throw new Error(`Project state file changed while hashing: ${pathValue}`); + } + return hash.digest("hex"); +} + +async function inspectProjectStateTree( + root: string +): Promise { + const entries: ProjectStateTreeEntry[] = []; + let totalBytes = 0; + const visit = async (directory: string, relativePath: string) => { + const before = await lstat(directory); + if (before.isSymbolicLink() || !before.isDirectory()) { + throw new Error(`Refusing unsafe project state directory: ${directory}`); + } + entries.push({ + path: relativePath || ".", + type: "directory", + mode: permissionMode(before.mode), + }); + const children = (await readdir(directory, { withFileTypes: true })).sort( + (left, right) => left.name.localeCompare(right.name) + ); + for (const child of children) { + if (entries.length >= PROJECT_STATE_TREE_MAX_ENTRIES) { + throw new Error( + `Project state tree exceeds ${PROJECT_STATE_TREE_MAX_ENTRIES} entries: ${root}` + ); + } + const childPath = join(directory, child.name); + const childRelative = relativePath + ? `${relativePath}/${child.name}` + : child.name; + const metadata = await lstat(childPath); + if (metadata.isSymbolicLink()) { + throw new Error(`Refusing symlinked project state: ${childPath}`); + } + if (metadata.isDirectory()) { + await visit(childPath, childRelative); + continue; + } + if (!metadata.isFile() || metadata.nlink !== 1) { + throw new Error(`Refusing unsafe project state file: ${childPath}`); + } + totalBytes += metadata.size; + if (totalBytes > PROJECT_STATE_TREE_MAX_BYTES) { + throw new Error( + `Project state tree exceeds ${PROJECT_STATE_TREE_MAX_BYTES} bytes: ${root}` + ); + } + const digest = await hashProjectStateFile(childPath, metadata.size); + const after = await lstat(childPath); + if ( + after.isSymbolicLink() || + !after.isFile() || + after.nlink !== 1 || + after.dev !== metadata.dev || + after.ino !== metadata.ino || + after.size !== metadata.size || + after.mtimeMs !== metadata.mtimeMs + ) { + throw new Error( + `Project state file changed while inspecting: ${childPath}` + ); + } + entries.push({ + path: childRelative, + type: "file", + mode: permissionMode(metadata.mode), + size: metadata.size, + sha256: digest, + }); + } + const after = await lstat(directory); + if ( + after.isSymbolicLink() || + !after.isDirectory() || + after.dev !== before.dev || + after.ino !== before.ino || + after.mtimeMs !== before.mtimeMs + ) { + throw new Error( + `Project state directory changed while inspecting: ${directory}` + ); + } + }; + await visit(root, ""); + return { + entries, + sha256: sha256(stableJson(entries)), + }; +} + +function assertDisjointProjectStateTrees(args: { + allowRebuildableOverlaps?: boolean; + destination: ProjectStateTree; + destinationPath: string; + source: ProjectStateTree; + sourcePath: string; +}): string[] { + const destinationEntries = new Map( + args.destination.entries.map((entry) => [entry.path, entry]) + ); + const rebuildableOverlaps: string[] = []; + for (const sourceEntry of args.source.entries) { + if (sourceEntry.path === ".") { + continue; + } + const destinationEntry = destinationEntries.get(sourceEntry.path); + if (!destinationEntry) { + continue; + } + if ( + sourceEntry.type === "directory" && + destinationEntry.type === "directory" + ) { + continue; + } + if ( + sourceEntry.type === "file" && + destinationEntry.type === "file" && + args.allowRebuildableOverlaps === true && + REBUILDABLE_PROJECT_STATE_PATHS.has(sourceEntry.path) + ) { + rebuildableOverlaps.push(sourceEntry.path); + continue; + } + throw new Error( + `Refusing conflicting legacy and selected project state at ${sourceEntry.path}: ${args.sourcePath} and ${args.destinationPath}` + ); + } + return rebuildableOverlaps.sort(); +} + +async function removeUnreviewedEmptyStateDirectories( + root: string, + reviewedEntries: ProjectStateTreeEntry[] +): Promise { + const reviewed = new Set(reviewedEntries.map((entry) => entry.path)); + const current = await inspectProjectStateTree(root); + const extraFiles = current.entries.filter( + (entry) => entry.type === "file" && !reviewed.has(entry.path) + ); + if (extraFiles.length > 0) { + throw new Error( + `Project state gained unreviewed files during migration: ${extraFiles + .map((entry) => join(root, entry.path)) + .join(", ")}` + ); + } + const extraDirectories = current.entries + .filter( + (entry) => + entry.type === "directory" && + entry.path !== "." && + !reviewed.has(entry.path) + ) + .sort( + (left, right) => + right.path.split("/").length - left.path.split("/").length || + right.path.localeCompare(left.path) + ); + for (const directory of extraDirectories) { + await rmdir(join(root, directory.path)); + } +} + +async function planLegacyProjectStateMigrations(args: { + aiRoot: string; + homeDir: string; + legacyAiRoots: string[]; +}): Promise { + const projectsRoot = join(facultLocalStateRoot(args.homeDir), "projects"); + const selectedKey = executionMachineStateProjectKey( + args.aiRoot, + args.homeDir + ); + const legacyKeys = uniqueSorted( + args.legacyAiRoots.map((root) => + legacyMachineStateProjectKey(root, args.homeDir) + ) + ).filter((key) => key !== selectedKey); + if (!selectedKey || legacyKeys.length === 0) { + return []; + } + const globalRoot = preferredGlobalAiRoot(args.homeDir); + const candidates: Array< + Pick< + ProjectStateMigrationPlanEntry, + "source" | "destination" | "reason" + > & { allowRebuildableOverlaps?: boolean } + > = legacyKeys.flatMap((legacyKey) => [ + { + source: join(projectsRoot, legacyKey), + destination: join(projectsRoot, selectedKey), + reason: + "Preserve legacy path-keyed project runtime, journal, managed, and autosync state.", + allowRebuildableOverlaps: true, + }, + ...(["writebacks", "evolution", "reconciliation"] as const).map( + (artifactDir) => ({ + source: join(globalRoot, artifactDir, "projects", legacyKey), + destination: join(globalRoot, artifactDir, "projects", selectedKey), + reason: `Preserve legacy path-keyed ${artifactDir} review mirrors.`, + }) + ), + ]); + const planned: ProjectStateMigrationPlanEntry[] = []; + const claimedDestinations = new Map(); + for (const candidate of candidates) { + const source = await lstatIfExists(candidate.source); + if (!source) { + continue; + } + if (source.isSymbolicLink() || !source.isDirectory()) { + throw new Error( + `Refusing unsafe legacy project state: ${candidate.source}` + ); + } + const claimedSource = claimedDestinations.get(candidate.destination); + if (claimedSource) { + throw new Error( + `Refusing multiple legacy project state sources for ${candidate.destination}: ${claimedSource} and ${candidate.source}` + ); + } + claimedDestinations.set(candidate.destination, candidate.source); + const sourceTree = await inspectProjectStateTree(candidate.source); + const destination = await lstatIfExists(candidate.destination); + if (!destination) { + planned.push({ + ...candidate, + strategy: "rename", + sourceTreeSha256: sourceTree.sha256, + destinationTreeSha256: null, + rebuildableOverlaps: + candidate.allowRebuildableOverlaps === true + ? sourceTree.entries + .filter( + (entry) => + entry.type === "file" && + REBUILDABLE_PROJECT_STATE_PATHS.has(entry.path) + ) + .map((entry) => entry.path) + .sort() + : [], + }); + continue; + } + if (destination.isSymbolicLink() || !destination.isDirectory()) { + throw new Error( + `Refusing unsafe selected project state: ${candidate.destination}` + ); + } + const destinationTree = await inspectProjectStateTree( + candidate.destination + ); + const rebuildableOverlaps = assertDisjointProjectStateTrees({ + allowRebuildableOverlaps: candidate.allowRebuildableOverlaps, + source: sourceTree, + sourcePath: candidate.source, + destination: destinationTree, + destinationPath: candidate.destination, + }); + planned.push({ + ...candidate, + strategy: "merge-disjoint", + sourceTreeSha256: sourceTree.sha256, + destinationTreeSha256: destinationTree.sha256, + rebuildableOverlaps, + }); + } + return planned; +} + +async function migrateLegacyProjectState(args: { + afterQuarantine?: (args: { + destination: string; + index: number; + quarantine: string; + source: string; + }) => Promise; + beforeQuarantine?: (args: { + destination: string; + index: number; + quarantine: string; + source: string; + }) => Promise; + expected: ProjectStateMigrationPlanEntry[]; + beforeRename?: (args: { + destination: string; + index: number; + source: string; + }) => Promise; + beforeRestore?: (args: { + destination: string; + index: number; + source: string; + }) => Promise; +}): Promise { + const planned: Array< + ProjectStateMigrationPlanEntry & { + destinationDev: number | null; + destinationEntries: ProjectStateTreeEntry[] | null; + destinationIno: number | null; + sourceEntries: ProjectStateTreeEntry[]; + sourceDev: number; + sourceIno: number; + } + > = []; + for (const candidate of args.expected) { + const source = await lstatIfExists(candidate.source); + if (!source || source.isSymbolicLink() || !source.isDirectory()) { + throw new Error( + `Reviewed legacy project state migration is stale: ${candidate.source}` + ); + } + const sourceTree = await inspectProjectStateTree(candidate.source); + if (sourceTree.sha256 !== candidate.sourceTreeSha256) { + throw new Error( + `Reviewed legacy project state migration is stale: ${candidate.source}` + ); + } + const destination = await lstatIfExists(candidate.destination); + if (candidate.strategy === "rename" && destination) { + throw new Error( + `Reviewed legacy project state migration is stale: ${candidate.destination}` + ); + } + if ( + candidate.strategy === "merge-disjoint" && + (!destination || + destination.isSymbolicLink() || + !destination.isDirectory()) + ) { + throw new Error( + `Reviewed legacy project state migration is stale: ${candidate.destination}` + ); + } + const destinationTree = + candidate.strategy === "merge-disjoint" && destination + ? await inspectProjectStateTree(candidate.destination) + : null; + if (destinationTree) { + if (destinationTree.sha256 !== candidate.destinationTreeSha256) { + throw new Error( + `Reviewed legacy project state migration is stale: ${candidate.destination}` + ); + } + const rebuildableOverlaps = assertDisjointProjectStateTrees({ + allowRebuildableOverlaps: candidate.rebuildableOverlaps.length > 0, + source: sourceTree, + sourcePath: candidate.source, + destination: destinationTree, + destinationPath: candidate.destination, + }); + if ( + stableJson(rebuildableOverlaps) !== + stableJson(candidate.rebuildableOverlaps) + ) { + throw new Error( + `Reviewed legacy project state migration is stale: ${candidate.source}` + ); + } + } + planned.push({ + ...candidate, + sourceDev: source.dev, + sourceIno: source.ino, + sourceEntries: sourceTree.entries, + destinationDev: destination?.dev ?? null, + destinationIno: destination?.ino ?? null, + destinationEntries: destinationTree?.entries ?? null, + }); + } + if (args.expected.length === 0) { + return null; + } + type MovedPath = { + dev: number; + ino: number; + relativePath: string; + }; + type RebuildableFileBackup = { + contents: Uint8Array; + mode: number; + relativePath: string; + sha256: string; + }; + type QuarantinedSource = { + dev: number; + files: RebuildableFileBackup[]; + ino: number; + path: string; + }; + type CompletedMigration = (typeof planned)[number] & { + movedPaths: MovedPath[]; + quarantine: QuarantinedSource | null; + }; + const completed: CompletedMigration[] = []; + let commitStarted = false; + + const mergeDisjoint = async ( + source: string, + destination: string, + relativePath: string, + movedPaths: MovedPath[], + rebuildableOverlaps: Set + ): Promise => { + const entries = (await readdir(source, { withFileTypes: true })).sort( + (left, right) => left.name.localeCompare(right.name) + ); + for (const entry of entries) { + const sourcePath = join(source, entry.name); + const destinationPath = join(destination, entry.name); + const childRelative = relativePath + ? `${relativePath}/${entry.name}` + : entry.name; + const sourceMetadata = await lstat(sourcePath); + const destinationMetadata = await lstatIfExists(destinationPath); + if (!destinationMetadata) { + await rename(sourcePath, destinationPath); + movedPaths.push({ + relativePath: childRelative, + dev: sourceMetadata.dev, + ino: sourceMetadata.ino, + }); + continue; + } + if ( + sourceMetadata.isDirectory() && + !sourceMetadata.isSymbolicLink() && + destinationMetadata.isDirectory() && + !destinationMetadata.isSymbolicLink() + ) { + await mergeDisjoint( + sourcePath, + destinationPath, + childRelative, + movedPaths, + rebuildableOverlaps + ); + continue; + } + if ( + rebuildableOverlaps.has(childRelative) && + sourceMetadata.isFile() && + !sourceMetadata.isSymbolicLink() && + sourceMetadata.nlink === 1 && + destinationMetadata.isFile() && + !destinationMetadata.isSymbolicLink() && + destinationMetadata.nlink === 1 + ) { + continue; + } + throw new Error( + `Project state changed during disjoint merge: ${sourcePath}` + ); + } + }; + + const assertReviewedMergeRemainder = ( + entry: CompletedMigration, + remaining: ProjectStateTree + ): void => { + const remainingFiles = remaining.entries.filter( + (item) => item.type === "file" + ); + const expectedFiles = entry.sourceEntries.filter( + (item) => + item.type === "file" && entry.rebuildableOverlaps.includes(item.path) + ); + if ( + stableJson(remainingFiles) !== stableJson(expectedFiles) || + stableJson(remainingFiles.map((item) => item.path).sort()) !== + stableJson(entry.rebuildableOverlaps) + ) { + throw new Error( + `Legacy project state merge left unreviewed content: ${entry.source}` + ); + } + }; + + const captureRebuildableFiles = async ( + entry: CompletedMigration + ): Promise => { + const backups: RebuildableFileBackup[] = []; + for (const relativePath of entry.rebuildableOverlaps) { + const reviewed = entry.sourceEntries.find( + (item) => item.type === "file" && item.path === relativePath + ); + if ( + !reviewed || + reviewed.sha256 === undefined || + reviewed.size === undefined + ) { + throw new Error( + `Reviewed rebuildable project state is missing: ${join(entry.source, relativePath)}` + ); + } + const pathValue = join(entry.source, relativePath); + const before = await lstat(pathValue); + const contents = await readFile(pathValue); + const after = await lstat(pathValue); + const digest = createHash("sha256").update(contents).digest("hex"); + if ( + before.isSymbolicLink() || + !before.isFile() || + before.nlink !== 1 || + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + before.size !== reviewed.size || + permissionMode(before.mode) !== reviewed.mode || + digest !== reviewed.sha256 + ) { + throw new Error( + `Rebuildable project state changed before quarantine: ${pathValue}` + ); + } + backups.push({ + contents, + mode: reviewed.mode, + relativePath, + sha256: reviewed.sha256, + }); + } + return backups; + }; + + const removeEmptyStateTree = async (root: string): Promise => { + const tree = await inspectProjectStateTree(root); + if (tree.entries.some((item) => item.type === "file")) { + throw new Error( + `Quarantined legacy project state still contains files: ${root}` + ); + } + for (const directory of tree.entries + .filter((item) => item.type === "directory" && item.path !== ".") + .sort( + (left, right) => + right.path.split("/").length - left.path.split("/").length || + right.path.localeCompare(left.path) + )) { + await rmdir(join(root, directory.path)); + } + await rmdir(root); + }; + + const treeContainsReviewedEntries = ( + tree: ProjectStateTree, + reviewed: ProjectStateTreeEntry[], + replaceable: ReadonlySet = new Set() + ): boolean => { + const current = new Map(tree.entries.map((entry) => [entry.path, entry])); + return reviewed.every( + (entry) => + replaceable.has(entry.path) || + stableJson(current.get(entry.path)) === stableJson(entry) + ); + }; + + const restoreQuarantinedSource = async ( + entry: CompletedMigration + ): Promise => { + const quarantine = entry.quarantine; + if (!quarantine) { + return; + } + const source = await lstatIfExists(entry.source); + if (source && (source.isSymbolicLink() || !source.isDirectory())) { + throw new Error( + `Legacy project state path was recreated unsafely during compensation: ${entry.source}` + ); + } + const quarantined = await lstatIfExists(quarantine.path); + if (quarantined) { + if ( + quarantined.isSymbolicLink() || + !quarantined.isDirectory() || + quarantined.dev !== quarantine.dev || + quarantined.ino !== quarantine.ino + ) { + throw new Error( + `Quarantined legacy project state changed before compensation: ${quarantine.path}` + ); + } + if (source) { + await mergeDisjoint(quarantine.path, entry.source, "", [], new Set()); + await removeEmptyStateTree(quarantine.path); + } else { + await rename(quarantine.path, entry.source); + } + } else if (!source) { + await mkdir(entry.source, { recursive: true, mode: 0o700 }); + } + }; + + const compensate = async (): Promise => { + const failures: unknown[] = []; + for (const [reverseIndex, entry] of completed.toReversed().entries()) { + try { + await args.beforeRestore?.({ + ...entry, + index: completed.length - reverseIndex - 1, + }); + const destination = await lstatIfExists(entry.destination); + if ( + !destination || + destination.isSymbolicLink() || + !destination.isDirectory() || + (entry.strategy === "rename" && + (destination.dev !== entry.sourceDev || + destination.ino !== entry.sourceIno)) || + (entry.strategy === "merge-disjoint" && + (destination.dev !== entry.destinationDev || + destination.ino !== entry.destinationIno)) + ) { + throw new Error( + `Migrated project state destination changed before compensation: ${entry.destination}` + ); + } + if (entry.strategy === "rename") { + const source = await lstatIfExists(entry.source); + if (source) { + if (source.isSymbolicLink() || !source.isDirectory()) { + throw new Error( + `Legacy project state path was recreated unsafely during compensation: ${entry.source}` + ); + } + const sourceTree = await inspectProjectStateTree(entry.source); + const destinationTree = await inspectProjectStateTree( + entry.destination + ); + assertDisjointProjectStateTrees({ + allowRebuildableOverlaps: false, + source: destinationTree, + sourcePath: entry.destination, + destination: sourceTree, + destinationPath: entry.source, + }); + await mergeDisjoint( + entry.destination, + entry.source, + "", + [], + new Set() + ); + await removeEmptyStateTree(entry.destination); + if ( + !treeContainsReviewedEntries( + await inspectProjectStateTree(entry.source), + entry.sourceEntries + ) + ) { + throw new Error( + `Migrated project state destination changed before compensation: ${entry.destination}` + ); + } + } else { + await removeUnreviewedEmptyStateDirectories( + entry.destination, + entry.sourceEntries + ); + if ( + (await inspectProjectStateTree(entry.destination)).sha256 !== + entry.sourceTreeSha256 + ) { + throw new Error( + `Migrated project state destination changed before compensation: ${entry.destination}` + ); + } + await rename(entry.destination, entry.source); + } + continue; + } + await restoreQuarantinedSource(entry); + const source = await lstatIfExists(entry.source); + if (!source) { + if (!commitStarted) { + throw new Error( + `Legacy project state path disappeared before compensation: ${entry.source}` + ); + } + await mkdir(entry.source, { recursive: true, mode: 0o700 }); + const rootMode = entry.sourceEntries.find( + (directory) => directory.path === "." + )?.mode; + if (rootMode !== undefined) { + await chmod(entry.source, rootMode); + } + } else if (source.isSymbolicLink() || !source.isDirectory()) { + throw new Error( + `Legacy project state path changed before compensation: ${entry.source}` + ); + } + for (const movedPath of entry.movedPaths.toReversed()) { + const destinationPath = join( + entry.destination, + movedPath.relativePath + ); + const sourcePath = join(entry.source, movedPath.relativePath); + if (await lstatIfExists(sourcePath)) { + throw new Error( + `Legacy project state path was recreated during compensation: ${sourcePath}` + ); + } + const moved = await lstatIfExists(destinationPath); + if ( + !moved || + moved.isSymbolicLink() || + moved.dev !== movedPath.dev || + moved.ino !== movedPath.ino + ) { + throw new Error( + `Merged project state changed before compensation: ${destinationPath}` + ); + } + await mkdir(dirname(sourcePath), { recursive: true, mode: 0o700 }); + await rename(destinationPath, sourcePath); + } + if (commitStarted) { + for (const directory of entry.sourceEntries.filter( + (item) => item.type === "directory" + )) { + if (directory.path !== ".") { + await mkdir(join(entry.source, directory.path), { + recursive: true, + mode: directory.mode, + }); + await chmod(join(entry.source, directory.path), directory.mode); + } + } + } + for (const backup of entry.quarantine?.files ?? []) { + const pathValue = join(entry.source, backup.relativePath); + const existing = await lstatIfExists(pathValue); + if (existing) { + if ( + existing.isSymbolicLink() || + !existing.isFile() || + existing.nlink !== 1 || + permissionMode(existing.mode) !== backup.mode || + createHash("sha256") + .update(await readFile(pathValue)) + .digest("hex") !== backup.sha256 + ) { + throw new Error( + `Legacy rebuildable state was replaced during compensation: ${pathValue}` + ); + } + continue; + } + await mkdir(dirname(pathValue), { + recursive: true, + mode: 0o700, + }); + await writeFile(pathValue, backup.contents, { + mode: backup.mode, + }); + await chmod(pathValue, backup.mode); + } + if (!entry.destinationEntries) { + throw new Error( + `Reviewed selected project state is missing: ${entry.destination}` + ); + } + await removeUnreviewedEmptyStateDirectories( + entry.destination, + entry.destinationEntries + ); + const restoredSource = await inspectProjectStateTree(entry.source); + if ( + !( + restoredSource.sha256 === entry.sourceTreeSha256 || + treeContainsReviewedEntries(restoredSource, entry.sourceEntries) + ) || + (await inspectProjectStateTree(entry.destination)).sha256 !== + entry.destinationTreeSha256 + ) { + throw new Error( + `Merged project state changed before compensation: ${entry.source}` + ); + } + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) { + throw new AggregateError( + failures, + "Legacy project state migration compensation was incomplete" + ); + } + }; + try { + for (const [index, entry] of planned.entries()) { + await args.beforeRename?.({ ...entry, index }); + const source = await lstatIfExists(entry.source); + const sourceTree = source + ? await inspectProjectStateTree(entry.source) + : null; + const destination = await lstatIfExists(entry.destination); + if ( + !source || + source.isSymbolicLink() || + !source.isDirectory() || + source.dev !== entry.sourceDev || + source.ino !== entry.sourceIno || + sourceTree?.sha256 !== entry.sourceTreeSha256 + ) { + throw new Error( + `Project state changed during legacy migration: ${entry.source}` + ); + } + if (entry.strategy === "rename") { + if (destination) { + throw new Error( + `Project state changed during legacy migration: ${entry.destination}` + ); + } + await rename(entry.source, entry.destination); + completed.push({ ...entry, movedPaths: [], quarantine: null }); + continue; + } + if ( + !destination || + destination.isSymbolicLink() || + !destination.isDirectory() || + destination.dev !== entry.destinationDev || + destination.ino !== entry.destinationIno || + (await inspectProjectStateTree(entry.destination)).sha256 !== + entry.destinationTreeSha256 + ) { + throw new Error( + `Project state changed during legacy migration: ${entry.destination}` + ); + } + const migration: CompletedMigration = { + ...entry, + movedPaths: [], + quarantine: null, + }; + completed.push(migration); + await mergeDisjoint( + entry.source, + entry.destination, + "", + migration.movedPaths, + new Set(entry.rebuildableOverlaps) + ); + } + } catch (error) { + try { + await compensate(); + } catch (compensationError) { + throw new AggregateError( + [error, compensationError], + "Legacy project state migration failed and compensation was incomplete" + ); + } + throw error; + } + const commit = async () => { + const assertRenameCommitBoundary = async ( + entry: CompletedMigration + ): Promise => { + const destination = await lstatIfExists(entry.destination); + if ( + (await lstatIfExists(entry.source)) || + !destination || + destination.isSymbolicLink() || + !destination.isDirectory() || + destination.dev !== entry.sourceDev || + destination.ino !== entry.sourceIno || + !treeContainsReviewedEntries( + await inspectProjectStateTree(entry.destination), + entry.sourceEntries, + new Set(entry.rebuildableOverlaps) + ) + ) { + throw new Error( + `Legacy project state changed before commit: ${entry.source}` + ); + } + }; + const pending = await Promise.all( + completed.map(async (entry) => { + if (entry.strategy === "rename") { + await assertRenameCommitBoundary(entry); + return { + entry, + kind: "rename" as const, + }; + } + const remaining = await inspectProjectStateTree(entry.source); + assertReviewedMergeRemainder(entry, remaining); + return { + entry, + files: await captureRebuildableFiles(entry), + kind: "merge-disjoint" as const, + remaining, + }; + }) + ); + commitStarted = true; + for (const [index, candidate] of pending.entries()) { + if (candidate.kind === "rename") { + await assertRenameCommitBoundary(candidate.entry); + continue; + } + const { entry, files, remaining } = candidate; + const quarantinePath = `${entry.source}.fclt-quarantine-${randomUUID()}`; + await args.beforeQuarantine?.({ + destination: entry.destination, + index, + quarantine: quarantinePath, + source: entry.source, + }); + const source = await lstatIfExists(entry.source); + if ( + !source || + source.isSymbolicLink() || + !source.isDirectory() || + source.dev !== entry.sourceDev || + source.ino !== entry.sourceIno || + (await inspectProjectStateTree(entry.source)).sha256 !== + remaining.sha256 || + (await lstatIfExists(quarantinePath)) + ) { + throw new Error( + `Legacy project state changed before quarantine: ${entry.source}` + ); + } + await rename(entry.source, quarantinePath); + entry.quarantine = { + dev: entry.sourceDev, + files, + ino: entry.sourceIno, + path: quarantinePath, + }; + const quarantined = await lstatIfExists(quarantinePath); + if ( + !quarantined || + quarantined.isSymbolicLink() || + !quarantined.isDirectory() || + quarantined.dev !== entry.sourceDev || + quarantined.ino !== entry.sourceIno || + (await inspectProjectStateTree(quarantinePath)).sha256 !== + remaining.sha256 || + (await lstatIfExists(entry.source)) + ) { + throw new Error( + `Legacy project state changed at quarantine boundary: ${entry.source}` + ); + } + await args.afterQuarantine?.({ + destination: entry.destination, + index, + quarantine: quarantinePath, + source: entry.source, + }); + if ( + (await lstatIfExists(entry.source)) || + (await inspectProjectStateTree(quarantinePath)).sha256 !== + remaining.sha256 + ) { + throw new Error( + `Legacy project state changed after quarantine: ${entry.source}` + ); + } + const assertQuarantineBinding = async (): Promise => { + const current = await lstatIfExists(quarantinePath); + if ( + !current || + current.isSymbolicLink() || + !current.isDirectory() || + current.dev !== entry.sourceDev || + current.ino !== entry.sourceIno + ) { + throw new Error( + `Quarantined legacy project state binding changed: ${quarantinePath}` + ); + } + }; + for (const file of files) { + if (await lstatIfExists(entry.source)) { + throw new Error( + `Legacy project state path reappeared during quarantine cleanup: ${entry.source}` + ); + } + await assertQuarantineBinding(); + await unlinkVerifiedFileAt({ + directoryPath: dirname(join(quarantinePath, file.relativePath)), + expectedSha256: file.sha256, + fileName: basename(file.relativePath), + maxBytes: PROJECT_STATE_TREE_MAX_BYTES, + safeRoot: quarantinePath, + }); + } + const directories = remaining.entries + .filter((item) => item.type === "directory" && item.path !== ".") + .sort( + (left, right) => + right.path.split("/").length - left.path.split("/").length || + right.path.localeCompare(left.path) + ); + for (const directory of directories) { + if (await lstatIfExists(entry.source)) { + throw new Error( + `Legacy project state path reappeared during quarantine cleanup: ${entry.source}` + ); + } + await assertQuarantineBinding(); + await rmdir(join(quarantinePath, directory.path)); + } + if (await lstatIfExists(entry.source)) { + throw new Error( + `Legacy project state path reappeared during quarantine cleanup: ${entry.source}` + ); + } + await assertQuarantineBinding(); + await rmdir(quarantinePath); + if (await lstatIfExists(entry.source)) { + throw new Error( + `Legacy project state path reappeared after quarantine cleanup: ${entry.source}` + ); + } + } + for (const candidate of pending) { + if (candidate.kind === "rename") { + await assertRenameCommitBoundary(candidate.entry); + } + } + }; + return { commit, restore: compensate }; +} + +function emptyRegistry(): ProjectRegistry { + return { + version: 1, + updatedAt: "", + projects: {}, + }; +} + +function parseRegistryText(text: string, pathValue: string): ProjectRegistry { + try { + const parsed = JSON.parse(text) as unknown; + if ( + isRecord(parsed) && + parsed.version === 1 && + typeof parsed.updatedAt === "string" && + isRecord(parsed.projects) && + Object.entries(parsed.projects).every( + ([key, entry]) => + REPOSITORY_ID_RE.test(key) && + isReceiptRegistryEntry(entry) && + entry.repositoryId === key + ) + ) { + return parsed as unknown as ProjectRegistry; + } + } catch { + // Fall through to the explicit corruption error. + } + throw new Error(`Project registry is invalid: ${pathValue}`); +} + +async function loadRegistry(homeDir: string): Promise { + const pathValue = projectRegistryPath(homeDir); + const snapshot = await canonicalFileSnapshot(pathValue); + if (!snapshot) { + return emptyRegistry(); + } + return parseRegistryText(snapshot.content, pathValue); +} + +async function loadRegistrySnapshot(homeDir: string): Promise<{ + before: string | null; + beforeIdentity: { dev: number; ino: number } | null; + beforeMode: number | null; + registry: ProjectRegistry; +}> { + const pathValue = projectRegistryPath(homeDir); + const snapshot = await canonicalFileSnapshot(pathValue); + const before = snapshot?.content ?? null; + return { + before, + beforeIdentity: snapshot + ? { dev: snapshot.metadata.dev, ino: snapshot.metadata.ino } + : null, + beforeMode: snapshot ? permissionMode(snapshot.metadata.mode) : null, + registry: + before === null ? emptyRegistry() : parseRegistryText(before, pathValue), + }; +} + +function registryEntryMatchesIdentity(args: { + entry: ProjectRegistryEntry; + identity: RepositoryIdentity; + key: string; + projectRoot?: string; + verifiedPortableAliases?: ReadonlySet; +}): boolean { + const recordedPrimaryIds = new Set([args.key, args.entry.repositoryId]); + if (recordedPrimaryIds.has(args.identity.id)) { + return true; + } + const recordedIds = new Set([ + ...recordedPrimaryIds, + ...(args.entry.aliases ?? []), + ]); + if ( + args.identity.stability === "portable" && + recordedIds.has(args.identity.id) && + args.verifiedPortableAliases?.has(args.identity.id) + ) { + return true; + } + const identityProofs = [ + repositoryIdentityAliasForPrimary(args.identity), + ...args.identity.aliases, + ]; + const matchingCommonDirectory = identityProofs.some( + (alias) => alias.kind === "git-common-dir" && recordedIds.has(alias.id) + ); + const matchingRootAlias = identityProofs.some( + (alias) => alias.kind === "root-commit" && recordedIds.has(alias.id) + ); + if (matchingCommonDirectory && matchingRootAlias) { + return true; + } + return matchingCommonDirectory; +} + +function registryStoredLocationMatchesIdentity(args: { + entry: ProjectRegistryEntry; + identity: RepositoryIdentity; + key: string; + verifiedPortableAliases?: ReadonlySet; +}): boolean { + const recordedPrimaryIds = new Set([args.key, args.entry.repositoryId]); + if (recordedPrimaryIds.has(args.identity.id)) { + return true; + } + const recordedIds = new Set([ + ...recordedPrimaryIds, + ...(args.entry.aliases ?? []), + ]); + if ( + args.identity.stability === "portable" && + recordedIds.has(args.identity.id) && + args.verifiedPortableAliases?.has(args.identity.id) + ) { + return true; + } + const identityProofs = [ + repositoryIdentityAliasForPrimary(args.identity), + ...args.identity.aliases, + ]; + const matchingCommonDirectory = identityProofs.some( + (alias) => alias.kind === "git-common-dir" && recordedIds.has(alias.id) + ); + const matchingRootAlias = identityProofs.some( + (alias) => alias.kind === "root-commit" && recordedIds.has(alias.id) + ); + return matchingCommonDirectory && matchingRootAlias; +} + +async function verifiedPortableAliases(args: { + entry: ProjectRegistryEntry; + key: string; +}): Promise> { + const recordedIds = new Set([ + args.key, + args.entry.repositoryId, + ...(args.entry.aliases ?? []), + ]); + const verified = new Set(); + for (const location of args.entry.locations) { + try { + const identity = await resolveUnstabilizedRepositoryIdentity( + location.path + ); + if (identity.stability === "portable" && recordedIds.has(identity.id)) { + const locationMatches = registryStoredLocationMatchesIdentity({ + entry: args.entry, + identity, + key: args.key, + }); + if (locationMatches) { + verified.add(identity.id); + } + } + } catch { + // Stale or inaccessible registry locations are not identity proof. + } + } + return verified; +} + +async function stabilizeRepositoryIdentity(args: { + homeDir: string; + identity: RepositoryIdentity; + projectRoot: string; +}): Promise { + const registry = await loadRegistry(args.homeDir); + const matches: [string, ProjectRegistryEntry][] = []; + for (const [key, entry] of Object.entries(registry.projects)) { + const portableAliases = await verifiedPortableAliases({ entry, key }); + if ( + registryEntryMatchesIdentity({ + verifiedPortableAliases: portableAliases, + key, + entry, + identity: args.identity, + projectRoot: args.projectRoot, + }) + ) { + matches.push([key, entry]); + } + } + if (matches.length !== 1) { + return args.identity; + } + const entry = matches[0]?.[1]; + if (!entry || entry.repositoryId === args.identity.id) { + return args.identity; + } + const aliases = new Map( + [ + repositoryIdentityAliasForPrimary(args.identity), + ...args.identity.aliases, + ].map((alias) => [alias.id, alias]) + ); + aliases.delete(entry.repositoryId); + return { + id: entry.repositoryId, + kind: entry.identityKind, + fingerprint: entry.identityFingerprint, + stability: + entry.identityKind === "git-common-dir" ? "machine-local" : "portable", + aliases: [...aliases.values()].sort((left, right) => + left.id.localeCompare(right.id) + ), + }; +} + +async function atomicWrite( + pathValue: string, + content: string, + commitBoundary?: { + beforeCommit?: () => Promise; + beforeExchange?: () => Promise; + expected?: { + contents: string; + identity?: { dev: number; ino: number }; + mode: number; + } | null; + mode?: number; + safeRoot?: string; + validate?: () => Promise; + } +): Promise { + if (commitBoundary?.safeRoot) { + await assertSafeDescendantPath({ + root: commitBoundary.safeRoot, + target: pathValue, + targetKind: "file", + }); + } + await mkdir(dirname(pathValue), { recursive: true }); + if (commitBoundary && "expected" in commitBoundary) { + await replaceVerifiedFileAt({ + beforeCommit: commitBoundary.beforeCommit, + beforeExchange: commitBoundary.beforeExchange, + contents: content, + directoryPath: dirname(pathValue), + expected: commitBoundary.expected ?? null, + fileName: basename(pathValue), + maxBytes: PROJECT_CANONICAL_FILE_MAX_BYTES, + mode: commitBoundary.mode ?? MACHINE_LOCAL_FILE_MODE, + safeRoot: commitBoundary.safeRoot, + }); + return; + } + const temporary = `${pathValue}.tmp-${process.pid}-${randomUUID()}`; + try { + await writeFile(temporary, content, { + encoding: "utf8", + mode: commitBoundary?.mode ?? MACHINE_LOCAL_FILE_MODE, + }); + await chmod(temporary, commitBoundary?.mode ?? MACHINE_LOCAL_FILE_MODE); + await commitBoundary?.beforeCommit?.(); + if (commitBoundary?.safeRoot) { + await assertSafeDescendantPath({ + root: commitBoundary.safeRoot, + target: pathValue, + targetKind: "file", + }); + } + await commitBoundary?.validate?.(); + await rename(temporary, pathValue); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +async function assertSafeDescendantPath(args: { + root: string; + target: string; + targetKind: "directory" | "file"; +}): Promise { + const root = resolve(args.root); + const target = resolve(args.target); + const rel = relative(root, target); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`Machine-local state target escapes its root: ${target}`); + } + const rootStat = await lstat(root).catch(() => null); + if (rootStat && (rootStat.isSymbolicLink() || !rootStat.isDirectory())) { + throw new Error(`Refusing unsafe machine-local state root: ${root}`); + } + const parts = rel.split(PATH_PART_SPLIT_RE).filter(Boolean); + let current = root; + for (const [index, part] of parts.entries()) { + current = join(current, part); + const metadata = await lstat(current).catch(() => null); + if (!metadata) { + continue; + } + const isTarget = index === parts.length - 1; + if (metadata.isSymbolicLink()) { + throw new Error( + `Refusing symlinked machine-local state path: ${current}` + ); + } + if ( + !(isTarget || metadata.isDirectory()) || + (isTarget && args.targetKind === "file" && !metadata.isFile()) || + (isTarget && args.targetKind === "directory" && !metadata.isDirectory()) + ) { + throw new Error(`Refusing unsafe machine-local state path: ${current}`); + } + } +} + +async function prepareMachineLocalStateRoot(homeDir: string): Promise { + const root = facultLocalStateRoot(homeDir); + await assertSafeDescendantPath({ + root: dirname(root), + target: root, + targetKind: "directory", + }); + await mkdir(root, { recursive: true, mode: 0o700 }); + const metadata = await lstat(root); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error(`Refusing unsafe machine-local state root: ${root}`); + } + return root; +} + +function projectMutationLockOwnerPath(lockPath: string): string { + return join(lockPath, "owner.json"); +} + +function parseProjectMutationLockOwner( + content: string +): ProjectMutationLockOwner | null { + try { + const parsed = JSON.parse(content) as Partial; + if ( + parsed.version === 2 && + typeof parsed.endpoint === "string" && + typeof parsed.ownerId === "string" && + typeof parsed.pid === "number" && + Number.isSafeInteger(parsed.pid) && + parsed.pid > 0 && + typeof parsed.acquiredAt === "string" && + parsed.transport === "ipc-socket" + ) { + return parsed as ProjectMutationLockOwner; + } + } catch { + return null; + } + return null; +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function projectMutationLockEndpoint(ownerId: string): string { + return process.platform === "win32" + ? `\\\\.\\pipe\\fclt-project-mutation-${ownerId}` + : join(tmpdir(), `fclt-project-mutation-${ownerId}.sock`); +} + +async function listenForProjectMutationLock( + owner: ProjectMutationLockOwner +): Promise { + const server = createServer((socket) => { + socket.end(`${owner.ownerId}\n`); + }); + await new Promise((resolvePromise, rejectPromise) => { + server.once("error", rejectPromise); + server.listen(owner.endpoint, () => resolvePromise()); + }); + return server; +} + +function projectMutationLockOwnerIsLive( + owner: ProjectMutationLockOwner +): boolean { + // A live PID is authoritative. Its event loop may be synchronously stalled, + // so an IPC timeout is not proof that the owner is abandoned. Ambiguous PID + // reuse therefore fails safe instead of risking concurrent mutation. + return processIsAlive(owner.pid); +} + +async function closeProjectMutationLockServer(server: Server): Promise { + if (!server.listening) { + return; + } + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error) { + rejectPromise(error); + return; + } + resolvePromise(); + }); + }); +} + +async function removeProjectMutationLockEndpoint( + owner: ProjectMutationLockOwner +): Promise { + if ( + process.platform === "win32" || + owner.endpoint !== projectMutationLockEndpoint(owner.ownerId) + ) { + return; + } + const entry = await lstat(owner.endpoint).catch(() => null); + if (entry?.isSocket() && !entry.isSymbolicLink()) { + await rm(owner.endpoint, { force: true }); + } +} + +interface ProjectMutationLockObservation { + content: string | null; + dev: number; + ino: number; + mtimeMs: number; + owner: ProjectMutationLockOwner | null; +} + +async function observeProjectMutationLock( + lockPath: string +): Promise { + const lock = await lstatIfExists(lockPath); + if (!lock) { + return null; + } + if (lock.isSymbolicLink() || !lock.isDirectory()) { + throw new Error( + `Refusing unsafe project enrollment mutation lock: ${lockPath}` + ); + } + const ownerPath = projectMutationLockOwnerPath(lockPath); + const ownerSnapshot = await canonicalFileSnapshot(ownerPath); + const lockAfter = await lstatIfExists(lockPath); + if ( + !lockAfter || + lockAfter.isSymbolicLink() || + !lockAfter.isDirectory() || + !canonicalMetadataMatches(lock, lockAfter) + ) { throw new Error( - `Generated or Git-internal guidance cannot be adopted: ${value}` + `Project enrollment mutation lock changed while reading its owner: ${lockPath}` ); } - return normalized; + if (!ownerSnapshot) { + return { + content: null, + dev: lock.dev, + ino: lock.ino, + mtimeMs: lock.mtimeMs, + owner: null, + }; + } + const content = ownerSnapshot.content; + return { + content, + dev: lock.dev, + ino: lock.ino, + mtimeMs: lock.mtimeMs, + owner: parseProjectMutationLockOwner(content), + }; } -async function previewGuidance(args: { - projectRoot: string; - paths: string[]; -}): Promise { - const previews: GuidancePreview[] = []; - for (const rawPath of args.paths) { - const pathValue = ensureRepoRelativeMarkdown(rawPath); - const absolutePath = resolve(args.projectRoot, pathValue); - const rel = relative(args.projectRoot, absolutePath); - if (rel.startsWith("..") || isAbsolute(rel)) { - throw new Error(`Guidance is outside the repository: ${rawPath}`); +function sameProjectMutationLockObservation( + left: ProjectMutationLockObservation, + right: ProjectMutationLockObservation +): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.content === right.content + ); +} + +async function reclaimAbandonedProjectMutationLock(args: { + lockPath: string; + observed: ProjectMutationLockObservation; +}): Promise { + const recoveryPath = join(args.lockPath, "recovery"); + try { + await mkdir(recoveryPath, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return false; } - const guidanceStat = await lstat(absolutePath).catch(() => null); - if (!guidanceStat?.isFile() || guidanceStat.isSymbolicLink()) { - throw new Error( - `Refusing guidance adoption from ${pathValue}: the source must be a regular file` - ); + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return true; } - const tracked = await runGit({ - cwd: args.projectRoot, - argv: ["ls-files", "--error-unmatch", "--", pathValue], - }); - const dirty = await runGit({ - cwd: args.projectRoot, - argv: ["status", "--porcelain=v1", "--", pathValue], - }); - if (tracked.exitCode !== 0 || dirty.exitCode !== 0 || dirty.stdout) { - throw new Error( - `Refusing guidance adoption from ${pathValue}: the source must be tracked and clean` - ); + throw error; + } + const quarantine = `${args.lockPath}.abandoned-${randomUUID()}`; + try { + const current = await observeProjectMutationLock(args.lockPath); + if ( + !( + current?.owner && + sameProjectMutationLockObservation(current, args.observed) + ) + ) { + return false; } - const content = await readFile(absolutePath, "utf8").catch(() => { - throw new Error(`Unable to read guidance source: ${pathValue}`); - }); - const findings = privacyFindings(content); - if (findings.length > 0) { - throw new Error( - `Refusing guidance adoption from ${pathValue}: ${findings.join(", ")}` - ); + if (await projectMutationLockOwnerIsLive(current.owner)) { + return false; } - previews.push({ - path: pathValue, - sha256: sha256(content), - content, - gitState: "clean-tracked", - adoption: "reference", - }); + await rename(args.lockPath, quarantine); + await rm(quarantine, { recursive: true, force: true }); + if (current.owner) { + await removeProjectMutationLockEndpoint(current.owner); + } + return true; + } finally { + await rm(recoveryPath, { recursive: true, force: true }).catch( + () => undefined + ); } - return previews; } -function uniqueSorted(values: T[]): T[] { - return [...new Set(values)].sort() as T[]; +async function releaseProjectMutationLock(args: { + lockPath: string; + ownerContent: string; +}): Promise { + const current = await observeProjectMutationLock(args.lockPath); + if (!current || current.content !== args.ownerContent) { + throw new Error( + "Project enrollment mutation lock ownership changed before release" + ); + } + const quarantine = `${args.lockPath}.released-${randomUUID()}`; + await rename(args.lockPath, quarantine); + await rm(quarantine, { recursive: true, force: true }); } -function projectRegistryPath(homeDir: string): string { - return join(facultLocalStateRoot(homeDir), "projects", "registry.json"); +async function withProjectsMutationLock( + homeDir: string, + operation: () => Promise, + attempts = PROJECT_MUTATION_LOCK_ATTEMPTS +): Promise { + const stateRoot = await prepareMachineLocalStateRoot(homeDir); + const projectsRoot = join(stateRoot, "projects"); + await assertSafeDescendantPath({ + root: stateRoot, + target: projectsRoot, + targetKind: "directory", + }); + await mkdir(projectsRoot, { recursive: true, mode: 0o700 }); + const lockPath = projectMutationLockPath(homeDir); + const ownerId = randomUUID(); + const owner: ProjectMutationLockOwner = { + version: 2, + endpoint: projectMutationLockEndpoint(ownerId), + ownerId, + pid: process.pid, + acquiredAt: new Date().toISOString(), + transport: "ipc-socket", + }; + const ownerContent = `${JSON.stringify(owner, null, 2)}\n`; + const server = await listenForProjectMutationLock(owner); + let acquired = false; + try { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + await mkdir(lockPath, { mode: 0o700 }); + try { + await writeFile( + projectMutationLockOwnerPath(lockPath), + ownerContent, + { + encoding: "utf8", + flag: "wx", + mode: 0o600, + } + ); + } catch (error) { + const quarantine = `${lockPath}.incomplete-${randomUUID()}`; + await rename(lockPath, quarantine); + await rm(quarantine, { recursive: true, force: true }); + throw error; + } + acquired = true; + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + } + const observed = await observeProjectMutationLock(lockPath); + if (!observed) { + continue; + } + const liveOwner = + observed.owner && + (await projectMutationLockOwnerIsLive(observed.owner)); + if (observed.owner && !liveOwner) { + const reclaimed = await reclaimAbandonedProjectMutationLock({ + lockPath, + observed, + }); + if (!reclaimed) { + await Bun.sleep(PROJECT_MUTATION_LOCK_RETRY_MS); + } + continue; + } + // An ownerless lock may still belong to a claimant paused between + // directory creation and owner publication. Automatic reclamation cannot + // distinguish that live initializer from a crash, so fail closed. + await Bun.sleep(PROJECT_MUTATION_LOCK_RETRY_MS); + } + if (!acquired) { + throw new Error( + "Another project enrollment mutation is still in progress" + ); + } + return await operation(); + } finally { + try { + if (acquired) { + await releaseProjectMutationLock({ lockPath, ownerContent }); + } + } finally { + await closeProjectMutationLockServer(server); + await removeProjectMutationLockEndpoint(owner); + } + } } -function projectReceiptsDir(homeDir: string): string { - return join(facultLocalStateRoot(homeDir), "projects", "receipts"); +async function captureArtifact(args: { + path: string; + afterContent: string | null; + afterMode?: number; + safeRoot?: string; +}): Promise { + const safeRootIdentity = args.safeRoot + ? await captureSafeRootIdentity(args.safeRoot) + : undefined; + const before = await regularFileText(args.path); + const beforeMetadata = before === null ? null : await lstat(args.path); + if ( + beforeMetadata && + (beforeMetadata.isSymbolicLink() || !beforeMetadata.isFile()) + ) { + throw new Error(`Refusing unsafe transaction artifact: ${args.path}`); + } + return { + path: args.path, + before, + beforeIdentity: beforeMetadata + ? { dev: beforeMetadata.dev, ino: beforeMetadata.ino } + : null, + beforeMode: beforeMetadata ? permissionMode(beforeMetadata.mode) : null, + afterContent: args.afterContent, + afterSha256: args.afterContent === null ? null : sha256(args.afterContent), + afterMode: + args.afterContent === null + ? null + : (args.afterMode ?? MACHINE_LOCAL_FILE_MODE), + afterSize: + args.afterContent === null ? null : Buffer.byteLength(args.afterContent), + safeRoot: args.safeRoot, + safeRootIdentity, + written: false, + }; } -function emptyRegistry(): ProjectRegistry { +async function captureSafeRootIdentity( + safeRoot: string +): Promise> { + const metadata = await lstat(safeRoot); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error(`Refusing unsafe transaction root: ${safeRoot}`); + } return { - version: 1, - updatedAt: "", - projects: {}, + dev: metadata.dev, + ino: metadata.ino, + uid: metadata.uid, }; } -async function loadRegistry(homeDir: string): Promise { - const pathValue = projectRegistryPath(homeDir); - const text = await fileText(pathValue); - if (!text) { - return emptyRegistry(); +async function assertArtifactSafeRootIdentity( + artifact: TransactionArtifact +): Promise { + if (!(artifact.safeRoot && artifact.safeRootIdentity)) { + throw new Error( + `Project enrollment cleanup is missing a safe root identity: ${artifact.path}` + ); } - try { - const parsed = JSON.parse(text) as ProjectRegistry; - if (parsed.version === 1 && parsed.projects) { - return parsed; + const metadata = await lstat(artifact.safeRoot); + if ( + metadata.isSymbolicLink() || + !metadata.isDirectory() || + metadata.dev !== artifact.safeRootIdentity.dev || + metadata.ino !== artifact.safeRootIdentity.ino || + metadata.uid !== artifact.safeRootIdentity.uid + ) { + throw new Error( + `Project enrollment cleanup safe root changed: ${artifact.safeRoot}` + ); + } +} + +async function artifactMatchesAfter( + artifact: TransactionArtifact +): Promise { + const current = await regularFileText(artifact.path); + if (artifact.afterSha256 === null) { + return current === null; + } + if ( + current === null || + artifact.afterMode === null || + sha256(current) !== artifact.afterSha256 + ) { + return false; + } + const metadata = await lstatIfExists(artifact.path); + return ( + metadata !== null && + metadata.isFile() && + !metadata.isSymbolicLink() && + permissionMode(metadata.mode) === artifact.afterMode + ); +} + +async function restoreOwnedArtifacts( + artifacts: TransactionArtifact[], + beforeRestoreCommit?: (args: { path: string }) => Promise +): Promise { + const preserved: string[] = []; + for (const artifact of artifacts.toReversed()) { + if (!artifact.written) { + continue; + } + if (!(await artifactMatchesAfter(artifact))) { + preserved.push(artifact.path); + continue; + } + if (artifact.before === null) { + if ( + artifact.afterSha256 === null || + artifact.afterSize === null || + !artifact.safeRoot + ) { + throw new Error( + `Project enrollment cleanup cannot verify a new artifact: ${artifact.path}` + ); + } + await unlinkVerifiedFileAt({ + beforeCommit: async () => { + await beforeRestoreCommit?.({ path: artifact.path }); + await assertArtifactSafeRootIdentity(artifact); + }, + directoryPath: dirname(artifact.path), + expectedSha256: artifact.afterSha256, + fileName: basename(artifact.path), + maxBytes: artifact.afterSize, + safeRoot: artifact.safeRoot, + }); + } else { + await replaceVerifiedFileAt({ + beforeExchange: async () => + await beforeRestoreCommit?.({ path: artifact.path }), + contents: artifact.before, + directoryPath: dirname(artifact.path), + expected: + artifact.afterContent === null + ? null + : { + contents: artifact.afterContent, + mode: artifact.afterMode ?? MACHINE_LOCAL_FILE_MODE, + }, + fileName: basename(artifact.path), + maxBytes: Math.max( + Buffer.byteLength(artifact.before), + artifact.afterContent === null + ? 0 + : Buffer.byteLength(artifact.afterContent) + ), + mode: artifact.beforeMode ?? MACHINE_LOCAL_FILE_MODE, + safeRoot: artifact.safeRoot, + }); } - } catch { - // Fall through to the explicit corruption error. } - throw new Error(`Project registry is invalid: ${pathValue}`); + return preserved.sort(); } -async function atomicWrite(pathValue: string, content: string): Promise { - await mkdir(dirname(pathValue), { recursive: true }); - const temporary = `${pathValue}.tmp-${process.pid}-${randomUUID()}`; - await writeFile(temporary, content, { - encoding: "utf8", - mode: 0o600, - }); - await rename(temporary, pathValue); +async function verifyOwnedArtifacts( + artifacts: TransactionArtifact[] +): Promise { + for (const artifact of artifacts) { + if (!artifact.written) { + continue; + } + if (!(await artifactMatchesAfter(artifact))) { + throw new Error( + `Project enrollment transaction verification failed: ${artifact.path}` + ); + } + } } async function saveRegistry(args: { homeDir: string; registry: ProjectRegistry; + snapshot: Awaited>; now: string; }): Promise { args.registry.updatedAt = args.now; - await atomicWrite( - projectRegistryPath(args.homeDir), - `${JSON.stringify(args.registry, null, 2)}\n` - ); + const pathValue = projectRegistryPath(args.homeDir); + await atomicWrite(pathValue, `${JSON.stringify(args.registry, null, 2)}\n`, { + expected: + args.snapshot.before === null + ? null + : { + contents: args.snapshot.before, + identity: args.snapshot.beforeIdentity ?? undefined, + mode: args.snapshot.beforeMode ?? MACHINE_LOCAL_FILE_MODE, + }, + safeRoot: facultLocalStateRoot(args.homeDir), + }); } function planHashInput( @@ -846,19 +3548,94 @@ function planHashInput( return plan; } +async function knownLegacyStateRoots(args: { + aiRoot: string; + homeDir: string; + identity: RepositoryIdentity; + invocationProjectRoot: string; + projectRoot: string; +}): Promise { + const roots = new Set([args.aiRoot]); + const addEquivalentRoot = async ( + candidateProjectRoot: string + ): Promise => { + const spelling = resolve(candidateProjectRoot); + let physical: string; + try { + physical = await realpath(spelling); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + if (physical === args.projectRoot) { + roots.add(join(spelling, ".ai")); + } + }; + await addEquivalentRoot(args.invocationProjectRoot); + const registry = await loadRegistry(args.homeDir); + for (const [key, entry] of Object.entries(registry.projects)) { + if ( + !registryStoredLocationMatchesIdentity({ + key, + entry, + identity: args.identity, + }) + ) { + continue; + } + for (const location of entry.locations) { + await addEquivalentRoot(location.path); + } + } + return uniqueSorted([...roots]); +} + export async function planProjectEnrollment(args: { + /** @internal Adversarial test hook; production callers must not set this. */ + beforeCanonicalPreviewRead?: () => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeGuidanceRead?: () => Promise; projectRoot: string; homeDir?: string; + /** @internal Platform override for cross-platform regression tests. */ + platform?: NodeJS.Platform; sources?: ProjectSource[]; cadence?: ProjectCadence; scheduling?: boolean; guidance?: string[]; }): Promise { const homeDir = resolve(args.homeDir ?? process.env.HOME ?? homedir()); - const projectRoot = await gitRoot(args.projectRoot); + const invocationProjectRoot = resolve(args.projectRoot); + if ((args.platform ?? process.platform) === "win32") { + throw new Error( + "Project enrollment planning is unsupported on win32 because conditional canonical replacement is unavailable" + ); + } + const projectRoot = await gitRoot(invocationProjectRoot); const aiRoot = join(projectRoot, ".ai"); + const globalRoot = resolveCliContextRoot({ + homeDir, + cwd: projectRoot, + scope: "global", + }); + if ( + pathsMayCollide(projectRoot, globalRoot) || + pathsMayCollide(aiRoot, globalRoot) + ) { + throw new Error( + `Project enrollment refused because the repository or its .ai root collides with the configured global AI root: ${globalRoot}` + ); + } await assertSafeCanonicalTargets(projectRoot, aiRoot); - const identity = await resolveRepositoryIdentity(projectRoot); + const rawIdentity = await resolveUnstabilizedRepositoryIdentity(projectRoot); + const identity = await stabilizeRepositoryIdentity({ + homeDir, + identity: rawIdentity, + projectRoot, + }); + const executionIdentity = repositoryExecutionIdentity(projectRoot); if (!REPOSITORY_ID_RE.test(identity.id)) { throw new Error("Unable to derive a valid repository identity"); } @@ -880,14 +3657,20 @@ export async function planProjectEnrollment(args: { (args.guidance ?? []).map(ensureRepoRelativeMarkdown) ); const guidancePreview = await previewGuidance({ + beforeRead: args.beforeGuidanceRead, projectRoot, paths: guidance, }); - const [worktree, existingIgnore, existingConfig] = await Promise.all([ - inspectRepository(projectRoot), - fileText(join(aiRoot, ".gitignore")), - fileText(join(aiRoot, "config.toml")), + await args.beforeCanonicalPreviewRead?.(); + const ignorePath = join(aiRoot, ".gitignore"); + const configPath = join(aiRoot, "config.toml"); + const [worktree, ignoreSnapshot, configSnapshot] = await Promise.all([ + inspectRepository(projectRoot, homeDir), + canonicalFileSnapshot(ignorePath), + canonicalFileSnapshot(configPath), ]); + const existingIgnore = ignoreSnapshot?.content ?? null; + const existingConfig = configSnapshot?.content ?? null; const ignoreContent = appendProtectiveIgnore(existingIgnore); const configSources = guidance.length > 0 ? uniqueSorted([...sources, "guidance"]) : sources; @@ -901,27 +3684,27 @@ export async function planProjectEnrollment(args: { const configContent = mergeProjectConfig(existingConfig, enrollmentConfig); const canonicalWrites = [ { - path: join(aiRoot, ".gitignore"), + path: ignorePath, content: ignoreContent, reason: "Protect generated and machine-local fclt state before any index is built.", - precondition: await filePrecondition(join(aiRoot, ".gitignore")), + precondition: filePreconditionFromSnapshot(ignorePath, ignoreSnapshot), }, { - path: join(aiRoot, "config.toml"), + path: configPath, content: configContent, reason: "Create the minimal repo-owned enrollment contract without installing the operating-model pack.", - precondition: await filePrecondition(join(aiRoot, "config.toml")), + precondition: filePreconditionFromSnapshot(configPath, configSnapshot), }, ]; const generatedWrites = [ { - path: facultAiIndexPath(homeDir, aiRoot), + path: join(executionMachineStateDir(homeDir, aiRoot), "ai", "index.json"), reason: "Machine-local generated capability index.", }, { - path: facultAiGraphPath(homeDir, aiRoot), + path: join(executionMachineStateDir(homeDir, aiRoot), "ai", "graph.json"), reason: "Machine-local generated capability graph.", }, ]; @@ -934,9 +3717,25 @@ export async function planProjectEnrollment(args: { path: projectReceiptsDir(homeDir), reason: "Machine-local rollback receipt.", }, + { + path: projectMutationLockPath(homeDir), + reason: "Temporary machine-local transaction lock.", + }, ]; + const legacyStateRoots = await knownLegacyStateRoots({ + aiRoot, + homeDir, + identity, + invocationProjectRoot, + projectRoot, + }); + const stateMigrations = await planLegacyProjectStateMigrations({ + aiRoot, + homeDir, + legacyAiRoots: legacyStateRoots, + }); const findings = [ - ...privacyFindings(ignoreContent), + ...privacyFindings(ignoreContent, { gitIgnorePatterns: true }), ...privacyFindings(configContent), ]; if (findings.length > 0) { @@ -952,7 +3751,7 @@ export async function planProjectEnrollment(args: { : []), ...(identity.stability === "machine-local" ? [ - "This repository has no portable remote or root commit; its fallback identity cannot correlate independent clones until the repository has a commit.", + "This repository has no portable remote; its machine-local primary prevents unrelated repositories with shared history from collapsing. Add a reviewed origin to correlate independent clones.", ] : []), ...(guidance.length === 0 @@ -967,6 +3766,7 @@ export async function planProjectEnrollment(args: { projectRoot, aiRoot, identity, + executionIdentity, worktree: { dirty: worktree.dirty, branch: worktree.branch, @@ -982,6 +3782,8 @@ export async function planProjectEnrollment(args: { canonicalWrites, generatedWrites, machineLocalWrites, + legacyStateRoots, + stateMigrations, protections: { ignoreWrittenFirst: true, managedRendering: false, @@ -1000,38 +3802,68 @@ export async function planProjectEnrollment(args: { }; } -async function verifyPreconditions(plan: ProjectEnrollmentPlan): Promise { +async function verifyPreconditions( + plan: ProjectEnrollmentPlan, + homeDir: string +): Promise { const { planSha256, ...withoutHash } = plan; - if ( - !PLAN_SHA_RE.test(planSha256) || - sha256(stableJson(planHashInput(withoutHash))) !== planSha256 - ) { + const currentPlanSha256 = sha256(stableJson(planHashInput(withoutHash))); + if (!PLAN_SHA_RE.test(planSha256) || currentPlanSha256 !== planSha256) { throw new Error("Enrollment plan content does not match its plan SHA"); } const currentRoot = await gitRoot(plan.projectRoot); if (currentRoot !== plan.projectRoot) { throw new Error("Enrollment plan repository root changed"); } - const currentIdentity = await resolveRepositoryIdentity(currentRoot); + const [currentIdentity, currentExecutionIdentity] = await Promise.all([ + resolveRepositoryIdentity(currentRoot, homeDir), + resolveRepositoryExecutionIdentity(currentRoot), + ]); if ( currentIdentity.id !== plan.identity.id || currentIdentity.kind !== plan.identity.kind || - currentIdentity.fingerprint !== plan.identity.fingerprint + currentIdentity.fingerprint !== plan.identity.fingerprint || + currentExecutionIdentity.id !== plan.executionIdentity.id || + currentExecutionIdentity.fingerprint !== plan.executionIdentity.fingerprint ) { - throw new Error("Enrollment plan repository identity changed"); + throw new Error("Enrollment plan repository or execution identity changed"); } - await assertSafeCanonicalTargets(plan.projectRoot, plan.aiRoot); - for (const write of plan.canonicalWrites) { - const current = await filePrecondition(write.path); + if ( + plan.legacyStateRoots.length === 0 || + !plan.legacyStateRoots.includes(plan.aiRoot) + ) { + throw new Error("Enrollment plan legacy state candidates are invalid"); + } + for (const legacyAiRoot of plan.legacyStateRoots) { if ( - current.existed !== write.precondition.existed || - current.sha256 !== write.precondition.sha256 + basename(legacyAiRoot) !== ".ai" || + (await realpath(dirname(legacyAiRoot))) !== plan.projectRoot ) { throw new Error( - `Enrollment plan is stale because ${write.path} changed; generate a new plan` + `Enrollment plan legacy state candidate is unsafe: ${legacyAiRoot}` ); } } + const currentKnownRoots = await knownLegacyStateRoots({ + aiRoot: plan.aiRoot, + homeDir, + identity: currentIdentity, + invocationProjectRoot: plan.projectRoot, + projectRoot: plan.projectRoot, + }); + if ( + currentKnownRoots.some( + (candidate) => !plan.legacyStateRoots.includes(candidate) + ) + ) { + throw new Error( + "Enrollment plan is stale because known legacy state candidates changed" + ); + } + await assertSafeCanonicalTargets(plan.projectRoot, plan.aiRoot); + for (const write of plan.canonicalWrites) { + await assertCanonicalWritePrecondition(write); + } const guidance = await previewGuidance({ projectRoot: plan.projectRoot, paths: plan.options.guidance, @@ -1048,30 +3880,194 @@ async function verifyPreconditions(plan: ProjectEnrollmentPlan): Promise { ); } } -} - -function upsertRegistryEntry(args: { - registry: ProjectRegistry; - plan: ProjectEnrollmentPlan; - now: string; - receiptId: string; -}): void { - const current = args.registry.projects[args.plan.identity.id]; - const location = current?.locations.find( +} + +async function assertCanonicalWritePrecondition( + write: ProjectEnrollmentPlan["canonicalWrites"][number] +): Promise { + const current = await filePrecondition(write.path); + if ( + current.existed !== write.precondition.existed || + current.sha256 !== write.precondition.sha256 || + current.mode !== write.precondition.mode + ) { + throw new Error( + `Enrollment plan is stale because ${write.path} changed; generate a new plan` + ); + } +} + +function registryEntryForIdentity(args: { + registry: ProjectRegistry; + identity: RepositoryIdentity; + projectRoot: string; +}): ProjectRegistryEntry | null { + const matches = Object.entries(args.registry.projects).filter( + ([key, entry]) => + registryEntryMatchesIdentity({ + key, + entry, + identity: args.identity, + projectRoot: args.projectRoot, + }) + ); + if (matches.length === 0) { + return null; + } + const primary = + matches.find(([key]) => key === args.identity.id)?.[1] ?? matches[0]?.[1]; + if (!primary) { + return null; + } + const locations = new Map(); + const history: ProjectRegistryHistory[] = []; + const aliases = new Set( + args.identity.aliases.map((alias) => alias.id) + ); + const activeReceipts: Record = {}; + for (const [key, entry] of matches) { + if (key !== args.identity.id) { + aliases.add(key); + } + for (const alias of entry.aliases ?? []) { + if (alias !== args.identity.id) { + aliases.add(alias); + } + } + for (const location of entry.locations) { + const current = locations.get(location.path); + locations.set(location.path, { + path: location.path, + firstSeenAt: + current && current.firstSeenAt < location.firstSeenAt + ? current.firstSeenAt + : location.firstSeenAt, + lastSeenAt: + current && current.lastSeenAt > location.lastSeenAt + ? current.lastSeenAt + : location.lastSeenAt, + }); + } + history.push(...entry.history); + Object.assign(activeReceipts, entry.activeReceipts ?? {}); + delete args.registry.projects[key]; + } + const merged: ProjectRegistryEntry = { + ...primary, + repositoryId: args.identity.id, + aliases: [...aliases].sort(), + identityKind: args.identity.kind, + identityFingerprint: args.identity.fingerprint, + locations: [...locations.values()].sort((left, right) => + left.path.localeCompare(right.path) + ), + history: history.sort( + (left, right) => + left.at.localeCompare(right.at) || + left.root.localeCompare(right.root) || + (left.receiptId ?? "").localeCompare(right.receiptId ?? "") + ), + activeReceipts, + }; + args.registry.projects[args.identity.id] = merged; + return merged; +} + +async function upsertRegistryEntry(args: { + homeDir: string; + registry: ProjectRegistry; + plan: ProjectEnrollmentPlan; + now: string; + receiptId: string; +}): Promise { + const current = registryEntryForIdentity({ + registry: args.registry, + identity: args.plan.identity, + projectRoot: args.plan.projectRoot, + }); + const reconciledCurrent = current ? structuredClone(current) : null; + if (reconciledCurrent?.activeReceipts) { + for (const pathValue of Object.keys(reconciledCurrent.activeReceipts)) { + if (pathValue === args.plan.projectRoot) { + continue; + } + if (pathsPhysicallyEquivalent(pathValue, args.plan.projectRoot)) { + delete reconciledCurrent.activeReceipts[pathValue]; + continue; + } + if (!(await lstatIfExists(pathValue))) { + delete reconciledCurrent.activeReceipts[pathValue]; + continue; + } + let inspected: DiscoveredProject; + try { + inspected = await inspectRepository(pathValue, args.homeDir, { + stabilizeIdentity: false, + }); + } catch (error) { + if (!(await lstatIfExists(pathValue))) { + delete reconciledCurrent.activeReceipts[pathValue]; + continue; + } + throw error; + } + if ( + !registryStoredLocationMatchesIdentity({ + key: args.plan.identity.id, + entry: reconciledCurrent, + identity: inspected.identity, + }) + ) { + delete reconciledCurrent.activeReceipts[pathValue]; + continue; + } + const receiptId = reconciledCurrent.activeReceipts[pathValue]; + if (!receiptId) { + throw new Error(`Missing active receipt for ${pathValue}`); + } + const receipt = await readReceipt({ + homeDir: args.homeDir, + receiptId, + }); + if ( + receipt.projectRoot !== pathValue || + receipt.executionId !== inspected.executionIdentity.id + ) { + delete reconciledCurrent.activeReceipts[pathValue]; + } + } + } + const location = reconciledCurrent?.locations.find( (candidate) => candidate.path === args.plan.projectRoot ); - const locations = current?.locations ?? []; - if (location) { - location.lastSeenAt = args.now; - } else { + const locations = [...(reconciledCurrent?.locations ?? [])]; + if (!location) { locations.push({ path: args.plan.projectRoot, firstSeenAt: args.now, lastSeenAt: args.now, }); + if (reconciledCurrent) { + reconciledCurrent.locations = structuredClone( + locations.sort((left, right) => left.path.localeCompare(right.path)) + ); + } + } + const registryEntryBefore = reconciledCurrent + ? structuredClone(reconciledCurrent) + : null; + if (location) { + location.lastSeenAt = args.now; } + const sortedLocations = locations.sort((left, right) => + left.path.localeCompare(right.path) + ); args.registry.projects[args.plan.identity.id] = { repositoryId: args.plan.identity.id, + aliases: uniqueSorted([ + ...(current?.aliases ?? []), + ...args.plan.identity.aliases.map((alias) => alias.id), + ]), identityKind: args.plan.identity.kind, identityFingerprint: args.plan.identity.fingerprint, decision: "selected", @@ -1079,13 +4075,11 @@ function upsertRegistryEntry(args: { cadence: args.plan.options.cadence, scheduling: args.plan.options.scheduling, guidance: args.plan.options.guidance, - locations: locations.sort((left, right) => - left.path.localeCompare(right.path) - ), - lastSuccessfulRun: current?.lastSuccessfulRun ?? null, + locations: sortedLocations, + lastSuccessfulRun: reconciledCurrent?.lastSuccessfulRun ?? null, pendingApprovals: [], history: [ - ...(current?.history ?? []), + ...(reconciledCurrent?.history ?? []), { at: args.now, action: "enrolled", @@ -1093,7 +4087,12 @@ function upsertRegistryEntry(args: { receiptId: args.receiptId, }, ], + activeReceipts: { + ...(reconciledCurrent?.activeReceipts ?? {}), + [args.plan.projectRoot]: args.receiptId, + }, }; + return registryEntryBefore; } export async function applyProjectEnrollment(args: { @@ -1101,6 +4100,53 @@ export async function applyProjectEnrollment(args: { expectedPlanSha256: string; homeDir?: string; now?: Date; + /** @internal Platform branch override for cross-platform regression tests. */ + platform?: NodeJS.Platform; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeCanonicalWrite?: (args: { + index: number; + path: string; + }) => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + afterGeneratedWrites?: () => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeRegistryWrite?: () => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeRegistryExchange?: () => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + afterRegistryWrite?: () => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + afterReceiptWrite?: () => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeCleanupRestore?: (args: { path: string }) => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + mutationLockAttempts?: number; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeLegacyStateRename?: (args: { + destination: string; + index: number; + source: string; + }) => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeLegacyStateRestore?: (args: { + destination: string; + index: number; + source: string; + }) => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeLegacyStateQuarantine?: (args: { + destination: string; + index: number; + quarantine: string; + source: string; + }) => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + afterLegacyStateQuarantine?: (args: { + destination: string; + index: number; + quarantine: string; + source: string; + }) => Promise; }): Promise<{ version: 1; applied: true; @@ -1116,82 +4162,339 @@ export async function applyProjectEnrollment(args: { "Apply requires the exact plan SHA from the reviewed preview" ); } - await verifyPreconditions(args.plan); + assertProjectRegistryMutationSupported(args.platform ?? process.platform); const homeDir = resolve(args.homeDir ?? process.env.HOME ?? homedir()); - const expectedGeneratedPaths = [ - facultAiIndexPath(homeDir, args.plan.aiRoot), - facultAiGraphPath(homeDir, args.plan.aiRoot), - ]; - const expectedMachinePaths = [ - projectRegistryPath(homeDir), - projectReceiptsDir(homeDir), - ]; - if ( - stableJson(args.plan.generatedWrites.map((write) => write.path)) !== - stableJson(expectedGeneratedPaths) || - stableJson(args.plan.machineLocalWrites.map((write) => write.path)) !== - stableJson(expectedMachinePaths) - ) { - throw new Error( - "Enrollment plan was created for a different machine-local state root" - ); - } - const now = (args.now ?? new Date()).toISOString(); - const receiptId = `enroll-${now.replace(NON_DIGIT_RE, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`; - const before = await Promise.all( - args.plan.canonicalWrites.map(async (write) => ({ - path: write.path, - before: await fileText(write.path), - afterSha256: sha256(write.content), - })) - ); - const registry = await loadRegistry(homeDir); - const written: string[] = []; - try { - for (const write of args.plan.canonicalWrites) { - await atomicWrite(write.path, write.content); - written.push(write.path); - } - await buildIndex({ - homeDir, - rootDir: args.plan.aiRoot, - force: false, - }); - const receipt: EnrollmentReceipt = { - version: 1, - id: receiptId, - createdAt: now, - repositoryId: args.plan.identity.id, - projectRoot: args.plan.projectRoot, - planSha256: args.plan.planSha256, - files: before, - }; - const receiptPath = join(projectReceiptsDir(homeDir), `${receiptId}.json`); - await atomicWrite(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); - upsertRegistryEntry({ registry, plan: args.plan, now, receiptId }); - await saveRegistry({ homeDir, registry, now }); - } catch (error) { - for (const original of before.toReversed()) { - if (original.before === null) { - await rm(original.path, { force: true }).catch(() => undefined); - } else { - await atomicWrite(original.path, original.before).catch( - () => undefined + return await withProjectsMutationLock( + homeDir, + async () => { + await verifyPreconditions(args.plan, homeDir); + const stateRoot = facultLocalStateRoot(homeDir); + const executionStateDir = executionMachineStateDir( + homeDir, + args.plan.aiRoot + ); + const expectedGeneratedPaths = [ + join(executionStateDir, "ai", "index.json"), + join(executionStateDir, "ai", "graph.json"), + ]; + const expectedMachinePaths = [ + projectRegistryPath(homeDir), + projectReceiptsDir(homeDir), + projectMutationLockPath(homeDir), + ]; + if ( + stableJson(args.plan.generatedWrites.map((write) => write.path)) !== + stableJson(expectedGeneratedPaths) || + stableJson(args.plan.machineLocalWrites.map((write) => write.path)) !== + stableJson(expectedMachinePaths) || + !executionStateDir.endsWith(args.plan.executionIdentity.id) + ) { + throw new Error( + "Enrollment plan was created for a different machine-local execution state root" ); } - } - throw error; + const currentStateMigrations = await planLegacyProjectStateMigrations({ + aiRoot: args.plan.aiRoot, + homeDir, + legacyAiRoots: args.plan.legacyStateRoots, + }); + if ( + stableJson(currentStateMigrations) !== + stableJson(args.plan.stateMigrations) + ) { + throw new Error( + "Enrollment plan is stale because project state migrations changed" + ); + } + for (const pathValue of expectedGeneratedPaths) { + await assertSafeDescendantPath({ + root: stateRoot, + target: pathValue, + targetKind: "file", + }); + } + const now = (args.now ?? new Date()).toISOString(); + const receiptId = `enroll-${now.replace(NON_DIGIT_RE, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`; + const receiptPath = join( + projectReceiptsDir(homeDir), + `${receiptId}.json` + ); + const canonicalArtifacts = await Promise.all( + args.plan.canonicalWrites.map( + async (write) => + await captureArtifact({ + path: write.path, + afterContent: write.content, + afterMode: write.precondition.mode ?? CANONICAL_FILE_MODE, + safeRoot: args.plan.projectRoot, + }) + ) + ); + const artifacts: TransactionArtifact[] = [...canonicalArtifacts]; + const stateMigration = await migrateLegacyProjectState({ + afterQuarantine: args.afterLegacyStateQuarantine, + beforeQuarantine: args.beforeLegacyStateQuarantine, + expected: args.plan.stateMigrations, + beforeRename: args.beforeLegacyStateRename, + beforeRestore: args.beforeLegacyStateRestore, + }); + try { + for (const [index, write] of args.plan.canonicalWrites.entries()) { + await assertSafeCanonicalTargets( + args.plan.projectRoot, + args.plan.aiRoot + ); + await assertCanonicalWritePrecondition(write); + const artifact = canonicalArtifacts[index]; + if (!artifact) { + throw new Error(`Enrollment artifact is missing: ${write.path}`); + } + await mkdir(dirname(write.path), { recursive: true }); + await replaceVerifiedFileAt({ + beforeExchange: async () => + await args.beforeCanonicalWrite?.({ index, path: write.path }), + contents: write.content, + directoryPath: dirname(write.path), + expected: + artifact.before === null + ? null + : { + contents: artifact.before, + identity: artifact.beforeIdentity ?? undefined, + mode: artifact.beforeMode ?? CANONICAL_FILE_MODE, + }, + fileName: basename(write.path), + maxBytes: PROJECT_CANONICAL_FILE_MAX_BYTES, + mode: write.precondition.mode ?? CANONICAL_FILE_MODE, + safeRoot: args.plan.projectRoot, + }); + artifact.written = true; + } + + const snapshot = await buildIndexSnapshot({ + homeDir, + rootDir: args.plan.aiRoot, + force: true, + machineStateDir: executionStateDir, + }); + const generatedContents = [ + `${JSON.stringify(snapshot.index, null, 2)}\n`, + `${JSON.stringify(snapshot.graph, null, 2)}\n`, + ]; + const generatedArtifacts = await Promise.all( + expectedGeneratedPaths.map( + async (pathValue, index) => + await captureArtifact({ + path: pathValue, + afterContent: generatedContents[index] ?? "", + safeRoot: stateRoot, + }) + ) + ); + artifacts.push(...generatedArtifacts); + for (const [index, artifact] of generatedArtifacts.entries()) { + await atomicWrite(artifact.path, generatedContents[index] ?? "", { + safeRoot: stateRoot, + }); + artifact.written = true; + } + await args.afterGeneratedWrites?.(); + + const registrySnapshot = await loadRegistrySnapshot(homeDir); + const registry = registrySnapshot.registry; + const registryEntryBefore = await upsertRegistryEntry({ + homeDir, + registry, + plan: args.plan, + now, + receiptId, + }); + registry.updatedAt = now; + const registryContent = `${JSON.stringify(registry, null, 2)}\n`; + const registryArtifact: TransactionArtifact = { + path: projectRegistryPath(homeDir), + before: registrySnapshot.before, + beforeIdentity: registrySnapshot.beforeIdentity, + beforeMode: registrySnapshot.beforeMode, + afterContent: registryContent, + afterSha256: sha256(registryContent), + afterMode: MACHINE_LOCAL_FILE_MODE, + afterSize: Buffer.byteLength(registryContent), + safeRoot: stateRoot, + safeRootIdentity: await captureSafeRootIdentity(stateRoot), + written: false, + }; + artifacts.push(registryArtifact); + await atomicWrite(registryArtifact.path, registryContent, { + beforeCommit: async () => await args.beforeRegistryWrite?.(), + beforeExchange: async () => await args.beforeRegistryExchange?.(), + expected: + registrySnapshot.before === null + ? null + : { + contents: registrySnapshot.before, + identity: registrySnapshot.beforeIdentity ?? undefined, + mode: registrySnapshot.beforeMode ?? MACHINE_LOCAL_FILE_MODE, + }, + safeRoot: stateRoot, + }); + registryArtifact.written = true; + await args.afterRegistryWrite?.(); + + const receipt: EnrollmentReceipt = { + version: 1, + id: receiptId, + createdAt: now, + repositoryId: args.plan.identity.id, + executionId: args.plan.executionIdentity.id, + projectRoot: args.plan.projectRoot, + planSha256: args.plan.planSha256, + registryEntryBefore, + files: canonicalArtifacts.map((artifact) => { + if (artifact.afterSha256 === null) { + throw new Error( + `Enrollment artifact is missing its written hash: ${artifact.path}` + ); + } + return { + path: artifact.path, + before: artifact.before, + beforeMode: artifact.beforeMode, + afterSha256: artifact.afterSha256, + afterMode: artifact.afterMode ?? CANONICAL_FILE_MODE, + }; + }), + }; + const receiptContent = `${JSON.stringify(receipt, null, 2)}\n`; + const receiptArtifact = await captureArtifact({ + path: receiptPath, + afterContent: receiptContent, + safeRoot: stateRoot, + }); + if (receiptArtifact.before !== null) { + throw new Error(`Enrollment receipt already exists: ${receiptId}`); + } + artifacts.push(receiptArtifact); + await atomicWrite(receiptPath, receiptContent, { + safeRoot: stateRoot, + }); + receiptArtifact.written = true; + await args.afterReceiptWrite?.(); + await verifyOwnedArtifacts(artifacts); + await readReceipt({ homeDir, receiptId }); + await stateMigration?.commit(); + } catch (error) { + const cleanupErrors: unknown[] = []; + try { + await restoreOwnedArtifacts(artifacts, args.beforeCleanupRestore); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + try { + await stateMigration?.restore(); + } catch (migrationError) { + cleanupErrors.push(migrationError); + } + if (cleanupErrors.length > 0) { + throw new AggregateError( + [error, ...cleanupErrors], + "Project enrollment failed and transaction cleanup was incomplete" + ); + } + throw error; + } + return { + version: 1, + applied: true, + repositoryId: args.plan.identity.id, + changedPaths: args.plan.canonicalWrites.map((write) => write.path), + generatedPaths: expectedGeneratedPaths, + registryPath: projectRegistryPath(homeDir), + receiptId, + rollbackCommand: `fclt project rollback --receipt ${receiptId} --apply`, + }; + }, + args.mutationLockAttempts + ); +} + +function isRecord(value: unknown): value is Record { + if (!(value && typeof value === "object" && !Array.isArray(value))) { + return false; } - return { - version: 1, - applied: true, - repositoryId: args.plan.identity.id, - changedPaths: written, - generatedPaths: args.plan.generatedWrites.map((entry) => entry.path), - registryPath: projectRegistryPath(homeDir), - receiptId, - rollbackCommand: `fclt project rollback --receipt ${receiptId} --apply`, - }; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isReceiptRegistryEntry(value: unknown): value is ProjectRegistryEntry { + if (!isRecord(value)) { + return false; + } + const aliasesValid = + value.aliases === undefined || + (Array.isArray(value.aliases) && + value.aliases.every( + (alias) => typeof alias === "string" && REPOSITORY_ID_RE.test(alias) + )); + const locationsValid = + Array.isArray(value.locations) && + value.locations.every( + (location) => + isRecord(location) && + typeof location.path === "string" && + isAbsolute(location.path) && + typeof location.firstSeenAt === "string" && + typeof location.lastSeenAt === "string" + ); + const historyValid = + Array.isArray(value.history) && + value.history.every( + (event) => + isRecord(event) && + typeof event.at === "string" && + typeof event.action === "string" && + PROJECT_HISTORY_ACTIONS.has(event.action) && + typeof event.root === "string" && + isAbsolute(event.root) && + (event.receiptId === undefined || + (typeof event.receiptId === "string" && + RECEIPT_ID_RE.test(event.receiptId))) + ); + const activeReceiptsValid = + value.activeReceipts === undefined || + (isRecord(value.activeReceipts) && + Object.entries(value.activeReceipts).every( + ([root, receiptId]) => + isAbsolute(root) && + typeof receiptId === "string" && + RECEIPT_ID_RE.test(receiptId) + )); + return ( + typeof value.repositoryId === "string" && + REPOSITORY_ID_RE.test(value.repositoryId) && + aliasesValid && + typeof value.identityKind === "string" && + ["remote", "root-commit", "git-common-dir"].includes(value.identityKind) && + typeof value.identityFingerprint === "string" && + typeof value.decision === "string" && + PROJECT_DECISIONS.has(value.decision) && + Array.isArray(value.sources) && + value.sources.every( + (source) => typeof source === "string" && PROJECT_SOURCES.has(source) + ) && + typeof value.cadence === "string" && + PROJECT_CADENCES.has(value.cadence) && + typeof value.scheduling === "boolean" && + Array.isArray(value.guidance) && + value.guidance.every((guidance) => typeof guidance === "string") && + locationsValid && + (value.lastSuccessfulRun === null || + typeof value.lastSuccessfulRun === "string") && + Array.isArray(value.pendingApprovals) && + value.pendingApprovals.every((approval) => typeof approval === "string") && + historyValid && + activeReceiptsValid + ); } async function readReceipt(args: { @@ -1205,9 +4508,11 @@ async function readReceipt(args: { projectReceiptsDir(args.homeDir), `${args.receiptId}.json` ); - const parsed = JSON.parse( - await readFile(pathValue, "utf8") - ) as EnrollmentReceipt; + const snapshot = await canonicalFileSnapshot(pathValue); + if (!snapshot) { + throw new Error(`Invalid enrollment receipt: ${args.receiptId}`); + } + const parsed = JSON.parse(snapshot.content) as EnrollmentReceipt; const validProjectRoot = typeof parsed.projectRoot === "string" && isAbsolute(parsed.projectRoot); const allowedFiles = new Set( @@ -1223,9 +4528,17 @@ async function readReceipt(args: { parsed.id !== args.receiptId || typeof parsed.repositoryId !== "string" || !REPOSITORY_ID_RE.test(parsed.repositoryId) || + typeof parsed.executionId !== "string" || + !EXECUTION_ID_RE.test(parsed.executionId) || !validProjectRoot || typeof parsed.planSha256 !== "string" || !PLAN_SHA_RE.test(parsed.planSha256) || + (parsed.registryEntryBefore !== undefined && + parsed.registryEntryBefore !== null && + !isReceiptRegistryEntry(parsed.registryEntryBefore)) || + (parsed.registryEntryBefore !== undefined && + parsed.registryEntryBefore !== null && + parsed.registryEntryBefore.repositoryId !== parsed.repositoryId) || !Array.isArray(parsed.files) || parsed.files.length !== 2 || parsed.files.some( @@ -1236,7 +4549,16 @@ async function readReceipt(args: { !allowedFiles.has(file.path) || typeof file.afterSha256 !== "string" || !PLAN_SHA_RE.test(file.afterSha256) || - (file.before !== null && typeof file.before !== "string") + (file.before !== null && typeof file.before !== "string") || + (file.beforeMode !== null && + (!Number.isSafeInteger(file.beforeMode) || + file.beforeMode < 0 || + file.beforeMode > 0o777)) || + (file.before === null && file.beforeMode !== null) || + (file.before !== null && file.beforeMode === null) || + !Number.isSafeInteger(file.afterMode) || + file.afterMode < 0 || + file.afterMode > 0o777 ) ) { throw new Error(`Invalid enrollment receipt: ${args.receiptId}`); @@ -1244,11 +4566,140 @@ async function readReceipt(args: { return parsed; } +function effectiveDecisionFromLaterHistory(args: { + activeReceipts: Record; + history: ProjectRegistryHistory[]; +}): ProjectDecision | null { + const activeReceiptIds = new Set(Object.values(args.activeReceipts)); + let decision: ProjectDecision | null = null; + for (const event of args.history) { + switch (event.action) { + case "enrolled": + if (event.receiptId && activeReceiptIds.has(event.receiptId)) { + decision = "selected"; + } + break; + case "disabled": + case "ignored": + case "inactive": + case "removed": + decision = event.action; + break; + default: + break; + } + } + return decision; +} + +function registryEntryAfterRollback(args: { + current: ProjectRegistryEntry; + now: string; + receipt: EnrollmentReceipt; +}): ProjectRegistryEntry { + const rollbackEvent: ProjectRegistryHistory = { + at: args.now, + action: "rolled-back", + root: args.receipt.projectRoot, + receiptId: args.receipt.id, + }; + const enrollmentIndex = args.current.history.findLastIndex( + (event) => + event.action === "enrolled" && + event.root === args.receipt.projectRoot && + event.receiptId === args.receipt.id + ); + if (enrollmentIndex < 0) { + throw new Error( + "Rollback refused because the registry enrollment history is incomplete" + ); + } + const enrollmentEvent = args.current.history[enrollmentIndex]; + if (!enrollmentEvent) { + throw new Error( + "Rollback refused because the registry enrollment history is incomplete" + ); + } + const laterHistory = args.current.history.slice(enrollmentIndex + 1); + const previous = args.receipt.registryEntryBefore; + if (!previous) { + const restored = structuredClone(args.current); + delete restored.activeReceipts?.[args.receipt.projectRoot]; + restored.decision = + effectiveDecisionFromLaterHistory({ + activeReceipts: restored.activeReceipts ?? {}, + history: laterHistory, + }) ?? + (Object.keys(restored.activeReceipts ?? {}).length > 0 + ? "selected" + : "disabled"); + restored.history.push(rollbackEvent); + return restored; + } + + const restored = structuredClone(previous); + const activeReceipts = { + ...(previous.activeReceipts ?? {}), + ...Object.fromEntries( + Object.entries(args.current.activeReceipts ?? {}).filter( + ([root]) => root !== args.receipt.projectRoot + ) + ), + }; + const previousReceipt = previous.activeReceipts?.[args.receipt.projectRoot]; + if (previousReceipt) { + activeReceipts[args.receipt.projectRoot] = previousReceipt; + } else { + delete activeReceipts[args.receipt.projectRoot]; + } + if (laterHistory.length > 0) { + const locations = new Map( + args.current.locations + .filter((location) => location.path !== args.receipt.projectRoot) + .map((location) => [location.path, location]) + ); + const previousLocation = previous.locations.find( + (location) => location.path === args.receipt.projectRoot + ); + if (previousLocation) { + locations.set(previousLocation.path, previousLocation); + } + restored.locations = [...locations.values()].sort((left, right) => + left.path.localeCompare(right.path) + ); + } + restored.activeReceipts = activeReceipts; + restored.decision = + effectiveDecisionFromLaterHistory({ + activeReceipts, + history: laterHistory, + }) ?? previous.decision; + restored.history = [ + ...previous.history, + enrollmentEvent, + ...laterHistory, + rollbackEvent, + ]; + return restored; +} + export async function rollbackProjectEnrollment(args: { receiptId: string; homeDir?: string; apply?: boolean; now?: Date; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeCanonicalRemove?: (args: { path: string }) => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeCanonicalRestore?: (args: { path: string }) => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeRegistryWrite?: () => Promise; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeRegistryExchange?: () => Promise; + /** @internal Platform branch override for cross-platform regression tests. */ + platform?: NodeJS.Platform; + /** @internal Platform branch override for removal regression tests. */ + removalPlatform?: NodeJS.Platform; }): Promise<{ version: 1; applied: boolean; @@ -1276,49 +4727,205 @@ export async function rollbackProjectEnrollment(args: { ], }; } - await assertSafeCanonicalTargets( - receipt.projectRoot, - join(receipt.projectRoot, ".ai") - ); - for (const file of receipt.files) { - const current = await fileText(file.path); - if (current === null || sha256(current) !== file.afterSha256) { + assertProjectRegistryMutationSupported(args.platform ?? process.platform); + return await withProjectsMutationLock(homeDir, async () => { + const currentReceipt = await readReceipt({ + homeDir, + receiptId: args.receiptId, + }); + const [currentIdentity, currentExecutionIdentity] = await Promise.all([ + resolveRepositoryIdentity(currentReceipt.projectRoot, homeDir), + resolveRepositoryExecutionIdentity(currentReceipt.projectRoot), + ]); + if ( + currentIdentity.id !== currentReceipt.repositoryId || + currentExecutionIdentity.id !== currentReceipt.executionId + ) { throw new Error( - `Rollback refused because an enrolled file changed after apply: ${file.path}` + "Rollback refused because the receipt project root no longer identifies the enrolled repository" ); } - } - for (const file of receipt.files.toReversed()) { - if (file.before === null) { - await rm(file.path, { force: true }); - } else { - await atomicWrite(file.path, file.before); + await assertSafeCanonicalTargets( + currentReceipt.projectRoot, + join(currentReceipt.projectRoot, ".ai") + ); + const registryPath = projectRegistryPath(homeDir); + const registrySnapshot = await loadRegistrySnapshot(homeDir); + const registryBefore = registrySnapshot.before; + if (registryBefore === null) { + throw new Error(`Project registry is invalid: ${registryPath}`); + } + const registry = parseRegistryText(registryBefore, registryPath); + const entryMatch = + Object.entries(registry.projects).find( + ([key]) => key === currentReceipt.repositoryId + ) ?? + Object.entries(registry.projects).find(([, candidate]) => + candidate.aliases?.includes(currentReceipt.repositoryId) + ); + const entryKey = entryMatch?.[0]; + const entry = entryMatch?.[1]; + if ( + !entry || + entry.activeReceipts?.[currentReceipt.projectRoot] !== currentReceipt.id + ) { + throw new Error( + "Rollback refused because this receipt is not the active enrollment for its checkout" + ); + } + for (const file of currentReceipt.files) { + const current = await regularFileText(file.path); + const metadata = await lstatIfExists(file.path); + if ( + current === null || + !metadata || + metadata.isSymbolicLink() || + !metadata.isFile() || + sha256(current) !== file.afterSha256 || + permissionMode(metadata.mode) !== file.afterMode + ) { + throw new Error( + `Rollback refused because an enrolled file changed after apply: ${file.path}` + ); + } } - } - const registry = await loadRegistry(homeDir); - const entry = registry.projects[receipt.repositoryId]; - if (entry) { const now = (args.now ?? new Date()).toISOString(); - entry.decision = "disabled"; - entry.history.push({ - at: now, - action: "rolled-back", - root: receipt.projectRoot, - receiptId: receipt.id, + if (!entryKey) { + throw new Error( + "Rollback refused because the active registry entry is missing" + ); + } + registry.projects[entryKey] = registryEntryAfterRollback({ + current: entry, + now, + receipt: currentReceipt, }); - await saveRegistry({ homeDir, registry, now }); - } - return { - version: 1, - applied: true, - receiptId: receipt.id, - repositoryId: receipt.repositoryId, - restores, - preserved: [ - projectRegistryPath(homeDir), - join(projectReceiptsDir(homeDir), `${receipt.id}.json`), - ], - }; + registry.updatedAt = now; + const registryContent = `${JSON.stringify(registry, null, 2)}\n`; + const registryArtifact: TransactionArtifact = { + path: registryPath, + before: registryBefore, + beforeIdentity: registrySnapshot.beforeIdentity, + beforeMode: registrySnapshot.beforeMode, + afterContent: registryContent, + afterSha256: sha256(registryContent), + afterMode: MACHINE_LOCAL_FILE_MODE, + afterSize: Buffer.byteLength(registryContent), + safeRoot: facultLocalStateRoot(homeDir), + safeRootIdentity: await captureSafeRootIdentity( + facultLocalStateRoot(homeDir) + ), + written: false, + }; + const canonicalArtifacts = await Promise.all( + currentReceipt.files.map( + async (file) => + await captureArtifact({ + path: file.path, + afterContent: file.before, + afterMode: file.beforeMode ?? undefined, + safeRoot: currentReceipt.projectRoot, + }) + ) + ); + for (const [index, file] of currentReceipt.files.entries()) { + const artifact = canonicalArtifacts[index]; + if ( + !artifact || + artifact.before === null || + artifact.beforeMode !== file.afterMode || + sha256(artifact.before) !== file.afterSha256 + ) { + throw new Error( + `Rollback refused because an enrolled file changed before its transaction snapshot: ${file.path}` + ); + } + } + const artifactByPath = new Map( + canonicalArtifacts.map((artifact) => [artifact.path, artifact]) + ); + const artifacts = [...canonicalArtifacts, registryArtifact]; + try { + for (const file of currentReceipt.files.toReversed()) { + const artifact = artifactByPath.get(file.path); + if (!artifact) { + throw new Error(`Rollback artifact is missing: ${file.path}`); + } + if (file.before === null) { + await unlinkVerifiedFileAt({ + beforeCommit: async () => + await args.beforeCanonicalRemove?.({ path: file.path }), + directoryPath: dirname(file.path), + expectedSha256: file.afterSha256, + fileName: basename(file.path), + maxBytes: PROJECT_CANONICAL_FILE_MAX_BYTES, + platform: args.removalPlatform, + safeRoot: currentReceipt.projectRoot, + }); + } else { + if (artifact.before === null) { + throw new Error( + `Rollback artifact is missing its enrolled content: ${file.path}` + ); + } + await replaceVerifiedFileAt({ + beforeExchange: async () => { + await args.beforeCanonicalRestore?.({ path: file.path }); + await assertSafeCanonicalTargets( + currentReceipt.projectRoot, + join(currentReceipt.projectRoot, ".ai") + ); + }, + contents: file.before, + directoryPath: dirname(file.path), + expected: { + contents: artifact.before, + identity: artifact.beforeIdentity ?? undefined, + mode: artifact.beforeMode ?? file.afterMode, + }, + fileName: basename(file.path), + maxBytes: PROJECT_CANONICAL_FILE_MAX_BYTES, + mode: file.beforeMode ?? CANONICAL_FILE_MODE, + safeRoot: currentReceipt.projectRoot, + }); + } + artifact.written = true; + } + await atomicWrite(registryPath, registryContent, { + beforeCommit: async () => await args.beforeRegistryWrite?.(), + beforeExchange: async () => await args.beforeRegistryExchange?.(), + expected: { + contents: registryBefore, + identity: registrySnapshot.beforeIdentity ?? undefined, + mode: registrySnapshot.beforeMode ?? MACHINE_LOCAL_FILE_MODE, + }, + safeRoot: facultLocalStateRoot(homeDir), + }); + registryArtifact.written = true; + await verifyOwnedArtifacts(artifacts); + } catch (error) { + try { + await restoreOwnedArtifacts(artifacts); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Project rollback failed and transaction cleanup was incomplete" + ); + } + throw error; + } + return { + version: 1, + applied: true, + receiptId: currentReceipt.id, + repositoryId: currentReceipt.repositoryId, + restores, + preserved: [ + projectRegistryPath(homeDir), + join(projectReceiptsDir(homeDir), `${currentReceipt.id}.json`), + ], + }; + }); } async function recordDecision(args: { @@ -1326,6 +4933,9 @@ async function recordDecision(args: { homeDir: string; decision: Exclude; now: Date; + dryRun?: boolean; + /** @internal Platform branch override for cross-platform regression tests. */ + platform?: NodeJS.Platform; }): Promise<{ version: 1; repositoryId: string; @@ -1333,63 +4943,93 @@ async function recordDecision(args: { preserved: string[]; }> { const projectRoot = await gitRoot(args.projectRoot); - const identity = await resolveRepositoryIdentity(projectRoot); - const registry = await loadRegistry(args.homeDir); - const now = args.now.toISOString(); - const current = registry.projects[identity.id]; - const locations = [...(current?.locations ?? [])]; - const location = locations.find( - (candidate) => candidate.path === projectRoot - ); - if (location) { - location.lastSeenAt = now; - } else { - locations.push({ - path: projectRoot, - firstSeenAt: now, - lastSeenAt: now, - }); + const identity = await resolveRepositoryIdentity(projectRoot, args.homeDir); + if (args.dryRun) { + return { + version: 1, + repositoryId: identity.id, + decision: args.decision, + preserved: [ + join(projectRoot, ".ai"), + projectRegistryPath(args.homeDir), + projectReceiptsDir(args.homeDir), + ], + }; } - const action = - args.decision === "disabled" - ? "disabled" - : args.decision === "removed" - ? "removed" - : args.decision; - registry.projects[identity.id] = { - repositoryId: identity.id, - identityKind: identity.kind, - identityFingerprint: identity.fingerprint, - decision: args.decision, - sources: current?.sources ?? [], - cadence: current?.cadence ?? "on-demand", - scheduling: false, - guidance: current?.guidance ?? [], - locations: locations.sort((left, right) => - left.path.localeCompare(right.path) - ), - lastSuccessfulRun: current?.lastSuccessfulRun ?? null, - pendingApprovals: current?.pendingApprovals ?? [], - history: [ - ...(current?.history ?? []), - { - at: now, - action, - root: projectRoot, - }, - ], - }; - await saveRegistry({ homeDir: args.homeDir, registry, now }); - return { - version: 1, - repositoryId: identity.id, - decision: args.decision, - preserved: [ - join(projectRoot, ".ai"), - projectRegistryPath(args.homeDir), - projectReceiptsDir(args.homeDir), - ], - }; + assertProjectRegistryMutationSupported(args.platform ?? process.platform); + return await withProjectsMutationLock(args.homeDir, async () => { + const registrySnapshot = await loadRegistrySnapshot(args.homeDir); + const registry = registrySnapshot.registry; + const now = args.now.toISOString(); + const current = registryEntryForIdentity({ + registry, + identity, + projectRoot, + }); + const locations = [...(current?.locations ?? [])]; + const location = locations.find( + (candidate) => candidate.path === projectRoot + ); + if (location) { + location.lastSeenAt = now; + } else { + locations.push({ + path: projectRoot, + firstSeenAt: now, + lastSeenAt: now, + }); + } + const action = + args.decision === "disabled" + ? "disabled" + : args.decision === "removed" + ? "removed" + : args.decision; + registry.projects[identity.id] = { + repositoryId: identity.id, + aliases: uniqueSorted([ + ...(current?.aliases ?? []), + ...identity.aliases.map((alias) => alias.id), + ]), + identityKind: identity.kind, + identityFingerprint: identity.fingerprint, + decision: args.decision, + sources: current?.sources ?? [], + cadence: current?.cadence ?? "on-demand", + scheduling: false, + guidance: current?.guidance ?? [], + locations: locations.sort((left, right) => + left.path.localeCompare(right.path) + ), + lastSuccessfulRun: current?.lastSuccessfulRun ?? null, + pendingApprovals: current?.pendingApprovals ?? [], + history: [ + ...(current?.history ?? []), + { + at: now, + action, + root: projectRoot, + }, + ], + activeReceipts: current?.activeReceipts ?? {}, + }; + await saveRegistry({ + homeDir: args.homeDir, + registry, + snapshot: registrySnapshot, + now, + }); + return { + version: 1, + repositoryId: identity.id, + decision: args.decision, + preserved: [ + join(projectRoot, ".ai"), + projectRegistryPath(args.homeDir), + projectReceiptsDir(args.homeDir), + ], + }; + }); } export async function buildProjectsStatus(args: { @@ -1404,26 +5044,58 @@ export async function buildProjectsStatus(args: { const registry = await loadRegistry(homeDir); const discovered = args.discoveryRoots && args.discoveryRoots.length > 0 - ? await discoverProjects({ roots: args.discoveryRoots }) + ? await discoverProjects({ + roots: args.discoveryRoots, + homeDir, + }) : null; const discoveredById = new Map(); + const rawDiscoveredIdentityByRoot = new Map(); for (const project of discovered?.projects ?? []) { const rows = discoveredById.get(project.identity.id) ?? []; rows.push(project); discoveredById.set(project.identity.id, rows); + rawDiscoveredIdentityByRoot.set( + project.root, + await resolveUnstabilizedRepositoryIdentity(project.root) + ); } const rows: ProjectStatusRow[] = []; const entries = new Map( Object.values(registry.projects).map((entry) => [entry.repositoryId, entry]) ); for (const [repositoryId, projects] of discoveredById) { + const first = projects[0]; + if (!first) { + continue; + } + const rawIdentity = + rawDiscoveredIdentityByRoot.get(first.root) ?? first.identity; + const aliasedEntry = [...entries.entries()].find(([key, entry]) => + registryStoredLocationMatchesIdentity({ + key, + entry, + identity: rawIdentity, + }) + ); + if (!entries.has(repositoryId) && aliasedEntry) { + entries.delete(aliasedEntry[0]); + entries.set(repositoryId, { + ...aliasedEntry[1], + repositoryId, + aliases: uniqueSorted([ + ...(aliasedEntry[1].aliases ?? []), + aliasedEntry[0], + ...first.identity.aliases.map((alias) => alias.id), + ]).filter((alias) => alias !== repositoryId), + identityKind: first.identity.kind, + identityFingerprint: first.identity.fingerprint, + }); + } if (!entries.has(repositoryId)) { - const first = projects[0]; - if (!first) { - continue; - } entries.set(repositoryId, { repositoryId, + aliases: first.identity.aliases.map((alias) => alias.id), identityKind: first.identity.kind, identityFingerprint: first.identity.fingerprint, decision: "inactive", @@ -1439,6 +5111,7 @@ export async function buildProjectsStatus(args: { lastSuccessfulRun: null, pendingApprovals: [], history: [], + activeReceipts: {}, }); } } @@ -1454,6 +5127,17 @@ export async function buildProjectsStatus(args: { ]) ); for (const project of discoveredById.get(entry.repositoryId) ?? []) { + const rawIdentity = + rawDiscoveredIdentityByRoot.get(project.root) ?? project.identity; + if ( + !registryStoredLocationMatchesIdentity({ + key: entry.repositoryId, + entry, + identity: rawIdentity, + }) + ) { + continue; + } locations.set(project.root, { path: project.root, exists: true, @@ -1464,37 +5148,99 @@ export async function buildProjectsStatus(args: { if (!location.exists) { location.exists = await pathExists(location.path); if (location.exists) { - const inspected = await inspectRepository(location.path).catch( - () => null - ); - location.exists = inspected !== null; - location.dirty = inspected?.dirty ?? null; + const inspected = await inspectRepository(location.path, homeDir, { + stabilizeIdentity: false, + }); + const identityMatches = registryStoredLocationMatchesIdentity({ + key: entry.repositoryId, + entry, + identity: inspected.identity, + }); + location.exists = identityMatches; + location.dirty = identityMatches ? inspected.dirty : null; } } } - const activeLocation = [...locations.values()].find( + const receiptLocations = Object.keys(entry.activeReceipts ?? {}).map( + (pathValue) => + locations.get(pathValue) ?? { + path: pathValue, + exists: false, + dirty: null, + } + ); + const fallbackLocation = [...locations.values()].find( (location) => location.exists ); + for (const receiptLocation of receiptLocations) { + if (!locations.has(receiptLocation.path)) { + locations.set(receiptLocation.path, receiptLocation); + } + } + const activeLocations = + receiptLocations.length > 0 + ? receiptLocations + : fallbackLocation + ? [fallbackLocation] + : []; + const activeLocation = activeLocations.find((location) => location.exists); const canonicalRoot = activeLocation ? join(activeLocation.path, ".ai") : null; - const config = canonicalRoot - ? await pathExists(join(canonicalRoot, "config.toml")) - : false; - const ignoreText = canonicalRoot - ? await fileText(join(canonicalRoot, ".gitignore")) - : null; - const protectiveIgnore = PROTECTIVE_IGNORE_LINES.slice(1).every((line) => - ignoreText?.split(LINE_SPLIT_RE).includes(line) + const activeHealth = await Promise.all( + activeLocations.map(async (location) => { + const aiRoot = join(location.path, ".ai"); + if (!location.exists) { + return { + aiRoot, + config: false, + generatedGraph: false, + generatedIndex: false, + protectiveIgnore: false, + }; + } + const [config, protectiveIgnore, generatedIndex, generatedGraph] = + await Promise.all([ + validProjectEnrollmentConfig(join(aiRoot, "config.toml"), entry), + hasEffectiveProtectiveIgnore(location.path), + validBoundedGeneratedJson( + facultAiIndexPath(homeDir, aiRoot), + isGeneratedIndex + ), + validBoundedGeneratedJson( + facultAiGraphPath(homeDir, aiRoot), + isGeneratedGraph + ), + ]); + return { + aiRoot, + config, + generatedGraph, + generatedIndex, + protectiveIgnore, + }; + }) ); - const generatedIndex = canonicalRoot - ? await pathExists(facultAiIndexPath(homeDir, canonicalRoot)) - : false; - const generatedGraph = canonicalRoot - ? await pathExists(facultAiGraphPath(homeDir, canonicalRoot)) - : false; + const config = + activeHealth.length > 0 && + activeHealth.every((location) => location.config); + const protectiveIgnore = + activeHealth.length > 0 && + activeHealth.every((location) => location.protectiveIgnore); + const generatedIndex = + activeHealth.length > 0 && + activeHealth.every((location) => location.generatedIndex); + const generatedGraph = + activeHealth.length > 0 && + activeHealth.every((location) => location.generatedGraph); const inactive = entry.decision !== "selected"; - const exists = canonicalRoot !== null && (await pathExists(canonicalRoot)); + const exists = + activeHealth.length > 0 && + ( + await Promise.all( + activeHealth.map((location) => pathExists(location.aiRoot)) + ) + ).every(Boolean); const coverage = inactive ? ("inactive" as const) : config && protectiveIgnore @@ -1602,12 +5348,12 @@ function printProjectHelp(): void { Usage: fclt project init [--project-root PATH] [--guidance PATH] [--source SOURCE] [--cadence on-demand|weekly|daily] [--json] - fclt project init --apply --plan-sha SHA [same options] - fclt project rollback --receipt ID [--apply] [--json] - fclt project disable --project-root PATH [--json] - fclt project ignore --project-root PATH [--json] - fclt project inactive --project-root PATH [--json] - fclt project remove --project-root PATH [--json] + fclt project init --apply --plan-sha SHA [same options] [--dry-run] + fclt project rollback --receipt ID [--apply] [--dry-run] [--json] + fclt project disable --project-root PATH [--dry-run] [--json] + fclt project ignore --project-root PATH [--dry-run] [--json] + fclt project inactive --project-root PATH [--dry-run] [--json] + fclt project remove --project-root PATH [--dry-run] [--json] Init prints an exact plan and performs no writes by default. Apply requires the SHA from that plan. Existing guidance is referenced only when explicitly @@ -1634,6 +5380,7 @@ export async function projectsCommand( if (command === "discover") { const result = await discoverProjects({ roots: flagValues(rest, "--root"), + homeDir: context.homeDir, since: flagValue(rest, "--since"), maxVisits: positiveIntegerFlag(rest, "--max-visits"), maxResults: positiveIntegerFlag(rest, "--max-results"), @@ -1686,6 +5433,7 @@ export async function projectCommand( const command = argv[0]; const rest = argv.slice(1); const json = rest.includes("--json"); + const dryRun = rest.includes("--dry-run"); const homeDir = resolve(context.homeDir ?? process.env.HOME ?? homedir()); const projectRoot = resolve( flagValue(rest, "--project-root") ?? context.cwd ?? process.cwd() @@ -1709,11 +5457,21 @@ export async function projectCommand( if (!expectedPlanSha256) { throw new Error("--apply requires --plan-sha from the reviewed plan"); } + if (dryRun) { + if (plan.planSha256 !== expectedPlanSha256) { + throw new Error( + "Apply requires the exact plan SHA from the reviewed preview" + ); + } + console.log(JSON.stringify(plan, null, 2)); + return; + } const result = await applyProjectEnrollment({ plan, expectedPlanSha256, homeDir, now: context.now?.(), + platform: context.platform, }); console.log(JSON.stringify(result, null, 2)); return; @@ -1726,8 +5484,9 @@ export async function projectCommand( const result = await rollbackProjectEnrollment({ receiptId, homeDir, - apply: rest.includes("--apply"), + apply: rest.includes("--apply") && !dryRun, now: context.now?.(), + platform: context.platform, }); console.log(JSON.stringify(result, null, 2)); return; @@ -1745,6 +5504,8 @@ export async function projectCommand( homeDir, decision, now: context.now?.() ?? new Date(), + dryRun, + platform: context.platform, }); if (json) { console.log(JSON.stringify(result, null, 2)); diff --git a/src/remote.test.ts b/src/remote.test.ts index 0ab1da62..68721f5a 100644 --- a/src/remote.test.ts +++ b/src/remote.test.ts @@ -1,20 +1,23 @@ import { afterEach, describe, expect, it } from "bun:test"; import { generateKeyPairSync, sign } from "node:crypto"; import { + chmod, + link, lstat, mkdir, mkdtemp, readFile, realpath, + rename, rm, symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { runFixtureGit } from "../test/git-fixture"; import { renderCanonicalText } from "./agents"; -import { facultAiIndexPath } from "./paths"; +import { facultAiIndexPath, facultLocalStateRoot } from "./paths"; import { checkRemoteUpdates, installRemoteItem, @@ -1738,6 +1741,64 @@ describe("templates command", () => { expect(await Bun.file(join(repoDir, ".ai")).exists()).toBe(false); }); + it("keeps project-ai alias apply zero-write under common --dry-run", async () => { + const { home } = await makeTempRoot(); + const repoDir = join(home, "repo"); + await initializeGitRepository(repoDir, home); + + const previewOutput = await withCapturedConsole(async () => { + await templatesCommand( + ["init", "project-ai", "--project-root", repoDir, "--json"], + { + homeDir: home, + cwd: repoDir, + } + ); + }); + expect(previewOutput.errors).toEqual([]); + const plan = JSON.parse(previewOutput.logs.join("\n")) as { + planSha256: string; + canonicalWrites: Array<{ path: string }>; + generatedWrites: Array<{ path: string }>; + machineLocalWrites: Array<{ path: string }>; + }; + + const dryRunOutput = await withCapturedConsole(async () => { + await templatesCommand( + [ + "init", + "project-ai", + "--project-root", + repoDir, + "--apply", + "--plan-sha", + plan.planSha256, + "--dry-run", + "--json", + ], + { + homeDir: home, + cwd: repoDir, + } + ); + }); + + expect(dryRunOutput.errors).toEqual([]); + expect( + (JSON.parse(dryRunOutput.logs.join("\n")) as { planSha256: string }) + .planSha256 + ).toBe(plan.planSha256); + expect(await Bun.file(join(repoDir, ".ai")).exists()).toBe(false); + expect(await Bun.file(facultLocalStateRoot(home)).exists()).toBe(false); + for (const pathValue of [ + ...plan.canonicalWrites, + ...plan.generatedWrites, + ...plan.machineLocalWrites, + ].map((write) => write.path)) { + expect(await Bun.file(pathValue).exists()).toBe(false); + } + }); + it("previews project-ai enrollment into an explicit root", async () => { const { home } = await makeTempRoot(); const repoDir = join(home, "repo"); @@ -2016,6 +2077,224 @@ describe("templates command", () => { ).toContain("/.facult/"); }); + it("refuses a symlinked project ignore leaf before full-pack writes", async () => { + const { home } = await makeTempRoot(); + const repoDir = join(home, "repo"); + const victimPath = join(home, "victim.txt"); + await initializeGitRepository(repoDir, home); + await mkdir(join(repoDir, ".ai"), { recursive: true }); + await writeFile(victimPath, "do not change\n", "utf8"); + await symlink(victimPath, join(repoDir, ".ai", ".gitignore")); + + const output = await withCapturedConsole(async () => { + await templatesCommand(["init", "operating-model", "--project"], { + homeDir: home, + cwd: repoDir, + }); + }); + + expect(process.exitCode).toBe(1); + expect(output.errors.join("\n")).toContain( + "Refusing unsafe project ignore file" + ); + expect(await readFile(victimPath, "utf8")).toBe("do not change\n"); + expect( + await Bun.file(join(repoDir, ".ai", "AGENTS.global.md")).exists() + ).toBe(false); + expect(await Bun.file(join(repoDir, ".ai", ".facult")).exists()).toBe( + false + ); + }); + + it("refuses a hard-linked project ignore leaf without changing its peer", async () => { + const { home } = await makeTempRoot(); + const repoDir = join(home, "repo"); + const victimPath = join(home, "victim.txt"); + await initializeGitRepository(repoDir, home); + await mkdir(join(repoDir, ".ai"), { recursive: true }); + await writeFile(victimPath, "do not change\n", "utf8"); + await link(victimPath, join(repoDir, ".ai", ".gitignore")); + + const output = await withCapturedConsole(async () => { + await templatesCommand(["init", "operating-model", "--project"], { + homeDir: home, + cwd: repoDir, + }); + }); + + expect(process.exitCode).toBe(1); + expect(output.errors.join("\n")).toContain( + "Refusing unsafe project ignore file" + ); + expect(await readFile(victimPath, "utf8")).toBe("do not change\n"); + expect( + await Bun.file(join(repoDir, ".ai", "AGENTS.global.md")).exists() + ).toBe(false); + expect(await Bun.file(join(repoDir, ".ai", ".facult")).exists()).toBe( + false + ); + }); + + it("propagates project ignore access failures without treating authored rules as absent", async () => { + if (process.platform === "win32") { + return; + } + const { home } = await makeTempRoot(); + const repoDir = join(home, "repo"); + const aiRoot = join(repoDir, ".ai"); + const ignorePath = join(aiRoot, ".gitignore"); + await initializeGitRepository(repoDir, home); + await mkdir(aiRoot, { recursive: true }); + await writeFile(ignorePath, "/authored-rule\n", "utf8"); + await chmod(aiRoot, 0o000); + let failure: unknown; + try { + await scaffoldBuiltinOperatingModelPack({ + homeDir: home, + rootDir: aiRoot, + }); + } catch (error) { + failure = error; + } finally { + await chmod(aiRoot, 0o700); + } + + expect((failure as NodeJS.ErrnoException | undefined)?.code).toBe("EACCES"); + expect(await readFile(ignorePath, "utf8")).toBe("/authored-rule\n"); + expect(await Bun.file(join(aiRoot, "AGENTS.global.md")).exists()).toBe( + false + ); + }); + + it("refuses a special-file project ignore leaf without opening it", async () => { + if (process.platform === "win32") { + return; + } + const { home } = await makeTempRoot(); + const repoDir = join(home, "repo"); + const ignorePath = join(repoDir, ".ai", ".gitignore"); + await initializeGitRepository(repoDir, home); + await mkdir(join(repoDir, ".ai"), { recursive: true }); + const fifo = Bun.spawnSync(["mkfifo", ignorePath]); + expect(fifo.exitCode).toBe(0); + + const output = await withCapturedConsole(async () => { + await templatesCommand(["init", "operating-model", "--project"], { + homeDir: home, + cwd: repoDir, + }); + }); + + expect(process.exitCode).toBe(1); + expect(output.errors.join("\n")).toContain( + "Refusing unsafe project ignore file" + ); + expect((await lstat(ignorePath)).isFIFO()).toBe(true); + expect( + await Bun.file(join(repoDir, ".ai", "AGENTS.global.md")).exists() + ).toBe(false); + }); + + it("reappends full-pack protections after existing ignore negations", async () => { + const { home } = await makeTempRoot(); + const repoDir = join(home, "repo"); + await initializeGitRepository(repoDir, home); + await mkdir(join(repoDir, ".ai"), { recursive: true }); + await writeFile( + join(repoDir, ".ai", ".gitignore"), + [ + "/.facult/", + "!/.facult/", + "/config.local.toml", + "!/config.local.toml", + "", + ].join("\n"), + "utf8" + ); + + await withMutedConsole(async () => { + await templatesCommand(["init", "operating-model", "--project"], { + homeDir: home, + cwd: repoDir, + }); + }); + + const ignore = await readFile(join(repoDir, ".ai", ".gitignore"), "utf8"); + expect(ignore.lastIndexOf("/.facult/")).toBeGreaterThan( + ignore.lastIndexOf("!/.facult/") + ); + expect(ignore.lastIndexOf("/config.local.toml")).toBeGreaterThan( + ignore.lastIndexOf("!/config.local.toml") + ); + }); + + it("preserves existing project ignore mode", async () => { + if (process.platform === "win32") { + return; + } + const { home } = await makeTempRoot(); + const repoDir = join(home, "repo"); + const ignorePath = join(repoDir, ".ai", ".gitignore"); + await initializeGitRepository(repoDir, home); + await mkdir(dirname(ignorePath), { recursive: true }); + await writeFile(ignorePath, "/authored-rule\n", "utf8"); + await chmod(ignorePath, 0o640); + + await scaffoldBuiltinOperatingModelPack({ + homeDir: home, + rootDir: join(repoDir, ".ai"), + }); + + expect((await lstat(ignorePath)).mode % 0o1000).toBe(0o640); + }); + + it("creates a repo-readable project ignore despite a restrictive umask", async () => { + if (process.platform === "win32") { + return; + } + const { home } = await makeTempRoot(); + const repoDir = join(home, "repo"); + await initializeGitRepository(repoDir, home); + + const proc = Bun.spawn( + [ + "sh", + "-c", + 'umask 077; exec bun run "$1" templates init operating-model --project', + "sh", + join(import.meta.dir, "index.ts"), + ], + { + cwd: repoDir, + env: { ...process.env, HOME: home }, + stdout: "pipe", + stderr: "pipe", + } + ); + const [exitCode, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stderr).text(), + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect( + (await lstat(join(repoDir, ".ai", ".gitignore"))).mode % 0o1000 + ).toBe(0o644); + expect( + ( + await lstat( + join( + repoDir, + ".ai", + ".facult", + "packs", + "facult-operating-model.json" + ) + ) + ).mode % 0o1000 + ).toBe(0o600); + }); + it("updates unmodified builtin operating-model files using the pack manifest", async () => { const { home } = await makeTempRoot(); const globalRoot = join(home, ".ai"); @@ -2128,6 +2407,30 @@ describe("templates command", () => { expect((await lstat(skillPath)).isSymbolicLink()).toBe(true); }); + it("preserves a project ignore edited at the final commit boundary", async () => { + const { home } = await makeTempRoot(); + const repoDir = join(home, "repo"); + const aiRoot = join(repoDir, ".ai"); + await mkdir(aiRoot, { recursive: true }); + const ignorePath = join(aiRoot, ".gitignore"); + const reviewedPath = join(aiRoot, ".gitignore.reviewed"); + await writeFile(ignorePath, "/before\n", "utf8"); + process.chdir(repoDir); + + await expect( + scaffoldBuiltinOperatingModelPack({ + homeDir: home, + rootDir: aiRoot, + beforeProjectIgnoreCommit: async () => { + await rename(ignorePath, reviewedPath); + await writeFile(ignorePath, "/concurrent\n", "utf8"); + }, + }) + ).rejects.toThrow("conditional commit boundary"); + expect(await readFile(ignorePath, "utf8")).toBe("/concurrent\n"); + expect(await readFile(reviewedPath, "utf8")).toBe("/before\n"); + }); + it("bootstraps the builtin operating-model pack into a project root", async () => { const { home } = await makeTempRoot(); const repoDir = join(home, "repo"); diff --git a/src/remote.ts b/src/remote.ts index 67f7b6df..0ef78355 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -1,8 +1,10 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; import { lstat, mkdir, + open, readdir, readFile, realpath, @@ -20,6 +22,7 @@ import { resolve, } from "node:path"; import { isCancel, multiselect, select, text } from "@clack/prompts"; +import { replaceVerifiedFileAt } from "./audit/safe-openat"; import { builtinOperatingModelInstallRelPath, facultBuiltinPackRoot, @@ -45,6 +48,7 @@ import { } from "./legacy-mutation-policy"; import { facultRootDir, + pathsPhysicallyEquivalent, projectRootFromAiRoot, readFacultConfig, withFacultRootScope, @@ -84,6 +88,8 @@ import { parseJsonLenient } from "./util/json"; const REMOTE_STATE_VERSION = 1; const VERSION_TOKEN_RE = /[A-Za-z]+|[0-9]+/g; +// biome-ignore lint/suspicious/noBitwiseOperators: secure open flags require OS bitmask composition. +const NOFOLLOW_READ_FLAGS = fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW; const QUERY_SPLIT_RE = /\s+/; const MD_EXT_RE = /\.md$/i; const FILE_EXT_RE = /\.[A-Za-z0-9]+$/; @@ -1593,18 +1599,128 @@ const PROJECT_AI_PROTECTIVE_IGNORE = `# fclt machine-local and generated state function appendProjectAiProtectiveIgnore(existing: string): string { const lines = existing.replace(/\r\n/g, "\n").split("\n"); - while (lines.at(-1) === "") { - lines.pop(); + const protections = PROJECT_AI_PROTECTIVE_IGNORE.trimEnd().split("\n"); + const protectedLines = new Set(protections); + const out = lines.filter((line) => !protectedLines.has(line)); + while (out.at(-1) === "") { + out.pop(); } - for (const line of PROJECT_AI_PROTECTIVE_IGNORE.trimEnd().split("\n")) { - if (!lines.includes(line)) { - if (line.startsWith("#") && lines.length > 0 && lines.at(-1) !== "") { - lines.push(""); - } - lines.push(line); + if (out.length > 0) { + out.push(""); + } + out.push(...protections); + return `${out.join("\n")}\n`; +} + +interface ProjectAiIgnoreSnapshot { + content: string; + identity: { + dev: number; + ino: number; + } | null; + mode: number | null; +} + +function assertSafeProjectAiIgnoreEntry( + pathValue: string, + entry: Awaited> +): void { + if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1) { + throw new Error(`Refusing unsafe project ignore file: ${pathValue}`); + } +} + +async function assertSafeProjectAiIgnoreParent( + pathValue: string +): Promise { + const parent = dirname(pathValue); + const entry = await lstat(parent); + if (entry.isSymbolicLink() || !entry.isDirectory()) { + throw new Error(`Refusing unsafe project ignore directory: ${parent}`); + } + if (!pathsPhysicallyEquivalent(await realpath(parent), resolve(parent))) { + throw new Error(`Refusing unsafe project ignore directory: ${parent}`); + } +} + +async function readProjectAiIgnore( + pathValue: string +): Promise { + let entry: Awaited>; + try { + entry = await lstat(pathValue); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { content: "", identity: null, mode: null }; } + throw error; } - return `${lines.join("\n")}\n`; + assertSafeProjectAiIgnoreEntry(pathValue, entry); + const handle = await open(pathValue, NOFOLLOW_READ_FLAGS); + try { + const opened = await handle.stat(); + if ( + !opened.isFile() || + opened.nlink !== 1 || + opened.dev !== entry.dev || + opened.ino !== entry.ino + ) { + throw new Error(`Refusing unsafe project ignore file: ${pathValue}`); + } + return { + content: await handle.readFile("utf8"), + identity: { dev: opened.dev, ino: opened.ino }, + mode: opened.mode % 0o1000, + }; + } finally { + await handle.close(); + } +} + +function sameProjectAiIgnoreSnapshot( + left: ProjectAiIgnoreSnapshot, + right: ProjectAiIgnoreSnapshot +): boolean { + return ( + left.content === right.content && + left.identity?.dev === right.identity?.dev && + left.identity?.ino === right.identity?.ino && + left.mode === right.mode + ); +} + +async function writeProjectAiIgnore( + pathValue: string, + content: string, + expected: ProjectAiIgnoreSnapshot, + beforeCommit: (() => Promise) | undefined, + safeRoot: string +): Promise { + await assertSafeProjectAiIgnoreParent(pathValue); + const current = await readProjectAiIgnore(pathValue); + if (!sameProjectAiIgnoreSnapshot(current, expected)) { + throw new Error(`Project ignore file changed before commit: ${pathValue}`); + } + await replaceVerifiedFileAt({ + beforeExchange: beforeCommit, + contents: content, + directoryPath: dirname(pathValue), + expected: + expected.identity === null + ? null + : { + contents: expected.content, + identity: expected.identity, + mode: expected.mode ?? 0o644, + }, + fileName: basename(pathValue), + maxBytes: Math.max( + Buffer.byteLength(content), + Buffer.byteLength(expected.content) + ), + mode: expected.mode ?? 0o644, + safeRoot, + }); } const OPERATING_MODEL_SNIPPET_FRAME = `## Working mode @@ -1681,6 +1797,8 @@ export async function scaffoldBuiltinOperatingModelPack(args: { force?: boolean; update?: boolean; installedAs?: string; + /** @internal Adversarial test hook; production callers must not set this. */ + beforeProjectIgnoreCommit?: () => Promise; }): Promise { const rootDir = resolve(args.rootDir); const packRoot = facultBuiltinPackRoot("facult-operating-model"); @@ -1695,15 +1813,21 @@ export async function scaffoldBuiltinOperatingModelPack(args: { if (projectRoot) { const ignorePath = join(rootDir, ".gitignore"); - const existingIgnore = (await pathExists(ignorePath)) - ? await Bun.file(ignorePath).text() - : ""; - const protectiveIgnore = appendProjectAiProtectiveIgnore(existingIgnore); - if (protectiveIgnore !== existingIgnore) { + const existingIgnore = await readProjectAiIgnore(ignorePath); + const protectiveIgnore = appendProjectAiProtectiveIgnore( + existingIgnore.content + ); + if (protectiveIgnore !== existingIgnore.content) { changedPaths.push(ignorePath); if (!args.dryRun) { await ensurePackDirectory(dirname(ignorePath)); - await Bun.write(ignorePath, protectiveIgnore); + await writeProjectAiIgnore( + ignorePath, + protectiveIgnore, + existingIgnore, + args.beforeProjectIgnoreCommit, + projectRoot + ); } } } @@ -3915,7 +4039,7 @@ export async function templatesCommand( forwarded.push(flag, value); } } - for (const flag of ["--apply", "--json"]) { + for (const flag of ["--apply", "--dry-run", "--json"]) { if (args.includes(flag)) { forwarded.push(flag); } diff --git a/src/repository-identity.test.ts b/src/repository-identity.test.ts new file mode 100644 index 00000000..dc246ef7 --- /dev/null +++ b/src/repository-identity.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { + normalizeRepositoryRemote, + type RepositoryCommonDirectoryMetadata, + repositoryCommonDirectoryFingerprint, + repositoryIdentityFromGitFacts, +} from "./repository-identity"; + +const BASE_METADATA: RepositoryCommonDirectoryMetadata = { + birthtimeNs: 100n, + ctimeNs: 200n, + dev: 10n, + ino: 20n, +}; + +function identityFor(metadata: RepositoryCommonDirectoryMetadata) { + return repositoryIdentityFromGitFacts({ + commonDir: ".git", + commonDirMetadata: metadata, + projectRoot: "fixture/repo", + rootCommit: "0123456789abcdef", + }); +} + +describe("machine-local repository identity", () => { + it("uses birth time to distinguish inode reuse while surviving a root rename", () => { + const original = identityFor(BASE_METADATA); + const recreated = identityFor({ + ...BASE_METADATA, + birthtimeNs: BASE_METADATA.birthtimeNs + 1n, + }); + const renamed = repositoryIdentityFromGitFacts({ + commonDir: ".git", + commonDirMetadata: BASE_METADATA, + projectRoot: "fixture/renamed-repo", + rootCommit: "0123456789abcdef", + }); + + expect(original.kind).toBe("git-common-dir"); + expect(original.id).not.toBe(recreated.id); + expect(renamed.id).toBe(original.id); + expect(original.aliases).toEqual([ + expect.objectContaining({ + kind: "root-commit", + stability: "portable", + }), + ]); + }); + + it("refuses mutable ctime when birth time is unavailable", () => { + expect(() => + identityFor({ + ...BASE_METADATA, + birthtimeNs: 0n, + ctimeNs: 300n, + }) + ).toThrow("birth time is unavailable"); + expect(() => + repositoryCommonDirectoryFingerprint({ + ...BASE_METADATA, + birthtimeNs: 0n, + ctimeNs: 0n, + }) + ).toThrow("birth time is unavailable"); + }); + + it("does not change identity when only mutable ctime changes", () => { + const first = identityFor({ + ...BASE_METADATA, + ctimeNs: 300n, + }); + const afterGitMutation = identityFor({ + ...BASE_METADATA, + ctimeNs: 301n, + }); + + expect(afterGitMutation.id).toBe(first.id); + }); + + it("does not expose the legacy dev-inode identity as an authorization alias", () => { + const identity = identityFor(BASE_METADATA); + const legacyId = `repo_${createHash("sha256") + .update(`git-common-dir:${BASE_METADATA.dev}:${BASE_METADATA.ino}`) + .digest("hex") + .slice(0, 24)}`; + + expect([ + identity.id, + ...identity.aliases.map((alias) => alias.id), + ]).not.toContain(legacyId); + }); + + it("keeps a remote primary portable when common-directory creation changes", () => { + const original = repositoryIdentityFromGitFacts({ + commonDir: ".git", + commonDirMetadata: BASE_METADATA, + originUrl: "https://github.com/example/project.git", + projectRoot: "fixture/repo", + rootCommit: "0123456789abcdef", + }); + const recreated = repositoryIdentityFromGitFacts({ + commonDir: ".git", + commonDirMetadata: { + ...BASE_METADATA, + birthtimeNs: BASE_METADATA.birthtimeNs + 1n, + }, + originUrl: "https://github.com/example/project.git", + projectRoot: "fixture/repo", + rootCommit: "0123456789abcdef", + }); + + expect(original.id).toBe(recreated.id); + expect(original.kind).toBe("remote"); + expect(original.stability).toBe("portable"); + expect( + original.aliases.find((alias) => alias.kind === "git-common-dir")?.id + ).not.toBe( + recreated.aliases.find((alias) => alias.kind === "git-common-dir")?.id + ); + }); + + it("preserves identity-bearing SSH usernames without retaining credentials", () => { + expect(normalizeRepositoryRemote("git@example.com:org/repo.git")).toBe( + "example.com/org/repo" + ); + expect( + normalizeRepositoryRemote("ssh://alice:secret@example.com/org/repo.git") + ).toBe("alice@example.com/org/repo"); + expect( + normalizeRepositoryRemote("ssh://%61lice@example.com/org/repo.git") + ).toBe("alice@example.com/org/repo"); + expect( + normalizeRepositoryRemote("ssh://g%69t@example.com/org/repo.git") + ).toBe("example.com/org/repo"); + expect(normalizeRepositoryRemote("%61lice@example.com:org/repo.git")).toBe( + "%2561lice@example.com/org/repo" + ); + expect(normalizeRepositoryRemote("foo#bar@example.com:org/repo.git")).toBe( + "foo%23bar@example.com/org/repo" + ); + expect(normalizeRepositoryRemote("foo?bar@example.com:org/repo.git")).toBe( + "foo%3Fbar@example.com/org/repo" + ); + expect(normalizeRepositoryRemote("foo:bar@example.com:org/repo.git")).toBe( + "foo%3Abar@example.com/org/repo" + ); + expect( + normalizeRepositoryRemote("foo#bar@example.com:org/repo.git") + ).not.toBe( + normalizeRepositoryRemote("foo#baz@other.example:other/repo.git") + ); + expect( + normalizeRepositoryRemote("%40ops@example.com:org/repo.git") + ).not.toBe( + normalizeRepositoryRemote("ssh://%40ops@example.com/org/repo.git") + ); + expect( + normalizeRepositoryRemote("ssh://alice%40ops@example.com/org/repo.git") + ).toBe("alice%40ops@example.com/org/repo"); + expect( + normalizeRepositoryRemote("ssh://alice%2Fops@example.com/org/repo.git") + ).toBe("alice%2Fops@example.com/org/repo"); + expect( + normalizeRepositoryRemote("ssh://alice%0Aops@example.com/org/repo.git") + ).toBe("alice%0Aops@example.com/org/repo"); + expect( + normalizeRepositoryRemote("https://alice:secret@example.com/org/repo.git") + ).toBe("example.com/org/repo"); + + const alice = repositoryIdentityFromGitFacts({ + commonDir: ".git", + commonDirMetadata: BASE_METADATA, + originUrl: "ssh://alice@example.com/org/repo.git", + projectRoot: "fixture/alice", + rootCommit: "0123456789abcdef", + }); + const bob = repositoryIdentityFromGitFacts({ + commonDir: ".git", + commonDirMetadata: { + ...BASE_METADATA, + ino: BASE_METADATA.ino + 1n, + }, + originUrl: "ssh://bob@example.com/org/repo.git", + projectRoot: "fixture/bob", + rootCommit: "0123456789abcdef", + }); + + expect(alice.fingerprint).toBe("alice@example.com/org/repo"); + expect(bob.fingerprint).toBe("bob@example.com/org/repo"); + expect(alice.id).not.toBe(bob.id); + }); +}); diff --git a/src/repository-identity.ts b/src/repository-identity.ts new file mode 100644 index 00000000..7bb91a6f --- /dev/null +++ b/src/repository-identity.ts @@ -0,0 +1,449 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + type BigIntStats, + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +const SCP_REMOTE_RE = /^([^@/\s]+@)?([^:/\s]+):(.+)$/; +const GIT_PROTOCOL_PREFIX_RE = /^git\+/; +const GIT_SUFFIX_RE = /\.git\/?$/; +const TRAILING_SLASH_RE = /\/+$/; +const WHITESPACE_RE = /\s+/; +const WINDOWS_DRIVE_LOCAL_RE = /^[A-Za-z]:/; +const WINDOWS_UNC_LOCAL_RE = /^\\\\/; +const GIT_POINTER_MAX_BYTES = 4096; + +export interface RepositoryIdentity { + id: string; + kind: "remote" | "root-commit" | "git-common-dir"; + fingerprint: string; + stability: "portable" | "machine-local"; + aliases: RepositoryIdentityAlias[]; +} + +export interface RepositoryIdentityAlias { + id: string; + kind: "remote" | "root-commit" | "git-common-dir"; + fingerprint: string; + stability: "portable" | "machine-local"; +} + +export interface RepositoryExecutionIdentity { + id: string; + fingerprint: string; +} + +export interface RepositoryCommonDirectoryMetadata { + birthtimeNs: bigint; + ctimeNs: bigint; + dev: bigint; + ino: bigint; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function repositoryCommonDirectoryFingerprint( + metadata: RepositoryCommonDirectoryMetadata +): string { + if (metadata.birthtimeNs <= 0n) { + throw new Error( + "Unable to derive a safe Git common-directory creation identity because birth time is unavailable" + ); + } + return `v2:${metadata.dev}:${metadata.ino}:birth:${metadata.birthtimeNs}`; +} + +export function normalizeRepositoryRemote(raw: string): string | null { + const value = raw.trim(); + if ( + !value || + value.startsWith("/") || + value.startsWith("./") || + WINDOWS_DRIVE_LOCAL_RE.test(value) || + WINDOWS_UNC_LOCAL_RE.test(value) + ) { + return null; + } + const scpMatch = value.includes("://") ? null : value.match(SCP_REMOTE_RE); + if (scpMatch) { + const usernameWithAt = scpMatch[1] ?? ""; + const username = usernameWithAt ? usernameWithAt.slice(0, -1) : ""; + const usernamePrefix = + username && username !== "git" ? `${encodeURIComponent(username)}@` : ""; + const hostname = scpMatch[2]?.toLowerCase() ?? ""; + const pathname = `/${scpMatch[3] ?? ""}` + .replace(GIT_SUFFIX_RE, "") + .replace(TRAILING_SLASH_RE, ""); + return `${usernamePrefix}${hostname}${pathname}`; + } + const asUrl = value.replace(GIT_PROTOCOL_PREFIX_RE, ""); + try { + const parsed = new URL(asUrl); + if (parsed.protocol === "file:") { + return null; + } + const normalizedSshUsername = + parsed.protocol === "ssh:" && parsed.username + ? decodeURIComponent(parsed.username) + : ""; + const sshUsernameIdentity = encodeURIComponent(normalizedSshUsername); + const sshUsername = + normalizedSshUsername && normalizedSshUsername !== "git" + ? `${sshUsernameIdentity}@` + : ""; + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + parsed.hostname = parsed.hostname.toLowerCase(); + parsed.pathname = parsed.pathname + .replace(GIT_SUFFIX_RE, "") + .replace(TRAILING_SLASH_RE, ""); + const port = + parsed.port && + !( + (parsed.protocol === "https:" && parsed.port === "443") || + (parsed.protocol === "http:" && parsed.port === "80") || + (parsed.protocol === "ssh:" && parsed.port === "22") + ) + ? `:${parsed.port}` + : ""; + return `${sshUsername}${parsed.hostname}${port}${parsed.pathname}`; + } catch { + return null; + } +} + +export function repositoryIdentityFromGitFacts(args: { + commonDir?: string | null; + commonDirMetadata?: RepositoryCommonDirectoryMetadata; + originUrl?: string | null; + projectRoot: string; + remoteUrls?: string[]; + rootCommit?: string | null; +}): RepositoryIdentity { + const normalizedOrigin = args.originUrl + ? normalizeRepositoryRemote(args.originUrl) + : null; + const normalizedRemotes = [ + ...new Set( + [ + normalizedOrigin, + ...(args.remoteUrls ?? []).map(normalizeRepositoryRemote), + ] + .filter((value): value is string => value !== null) + .sort() + ), + ]; + const rootCommit = args.rootCommit + ?.split("\n") + .map((value) => value.trim()) + .filter(Boolean) + .sort()[0]; + const commonPath = resolve(args.projectRoot, args.commonDir || ".git"); + let commonDirectoryMetadata = args.commonDirMetadata; + if (!commonDirectoryMetadata) { + let metadata: BigIntStats; + try { + metadata = lstatSync(commonPath, { bigint: true }); + } catch { + throw new Error( + "Unable to inspect the Git common directory for repository identity" + ); + } + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error("The Git common directory is unsafe"); + } + commonDirectoryMetadata = metadata; + } + const commonFingerprint = repositoryCommonDirectoryFingerprint( + commonDirectoryMetadata + ); + const commonAlias: RepositoryIdentityAlias = { + id: `repo_${sha256(`git-common-dir:${commonFingerprint}`).slice(0, 24)}`, + kind: "git-common-dir", + fingerprint: sha256(commonFingerprint), + stability: "machine-local", + }; + const primaryRemoteValue = + normalizedOrigin ?? + (normalizedRemotes.length === 1 + ? normalizedRemotes[0] + : normalizedRemotes.length > 1 + ? `set:${normalizedRemotes.join("\n")}` + : null); + const primaryRemote: RepositoryIdentityAlias | null = primaryRemoteValue + ? { + id: `repo_${sha256(`remote:${primaryRemoteValue}`).slice(0, 24)}`, + kind: "remote", + fingerprint: primaryRemoteValue, + stability: "portable", + } + : null; + const rootAlias: RepositoryIdentityAlias | null = rootCommit + ? { + id: `repo_${sha256(`root-commit:${rootCommit}`).slice(0, 24)}`, + kind: "root-commit", + fingerprint: rootCommit, + stability: "portable", + } + : null; + if (primaryRemote) { + return { + ...primaryRemote, + aliases: [rootAlias, commonAlias].filter( + (alias): alias is RepositoryIdentityAlias => alias !== null + ), + }; + } + if (rootAlias) { + return { + ...commonAlias, + aliases: [rootAlias], + }; + } + + return { + ...commonAlias, + aliases: [], + }; +} + +function readGitRemoteUrls(projectRoot: string): string[] { + const output = readGit(projectRoot, [ + "config", + "--get-regexp", + "^remote\\..*\\.url$", + ]); + if (!output) { + return []; + } + return output + .split("\n") + .map((line) => line.trim().split(WHITESPACE_RE, 2)[1] ?? "") + .filter(Boolean); +} + +export function repositoryIdentityAliasForPrimary( + identity: RepositoryIdentity +): RepositoryIdentityAlias { + return { + id: identity.id, + kind: identity.kind, + fingerprint: identity.fingerprint, + stability: identity.stability, + }; +} + +export function canonicalRepositoryPath( + projectRoot: string, + platform: NodeJS.Platform = process.platform +): string { + const canonical = realpathSync.native(resolve(projectRoot)); + return repositoryPathComparisonKey(canonical, platform); +} + +export function repositoryPathComparisonKey( + canonicalPath: string, + platform: NodeJS.Platform = process.platform +): string { + return platform === "win32" ? canonicalPath.toLowerCase() : canonicalPath; +} + +function pointerMetadataMatches( + left: BigIntStats, + right: BigIntStats +): boolean { + return ( + left.isFile() && + right.isFile() && + left.nlink === 1n && + right.nlink === 1n && + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.ctimeNs === right.ctimeNs && + left.mtimeNs === right.mtimeNs + ); +} + +function readBoundedRegularFileSync( + pathValue: string +): { contents: string; metadata: BigIntStats } | null { + let before: BigIntStats; + try { + before = lstatSync(pathValue, { bigint: true }); + } catch (error) { + const code = + error && typeof error === "object" && "code" in error + ? String(error.code) + : null; + if (code === "ENOENT" || code === "ENOTDIR") { + return null; + } + throw error; + } + if ( + before.isSymbolicLink() || + !before.isFile() || + before.nlink !== 1n || + before.size <= 0n || + before.size > BigInt(GIT_POINTER_MAX_BYTES) || + !constants.O_NOFOLLOW + ) { + throw new Error(`Refusing unsafe Git pointer file: ${pathValue}`); + } + const descriptor = openSync( + pathValue, + constants.O_RDONLY + constants.O_NOFOLLOW + (constants.O_NONBLOCK ?? 0) + ); + try { + const opened = fstatSync(descriptor, { bigint: true }); + if (!pointerMetadataMatches(before, opened)) { + throw new Error(`Git pointer changed before read: ${pathValue}`); + } + const contents = Buffer.alloc(Number(opened.size)); + let offset = 0; + while (offset < contents.length) { + const count = readSync( + descriptor, + contents, + offset, + contents.length - offset, + offset + ); + if (count === 0) { + throw new Error(`Git pointer changed during read: ${pathValue}`); + } + offset += count; + } + const trailing = Buffer.alloc(1); + if (readSync(descriptor, trailing, 0, 1, contents.length) !== 0) { + throw new Error(`Git pointer changed during read: ${pathValue}`); + } + const [afterRead, rebound] = [ + fstatSync(descriptor, { bigint: true }), + lstatSync(pathValue, { bigint: true }), + ]; + if ( + rebound.isSymbolicLink() || + !pointerMetadataMatches(opened, afterRead) || + !pointerMetadataMatches(afterRead, rebound) + ) { + throw new Error(`Git pointer changed during read: ${pathValue}`); + } + return { + contents: contents.toString("utf8"), + metadata: afterRead, + }; + } finally { + closeSync(descriptor); + } +} + +export function repositoryExecutionIdentity( + projectRoot: string +): RepositoryExecutionIdentity { + const canonicalRoot = canonicalRepositoryPath(projectRoot); + const dotGitPath = join(canonicalRoot, ".git"); + const dotGit = lstatSync(dotGitPath, { bigint: true }); + let gitDirectoryPath: string; + let executionMetadata: BigIntStats; + if (dotGit.isDirectory() && !dotGit.isSymbolicLink()) { + gitDirectoryPath = dotGitPath; + executionMetadata = dotGit; + } else if (dotGit.isFile() && !dotGit.isSymbolicLink() && dotGit.size > 0n) { + const pointerFile = readBoundedRegularFileSync(dotGitPath); + const pointer = pointerFile?.contents.trim() ?? ""; + if (!pointer.startsWith("gitdir:")) { + throw new Error( + "Unable to derive a safe worktree Git-directory identity" + ); + } + const target = pointer.slice("gitdir:".length).trim(); + if (!target || target.includes("\0")) { + throw new Error( + "Unable to derive a safe worktree Git-directory identity" + ); + } + gitDirectoryPath = realpathSync.native( + resolve(dirname(dotGitPath), target) + ); + if (!pointerFile) { + throw new Error("Unable to read the worktree Git-directory pointer"); + } + executionMetadata = pointerFile.metadata; + } else { + throw new Error("Unable to derive a safe worktree Git-directory identity"); + } + const gitDirectory = lstatSync(gitDirectoryPath, { bigint: true }); + if (gitDirectory.isSymbolicLink() || !gitDirectory.isDirectory()) { + throw new Error("The worktree Git directory is unsafe"); + } + const checkoutFingerprint = + repositoryCommonDirectoryFingerprint(executionMetadata); + const gitDirectoryFingerprint = + repositoryCommonDirectoryFingerprint(gitDirectory); + if (dotGit.isDirectory() && checkoutFingerprint !== gitDirectoryFingerprint) { + throw new Error("The checkout Git directory changed during inspection"); + } + const source = `${checkoutFingerprint}\n${gitDirectoryFingerprint}`; + return { + id: `worktree_${sha256(`git-dir:${source}`).slice(0, 24)}`, + fingerprint: sha256(`git-dir:${source}`), + }; +} + +function readGit(projectRoot: string, argv: string[]): string | null { + const result = spawnSync("git", ["-C", projectRoot, ...argv], { + encoding: "utf8", + env: { + ...process.env, + GIT_OPTIONAL_LOCKS: "0", + }, + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0 || typeof result.stdout !== "string") { + return null; + } + return result.stdout.trim() || null; +} + +export function resolveRepositoryIdentitySync( + projectRoot: string +): RepositoryIdentity | null { + const topLevel = readGit(projectRoot, ["rev-parse", "--show-toplevel"]); + if ( + !topLevel || + canonicalRepositoryPath(topLevel) !== canonicalRepositoryPath(projectRoot) + ) { + return null; + } + return repositoryIdentityFromGitFacts({ + projectRoot, + originUrl: readGit(projectRoot, ["remote", "get-url", "origin"]), + remoteUrls: readGitRemoteUrls(projectRoot), + rootCommit: readGit(projectRoot, ["rev-list", "--max-parents=0", "HEAD"]), + commonDir: readGit(projectRoot, ["rev-parse", "--git-common-dir"]), + }); +} + +export function resolveRepositoryExecutionIdentitySync( + projectRoot: string +): RepositoryExecutionIdentity | null { + const identity = resolveRepositoryIdentitySync(projectRoot); + if (!identity) { + return null; + } + return repositoryExecutionIdentity(projectRoot); +} diff --git a/src/setup.test.ts b/src/setup.test.ts index fe4ba563..b00acf3f 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -186,6 +186,23 @@ async function runCli(args: { return { code, stdout, stderr }; } +async function snapshotFiles(root: string): Promise<[string, string][]> { + const entries: [string, string][] = []; + for await (const pathValue of new Bun.Glob("**/*").scan({ + cwd: root, + dot: true, + onlyFiles: true, + })) { + entries.push([ + pathValue, + Buffer.from(await Bun.file(join(root, pathValue)).arrayBuffer()).toString( + "base64" + ), + ]); + } + return entries.sort(([left], [right]) => left.localeCompare(right)); +} + describe("zero-config setup", () => { it("dry-runs a fresh isolated home without writing or failing", async () => { const home = await tempHome("fclt-setup-dry-"); @@ -243,6 +260,158 @@ describe("zero-config setup", () => { expect(await Bun.file(join(home, ".ai")).exists()).toBe(false); }); + it("keeps every project mutation path zero-write under common --dry-run", async () => { + const home = await tempHome("fclt-project-dry-run-"); + const repo = await initRepo(home); + const preview = await runCli({ + home, + cwd: repo, + argv: ["project", "init", "--project-root", repo, "--json"], + }); + expect(preview.code).toBe(0); + const plan = JSON.parse(preview.stdout) as { planSha256: string }; + + const initDryRun = await runCli({ + home, + cwd: repo, + argv: [ + "project", + "init", + "--project-root", + repo, + "--apply", + "--plan-sha", + plan.planSha256, + "--dry-run", + "--json", + ], + }); + expect(initDryRun.code).toBe(0); + expect( + (JSON.parse(initDryRun.stdout) as { planSha256: string }).planSha256 + ).toBe(plan.planSha256); + expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + expect(await Bun.file(join(home, ".local-state")).exists()).toBe(false); + + const applied = await runCli({ + home, + cwd: repo, + argv: [ + "project", + "init", + "--project-root", + repo, + "--apply", + "--plan-sha", + plan.planSha256, + "--json", + ], + }); + expect(applied.code).toBe(0); + const receiptId = (JSON.parse(applied.stdout) as { receiptId: string }) + .receiptId; + const before = await snapshotFiles(home); + + const rollback = await runCli({ + home, + cwd: repo, + argv: [ + "project", + "rollback", + "--receipt", + receiptId, + "--apply", + "--dry-run", + "--json", + ], + }); + expect(rollback.code).toBe(0); + expect((JSON.parse(rollback.stdout) as { applied: boolean }).applied).toBe( + false + ); + const decisions = { + disable: "disabled", + ignore: "ignored", + inactive: "inactive", + remove: "removed", + } as const; + for (const [command, decision] of Object.entries(decisions)) { + const result = await runCli({ + home, + cwd: repo, + argv: [ + "project", + command, + "--project-root", + repo, + "--dry-run", + "--json", + ], + }); + expect(result.code).toBe(0); + expect((JSON.parse(result.stdout) as { decision: string }).decision).toBe( + decision + ); + } + expect(await snapshotFiles(home)).toEqual(before); + }, 20_000); + + it("shell-quotes project repair paths as executable argv", async () => { + if (process.platform === "win32") { + return; + } + const home = await tempHome("fclt-setup-quote-"); + const repo = join(home, "repo' ; touch INJECTED ; #"); + await mkdir(repo, { recursive: true }); + await runFixtureGit({ + argv: ["init", "--quiet", repo], + repoDir: repo, + homeDir: join(home, ".git-home"), + }); + const result = await bootstrapFclt({ + homeDir: home, + cwd: repo, + includeProject: true, + installCodexPlugin: false, + dryRun: true, + }); + const action = result.repairActions.find( + (candidate) => + candidate.scope === "project" && + candidate.command.includes("--plan-sha") + ); + expect(action).toBeDefined(); + const planSha256 = result.projectEnrollmentPlan?.planSha256; + expect(planSha256).toBeDefined(); + + const binDir = join(home, "bin"); + const fakeFclt = join(binDir, "fclt"); + await mkdir(binDir, { recursive: true }); + await writeFile(fakeFclt, '#!/bin/sh\nprintf "%s\\n" "$@"\n', "utf8"); + await chmod(fakeFclt, 0o755); + const proc = Bun.spawn(["/bin/sh", "-c", action?.command ?? ""], { + cwd: home, + env: { ...process.env, PATH: `${binDir}:/usr/bin:/bin` }, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + ]); + expect(exitCode).toBe(0); + expect(stdout.trim().split("\n")).toEqual([ + "project", + "init", + "--project-root", + repo, + "--apply", + "--plan-sha", + planSha256 ?? "", + ]); + expect(await Bun.file(join(home, "INJECTED")).exists()).toBe(false); + }); + it("preserves an invalid reconciliation config and reports repair", async () => { const home = await tempHome("fclt-setup-invalid-reconciliation-"); const root = join(home, ".ai"); @@ -346,6 +515,24 @@ describe("zero-config setup", () => { ); expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); + const textPreview = await runCli({ + home, + cwd: repo, + argv: ["setup", "--include-project", "--dry-run", "--no-codex-plugin"], + }); + expect(textPreview.code).toBe(0); + const serializedPlan = JSON.stringify( + planned.projectEnrollmentPlan, + null, + 2 + ); + const planOffset = textPreview.stdout.indexOf(serializedPlan); + const applyOffset = textPreview.stdout.indexOf( + "fclt project init --project-root" + ); + expect(planOffset).toBeGreaterThanOrEqual(0); + expect(applyOffset).toBeGreaterThan(planOffset); + const apply = await runCli({ home, cwd: repo, @@ -491,7 +678,7 @@ describe("zero-config setup", () => { 'import { dirname, join } from "node:path";', "const argv = process.argv.slice(2);", 'const pluginId = "fclt@hack-local";', - 'const version = "0.1.2";', + 'const version = "0.1.3";', 'const installedPath = join(process.env.HOME, ".codex", "plugins", "cache", "hack-local", "fclt", version);', 'mkdirSync(join(process.env.HOME, ".codex"), { recursive: true });', 'writeFileSync(join(process.env.HOME, ".codex", "config.toml"), `[plugins."` + pluginId + `"]\nenabled = true\n`);', diff --git a/src/setup.ts b/src/setup.ts index 34bbb4d6..c87a9ca2 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -2,9 +2,12 @@ import { homedir } from "node:os"; import { resolve } from "node:path"; import { refreshAiReviewArtifacts } from "./ai"; import { resolveCliContextRoot } from "./cli-context"; -import { buildDoctorReport, type DoctorReport } from "./doctor"; +import { buildDoctorReport, type DoctorReport, shellQuote } from "./doctor"; import { type SetupCodexPluginResult, setupCodexPlugin } from "./manage"; -import { facultAiReconciliationConfigPath } from "./paths"; +import { + facultAiReconciliationConfigPath, + pathsPhysicallyEquivalent, +} from "./paths"; import { type ProjectEnrollmentPlan, planProjectEnrollment } from "./projects"; import { initializeReconciliationConfig } from "./reconciliation-config"; import { @@ -93,8 +96,9 @@ export async function bootstrapFclt( const includeProject = opts.includeProject ?? false; const projectCandidateRoot = resolve(detectedProject ?? cwd, ".ai"); const projectTargetsGlobalRoot = - (detectedProject !== null && resolve(detectedProject) === globalRoot) || - projectCandidateRoot === globalRoot; + (detectedProject !== null && + pathsPhysicallyEquivalent(detectedProject, globalRoot)) || + pathsPhysicallyEquivalent(projectCandidateRoot, globalRoot); const projectRoot = includeProject && !projectTargetsGlobalRoot ? projectCandidateRoot : null; const changedPaths: string[] = []; @@ -180,7 +184,7 @@ export async function bootstrapFclt( ? [ { scope: "project" as const, - command: `fclt project init --project-root '${projectEnrollmentPlan.projectRoot}' --apply --plan-sha ${projectEnrollmentPlan.planSha256}`, + command: `fclt project init --project-root ${shellQuote(projectEnrollmentPlan.projectRoot)} --apply --plan-sha ${projectEnrollmentPlan.planSha256}`, reason: "Review the project enrollment plan before applying its minimal canonical layer.", }, @@ -289,6 +293,10 @@ export async function setupCommand(argv: string[]): Promise { console.log(`global: ${result.globalRoot}`); console.log(`project: ${result.projectRoot ?? "(none)"}`); console.log(`changed: ${result.changedPaths.length}`); + if (result.projectEnrollmentPlan) { + console.log("project enrollment plan:"); + console.log(JSON.stringify(result.projectEnrollmentPlan, null, 2)); + } if (result.repairActions.length > 0) { console.log("next actions:"); for (const action of result.repairActions) { From 60cd660ab4b0fef242860d081306705b6a2cdd11 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Wed, 29 Jul 2026 13:26:35 -0400 Subject: [PATCH 3/6] fix: close final onboarding review gaps --- src/projects.test.ts | 49 +++++++++++------ src/projects.ts | 125 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 150 insertions(+), 24 deletions(-) diff --git a/src/projects.test.ts b/src/projects.test.ts index 783083c6..cd7583a1 100644 --- a/src/projects.test.ts +++ b/src/projects.test.ts @@ -177,7 +177,7 @@ describe("project discovery", () => { expect(await Bun.file(join(repo, ".ai")).exists()).toBe(false); }); - it("skips an abandoned Git candidate without losing healthy discovery results", async () => { + it("propagates corrupt Git candidates instead of reporting complete discovery", async () => { const { root, home } = await makeFixture(); const portfolio = join(root, "portfolio"); const healthy = join(portfolio, "healthy"); @@ -190,18 +190,14 @@ describe("project discovery", () => { "utf8" ); - const discovery = await discoverProjects({ - roots: [portfolio], - homeDir: home, - maxVisits: 100, - maxResults: 10, - }); - - expect(discovery.projects.map((project) => project.root)).toEqual([ - healthy, - ]); - expect(discovery.groups).toHaveLength(1); - expect(discovery.bounds.truncated).toBe(false); + await expect( + discoverProjects({ + roots: [portfolio], + homeDir: home, + maxVisits: 100, + maxResults: 10, + }) + ).rejects.toThrow("Git repository inspection failed"); }); it("rejects missing explicit roots instead of reporting complete empty discovery", async () => { @@ -1675,6 +1671,15 @@ Read \\server\share\private.toml. "docs/windows-forward-unc.md": "# Local\n\nRead //server/share/private.toml.\n", "docs/secret.md": "# Secret\n\napi_key = abcdefghijklmnop\n", + "docs/github-token.md": + "# Secret\n\nghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ\n", + "docs/github-fine-grained-token.md": + "# Secret\n\ngithub_pat_11AA00_exampleExampleExampleExample\n", + "docs/github-stateless-token.md": `# Secret + +ghs_APP_ID.${"a".repeat(240)}.${"b".repeat(240)} +`, + "docs/aws-token.md": "# Secret\n\nAKIAIOSFODNN7EXAMPLE\n", }, }); await mkdir(join(repo, ".ai"), { recursive: true }); @@ -1730,6 +1735,20 @@ Read \\server\share\private.toml. guidance: ["docs/secret.md"], }) ).rejects.toThrow("secret-shaped content"); + for (const guidance of [ + "docs/github-token.md", + "docs/github-fine-grained-token.md", + "docs/github-stateless-token.md", + "docs/aws-token.md", + ]) { + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: [guidance], + }) + ).rejects.toThrow("secret-shaped content"); + } }); it("preserves existing ignore rules and versioned canonical config", async () => { @@ -4527,7 +4546,7 @@ describe("project enrollment lifecycle", () => { await writeFile(gitDir, "gitdir: /definitely/missing/git-dir\n", "utf8"); try { await expect(buildProjectsStatus({ homeDir: home })).rejects.toThrow( - "Not a Git repository" + "Git repository inspection failed" ); const reEnrollment = await planProjectEnrollment({ projectRoot: secondClone, @@ -4540,7 +4559,7 @@ describe("project enrollment lifecycle", () => { expectedPlanSha256: reEnrollment.planSha256, homeDir: home, }) - ).rejects.toThrow("Not a Git repository"); + ).rejects.toThrow("Git repository inspection failed"); expect(await readFile(first.registryPath, "utf8")).toBe(registryBefore); } finally { await rm(gitDir); diff --git a/src/projects.ts b/src/projects.ts index 385a7b5a..de76822c 100644 --- a/src/projects.ts +++ b/src/projects.ts @@ -95,6 +95,10 @@ const PROTECTIVE_IGNORE_LINES = [ ]; const SECRET_SHAPE_RE = /(?:api[_-]?key|access[_-]?token|client[_-]?secret|password)\s*[:=]\s*["']?[^\s"'#]{8,}/i; +const STANDALONE_CREDENTIAL_RE = + /\b(?:gh[pour]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255}|(?:AKIA|ASIA)[A-Z0-9]{16}|AIza[0-9A-Za-z_-]{35}|(?:sk|rk)_live_[0-9A-Za-z]{16,}|xox[baprs]-[0-9A-Za-z-]{10,})\b/; +const GITHUB_STATELESS_TOKEN_RE = + /(?:^|[^A-Za-z0-9_])ghs_[A-Za-z0-9._-]{36,}(?=$|[^A-Za-z0-9._-])/m; const PRIVATE_KEY_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/; const LOCAL_UNIX_ABSOLUTE_PATH_RE = /(?:^|[\s"'`([{=>:])\/(?!\/)(?=[^\s"'`)\]}>])/m; @@ -836,12 +840,29 @@ function filePreconditionFromSnapshot( } async function gitRoot(pathValue: string): Promise { + const resolvedPath = resolve(pathValue); const result = await runGit({ - cwd: resolve(pathValue), + cwd: resolvedPath, argv: ["rev-parse", "--show-toplevel"], }); if (result.exitCode !== 0 || !result.stdout) { - throw new Error(`Not a Git repository: ${resolve(pathValue)}`); + try { + await lstat(join(resolvedPath, ".git")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Not a Git repository: ${resolvedPath}`); + } + throw new Error( + `Git repository inspection failed for ${resolvedPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + throw new Error( + `Git repository inspection failed for ${resolvedPath}: ${ + result.stderr || "git rev-parse returned no repository root" + }` + ); } return await realpath(result.stdout).catch(() => resolve(result.stdout)); } @@ -897,7 +918,10 @@ export async function resolveRepositoryExecutionIdentity( async function inspectRepository( rootValue: string, homeDir?: string, - options?: { stabilizeIdentity?: boolean } + options?: { + stabilizeIdentity?: boolean; + stabilizationContext?: RepositoryStabilizationContext; + } ): Promise { const root = await gitRoot(rootValue); const [rawIdentity, branch, head, lastCommit, statusResult] = @@ -918,6 +942,7 @@ async function inspectRepository( identity: rawIdentity, projectRoot: root, homeDir: resolve(homeDir ?? process.env.HOME ?? homedir()), + context: options?.stabilizationContext, }); const aiRoot = join(root, ".ai"); return { @@ -1024,10 +1049,17 @@ export async function discoverProjects(args: { } const cutoff = parseSince(args.since, args.now ?? new Date()); const discovered = await discoverGitRoots({ roots, maxVisits, maxResults }); + const homeDir = resolve(args.homeDir ?? process.env.HOME ?? homedir()); + const stabilizationContext: RepositoryStabilizationContext = { + registry: await loadRegistry(homeDir), + portableAliasProofs: new Map(), + }; const inspected = await Promise.all( discovered.roots.map(async (root) => { try { - return await inspectRepository(root, args.homeDir); + return await inspectRepository(root, homeDir, { + stabilizationContext, + }); } catch (error) { if ( error instanceof Error && @@ -1453,7 +1485,12 @@ function privacyFindings( options?: { gitIgnorePatterns?: boolean } ): string[] { const findings: string[] = []; - if (SECRET_SHAPE_RE.test(content) || PRIVATE_KEY_RE.test(content)) { + if ( + SECRET_SHAPE_RE.test(content) || + STANDALONE_CREDENTIAL_RE.test(content) || + GITHUB_STATELESS_TOKEN_RE.test(content) || + PRIVATE_KEY_RE.test(content) + ) { findings.push("secret-shaped content"); } const filteredContent = options?.gitIgnorePatterns @@ -2878,15 +2915,57 @@ async function verifiedPortableAliases(args: { return verified; } +interface RepositoryStabilizationContext { + registry: ProjectRegistry; + portableAliasProofs: Map>>; +} + +function requiresPortableAliasProof(args: { + entry: ProjectRegistryEntry; + identity: RepositoryIdentity; + key: string; +}): boolean { + return ( + args.identity.stability === "portable" && + args.key !== args.identity.id && + args.entry.repositoryId !== args.identity.id && + (args.entry.aliases ?? []).includes(args.identity.id) + ); +} + +function cachedPortableAliases( + context: RepositoryStabilizationContext, + key: string, + entry: ProjectRegistryEntry +): Promise> { + const cached = context.portableAliasProofs.get(key); + if (cached) { + return cached; + } + const proof = verifiedPortableAliases({ entry, key }); + context.portableAliasProofs.set(key, proof); + return proof; +} + async function stabilizeRepositoryIdentity(args: { homeDir: string; identity: RepositoryIdentity; projectRoot: string; + context?: RepositoryStabilizationContext; }): Promise { - const registry = await loadRegistry(args.homeDir); + const context = args.context ?? { + registry: await loadRegistry(args.homeDir), + portableAliasProofs: new Map>>(), + }; const matches: [string, ProjectRegistryEntry][] = []; - for (const [key, entry] of Object.entries(registry.projects)) { - const portableAliases = await verifiedPortableAliases({ entry, key }); + for (const [key, entry] of Object.entries(context.registry.projects)) { + const portableAliases = requiresPortableAliasProof({ + entry, + identity: args.identity, + key, + }) + ? await cachedPortableAliases(context, key, entry) + : undefined; if ( registryEntryMatchesIdentity({ verifiedPortableAliases: portableAliases, @@ -3141,6 +3220,22 @@ interface ProjectMutationLockObservation { owner: ProjectMutationLockOwner | null; } +function projectMutationLockObservationRaced( + error: unknown, + lockPath: string +): boolean { + if (!(error instanceof Error)) { + return false; + } + const ownerPath = projectMutationLockOwnerPath(lockPath); + return ( + error.message === + `Project enrollment mutation lock changed while reading its owner: ${lockPath}` || + (error.message.startsWith("Canonical project file changed ") && + error.message.endsWith(`: ${ownerPath}`)) + ); +} + async function observeProjectMutationLock( lockPath: string ): Promise { @@ -3307,7 +3402,19 @@ async function withProjectsMutationLock( throw error; } } - const observed = await observeProjectMutationLock(lockPath); + let observed: ProjectMutationLockObservation | null; + try { + observed = await observeProjectMutationLock(lockPath); + } catch (error) { + if (projectMutationLockObservationRaced(error, lockPath)) { + // The winner may be publishing or releasing its lock. Waiting is + // safe; reclaiming or treating a transient observation as fatal is + // not. + await Bun.sleep(PROJECT_MUTATION_LOCK_RETRY_MS); + continue; + } + throw error; + } if (!observed) { continue; } From 3ac723bdf15e9b31f50ee5b33aeca5531aad659e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Wed, 29 Jul 2026 13:33:20 -0400 Subject: [PATCH 4/6] fix: retry mutation lock observation races --- src/projects.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++++ src/projects.ts | 54 ++++++++++++++++++++++++++++---------------- 2 files changed, 87 insertions(+), 19 deletions(-) diff --git a/src/projects.test.ts b/src/projects.test.ts index cd7583a1..7767407e 100644 --- a/src/projects.test.ts +++ b/src/projects.test.ts @@ -3745,6 +3745,58 @@ describe("project enrollment lifecycle", () => { expect(await Bun.file(lockPath).exists()).toBe(false); }); + it("does not expose observation races during concurrent abandoned-lock recovery", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + const lockPath = + plan.machineLocalWrites.find((write) => + write.path.endsWith("mutation.lock") + )?.path ?? ""; + await mkdir(lockPath, { recursive: true }); + await writeFile( + join(lockPath, "owner.json"), + `${JSON.stringify({ + version: 2, + endpoint: join(tmpdir(), "fclt-concurrent-abandoned-owner.sock"), + ownerId: "concurrent-abandoned-owner", + pid: 999_999, + acquiredAt: "2026-07-28T00:00:00.000Z", + transport: "ipc-socket", + })}\n`, + "utf8" + ); + + const results = await Promise.allSettled( + Array.from({ length: 25 }, () => + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }) + ) + ); + expect( + results.filter((result) => result.status === "fulfilled") + ).toHaveLength(1); + const rejected = results.filter( + (result): result is PromiseRejectedResult => result.status === "rejected" + ); + expect(rejected).toHaveLength(24); + expect( + rejected.every( + (result) => + result.reason instanceof Error && + result.reason.message.includes("Enrollment plan is stale") + ) + ).toBe(true); + expect(await Bun.file(lockPath).exists()).toBe(false); + }); + it("fails safe when a live owner is IPC-unresponsive or its PID was reused", async () => { if (process.platform === "win32") { return; diff --git a/src/projects.ts b/src/projects.ts index de76822c..60d17777 100644 --- a/src/projects.ts +++ b/src/projects.ts @@ -3220,20 +3220,16 @@ interface ProjectMutationLockObservation { owner: ProjectMutationLockOwner | null; } -function projectMutationLockObservationRaced( - error: unknown, - lockPath: string -): boolean { - if (!(error instanceof Error)) { - return false; +class ProjectMutationLockObservationRaceError extends Error { + constructor(lockPath: string, options?: { cause?: unknown }) { + super( + `Project enrollment mutation lock changed during observation: ${lockPath}`, + { + cause: options?.cause, + } + ); + this.name = "ProjectMutationLockObservationRaceError"; } - const ownerPath = projectMutationLockOwnerPath(lockPath); - return ( - error.message === - `Project enrollment mutation lock changed while reading its owner: ${lockPath}` || - (error.message.startsWith("Canonical project file changed ") && - error.message.endsWith(`: ${ownerPath}`)) - ); } async function observeProjectMutationLock( @@ -3249,7 +3245,21 @@ async function observeProjectMutationLock( ); } const ownerPath = projectMutationLockOwnerPath(lockPath); - const ownerSnapshot = await canonicalFileSnapshot(ownerPath); + let ownerSnapshot: CanonicalFileSnapshot | null; + try { + ownerSnapshot = await canonicalFileSnapshot(ownerPath); + } catch (error) { + if ( + error instanceof Error && + (error.message.startsWith("Canonical project file changed ") || + error.message.startsWith("Canonical project file parent changed ")) + ) { + throw new ProjectMutationLockObservationRaceError(lockPath, { + cause: error, + }); + } + throw error; + } const lockAfter = await lstatIfExists(lockPath); if ( !lockAfter || @@ -3257,9 +3267,7 @@ async function observeProjectMutationLock( !lockAfter.isDirectory() || !canonicalMetadataMatches(lock, lockAfter) ) { - throw new Error( - `Project enrollment mutation lock changed while reading its owner: ${lockPath}` - ); + throw new ProjectMutationLockObservationRaceError(lockPath); } if (!ownerSnapshot) { return { @@ -3309,7 +3317,15 @@ async function reclaimAbandonedProjectMutationLock(args: { } const quarantine = `${args.lockPath}.abandoned-${randomUUID()}`; try { - const current = await observeProjectMutationLock(args.lockPath); + let current: ProjectMutationLockObservation | null; + try { + current = await observeProjectMutationLock(args.lockPath); + } catch (error) { + if (error instanceof ProjectMutationLockObservationRaceError) { + return false; + } + throw error; + } if ( !( current?.owner && @@ -3406,7 +3422,7 @@ async function withProjectsMutationLock( try { observed = await observeProjectMutationLock(lockPath); } catch (error) { - if (projectMutationLockObservationRaced(error, lockPath)) { + if (error instanceof ProjectMutationLockObservationRaceError) { // The winner may be publishing or releasing its lock. Waiting is // safe; reclaiming or treating a transient observation as fatal is // not. From df4ffacba0339451cd283c8804bc7c697d4c9080 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Wed, 29 Jul 2026 13:40:21 -0400 Subject: [PATCH 5/6] fix: bound enrollment preview inputs --- src/projects.test.ts | 10 ++++++++- src/projects.ts | 52 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/projects.test.ts b/src/projects.test.ts index 7767407e..f12ce932 100644 --- a/src/projects.test.ts +++ b/src/projects.test.ts @@ -1680,6 +1680,7 @@ Read \\server\share\private.toml. ghs_APP_ID.${"a".repeat(240)}.${"b".repeat(240)} `, "docs/aws-token.md": "# Secret\n\nAKIAIOSFODNN7EXAMPLE\n", + "docs/oversized.md": `# Oversized\n\n${"a".repeat(1024 * 1024)}\n`, }, }); await mkdir(join(repo, ".ai"), { recursive: true }); @@ -1735,6 +1736,13 @@ ghs_APP_ID.${"a".repeat(240)}.${"b".repeat(240)} guidance: ["docs/secret.md"], }) ).rejects.toThrow("secret-shaped content"); + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + guidance: ["docs/oversized.md"], + }) + ).rejects.toThrow("exceeds the 1 MiB preview limit"); for (const guidance of [ "docs/github-token.md", "docs/github-fine-grained-token.md", @@ -4756,7 +4764,7 @@ describe("project enrollment lifecycle", () => { await rm(receiptPath); await writeFile(receiptPath, receipt, "utf8"); - await truncate(receiptPath, 1024 * 1024 + 1); + await truncate(receiptPath, 24 * 1024 * 1024 + 1); await expect( rollbackProjectEnrollment({ receiptId: applied.receiptId, diff --git a/src/projects.ts b/src/projects.ts index 60d17777..39c62918 100644 --- a/src/projects.ts +++ b/src/projects.ts @@ -127,6 +127,8 @@ const PLAN_SHA_RE = /^[a-f0-9]{64}$/; const PROJECT_MUTATION_LOCK_ATTEMPTS = 500; const PROJECT_MUTATION_LOCK_RETRY_MS = 10; const PROJECT_CANONICAL_FILE_MAX_BYTES = 1024 * 1024; +const PROJECT_GUIDANCE_FILE_MAX_BYTES = 1024 * 1024; +const PROJECT_RECEIPT_FILE_MAX_BYTES = 24 * 1024 * 1024; const PROJECT_GENERATED_FILE_MAX_BYTES = 64 * 1024 * 1024; const PROJECT_STATE_TREE_MAX_BYTES = 256 * 1024 * 1024; const PROJECT_STATE_TREE_MAX_ENTRIES = 32_768; @@ -673,7 +675,8 @@ function canonicalMetadataMatches(left: Stats, right: Stats): boolean { } async function canonicalFileSnapshot( - pathValue: string + pathValue: string, + maxBytes = PROJECT_CANONICAL_FILE_MAX_BYTES ): Promise { const parentPath = dirname(pathValue); const parentBefore = await lstatIfExists(parentPath); @@ -714,7 +717,7 @@ async function canonicalFileSnapshot( pathMetadata.nlink !== 1 || !Number.isSafeInteger(pathMetadata.size) || pathMetadata.size < 0 || - pathMetadata.size > PROJECT_CANONICAL_FILE_MAX_BYTES + pathMetadata.size > maxBytes ) { throw new Error(`Refusing unsafe canonical file: ${pathValue}`); } @@ -1587,13 +1590,40 @@ async function previewGuidance(args: { opened.isSymbolicLink() || opened.nlink !== 1 || opened.dev !== guidanceStat.dev || - opened.ino !== guidanceStat.ino + opened.ino !== guidanceStat.ino || + !Number.isSafeInteger(opened.size) || + opened.size < 0 || + opened.size > PROJECT_GUIDANCE_FILE_MAX_BYTES ) { throw new Error( - `Refusing guidance adoption from ${pathValue}: the source changed before read` + opened.size > PROJECT_GUIDANCE_FILE_MAX_BYTES + ? `Refusing guidance adoption from ${pathValue}: the source exceeds the 1 MiB preview limit` + : `Refusing guidance adoption from ${pathValue}: the source changed before read` + ); + } + const bytes = Buffer.alloc(opened.size); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await handle.read( + bytes, + offset, + bytes.length - offset, + offset + ); + if (bytesRead === 0) { + throw new Error( + `Refusing guidance adoption from ${pathValue}: the source changed while reading` + ); + } + offset += bytesRead; + } + const trailing = Buffer.alloc(1); + if ((await handle.read(trailing, 0, 1, opened.size)).bytesRead !== 0) { + throw new Error( + `Refusing guidance adoption from ${pathValue}: the source changed while reading` ); } - content = await handle.readFile("utf8"); + content = bytes.toString("utf8"); const afterRead = await handle.stat(); const rebound = await lstatIfExists(absolutePath); if ( @@ -4489,6 +4519,13 @@ export async function applyProjectEnrollment(args: { }), }; const receiptContent = `${JSON.stringify(receipt, null, 2)}\n`; + if ( + Buffer.byteLength(receiptContent) > PROJECT_RECEIPT_FILE_MAX_BYTES + ) { + throw new Error( + "Enrollment receipt exceeds the supported transaction size" + ); + } const receiptArtifact = await captureArtifact({ path: receiptPath, afterContent: receiptContent, @@ -4631,7 +4668,10 @@ async function readReceipt(args: { projectReceiptsDir(args.homeDir), `${args.receiptId}.json` ); - const snapshot = await canonicalFileSnapshot(pathValue); + const snapshot = await canonicalFileSnapshot( + pathValue, + PROJECT_RECEIPT_FILE_MAX_BYTES + ); if (!snapshot) { throw new Error(`Invalid enrollment receipt: ${args.receiptId}`); } From 8e28fe4bb846ed94c4357fda38538398aa128697 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Wed, 29 Jul 2026 13:44:57 -0400 Subject: [PATCH 6/6] fix: fail closed for unsupported project setup --- src/remote.test.ts | 15 +++++++++++++++ src/remote.ts | 7 +++++++ src/setup.test.ts | 15 +++++++++++++++ src/setup.ts | 5 +++++ 4 files changed, 42 insertions(+) diff --git a/src/remote.test.ts b/src/remote.test.ts index 68721f5a..b2b852a1 100644 --- a/src/remote.test.ts +++ b/src/remote.test.ts @@ -2106,6 +2106,21 @@ describe("templates command", () => { ); }); + it("rejects unsupported Windows project installation before full-pack writes", async () => { + const { home } = await makeTempRoot(); + const repoDir = join(home, "repo"); + await initializeGitRepository(repoDir, home); + + await expect( + scaffoldBuiltinOperatingModelPack({ + homeDir: home, + rootDir: join(repoDir, ".ai"), + platform: "win32", + }) + ).rejects.toThrow("unsupported on win32"); + expect(await Bun.file(join(repoDir, ".ai")).exists()).toBe(false); + }); + it("refuses a hard-linked project ignore leaf without changing its peer", async () => { const { home } = await makeTempRoot(); const repoDir = join(home, "repo"); diff --git a/src/remote.ts b/src/remote.ts index 0ef78355..d2668298 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -1797,6 +1797,8 @@ export async function scaffoldBuiltinOperatingModelPack(args: { force?: boolean; update?: boolean; installedAs?: string; + /** @internal Platform branch override for cross-platform regression tests. */ + platform?: NodeJS.Platform; /** @internal Adversarial test hook; production callers must not set this. */ beforeProjectIgnoreCommit?: () => Promise; }): Promise { @@ -1820,6 +1822,11 @@ export async function scaffoldBuiltinOperatingModelPack(args: { if (protectiveIgnore !== existingIgnore.content) { changedPaths.push(ignorePath); if (!args.dryRun) { + if ((args.platform ?? process.platform) === "win32") { + throw new Error( + "Project operating-model installation is unsupported on win32 because protective ignore replacement is unavailable" + ); + } await ensurePackDirectory(dirname(ignorePath)); await writeProjectAiIgnore( ignorePath, diff --git a/src/setup.test.ts b/src/setup.test.ts index b00acf3f..65d03ceb 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -556,6 +556,21 @@ describe("zero-config setup", () => { ); }, 20_000); + it("rejects requested project setup outside a Git checkout before writing", async () => { + const home = await tempHome("fclt-setup-no-project-"); + const before = await snapshotFiles(home); + + await expect( + bootstrapFclt({ + homeDir: home, + cwd: home, + includeProject: true, + installCodexPlugin: false, + }) + ).rejects.toThrow("outside a Git checkout"); + expect(await snapshotFiles(home)).toEqual(before); + }); + it("does not bootstrap a nested project inside a git-backed global root", async () => { const home = await tempHome("fclt-setup-global-root-"); const globalRoot = join(home, ".ai"); diff --git a/src/setup.ts b/src/setup.ts index c87a9ca2..95bade1d 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -94,6 +94,11 @@ export async function bootstrapFclt( }); const detectedProject = findGitRootFromPath(cwd); const includeProject = opts.includeProject ?? false; + if (includeProject && !detectedProject) { + throw new Error( + `Project setup was requested outside a Git checkout: ${cwd}` + ); + } const projectCandidateRoot = resolve(detectedProject ?? cwd, ".ai"); const projectTargetsGlobalRoot = (detectedProject !== null &&