diff --git a/README.md b/README.md index eb1d49b..1204d76 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,58 @@ manifests for drift, refuses unmanaged file conflicts unless `--force` is passed, removes stale managed mirrors only when safe, and writes local snapshots before mutating managed files. +### Managed project context + +`instructions project-context plan|apply` is the sole writer for the strict +`hasna.projects.project_context_bundle.v1` contract emitted by Projects. It +accepts bounded structured JSON from a regular file or stdin and never invokes +Projects, Todos, Conversations, or Mementos while rendering: + +```bash +projects context-bundle --json > ./project-context.json +instructions project-context plan \ + --runtime codewith \ + --workspace-root /absolute/workspace \ + --bundle ./project-context.json \ + --json +instructions project-context apply \ + --runtime codewith \ + --workspace-root /absolute/workspace \ + --bundle ./project-context.json \ + --json +``` + +The renderer writes one canonical `.hasna/instructions/project-context.md` +fragment, then a managed import in `CLAUDE.md`, a managed inline block in +`AGENTS.md`, and a managed inline block in `.codewith/CODEWITH.md` by default. +Codewith uses an import only when its existing +`HASNA_CONFIGS_CODEWITH_NATIVE_IMPORTS=1` capability gate is active or +`--codewith-native-imports` explicitly selects that supported runtime mode. +Bytes outside the managed marker pair are preserved. Codewith's +`.codewith/CODEWITH.override.md` takes precedence and causes the stable +`PROJECT_CONTEXT_SHADOWED` failure instead of an ignored write. +Existing Instructions session manifests are updated additively, and later +`instructions session plan|apply` runs recompose the validated durable project +context so routine Claude, Codewith, or Codex rerenders cannot discard it. + +Input is limited to 8 KiB, output to 4 KiB and six allowlisted argv commands. +The writer rejects unknown fields, hash/revision inconsistencies, credentials, +URLs, symlinks, malformed or conflicting markers, and older revisions. Applies +use a per-workspace lock, compare-and-swap hashes, same-directory fsynced temp +files and renames, and a metadata-only manifest written last. Existing-file +updates require an atomic exchange primitive (Linux `renameat2` or macOS +`renameatx_np`); unsupported platforms, including Windows, fail closed before +replacement rather than approximating an exchange. A same-project, +compatible last-known-good cache can be selected explicitly with +`--allow-stale-cache --expected-project-id `; its bounded age/status is +visible in the rendered context. + +Compatibility remains additive: project-context manifests keep +`hasna.configs.session-render/v1`, `Managed by @hasna/configs`, and +`ownedBy: open-configs`, while recording `canonicalOwner: instructions`. The +legacy `@hasna/configs` 0.2.45 `configs` executable remains available as a bin +alias; no separate Configs repository or flag-day manifest v2 is introduced. + `instructions init` and `bun run seed` also seed `global-agent-rules-standard`, the managed global/system prompt source for session renaming, task-scoped worktrees, PR-first landing, protected-branch diff --git a/hasna.contract.json b/hasna.contract.json index 5abc5b6..3082334 100644 --- a/hasna.contract.json +++ b/hasna.contract.json @@ -7,6 +7,7 @@ "description": "AI coding agent instruction & configuration manager (formerly @hasna/configs). CLI + MCP + HTTP API (instructions-serve) + generated SDK. Cloud mode (HASNA_INSTRUCTIONS_DATABASE_URL set) is PURE REMOTE per Amendment A1: the serve process reads/writes the shared cloud Postgres directly with API-key auth via @hasna/contracts. The MCP server keeps the legacy name 'configs' aliased for the existing fleet.", "bins": [ "instructions", + "configs", "instructions-mcp", "instructions-serve", "configs-mcp" diff --git a/package.json b/package.json index 859bc88..37b4d33 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "types": "dist/index.d.ts", "bin": { "instructions": "dist/cli/index.js", + "configs": "dist/cli/index.js", "instructions-mcp": "dist/mcp/index.js", "instructions-serve": "dist/server/index.js", "configs-mcp": "dist/mcp/index.js" diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 8107cc9..b96aab5 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -2,7 +2,7 @@ import { registerEventsCommands } from "@hasna/events/commander"; import { program } from "commander"; import chalk from "chalk"; -import { existsSync, readFileSync, writeSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, readSync, writeSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join, resolve } from "node:path"; import { applyConfig, applyConfigs, expandPath } from "../lib/apply.js"; @@ -19,6 +19,14 @@ import { ensurePlatformProfiles } from "../lib/platform-profiles.js"; import { ensureProjectDashboardStandardConfig } from "../lib/project-dashboard-standard.js"; import { ensureGlobalAgentRulesStandardConfig } from "../lib/global-agent-rules-standard.js"; import { ensureDangerousOperationGuardStandardConfig } from "../lib/dangerous-operation-guard-standard.js"; +import { + ProjectContextError, + PROJECT_CONTEXT_MAX_INPUT_BYTES, + applyProjectContext, + parseProjectContextBundle, + planProjectContext, + type ProjectContextRuntime, +} from "../lib/project-context.js"; import { getConfigsStatus } from "../status.js"; import { resolveConfigStore, isCloudMode, type ConfigStore } from "../data/config-store.js"; import { DEFAULT_LIST_LIMIT, paginate, parseLimit, truncateMiddle, truncateText } from "../lib/compact-output.js"; @@ -197,6 +205,62 @@ function planJsonForOutput(plan: SessionRenderPlan) { }; } +function parseProjectContextRuntime(value: string): ProjectContextRuntime { + if (value === "codex") return "agents"; + if (value === "claude" || value === "codewith" || value === "agents") return value; + throw new ProjectContextError("PROJECT_CONTEXT_INVALID", `unsupported runtime ${value}; expected claude|codewith|agents|codex`); +} + +function readProjectContextBundleOption(value: string | undefined, allowMissing = false): { json?: string; sourcePath?: string } { + if (value === undefined) return {}; + if (value === "-") return { json: readBoundedProjectContextStdin() }; + const path = resolveSessionPath(value); + if (!existsSync(path)) { + if (allowMissing) return {}; + throw new ProjectContextError("PROJECT_CONTEXT_INPUT_MISSING", `bundle file not found: ${path}`); + } + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new ProjectContextError("PROJECT_CONTEXT_SYMLINK_REJECTED", "bundle input must be a regular non-symlink file"); + } + if (stat.size > PROJECT_CONTEXT_MAX_INPUT_BYTES) { + throw new ProjectContextError("PROJECT_CONTEXT_INPUT_TOO_LARGE", `bundle exceeds ${PROJECT_CONTEXT_MAX_INPUT_BYTES} bytes`); + } + return { json: readFileSync(path, "utf8"), sourcePath: path }; +} + +function readBoundedProjectContextStdin(): string { + const chunks: Buffer[] = []; + const chunk = Buffer.allocUnsafe(4_096); + let total = 0; + while (true) { + const bytesRead = readSync(0, chunk, 0, chunk.length, null); + if (bytesRead === 0) break; + total += bytesRead; + if (total > PROJECT_CONTEXT_MAX_INPUT_BYTES) { + throw new ProjectContextError("PROJECT_CONTEXT_INPUT_TOO_LARGE", `bundle exceeds ${PROJECT_CONTEXT_MAX_INPUT_BYTES} bytes`); + } + chunks.push(Buffer.from(chunk.subarray(0, bytesRead))); + } + return Buffer.concat(chunks, total).toString("utf8"); +} + +function parsePositiveInteger(value: string | undefined, label: string): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) throw new ProjectContextError("PROJECT_CONTEXT_INVALID", `${label} must be a positive integer`); + return parsed; +} + +function printProjectContextFailure(error: unknown, json: boolean): void { + const normalized = error instanceof ProjectContextError + ? error + : new ProjectContextError("PROJECT_CONTEXT_FAILED", error instanceof Error ? error.message : String(error)); + if (json) printJson({ ok: false, error: { code: normalized.code, message: normalized.message } }); + else console.error(chalk.red(normalized.message)); + process.exitCode = 1; +} + function parseVarArgs(values?: string[]): ProfileVariables | undefined { if (!values || values.length === 0) return undefined; const vars: ProfileVariables = {}; @@ -714,6 +778,102 @@ profileCmd.command("delete ").description("Delete a profile").action(async ( } catch (e) { console.error(chalk.red(e instanceof Error ? e.message : String(e))); process.exit(1); } }); +// ── project-context ────────────────────────────────────────────────────────── +const projectContextCmd = program.command("project-context") + .description("Validate and atomically render a strict Projects context bundle"); + +projectContextCmd.command("plan") + .description("Validate a bundle and preview its bounded provider adapter without writing") + .requiredOption("--runtime ", "selected consumer (claude|codewith|agents|codex)") + .requiredOption("--workspace-root ", "absolute project or coordination workspace root") + .requiredOption("--bundle ", "durable v1 JSON file, or - for stdin") + .option("--codewith-native-imports", "declare that the selected Codewith runtime consumes native @ imports") + .option("--json", "output plan JSON") + .action((opts) => { + try { + const runtime = parseProjectContextRuntime(opts.runtime); + const input = readProjectContextBundleOption(opts.bundle); + const bundle = parseProjectContextBundle(input.json!); + const plan = planProjectContext({ + workspace_root: resolveSessionPath(opts.workspaceRoot), + runtime, + bundle, + source_path: input.sourcePath, + codewith_native_imports: opts.codewithNativeImports, + }); + const output = { + ok: true, + dry_run: true, + runtime: plan.runtime, + workspace_root: plan.workspace_root, + project_id: plan.bundle.project.id, + revision: plan.bundle.revision, + hash: plan.bundle.hash, + status: plan.status, + age_seconds: plan.age_seconds, + target_path: plan.target_path, + fragment_path: plan.fragment_path, + manifest_path: plan.manifest_path, + rendered_bytes: Buffer.byteLength(plan.fragment, "utf8"), + included_commands: plan.included_commands, + warnings: plan.warnings, + }; + if (opts.json) printJson(output); + else { + console.log(chalk.bold("project context render plan")); + console.log(`${chalk.cyan("runtime:")} ${output.runtime}`); + console.log(`${chalk.cyan("project:")} ${output.project_id} @ ${output.revision}`); + console.log(`${chalk.cyan("target:")} ${output.target_path}`); + console.log(`${chalk.cyan("fragment:")} ${output.fragment_path} (${output.rendered_bytes} bytes)`); + console.log(chalk.dim("Dry run only. No files were written.")); + } + } catch (error) { + printProjectContextFailure(error, opts.json === true); + } + }); + +projectContextCmd.command("apply") + .description("Atomically write project context with cache, CAS, and manifest-last semantics") + .requiredOption("--runtime ", "selected consumer (claude|codewith|agents|codex)") + .requiredOption("--workspace-root ", "absolute project or coordination workspace root") + .option("--bundle ", "durable v1 JSON file, or - for stdin") + .option("--expected-project-id ", "required same-ID guard for stale-cache fallback") + .option("--allow-stale-cache", "use a compatible same-ID last-known-good cache when input is unavailable or a newer major") + .option("--max-stale-age-seconds ", "bounded cache age (default 3600, maximum 604800)") + .option("--codewith-native-imports", "declare that the selected Codewith runtime consumes native @ imports") + .option("--force", "repair malformed or mismatched managed markers while preserving bytes outside the forced range") + .option("--dry-run", "validate and preview without writing") + .option("--json", "output apply JSON") + .action((opts) => { + try { + const runtime = parseProjectContextRuntime(opts.runtime); + const input = readProjectContextBundleOption(opts.bundle, opts.allowStaleCache === true); + const result = applyProjectContext({ + workspace_root: resolveSessionPath(opts.workspaceRoot), + runtime, + bundle_json: input.json, + source_path: input.sourcePath, + expected_project_id: opts.expectedProjectId, + allow_stale_cache: opts.allowStaleCache, + max_stale_age_seconds: parsePositiveInteger(opts.maxStaleAgeSeconds, "max stale age"), + codewith_native_imports: opts.codewithNativeImports, + force: opts.force, + dry_run: opts.dryRun, + }); + if (opts.json) printJson({ ok: true, ...result }); + else { + const prefix = result.dry_run ? chalk.yellow("[dry-run]") : chalk.green("OK"); + console.log(`${prefix} project context ${result.runtime}`); + console.log(`${chalk.cyan("project:")} ${result.project_id} @ ${result.revision}`); + console.log(`${chalk.cyan("status:")} ${result.status} (${result.age_seconds}s)`); + console.log(`${chalk.cyan("target:")} ${result.target_path}`); + console.log(`${chalk.cyan("manifest:")} ${result.manifest_path}`); + } + } catch (error) { + printProjectContextFailure(error, opts.json === true); + } + }); + // ── session ────────────────────────────────────────────────────────────────── const sessionCmd = program.command("session").description("Plan and apply session-scoped agent instruction files"); @@ -728,6 +888,7 @@ sessionCmd.command("plan") .option("--config ", "stored config source by id/slug; repeatable; layer aliases match --source", collectOption, []) .option("--identity-export ", "OpenIdentities configs instruction export JSON; repeatable", collectOption, []) .option("--replace-source ", "source id that replaces earlier layers instead of appending", collectOption, []) + .option("--codewith-native-imports", "select the gated Codewith native @ import adapter") .option("--allow-empty-sources", "allow an explicit empty render plan") .option("--json", "output dry-run JSON") .action(async (opts) => { @@ -744,6 +905,7 @@ sessionCmd.command("plan") targetHome: opts.targetHome, projectRoot: opts.projectRoot, sessionId: opts.sessionId, + codewithNativeImports: opts.codewithNativeImports, allowEmptySources: opts.allowEmptySources, sources, }); @@ -784,6 +946,7 @@ sessionCmd.command("apply") .option("--config ", "stored config source by id/slug; repeatable; layer aliases match --source", collectOption, []) .option("--identity-export ", "OpenIdentities configs instruction export JSON; repeatable", collectOption, []) .option("--replace-source ", "source id that replaces earlier layers instead of appending", collectOption, []) + .option("--codewith-native-imports", "select the gated Codewith native @ import adapter") .option("--allow-empty-sources", "allow an explicit empty render") .option("--dry-run", "preview writes and conflicts without writing") .option("--force", "overwrite existing unmanaged files") @@ -802,6 +965,7 @@ sessionCmd.command("apply") targetHome: opts.targetHome, projectRoot: opts.projectRoot, sessionId: opts.sessionId, + codewithNativeImports: opts.codewithNativeImports, allowEmptySources: opts.allowEmptySources, sources, }); diff --git a/src/cli/project-context.test.ts b/src/cli/project-context.test.ts new file mode 100644 index 0000000..0434245 --- /dev/null +++ b/src/cli/project-context.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { computeProjectContextSourceHash, type ProjectContextBundleV1 } from "../lib/project-context"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +function runCli(args: string[], input?: string) { + return spawnSync("bun", ["src/cli/index.tsx", ...args], { + cwd: repoRoot, + encoding: "utf8", + input, + env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, + }); +} + +function bundle(): ProjectContextBundleV1 { + const value: ProjectContextBundleV1 = { + schema: "hasna.projects.project_context_bundle.v1", + generated_at: "2026-07-22T10:00:00.000Z", + hash: "", + revision: "rev-9", + freshness: "fresh", + resolution: { source: "marker", conflict: false, create_allowed: false }, + authority: { owner: "projects", mode: "api", storage: "cloud", availability: "available" }, + project: { + id: "wks_cli", + slug: "cli-context", + name: "CLI Context", + kind: "project", + status: "active", + path: "/safe/cli-context", + updated_at: "2026-07-22T09:59:00.000Z", + }, + links: { + todos: { state: "linked", project_id: "todo-project", task_list_id: "todo-list" }, + conversations: { state: "linked", channel: "internal-cli-context" }, + mementos: { state: "linked", project_id: "memory-project", scope: "project" }, + }, + station: { station_id: "station01", machine_id: "machine01" }, + commands: [{ name: "show", argv: ["projects", "show", "wks_cli", "--json"] }], + }; + value.hash = computeProjectContextSourceHash(value); + return value; +} + +describe("project-context CLI", () => { + test("exposes plan/apply structured-input and stale-cache controls", () => { + const help = runCli(["project-context", "apply", "--help"]); + expect(help.status).toBe(0); + expect(help.stdout).toContain("--bundle "); + expect(help.stdout).toContain("--allow-stale-cache"); + expect(help.stdout).toContain("--expected-project-id"); + expect(help.stdout).toContain("--codewith-native-imports"); + expect(help.stdout).toContain("claude|codewith|agents|codex"); + }); + + test("plans from a durable file without writing and applies through the codex alias", () => { + const root = mkdtempSync(join(tmpdir(), "instructions-project-context-cli-")); + try { + const path = join(root, "bundle.json"); + writeFileSync(path, `${JSON.stringify(bundle())}\n`); + const plan = runCli([ + "project-context", "plan", + "--runtime", "codex", + "--workspace-root", root, + "--bundle", path, + "--json", + ]); + expect(plan.status).toBe(0); + const planned = JSON.parse(plan.stdout) as { ok: boolean; runtime: string; project_id: string }; + expect(planned).toMatchObject({ ok: true, runtime: "agents", project_id: "wks_cli" }); + expect(existsSync(join(root, "AGENTS.md"))).toBe(false); + expect(existsSync(join(root, ".hasna"))).toBe(false); + + const dryRun = runCli([ + "project-context", "apply", + "--runtime", "codex", + "--workspace-root", root, + "--bundle", path, + "--dry-run", + "--json", + ]); + expect(dryRun.status).toBe(0); + expect((JSON.parse(dryRun.stdout) as { applied: boolean; dry_run: boolean })).toMatchObject({ applied: false, dry_run: true }); + expect(existsSync(join(root, "AGENTS.md"))).toBe(false); + expect(existsSync(join(root, ".hasna"))).toBe(false); + + const apply = runCli([ + "project-context", "apply", + "--runtime", "codex", + "--workspace-root", root, + "--bundle", path, + "--json", + ]); + expect(apply.status).toBe(0); + expect((JSON.parse(apply.stdout) as { ok: boolean; applied: boolean })).toMatchObject({ ok: true, applied: true }); + expect(readFileSync(join(root, "AGENTS.md"), "utf8")).toContain("CLI Context"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("accepts stdin but records only durable cache provenance", () => { + const root = mkdtempSync(join(tmpdir(), "instructions-project-context-cli-")); + try { + const result = runCli([ + "project-context", "apply", + "--runtime", "codewith", + "--workspace-root", root, + "--bundle", "-", + "--json", + ], `${JSON.stringify(bundle())}\n`); + expect(result.status).toBe(0); + const manifest = readFileSync(join(root, ".hasna", "project-context-manifest.json"), "utf8"); + expect(manifest).toContain("project-context-cache.json"); + expect(manifest).not.toContain("/dev/fd"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("uses explicit stale cache fallback when a requested bundle file is unavailable", () => { + const root = mkdtempSync(join(tmpdir(), "instructions-project-context-cli-")); + try { + const seed = runCli([ + "project-context", "apply", + "--runtime", "agents", + "--workspace-root", root, + "--bundle", "-", + "--json", + ], `${JSON.stringify(bundle())}\n`); + expect(seed.status).toBe(0); + + const fallback = runCli([ + "project-context", "apply", + "--runtime", "agents", + "--workspace-root", root, + "--bundle", join(root, "producer-did-not-create.json"), + "--allow-stale-cache", + "--expected-project-id", "wks_cli", + "--max-stale-age-seconds", "604800", + "--json", + ]); + expect(fallback.status).toBe(0); + expect((JSON.parse(fallback.stdout) as { status: string }).status).toBe("stale-cache"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("returns a stable JSON error for strict-contract violations", () => { + const root = mkdtempSync(join(tmpdir(), "instructions-project-context-cli-")); + try { + const invalid = { ...bundle(), arbitrary: true }; + const result = runCli([ + "project-context", "apply", + "--runtime", "claude", + "--workspace-root", root, + "--bundle", "-", + "--json", + ], JSON.stringify(invalid)); + expect(result.status).toBe(1); + const body = JSON.parse(result.stdout) as { ok: boolean; error: { code: string; message: string } }; + expect(body).toEqual({ + ok: false, + error: { + code: "PROJECT_CONTEXT_INVALID", + message: expect.stringContaining("PROJECT_CONTEXT_INVALID"), + }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rejects oversized file and stdin input before parsing", () => { + const root = mkdtempSync(join(tmpdir(), "instructions-project-context-cli-")); + try { + const oversized = "x".repeat((8 * 1024) + 1); + const path = join(root, "oversized.json"); + writeFileSync(path, oversized); + for (const [source, input] of [[path, undefined], ["-", oversized]] as const) { + const result = runCli([ + "project-context", "plan", + "--runtime", "claude", + "--workspace-root", root, + "--bundle", source, + "--json", + ], input); + expect(result.status).toBe(1); + expect((JSON.parse(result.stdout) as { error: { code: string } }).error.code).toBe("PROJECT_CONTEXT_INPUT_TOO_LARGE"); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cli/session.test.ts b/src/cli/session.test.ts index 89994cb..9ee6f74 100644 --- a/src/cli/session.test.ts +++ b/src/cli/session.test.ts @@ -27,9 +27,43 @@ describe("configs session CLI", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("global|provider|tool|account|machine|division|workspace|project|repo|path|identity|agent|session|local"); expect(result.stdout).toContain("--project-root"); + expect(result.stdout).toContain("--codewith-native-imports"); expect(result.stdout).toContain("--allow-empty-sources"); }); + test("selects the gated Codewith native-import adapter from session CLI", () => { + const home = mkdtempSync(join(tmpdir(), "open-configs-session-cli-")); + try { + mkdirSync(join(home, "sources"), { recursive: true }); + writeFileSync(join(home, "sources", "global.md"), "Global native-import source"); + const result = runCli([ + "session", + "plan", + "--tool", + "codewith", + "--profile", + "account999", + "--target-home", + "~/codewith-home", + "--source", + "global:global-cli=~/sources/global.md", + "--codewith-native-imports", + "--json", + ], { + HOME: home, + HASNA_CONFIGS_HOME: join(home, ".hasna", "configs"), + HASNA_CONFIGS_CODEWITH_NATIVE_IMPORTS: undefined, + }); + + expect(result.status).toBe(0); + const plan = JSON.parse(result.stdout) as { adapter: { mode: string }; manifest: { adapterMode: string } }; + expect(plan.adapter.mode).toBe("native-imports"); + expect(plan.manifest.adapterMode).toBe("native-imports"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test("fails closed when no sources are provided unless explicitly allowed", () => { const home = mkdtempSync(join(tmpdir(), "open-configs-session-cli-")); try { diff --git a/src/index.ts b/src/index.ts index db72eb6..5993a31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,6 +70,36 @@ export { checkSessionRenderDrift, SessionApplyError, } from "./lib/session-apply.js"; + +// Lib — strict Projects context bundle rendering +export { + LEGACY_CONFIGS_COMPAT_VERSION, + LEGACY_CONFIGS_EXECUTABLE, + LEGACY_CONFIGS_PACKAGE, + PROJECT_CONTEXT_CACHE_PATH, + PROJECT_CONTEXT_FRAGMENT_PATH, + PROJECT_CONTEXT_LOCK_PATH, + PROJECT_CONTEXT_MANAGED_COMMENT, + PROJECT_CONTEXT_MANIFEST_PATH, + PROJECT_CONTEXT_MAX_COMMANDS, + PROJECT_CONTEXT_MAX_INPUT_BYTES, + PROJECT_CONTEXT_MAX_RENDERED_BYTES, + PROJECT_CONTEXT_SCHEMA, + ProjectContextError, + applyProjectContext, + computeProjectContextSourceHash, + parseProjectContextBundle, + planProjectContext, +} from "./lib/project-context.js"; +export type { + ProjectContextApplyOptions, + ProjectContextApplyResult, + ProjectContextBundleV1, + ProjectContextPlan, + ProjectContextPlanInput, + ProjectContextRuntime, + ProjectContextStatus, +} from "./lib/project-context.js"; export type { SessionApplyAction, SessionApplyFileResult, diff --git a/src/lib/project-context.test.ts b/src/lib/project-context.test.ts new file mode 100644 index 0000000..887f48c --- /dev/null +++ b/src/lib/project-context.test.ts @@ -0,0 +1,1701 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + PROJECT_CONTEXT_FRAGMENT_PATH, + PROJECT_CONTEXT_MANAGED_COMMENT, + PROJECT_CONTEXT_MANIFEST_PATH, + ProjectContextError, + applyProjectContext, + computeProjectContextSourceHash, + parseProjectContextBundle, + planProjectContext, + removeProjectContextCoordinatedFile, + type ProjectContextBundleV1, + type ProjectContextRuntime, +} from "./project-context"; +import { CODEWITH_NATIVE_IMPORTS_ENV, planSessionRender, type SessionRenderTool } from "./session-render"; +import { applySessionRender } from "./session-apply"; + +let tmpRoot = ""; +let previousCodewithNativeImports: string | undefined; + +beforeEach(() => { + previousCodewithNativeImports = process.env[CODEWITH_NATIVE_IMPORTS_ENV]; + delete process.env[CODEWITH_NATIVE_IMPORTS_ENV]; + tmpRoot = mkdtempSync(join(tmpdir(), "instructions-project-context-")); +}); + +afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + if (previousCodewithNativeImports === undefined) delete process.env[CODEWITH_NATIVE_IMPORTS_ENV]; + else process.env[CODEWITH_NATIVE_IMPORTS_ENV] = previousCodewithNativeImports; +}); + +function makeBundle(overrides: Partial = {}): ProjectContextBundleV1 { + const bundle: ProjectContextBundleV1 = { + schema: "hasna.projects.project_context_bundle.v1", + generated_at: "2026-07-22T10:00:00.000Z", + hash: "", + revision: "rev-7", + freshness: "fresh", + resolution: { + source: "marker", + conflict: false, + create_allowed: false, + }, + authority: { + owner: "projects", + mode: "api", + storage: "cloud", + availability: "available", + }, + project: { + id: "wks_ZXg7liK4CFJ1KZjC_Fg_b", + slug: "agent-executive-assistant", + name: "Executive Assistant", + kind: "project", + status: "active", + path: "/home/hasna/.hasna/projects/workspaces/wks_ZXg7liK4CFJ1KZjC_Fg_b", + updated_at: "2026-07-22T09:59:00.000Z", + }, + links: { + todos: { + state: "linked", + project_id: "fbe046b7-a364-4f1c-8658-81e7234d8025", + task_list_id: "17ffb138-8db7-485b-ae3f-d5d1852ef815", + }, + conversations: { + state: "linked", + channel: "internal-ea", + }, + mementos: { + state: "linked", + project_id: "wks_ZXg7liK4CFJ1KZjC_Fg_b", + scope: "project", + }, + }, + station: { + machine_id: "447614a0-1639-44e1-87a4-f396f8502a96", + station_id: "station01", + }, + commands: [ + { name: "show", argv: ["projects", "show", "wks_ZXg7liK4CFJ1KZjC_Fg_b", "--json"] }, + { name: "why", argv: ["projects", "why", "wks_ZXg7liK4CFJ1KZjC_Fg_b", "--json"] }, + ], + ...overrides, + }; + bundle.hash = computeProjectContextSourceHash(bundle); + return bundle; +} + +function bundleJson(bundle = makeBundle()): string { + return `${JSON.stringify(bundle)}\n`; +} + +function expectCode(fn: () => unknown, code: string): void { + try { + fn(); + throw new Error(`Expected ${code}`); + } catch (error) { + expect(error).toBeInstanceOf(ProjectContextError); + expect((error as ProjectContextError).code).toBe(code); + } +} + +describe("project context bundle validation", () => { + test("accepts the strict allowlisted v1 contract and validates its source hash", () => { + const parsed = parseProjectContextBundle(bundleJson()); + + expect(parsed.project.slug).toBe("agent-executive-assistant"); + expect(parsed.revision).toBe("rev-7"); + expect(parsed.commands).toHaveLength(2); + }); + + test("rejects additional properties, inconsistent hashes, bad enums, and too many argv commands", () => { + const extra = { ...makeBundle(), metadata: { arbitrary: true } }; + expectCode(() => parseProjectContextBundle(JSON.stringify(extra)), "PROJECT_CONTEXT_INVALID"); + + const badHash = makeBundle(); + badHash.project.name = "Changed after hashing"; + expectCode(() => parseProjectContextBundle(JSON.stringify(badHash)), "PROJECT_CONTEXT_HASH_MISMATCH"); + + const badEnum = makeBundle({ authority: { owner: "projects", mode: "api", storage: "unknown" as "cloud", availability: "available" } }); + badEnum.hash = computeProjectContextSourceHash(badEnum); + expectCode(() => parseProjectContextBundle(JSON.stringify(badEnum)), "PROJECT_CONTEXT_INVALID"); + + const commands = Array.from({ length: 7 }, () => ({ + name: "show" as const, + argv: ["projects", "show", "wks_ZXg7liK4CFJ1KZjC_Fg_b", "--json"], + })); + const tooMany = makeBundle({ commands }); + tooMany.hash = computeProjectContextSourceHash(tooMany); + expectCode(() => parseProjectContextBundle(JSON.stringify(tooMany)), "PROJECT_CONTEXT_INVALID"); + }); + + test("rejects parseable non-ISO and impossible calendar timestamps", () => { + const nonIso = makeBundle({ generated_at: "July 22, 2026 10:00:00 UTC" }); + nonIso.hash = computeProjectContextSourceHash(nonIso); + expectCode(() => parseProjectContextBundle(nonIso), "PROJECT_CONTEXT_INVALID"); + + const impossible = makeBundle({ + project: { ...makeBundle().project, updated_at: "2026-02-30T09:59:00.000Z" }, + }); + impossible.hash = computeProjectContextSourceHash(impossible); + expectCode(() => parseProjectContextBundle(impossible), "PROJECT_CONTEXT_INVALID"); + }); + + test("rejects a future-dated live bundle instead of reporting it as fresh age zero", () => { + const future = makeBundle({ generated_at: "2026-07-22T10:03:00.000Z" }); + future.hash = computeProjectContextSourceHash(future); + expectCode(() => planProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle: future, + now: new Date("2026-07-22T10:02:00.000Z"), + }), "PROJECT_CONTEXT_INVALID"); + }); + + test("enforces the 8 KiB encoded input limit before parsing", () => { + expectCode(() => parseProjectContextBundle(`{"padding":"${"x".repeat(8_192)}"}`), "PROJECT_CONTEXT_INPUT_TOO_LARGE"); + }); + + test("normalizes non-serializable in-process inputs to a stable validation error", () => { + const circular: Record = {}; + circular.self = circular; + expectCode(() => parseProjectContextBundle(undefined), "PROJECT_CONTEXT_INVALID"); + expectCode(() => parseProjectContextBundle(circular), "PROJECT_CONTEXT_INVALID"); + }); + + test("rejects credential canaries and shell-shaped command arguments", () => { + const secret = makeBundle({ + project: { + ...makeBundle().project, + name: ["sk", "ant", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"].join("-"), + }, + }); + secret.hash = computeProjectContextSourceHash(secret); + expectCode(() => parseProjectContextBundle(JSON.stringify(secret)), "PROJECT_CONTEXT_SECRET_REJECTED"); + + const shell = makeBundle({ + commands: [{ name: "show", argv: ["projects", "show", "$(touch /tmp/nope)"] }], + }); + shell.hash = computeProjectContextSourceHash(shell); + expectCode(() => parseProjectContextBundle(JSON.stringify(shell)), "PROJECT_CONTEXT_INVALID"); + + }); + + test("accepts producer-valid name and slug punctuation without Markdown injection", () => { + const bundle = makeBundle({ + project: { + ...makeBundle().project, + slug: "Test.Slug", + name: "Release `v2`\n**still data**", + }, + }); + bundle.hash = computeProjectContextSourceHash(bundle); + expect(parseProjectContextBundle(bundle).project.slug).toBe("Test.Slug"); + const plan = planProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle, + source_path: join(tmpRoot, "bundle.json"), + }); + expect(plan.fragment).toContain("Release \\u0060v2\\u0060\\n**still data**"); + expect(plan.fragment).toContain("`Test.Slug`"); + expect(plan.fragment).not.toContain("Release `v2`"); + }); + + test("accepts an explicitly linked Mementos project with an independent workspace-shaped ID", () => { + const bundle = makeBundle({ + links: { + ...makeBundle().links, + mementos: { state: "linked", project_id: "wks_mementos_independent", scope: "project" }, + }, + }); + bundle.hash = computeProjectContextSourceHash(bundle); + + expect(parseProjectContextBundle(bundle).links.mementos.project_id).toBe("wks_mementos_independent"); + }); +}); + +describe("project context planning", () => { + test("builds one bounded canonical fragment and drops optional commands before core identity", () => { + const projectId = `wks_${"a".repeat(300)}`; + const commands = Array.from({ length: 6 }, () => ({ + name: "show" as const, + argv: ["projects", "show", projectId, "--json"], + })); + const bundle = makeBundle({ + freshness: "stale", + authority: { owner: "projects", mode: "api", storage: "cloud", availability: "unavailable" }, + project: { + ...makeBundle().project, + id: projectId, + name: "N".repeat(250), + path: `/${"segment/".repeat(80)}project`, + }, + links: { + ...makeBundle().links, + todos: { state: "partial", project_id: "todo", task_list_id: null }, + mementos: { state: "linked", project_id: "memory", scope: "project" }, + }, + commands, + }); + bundle.hash = computeProjectContextSourceHash(bundle); + + const plan = planProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle, + source_path: join(tmpRoot, "bundle.json"), + }); + + expect(Buffer.byteLength(plan.fragment, "utf8")).toBeLessThanOrEqual(4_096); + expect(Math.ceil(plan.fragment.length / 4)).toBeLessThanOrEqual(1_000); + expect(plan.fragment).toContain(projectId); + expect(plan.fragment).toContain("Status: `active`"); + expect(plan.included_commands).toBeLessThan(6); + expect(plan.warnings).toHaveLength(3); + }); + + test("drops the final optional command when core identity alone fits", () => { + let plan: ReturnType | null = null; + for (let nameLength = 1_200; nameLength <= 3_200 && plan === null; nameLength += 25) { + const bundle = makeBundle({ + project: { ...makeBundle().project, name: "N".repeat(nameLength) }, + commands: [{ name: "show", argv: ["projects", "show", "wks_ZXg7liK4CFJ1KZjC_Fg_b", "--json"] }], + }); + bundle.hash = computeProjectContextSourceHash(bundle); + try { + const candidate = planProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle, + source_path: join(tmpRoot, "bundle.json"), + }); + if (candidate.included_commands === 0) plan = candidate; + } catch (error) { + expect((error as ProjectContextError).code).toBe("PROJECT_CONTEXT_RENDER_TOO_LARGE"); + } + } + expect(plan).not.toBeNull(); + expect(plan!.fragment).not.toContain("## Safe Next Commands"); + }); + + test("returns PROJECT_CONTEXT_SHADOWED when Codewith would ignore its managed target", () => { + mkdirSync(join(tmpRoot, ".codewith"), { recursive: true }); + writeFileSync(join(tmpRoot, ".codewith", "CODEWITH.override.md"), "override\n"); + + expectCode(() => planProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle: makeBundle(), + source_path: join(tmpRoot, "bundle.json"), + }), "PROJECT_CONTEXT_SHADOWED"); + }); + + test("creates the consumed .codewith target ahead of root CODEWITH and AGENTS fallbacks without editing them", () => { + const rootOverride = join(tmpRoot, "CODEWITH.override.md"); + const rootCodewith = join(tmpRoot, "CODEWITH.md"); + const rootAgents = join(tmpRoot, "AGENTS.md"); + writeFileSync(rootOverride, "root override fallback bytes\n"); + writeFileSync(rootCodewith, "root CODEWITH fallback bytes\n"); + writeFileSync(rootAgents, "legacy AGENTS fallback bytes\n"); + + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + + expect(readFileSync(rootOverride, "utf8")).toBe("root override fallback bytes\n"); + expect(readFileSync(rootCodewith, "utf8")).toBe("root CODEWITH fallback bytes\n"); + expect(readFileSync(rootAgents, "utf8")).toBe("legacy AGENTS fallback bytes\n"); + expect(readFileSync(join(tmpRoot, ".codewith", "CODEWITH.md"), "utf8")).toContain("Executive Assistant"); + }); +}); + +describe("project context adapters and managed edits", () => { + const cases: Array<{ runtime: ProjectContextRuntime; target: string; imported: boolean }> = [ + { runtime: "claude", target: "CLAUDE.md", imported: true }, + { runtime: "codewith", target: ".codewith/CODEWITH.md", imported: false }, + { runtime: "agents", target: "AGENTS.md", imported: false }, + ]; + + for (const adapter of cases) { + test(`preserves user bytes and applies the deterministic ${adapter.runtime} adapter`, () => { + const target = join(tmpRoot, ...adapter.target.split("/")); + mkdirSync(join(target, ".."), { recursive: true }); + const before = "USER PREFIX\r\n\r\n"; + const after = "\r\nUSER SUFFIX\r\n"; + writeFileSync(target, `${before}${after}`); + chmodSync(target, 0o640); + + const result = applyProjectContext({ + workspace_root: tmpRoot, + runtime: adapter.runtime, + bundle_json: bundleJson(), + source_path: join(tmpRoot, "project-context.json"), + }); + + const rendered = readFileSync(target, "utf8"); + expect(rendered.startsWith(`${before}${after}`)).toBe(true); + expect(rendered).toContain("Managed by @hasna/configs project context"); + expect(rendered.includes("@" + (adapter.runtime === "codewith" ? "../" : "") + PROJECT_CONTEXT_FRAGMENT_PATH)).toBe(adapter.imported); + expect(rendered).toContain(adapter.imported ? "project-context.md" : "Executive Assistant"); + expect(statSync(target).mode & 0o777).toBe(0o640); + expect(result.applied).toBe(true); + expect(existsSync(join(tmpRoot, ...PROJECT_CONTEXT_FRAGMENT_PATH.split("/")))).toBe(true); + expect(existsSync(join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")))).toBe(true); + }); + } + + test("uses the existing Codewith native-import gate when it is enabled", () => { + process.env[CODEWITH_NATIVE_IMPORTS_ENV] = "1"; + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + const rendered = readFileSync(join(tmpRoot, ".codewith", "CODEWITH.md"), "utf8"); + expect(rendered).toContain(`@../${PROJECT_CONTEXT_FRAGMENT_PATH}`); + expect(rendered).not.toContain("# Managed Project Context"); + }); + + test("replaces only the managed block while retaining later user edits byte-for-byte", () => { + const first = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + expect(first.applied).toBe(true); + const target = join(tmpRoot, "AGENTS.md"); + const current = readFileSync(target, "utf8"); + writeFileSync(target, `prefix\n${current}suffix without final newline`); + + const nextBundle = makeBundle({ revision: "rev-8", project: { ...makeBundle().project, name: "Executive Assistant Canonical" } }); + nextBundle.hash = computeProjectContextSourceHash(nextBundle); + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(nextBundle), + source_path: join(tmpRoot, "bundle.json"), + }); + + const updated = readFileSync(target, "utf8"); + expect(updated.startsWith("prefix\n")).toBe(true); + expect(updated.endsWith("suffix without final newline")).toBe(true); + expect(updated).toContain("Executive Assistant Canonical"); + expect(updated).not.toContain("Project: `Executive Assistant` (`agent-executive-assistant`)"); + }); + + test("fails duplicate, nested, malformed, and mismatched markers without force", () => { + const target = join(tmpRoot, "AGENTS.md"); + const begin = ""; + const end = ""; + writeFileSync(target, `${begin}\n${begin}\ntext\n${end}\n`); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }), "MANAGED_BLOCK_INVALID"); + + const forced = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + force: true, + }); + expect(forced.applied).toBe(true); + expect(readFileSync(target, "utf8").match(/project context BEGIN/g)).toHaveLength(1); + }); + + test("rejects a well-formed managed block for another project even with force", () => { + const target = join(tmpRoot, "AGENTS.md"); + writeFileSync(target, [ + "before", + "", + "other project", + "", + "after", + ].join("\n")); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }), "MANAGED_BLOCK_CONFLICT"); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + force: true, + }), "MANAGED_BLOCK_CONFLICT"); + }); + + test("rejects symlinked workspace targets and managed paths", () => { + const outside = join(tmpRoot, "outside.md"); + writeFileSync(outside, "outside\n"); + symlinkSync(outside, join(tmpRoot, "AGENTS.md")); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }), "PROJECT_CONTEXT_SYMLINK_REJECTED"); + expect(readFileSync(outside, "utf8")).toBe("outside\n"); + }); + + test("works in a non-git coordination workspace", () => { + expect(existsSync(join(tmpRoot, ".git"))).toBe(false); + const result = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + expect(result.applied).toBe(true); + }); +}); + +describe("legacy migration and compatibility", () => { + test("keeps the configs executable and service contract aliases additive", () => { + const repoRoot = join(import.meta.dir, "../.."); + const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { name: string; bin: Record }; + const contract = JSON.parse(readFileSync(join(repoRoot, "hasna.contract.json"), "utf8")) as { bins: string[] }; + expect(pkg.name).toBe("@hasna/instructions"); + expect(pkg.bin.configs).toBe("dist/cli/index.js"); + expect(contract.bins).toContain("configs"); + }); + + test("dual-reads the pre-canonical BEGIN/END marker form without adding a second block", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + const target = join(tmpRoot, "AGENTS.md"); + const legacy = readFileSync(target, "utf8") + .replace("", + "# codewith session instructions", + "", + "Profile: live-codewith", + "", + "# friday", + "", + "Source: /dev/fd/63", + "", + "## Workspace", + "", + "- The local marker is `.project.json`, with project id `wks_ZXg7liK4CFJ1KZjC_Fg_b`, slug `ea`, name `EA`, and kind `project`.", + "- Use `internal-ea` as the project conversations channel.", + "", + "## Modus Operandi", + "", + "Keep unrelated generated instructions.", + "", + ].join("\n"); + writeFileSync(target, stale); + const staleSha = new Bun.CryptoHasher("sha256").update(stale).digest("hex"); + writeFileSync(manifestPath, `${JSON.stringify({ + schema: "hasna.configs.session-render/v1", + tool: "codewith", + adapterMode: "flattened-markdown", + profile: "live-codewith", + targetOwner: { ownedBy: "open-configs" }, + sourceHash: "legacy", + sources: [ + { id: "friday", path: "/dev/fd/63", provenance: null }, + { id: "global-rules", path: "/durable/global-rules.md", provenance: null }, + ], + files: [ + { relativePath: "CODEWITH.md", sha256: staleSha, role: "index", sourceIds: ["friday"] }, + { relativePath: ".hasna/instructions/01-global.md", sha256: "a".repeat(64), role: "fragment", sourceIds: ["global-rules"] }, + ], + }, null, 2)}\n`); + + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "project-context.json"), + }); + + const rendered = readFileSync(target, "utf8"); + expect(rendered).toContain("Keep unrelated generated instructions."); + expect(rendered).toContain("Project: `Executive Assistant` (`agent-executive-assistant`)"); + expect(rendered).not.toContain("slug `ea`, name `EA`"); + expect(rendered.match(/project context BEGIN/g)).toHaveLength(1); + + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + profile: string; + targetOwner: { ownedBy: string; canonicalOwner: string }; + sources: Array<{ id: string; path: string | null }>; + files: Array<{ relativePath: string; sourceIds: string[] }>; + projectContext: { projectId: string }; + }; + expect(manifest.profile).toBe("live-codewith"); + expect(manifest.targetOwner).toMatchObject({ ownedBy: "open-configs", canonicalOwner: "instructions" }); + expect(manifest.sources).toContainEqual(expect.objectContaining({ id: "friday", path: "/dev/fd/63" })); + expect(manifest.sources).toContainEqual(expect.objectContaining({ id: "global-rules", path: "/durable/global-rules.md" })); + const projectSource = manifest.sources.find((source) => source.id === "project-context-bundle"); + expect(projectSource?.path?.endsWith("project-context-cache.json")).toBe(true); + expect(manifest.files.find((file) => file.relativePath === "CODEWITH.md")?.sourceIds).toEqual(["friday", "project-context-bundle"]); + expect(manifest.files).toContainEqual(expect.objectContaining({ relativePath: ".hasna/instructions/01-global.md", sourceIds: ["global-rules"] })); + expect(manifest.projectContext.projectId).toBe("wks_ZXg7liK4CFJ1KZjC_Fg_b"); + }); + + test("validates an existing legacy Codewith manifest before changing managed outputs", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + const fragmentPath = join(tmpRoot, ...PROJECT_CONTEXT_FRAGMENT_PATH.split("/")); + const targetPath = join(tmpRoot, ".codewith", "CODEWITH.md"); + const cachePath = join(tmpRoot, ".hasna", "project-context-cache.json"); + const before = [fragmentPath, targetPath, cachePath].map((path) => readFileSync(path, "utf8")); + const legacyPath = join(tmpRoot, ".codewith", ".hasna", "session-render-manifest.json"); + mkdirSync(join(tmpRoot, ".codewith", ".hasna"), { recursive: true }); + writeFileSync(legacyPath, "{malformed\n"); + const next = makeBundle({ revision: "rev-8" }); + next.hash = computeProjectContextSourceHash(next); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(next), + source_path: join(tmpRoot, "next.json"), + }), "PROJECT_CONTEXT_MANIFEST_INVALID"); + expect([fragmentPath, targetPath, cachePath].map((path) => readFileSync(path, "utf8"))).toEqual(before); + }); + + test("rejects credential-like metadata retained from a legacy session manifest", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + const fragmentPath = join(tmpRoot, ...PROJECT_CONTEXT_FRAGMENT_PATH.split("/")); + const targetPath = join(tmpRoot, ".codewith", "CODEWITH.md"); + const cachePath = join(tmpRoot, ".hasna", "project-context-cache.json"); + const before = [fragmentPath, targetPath, cachePath].map((path) => readFileSync(path, "utf8")); + const sessionManifestPath = join(tmpRoot, ".codewith", ".hasna", "session-render-manifest.json"); + const sessionManifest = JSON.parse(readFileSync(sessionManifestPath, "utf8")) as Record; + sessionManifest.warnings = [["sk", "ant", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"].join("-")]; + writeFileSync(sessionManifestPath, `${JSON.stringify(sessionManifest)}\n`); + const next = makeBundle({ revision: "rev-8" }); + next.hash = computeProjectContextSourceHash(next); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(next), + source_path: join(tmpRoot, "next.json"), + }), "PROJECT_CONTEXT_MANIFEST_INVALID"); + expect([fragmentPath, targetPath, cachePath].map((path) => readFileSync(path, "utf8"))).toEqual(before); + }); + + test("keeps project context composed across routine Claude, Codewith, and Codex session rerenders", () => { + const cases: Array<{ runtime: ProjectContextRuntime; tool: SessionRenderTool; targetHome: (root: string) => string; target: (root: string) => string }> = [ + { runtime: "claude", tool: "claude", targetHome: (root) => root, target: (root) => join(root, "CLAUDE.md") }, + { runtime: "codewith", tool: "codewith", targetHome: (root) => join(root, ".codewith"), target: (root) => join(root, ".codewith", "CODEWITH.md") }, + { runtime: "agents", tool: "codex", targetHome: (root) => root, target: (root) => join(root, "AGENTS.md") }, + ]; + + for (const item of cases) { + const root = join(tmpRoot, item.runtime); + mkdirSync(item.targetHome(root), { recursive: true }); + const first = planSessionRender({ + tool: item.tool, + profile: "live-codewith", + targetHome: item.targetHome(root), + sources: [{ + id: "global-rules", + layer: "global", + content: "Original session rules.", + provenance: { source: "test-fixture", generatedAt: "2026-07-22T09:00:00.000Z" }, + }], + }); + expect(applySessionRender(first).applied).toBe(true); + const sessionManifestPath = item.runtime === "codewith" + ? join(root, ".codewith", ".hasna", "session-render-manifest.json") + : join(root, ".hasna", "session-render-manifest.json"); + const beforeContext = JSON.parse(readFileSync(sessionManifestPath, "utf8")) as Record; + beforeContext.warnings = ["pre-existing session warning"]; + writeFileSync(sessionManifestPath, `${JSON.stringify(beforeContext, null, 2)}\n`); + + applyProjectContext({ + workspace_root: root, + runtime: item.runtime, + bundle_json: bundleJson(), + source_path: join(root, "bundle.json"), + }); + const compatibilityManifest = JSON.parse(readFileSync(sessionManifestPath, "utf8")) as { + env: Record; + warnings: string[]; + sources: Array<{ id: string; provenance: unknown }>; + }; + expect(Object.values(compatibilityManifest.env)).toContain(item.targetHome(root)); + expect(compatibilityManifest.warnings).toContain("pre-existing session warning"); + expect(compatibilityManifest.sources.find((source) => source.id === "global-rules")?.provenance).toEqual({ + source: "test-fixture", + generatedAt: "2026-07-22T09:00:00.000Z", + }); + + const rerender = planSessionRender({ + tool: item.tool, + profile: "live-codewith", + targetHome: item.targetHome(root), + sources: [{ id: "global-rules", layer: "global", content: "Updated session rules." }], + }); + const index = rerender.files.find((file) => file.role === "index"); + expect(index?.content).toContain(PROJECT_CONTEXT_MANAGED_COMMENT); + expect(rerender.manifest.sources.map((source) => source.id)).toContain("project-context-bundle"); + expect(rerender.manifest.projectContext?.projectId).toBe("wks_ZXg7liK4CFJ1KZjC_Fg_b"); + + const result = applySessionRender(rerender); + expect(result.applied).toBe(true); + expect(result.conflicts).toEqual([]); + const updatedRules = rerender.files.find((file) => file.content.includes("Updated session rules.")); + expect(updatedRules).toBeDefined(); + expect(readFileSync(updatedRules!.path, "utf8")).toContain("Updated session rules."); + const rendered = readFileSync(item.target(root), "utf8"); + expect(rendered).toContain(PROJECT_CONTEXT_MANAGED_COMMENT); + expect(rendered.match(/project context BEGIN/g)).toHaveLength(1); + } + }); + + test("rejects a stale session plan instead of downgrading newer durable project context", () => { + const first = planSessionRender({ + tool: "codex", + profile: "live-codewith", + targetHome: tmpRoot, + sources: [{ id: "global-rules", layer: "global", content: "Original session rules." }], + }); + expect(applySessionRender(first).applied).toBe(true); + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + + const stalePlan = planSessionRender({ + tool: "codex", + profile: "live-codewith", + targetHome: tmpRoot, + sources: [{ id: "global-rules", layer: "global", content: "Stale planned rules." }], + }); + const newer = makeBundle({ revision: "rev-8" }); + newer.hash = computeProjectContextSourceHash(newer); + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(newer), + source_path: join(tmpRoot, "newer.json"), + }); + + expectCode(() => applySessionRender(stalePlan), "PROJECT_CONTEXT_SESSION_STALE"); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain("revision=rev-8"); + expect(readFileSync(join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")), "utf8")).toContain('"revision": "rev-8"'); + expect(existsSync(join(tmpRoot, ".hasna", "project-context.lock"))).toBe(false); + + const freshPlan = planSessionRender({ + tool: "codex", + profile: "live-codewith", + targetHome: tmpRoot, + sources: [{ id: "global-rules", layer: "global", content: "Fresh planned rules." }], + }); + expect(applySessionRender(freshPlan).applied).toBe(true); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain("Fresh planned rules."); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain("revision=rev-8"); + }); + + test("rejects a session plan created before the first project-context activation", () => { + const stalePlan = planSessionRender({ + tool: "codex", + profile: "live-codewith", + targetHome: tmpRoot, + sources: [{ id: "global-rules", layer: "global", content: "Pre-activation session rules." }], + }); + expect(stalePlan.projectContextGuard).toBeDefined(); + + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + + expectCode(() => applySessionRender(stalePlan), "PROJECT_CONTEXT_SESSION_STALE"); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain(PROJECT_CONTEXT_MANAGED_COMMENT); + expect(readFileSync(join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")), "utf8")).toContain('"revision": "rev-7"'); + + const freshPlan = planSessionRender({ + tool: "codex", + profile: "live-codewith", + targetHome: tmpRoot, + sources: [{ id: "global-rules", layer: "global", content: "Post-activation session rules." }], + }); + expect(applySessionRender(freshPlan).applied).toBe(true); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain("Post-activation session rules."); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain(PROJECT_CONTEXT_MANAGED_COMMENT); + }); + + test("preserves an ordinary edit made after session guard validation", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + const target = join(tmpRoot, "AGENTS.md"); + const sessionManifest = join(tmpRoot, ".hasna", "session-render-manifest.json"); + const manifestBefore = readFileSync(sessionManifest, "utf8"); + const plan = planSessionRender({ + tool: "codex", + profile: "live-codewith", + targetHome: tmpRoot, + sources: [{ id: "global-rules", layer: "global", content: "New planned session rules." }], + }); + const concurrentEdit = `${readFileSync(target, "utf8")}ordinary concurrent edit\n`; + + expect(() => applySessionRender(plan, { + test_hooks: { + before_apply_writes: () => writeFileSync(target, concurrentEdit), + }, + })).toThrow("changed after planning"); + + expect(readFileSync(target, "utf8")).toBe(concurrentEdit); + expect(readFileSync(sessionManifest, "utf8")).toBe(manifestBefore); + expect(existsSync(join(tmpRoot, ".hasna", "project-context.lock"))).toBe(false); + }); + + test("rejects Codewith rerenders when an override shadows the managed target", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + const targetHome = join(tmpRoot, ".codewith"); + const stalePlan = planSessionRender({ + tool: "codewith", + profile: "live-codewith", + targetHome, + sources: [{ id: "global-rules", layer: "global", content: "Session rules." }], + }); + + writeFileSync(join(targetHome, "CODEWITH.override.md"), "shadowing user override\n"); + expectCode(() => applySessionRender(stalePlan), "PROJECT_CONTEXT_SESSION_STALE"); + expectCode(() => planSessionRender({ + tool: "codewith", + profile: "live-codewith", + targetHome, + sources: [{ id: "global-rules", layer: "global", content: "Fresh session rules." }], + }), "PROJECT_CONTEXT_SHADOWED"); + expect(readFileSync(join(targetHome, "CODEWITH.md"), "utf8")).toContain(PROJECT_CONTEXT_MANAGED_COMMENT); + }); + + test("rejects a mismatched Codewith project root instead of bypassing context guards", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + const unrelatedRoot = join(tmpRoot, "unrelated"); + mkdirSync(unrelatedRoot); + expectCode(() => planSessionRender({ + tool: "codewith", + profile: "live-codewith", + targetHome: join(tmpRoot, ".codewith"), + projectRoot: unrelatedRoot, + sources: [{ id: "global-rules", layer: "global", content: "Session rules." }], + }), "PROJECT_CONTEXT_PATH_INVALID"); + expect(readFileSync(join(tmpRoot, ".codewith", "CODEWITH.md"), "utf8")).toContain(PROJECT_CONTEXT_MANAGED_COMMENT); + }); + + test("keeps the durable Codewith adapter mode authoritative across session rerenders", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + codewith_native_imports: true, + }); + const targetHome = join(tmpRoot, ".codewith"); + expect(readFileSync(join(targetHome, "CODEWITH.md"), "utf8")).toContain(`@../${PROJECT_CONTEXT_FRAGMENT_PATH}`); + + expectCode(() => planSessionRender({ + tool: "codewith", + profile: "live-codewith", + targetHome, + sources: [{ id: "global-rules", layer: "global", content: "Mismatched mode rules." }], + }), "PROJECT_CONTEXT_ADAPTER_MISMATCH"); + + process.env[CODEWITH_NATIVE_IMPORTS_ENV] = "1"; + const matchingPlan = planSessionRender({ + tool: "codewith", + profile: "live-codewith", + targetHome, + sources: [{ id: "global-rules", layer: "global", content: "Matching mode rules." }], + }); + expect(applySessionRender(matchingPlan).applied).toBe(true); + expect(readFileSync(join(targetHome, "CODEWITH.md"), "utf8")).toContain(`@../${PROJECT_CONTEXT_FRAGMENT_PATH}`); + const sessionManifest = JSON.parse(readFileSync(join(targetHome, ".hasna", "session-render-manifest.json"), "utf8")) as { adapterMode: string }; + expect(sessionManifest.adapterMode).toBe("native-imports"); + }); + + test("creates an adoption manifest when project context arrives before the first session render", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + expect(existsSync(join(tmpRoot, ".hasna", "session-render-manifest.json"))).toBe(true); + + const firstSession = planSessionRender({ + tool: "codex", + profile: "live-codewith", + targetHome: tmpRoot, + sources: [{ id: "global-rules", layer: "global", content: "First session rules." }], + }); + const applied = applySessionRender(firstSession); + expect(applied.applied).toBe(true); + expect(applied.conflicts).toEqual([]); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain("First session rules."); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain(PROJECT_CONTEXT_MANAGED_COMMENT); + }); + + test("uses a separate bounded reader for valid session manifests larger than 32 KiB", () => { + const first = planSessionRender({ + tool: "claude", + profile: "live-codewith", + targetHome: tmpRoot, + sources: [{ id: "global-rules", layer: "global", content: "Session rules." }], + }); + expect(applySessionRender(first).applied).toBe(true); + const sessionManifestPath = join(tmpRoot, ".hasna", "session-render-manifest.json"); + const manifest = JSON.parse(readFileSync(sessionManifestPath, "utf8")) as Record; + manifest.compatibilityPadding = "x".repeat(40 * 1024); + writeFileSync(sessionManifestPath, `${JSON.stringify(manifest)}\n`); + expect(statSync(sessionManifestPath).size).toBeGreaterThan(32 * 1024); + + expect(applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }).applied).toBe(true); + const updated = JSON.parse(readFileSync(sessionManifestPath, "utf8")) as Record; + expect(updated.compatibilityPadding).toBeUndefined(); + expect((updated.projectContext as { projectId: string }).projectId).toBe("wks_ZXg7liK4CFJ1KZjC_Fg_b"); + }); + + test("does not inject context into a provider runtime that was not selected", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + + const claude = planSessionRender({ + tool: "claude", + profile: "live-codewith", + targetHome: tmpRoot, + sources: [{ id: "global-rules", layer: "global", content: "Claude rules." }], + }); + expect(claude.manifest.projectContext).toBeUndefined(); + expect(claude.manifest.sources.map((source) => source.id)).not.toContain("project-context-bundle"); + expect(claude.files[0]?.content).not.toContain(PROJECT_CONTEXT_MANAGED_COMMENT); + }); +}); + +describe("cache, revision, crash, and race safety", () => { + test("uses only a compatible same-ID bounded stale cache with a visible age", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:00:30.000Z"), + }); + + const cached = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + allow_stale_cache: true, + expected_project_id: "wks_ZXg7liK4CFJ1KZjC_Fg_b", + max_stale_age_seconds: 300, + now: new Date("2026-07-22T10:02:00.000Z"), + }); + + expect(cached.status).toBe("stale-cache"); + expect(cached.age_seconds).toBe(120); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain("stale cache (age 120s)"); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + allow_stale_cache: true, + expected_project_id: "wks_other", + max_stale_age_seconds: 300, + now: new Date("2026-07-22T10:02:00.000Z"), + }), "PROJECT_CONTEXT_CACHE_ID_MISMATCH"); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + allow_stale_cache: true, + expected_project_id: "wks_ZXg7liK4CFJ1KZjC_Fg_b", + max_stale_age_seconds: 30, + now: new Date("2026-07-22T10:02:00.000Z"), + }), "PROJECT_CONTEXT_CACHE_EXPIRED"); + }); + + test("rejects future-dated cache metadata and bundle timestamps", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:00:30.000Z"), + }); + + const cachePath = join(tmpRoot, ".hasna", "project-context-cache.json"); + const original = readFileSync(cachePath, "utf8"); + const futureCache = JSON.parse(original) as { + cached_at: string; + hash: string; + bundle: ProjectContextBundleV1; + }; + futureCache.cached_at = "2026-07-22T10:03:00.000Z"; + writeFileSync(cachePath, `${JSON.stringify(futureCache)}\n`); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + allow_stale_cache: true, + expected_project_id: "wks_ZXg7liK4CFJ1KZjC_Fg_b", + max_stale_age_seconds: 300, + now: new Date("2026-07-22T10:02:00.000Z"), + }), "PROJECT_CONTEXT_CACHE_INVALID"); + + const futureBundleCache = JSON.parse(original) as { + cached_at: string; + hash: string; + bundle: ProjectContextBundleV1; + }; + futureBundleCache.bundle.generated_at = "2026-07-22T10:03:00.000Z"; + futureBundleCache.bundle.hash = computeProjectContextSourceHash(futureBundleCache.bundle); + futureBundleCache.hash = futureBundleCache.bundle.hash; + writeFileSync(cachePath, `${JSON.stringify(futureBundleCache)}\n`); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + allow_stale_cache: true, + expected_project_id: "wks_ZXg7liK4CFJ1KZjC_Fg_b", + max_stale_age_seconds: 300, + now: new Date("2026-07-22T10:02:00.000Z"), + }), "PROJECT_CONTEXT_CACHE_INVALID"); + }); + + test("fails closed on malformed manifests and cache metadata", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + + const manifestPath = join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record; + manifest.files = [{ relativePath: "../../user-file", role: "fragment", sha256: "0".repeat(64) }]; + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + const next = makeBundle({ revision: "rev-8" }); + next.hash = computeProjectContextSourceHash(next); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(next), + source_path: join(tmpRoot, "next.json"), + }), "PROJECT_CONTEXT_MANIFEST_INVALID"); + + rmSync(manifestPath); + const cachePath = join(tmpRoot, ".hasna", "project-context-cache.json"); + const cache = JSON.parse(readFileSync(cachePath, "utf8")) as Record; + cache.untrusted = "must not be accepted"; + writeFileSync(cachePath, `${JSON.stringify(cache)}\n`); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + allow_stale_cache: true, + expected_project_id: "wks_ZXg7liK4CFJ1KZjC_Fg_b", + max_stale_age_seconds: 300, + now: new Date("2026-07-22T10:02:00.000Z"), + }), "PROJECT_CONTEXT_CACHE_INVALID"); + }); + + test("fails unknown majors by default and can fall back only to an explicit same-ID cache", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:00:30.000Z"), + }); + const future = { ...makeBundle(), schema: "hasna.projects.project_context_bundle.v2" }; + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: JSON.stringify(future), + source_path: join(tmpRoot, "future.json"), + }), "PROJECT_CONTEXT_UNSUPPORTED_VERSION"); + + const fallback = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: JSON.stringify(future), + source_path: join(tmpRoot, "future.json"), + allow_stale_cache: true, + expected_project_id: "wks_ZXg7liK4CFJ1KZjC_Fg_b", + max_stale_age_seconds: 300, + now: new Date("2026-07-22T10:02:00.000Z"), + }); + expect(fallback.status).toBe("stale-cache"); + }); + + test("prevents older revisions and equal-revision hash conflicts but permits a higher-revision rollback payload", () => { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + + const older = makeBundle({ revision: "rev-6" }); + older.hash = computeProjectContextSourceHash(older); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(older), + source_path: join(tmpRoot, "older.json"), + }), "PROJECT_CONTEXT_REVISION_STALE"); + + const conflict = makeBundle({ project: { ...makeBundle().project, name: "Conflicting Same Revision" } }); + conflict.hash = computeProjectContextSourceHash(conflict); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(conflict), + source_path: join(tmpRoot, "conflict.json"), + }), "PROJECT_CONTEXT_REVISION_CONFLICT"); + + const rollback = makeBundle({ + revision: "rev-8", + project: { ...makeBundle().project, name: "Rollback Target Identity" }, + }); + rollback.hash = computeProjectContextSourceHash(rollback); + const result = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(rollback), + source_path: join(tmpRoot, "rollback.json"), + }); + expect(result.revision).toBe("rev-8"); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain("Rollback Target Identity"); + }); + + test("orders producer-default timestamp revisions and encodes them safely in markers", () => { + const current = makeBundle({ revision: "2026-07-22 10:00:00" }); + current.hash = computeProjectContextSourceHash(current); + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(current), + source_path: join(tmpRoot, "bundle.json"), + }); + expect(readFileSync(join(tmpRoot, ".codewith", "CODEWITH.md"), "utf8")).toContain("revision=2026-07-22%2010%3A00%3A00"); + + const older = makeBundle({ revision: "2026-07-22 09:59:59" }); + older.hash = computeProjectContextSourceHash(older); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(older), + source_path: join(tmpRoot, "older.json"), + }), "PROJECT_CONTEXT_REVISION_STALE"); + + const newer = makeBundle({ revision: "2026-07-22 10:00:01" }); + newer.hash = computeProjectContextSourceHash(newer); + expect(applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(newer), + source_path: join(tmpRoot, "newer.json"), + }).revision).toBe("2026-07-22 10:00:01"); + }); + + test("holds a per-workspace lock and retries one observed hash race", () => { + const target = join(tmpRoot, "AGENTS.md"); + writeFileSync(target, "user text\n"); + let raced = false; + const result = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + before_compare: ({ attempt }) => { + if (attempt === 0 && !raced) { + raced = true; + writeFileSync(target, "user text changed concurrently\n"); + } + }, + }, + }); + expect(result.race_retries).toBe(1); + expect(readFileSync(target, "utf8")).toContain("user text changed concurrently"); + + expect(existsSync(join(tmpRoot, ".hasna", "project-context.lock"))).toBe(false); + }); + + test("rechecks target CAS immediately before replacement and before committing the manifest", () => { + const target = join(tmpRoot, "AGENTS.md"); + writeFileSync(target, "initial user bytes\n"); + let changedBeforeReplacement = false; + const first = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + after_fragment: ({ attempt }) => { + if (attempt === 0 && !changedBeforeReplacement) { + changedBeforeReplacement = true; + writeFileSync(target, "concurrent bytes before replacement\n"); + } + }, + }, + }); + expect(first.race_retries).toBe(1); + expect(readFileSync(target, "utf8")).toContain("concurrent bytes before replacement"); + + const next = makeBundle({ revision: "rev-8" }); + next.hash = computeProjectContextSourceHash(next); + let changedBeforeManifest = false; + const second = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(next), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + before_manifest: ({ attempt }) => { + if (attempt === 0 && !changedBeforeManifest) { + changedBeforeManifest = true; + writeFileSync(target, `${readFileSync(target, "utf8")}concurrent bytes before manifest\n`); + } + }, + }, + }); + expect(second.race_retries).toBe(1); + expect(readFileSync(target, "utf8")).toContain("concurrent bytes before manifest"); + expect(readFileSync(join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")), "utf8")).toContain('"revision": "rev-8"'); + + const finalBundle = makeBundle({ revision: "rev-9" }); + finalBundle.hash = computeProjectContextSourceHash(finalBundle); + let changedAfterExchange = false; + const third = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(finalBundle), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + after_target_exchange: ({ attempt }) => { + if (attempt === 0 && !changedAfterExchange) { + changedAfterExchange = true; + writeFileSync(target, `${readFileSync(target, "utf8")}concurrent bytes after atomic exchange\n`); + } + }, + }, + }); + expect(third.race_retries).toBe(1); + expect(readFileSync(target, "utf8")).toContain("concurrent bytes after atomic exchange"); + expect(readFileSync(join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")), "utf8")).toContain('"revision": "rev-9"'); + }); + + test("never installs a tampered displaced temp file during exchange recovery", () => { + const target = join(tmpRoot, "AGENTS.md"); + writeFileSync(target, "authoritative user bytes\n"); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + after_target_exchange: () => { + const displaced = readdirSync(tmpRoot).find((entry) => /^\.project-context-.*\.tmp$/.test(entry)); + if (!displaced) throw new Error("expected displaced target temp"); + writeFileSync(join(tmpRoot, displaced), "tampered displaced bytes\n"); + }, + }, + }), "PROJECT_CONTEXT_ATOMIC_REPLACE_CONFLICT"); + + const rendered = readFileSync(target, "utf8"); + expect(rendered).toContain("authoritative user bytes"); + expect(rendered).toContain(PROJECT_CONTEXT_MANAGED_COMMENT); + expect(rendered).not.toContain("tampered displaced bytes"); + expect(existsSync(join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")))).toBe(false); + expect(readdirSync(tmpRoot).some((entry) => /^\.project-context-.*\.tmp$/.test(entry))).toBe(true); + }); + + test("rejects prepared temp tampering before creating or replacing a target", () => { + for (const existingTarget of [false, true]) { + const workspaceRoot = join(tmpRoot, existingTarget ? "existing" : "new"); + mkdirSync(workspaceRoot, { recursive: true }); + const target = join(workspaceRoot, "AGENTS.md"); + const original = "authoritative existing bytes\n"; + if (existingTarget) writeFileSync(target, original); + + expectCode(() => applyProjectContext({ + workspace_root: workspaceRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(workspaceRoot, "bundle.json"), + test_hooks: { + before_target_install: ({ temp_path: tempPath }) => { + writeFileSync(tempPath, "tampered prepared bytes\n"); + }, + }, + }), "PROJECT_CONTEXT_HASH_RACE"); + + if (existingTarget) expect(readFileSync(target, "utf8")).toBe(original); + else expect(existsSync(target)).toBe(false); + expect(existsSync(join(workspaceRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")))).toBe(false); + } + }); + + test("never follows a managed parent replaced by a symlink during installation", () => { + const managedParent = join(tmpRoot, ".codewith"); + const displacedParent = join(tmpRoot, ".codewith-displaced"); + const outside = join(tmpRoot, "outside"); + mkdirSync(outside); + let swapped = false; + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + before_target_install: () => { + if (swapped) return; + swapped = true; + renameSync(managedParent, displacedParent); + symlinkSync(outside, managedParent, "dir"); + }, + }, + }), "PROJECT_CONTEXT_SYMLINK_REJECTED"); + + expect(existsSync(join(outside, "CODEWITH.md"))).toBe(false); + expect(existsSync(join(displacedParent, "CODEWITH.md"))).toBe(false); + expect(existsSync(join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")))).toBe(false); + }); + + test("fails closed before replacing an existing target without atomic exchange support", () => { + const target = join(tmpRoot, "AGENTS.md"); + const original = "existing user target bytes\n"; + writeFileSync(target, original); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { atomic_exchange_unavailable: true }, + }), "PROJECT_CONTEXT_ATOMIC_REPLACE_UNAVAILABLE"); + + expect(readFileSync(target, "utf8")).toBe(original); + expect(existsSync(join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")))).toBe(false); + expect(existsSync(join(tmpRoot, ".hasna", "project-context.lock"))).toBe(false); + }); + + test("keeps first-time rendering available on create-only platforms and fails closed on replacement", () => { + expect(applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { portable_create_only: true }, + }).applied).toBe(true); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain(PROJECT_CONTEXT_MANAGED_COMMENT); + + const next = makeBundle({ revision: "rev-8" }); + next.hash = computeProjectContextSourceHash(next); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(next), + source_path: join(tmpRoot, "next.json"), + test_hooks: { portable_create_only: true }, + }), "PROJECT_CONTEXT_ATOMIC_REPLACE_UNAVAILABLE"); + expect(readFileSync(join(tmpRoot, "AGENTS.md"), "utf8")).toContain("revision=rev-7"); + }); + + test("never overwrites a concurrently recreated target while recovering a displaced deletion", () => { + for (const forcePortableFileOps of [false, true]) { + const target = join(tmpRoot, forcePortableFileOps ? "portable-delete.md" : "anchored-delete.md"); + const original = "managed-before-delete\n"; + const concurrent = "concurrent-recreation\n"; + let displacedPath = ""; + writeFileSync(target, original); + + expect(() => removeProjectContextCoordinatedFile({ + path: target, + workspace_root: tmpRoot, + expected_hash: createHash("sha256").update(original).digest("hex"), + allow_portable_removal: true, + force_portable_file_ops: forcePortableFileOps, + test_hooks: { + after_displace: (path) => { + displacedPath = path; + writeFileSync(target, concurrent); + }, + }, + })).toThrow("changed during"); + expect(readFileSync(target, "utf8")).toBe(concurrent); + expect(displacedPath).not.toBe(""); + expect(readFileSync(displacedPath, "utf8")).toBe(original); + } + }); + + test("removes only its own lock inode when lock initialization fails", () => { + const lockPath = join(tmpRoot, ".hasna", "project-context.lock"); + expect(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + after_lock_open: () => { + throw new Error("simulated lock initialization failure"); + }, + }, + })).toThrow("simulated lock initialization failure"); + expect(existsSync(lockPath)).toBe(false); + expect(applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }).applied).toBe(true); + }); + + test("recovers a stale malformed lock left by a pre-atomic renderer crash", () => { + const lockPath = join(tmpRoot, ".hasna", "project-context.lock"); + mkdirSync(join(lockPath, ".."), { recursive: true }); + writeFileSync(lockPath, ""); + const stale = new Date(Date.now() - (10 * 60 * 1_000)); + utimesSync(lockPath, stale, stale); + expect(applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }).applied).toBe(true); + expect(existsSync(lockPath)).toBe(false); + }); + + test("recovers an old lock whose PID has been reused by a live process", () => { + const lockPath = join(tmpRoot, ".hasna", "project-context.lock"); + mkdirSync(join(lockPath, ".."), { recursive: true }); + writeFileSync(lockPath, `${JSON.stringify({ + schema: "hasna.instructions.project-context-lock/v1", + pid: process.pid, + nonce: "crashed-owner-with-reused-pid", + created_at: new Date(Date.now() - (10 * 60 * 1_000)).toISOString(), + })}\n`); + + expect(applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }).applied).toBe(true); + expect(existsSync(lockPath)).toBe(false); + }); + + test("does not evict a genuine live renderer solely because its lock is old", () => { + const lockPath = join(tmpRoot, ".hasna", "project-context.lock"); + let processStartId: string | null = null; + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + after_lock_open: () => { + const lock = JSON.parse(readFileSync(lockPath, "utf8")) as { process_start_id?: unknown }; + processStartId = typeof lock.process_start_id === "string" ? lock.process_start_id : null; + }, + }, + }); + expect(processStartId).not.toBeNull(); + writeFileSync(lockPath, `${JSON.stringify({ + schema: "hasna.instructions.project-context-lock/v1", + pid: process.pid, + nonce: "genuine-long-running-owner", + created_at: new Date(Date.now() - (10 * 60 * 1_000)).toISOString(), + process_start_id: processStartId, + })}\n`); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }), "PROJECT_CONTEXT_LOCKED"); + expect(existsSync(lockPath)).toBe(true); + }); + + test("falls back to bounded lock age when process-start inspection is unavailable", () => { + const lockPath = join(tmpRoot, ".hasna", "project-context.lock"); + mkdirSync(join(lockPath, ".."), { recursive: true }); + writeFileSync(lockPath, `${JSON.stringify({ + schema: "hasna.instructions.project-context-lock/v1", + pid: process.pid, + nonce: "stale-owner-hidden-by-process-policy", + created_at: new Date(Date.now() - (10 * 60 * 1_000)).toISOString(), + process_start_id: "recorded-owner-start", + })}\n`); + + expect(applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + process_start_identity: () => null, + }, + }).applied).toBe(true); + expect(existsSync(lockPath)).toBe(false); + }); + + test("does not remove a new owner that replaces a stale lock during takeover", () => { + const lockPath = join(tmpRoot, ".hasna", "project-context.lock"); + mkdirSync(join(lockPath, ".."), { recursive: true }); + writeFileSync(lockPath, `${JSON.stringify({ + schema: "hasna.instructions.project-context-lock/v1", + pid: 99_999_999, + nonce: "stale-owner", + })}\n`); + const replacement = `${JSON.stringify({ + schema: "hasna.instructions.project-context-lock/v1", + pid: process.pid, + nonce: "new-owner", + })}\n`; + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + before_stale_lock_remove: (path) => { + rmSync(path); + writeFileSync(path, replacement); + }, + }, + }), "PROJECT_CONTEXT_LOCKED"); + expect(readFileSync(lockPath, "utf8")).toBe(replacement); + expect(existsSync(join(tmpRoot, "CLAUDE.md"))).toBe(false); + }); + + test("fails without removing a lock file replaced by another renderer", () => { + const lockPath = join(tmpRoot, ".hasna", "project-context.lock"); + const replacement = `${JSON.stringify({ + schema: "hasna.instructions.project-context-lock/v1", + pid: process.pid, + nonce: "replacement-owner", + })}\n`; + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + before_compare: () => { + rmSync(lockPath); + writeFileSync(lockPath, replacement); + }, + }, + }), "PROJECT_CONTEXT_LOCK_LOST"); + expect(readFileSync(lockPath, "utf8")).toBe(replacement); + expect(existsSync(join(tmpRoot, "CLAUDE.md"))).toBe(false); + }); + + test("rejects a concurrent renderer, recovers a dead-process lock, and fails safely after the second hash race", () => { + let concurrentCode = ""; + const first = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + before_compare: () => { + try { + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + } catch (error) { + concurrentCode = (error as ProjectContextError).code; + } + }, + }, + }); + expect(first.applied).toBe(true); + expect(concurrentCode).toBe("PROJECT_CONTEXT_LOCKED"); + + const lockPath = join(tmpRoot, ".hasna", "project-context.lock"); + writeFileSync(lockPath, `${JSON.stringify({ schema: "hasna.instructions.project-context-lock/v1", pid: 99_999_999 })}\n`); + const next = makeBundle({ revision: "rev-8" }); + next.hash = computeProjectContextSourceHash(next); + expect(applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(next), + source_path: join(tmpRoot, "bundle.json"), + }).revision).toBe("rev-8"); + + const target = join(tmpRoot, "CLAUDE.md"); + const newest = makeBundle({ revision: "rev-9" }); + newest.hash = computeProjectContextSourceHash(newest); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(newest), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + before_compare: ({ attempt }) => writeFileSync(target, `${readFileSync(target, "utf8")}race-${attempt}\n`), + }, + }), "PROJECT_CONTEXT_HASH_RACE"); + expect(readFileSync(join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")), "utf8")).toContain('"revision": "rev-8"'); + }); + + test("leaves the manifest last on an injected crash and safely repairs on rerun", () => { + expect(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + test_hooks: { + after_target: () => { + throw new Error("simulated crash"); + }, + }, + })).toThrow("simulated crash"); + + expect(existsSync(join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")))).toBe(false); + const repaired = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "claude", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + expect(repaired.applied).toBe(true); + }); + + test("stores only bounded managed metadata and hashes in manifests and snapshots", () => { + writeFileSync(join(tmpRoot, "AGENTS.md"), "PRIVATE USER PROSE THAT MUST NOT ENTER MANIFESTS\n"); + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(), + source_path: join(tmpRoot, "bundle.json"), + }); + + const manifestPath = join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")); + const manifest = readFileSync(manifestPath, "utf8"); + expect(manifest).not.toContain("PRIVATE USER PROSE"); + expect(manifest).not.toContain("content"); + const parsed = JSON.parse(manifest) as { + schema: string; + targetOwner: { ownedBy: string; canonicalOwner: string }; + compatibility: { legacyPackage: string; legacyVersion: string; legacyExecutable: string; managedBy: string }; + }; + expect(parsed.schema).toBe("hasna.configs.session-render/v1"); + expect(parsed.targetOwner).toMatchObject({ ownedBy: "open-configs", canonicalOwner: "instructions" }); + expect(parsed.compatibility).toMatchObject({ + legacyPackage: "@hasna/configs", + legacyVersion: "0.2.45", + legacyExecutable: "configs", + managedBy: "@hasna/configs", + }); + + const snapshotsDir = join(tmpRoot, ".hasna", "project-context-snapshots"); + if (existsSync(snapshotsDir)) { + for (const entry of Array.from(new Bun.Glob("*.json").scanSync(snapshotsDir))) { + expect(readFileSync(join(snapshotsDir, entry), "utf8")).not.toContain("PRIVATE USER PROSE"); + } + } + }); +}); diff --git a/src/lib/project-context.ts b/src/lib/project-context.ts new file mode 100644 index 0000000..501fbdf --- /dev/null +++ b/src/lib/project-context.ts @@ -0,0 +1,3362 @@ +import { createHash, randomUUID } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { dlopen, FFIType } from "bun:ffi"; +import { + closeSync, + constants, + existsSync, + fstatSync, + fsyncSync, + lstatSync, + linkSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, parse, relative, resolve } from "node:path"; +import { z } from "zod"; +import { scanSecrets } from "./redact.js"; +import type { SessionRenderFile, SessionRenderManifest, SessionRenderMode, SessionRenderTool } from "./session-render.js"; +import { CODEWITH_NATIVE_IMPORTS_ENV, SESSION_INSTRUCTION_LAYERS, SESSION_RENDER_SCHEMA } from "./session-render-contract.js"; + +export const PROJECT_CONTEXT_SCHEMA = "hasna.projects.project_context_bundle.v1" as const; +export const PROJECT_CONTEXT_MAX_INPUT_BYTES = 8 * 1024; +export const PROJECT_CONTEXT_MAX_RENDERED_BYTES = 4 * 1024; +export const PROJECT_CONTEXT_MAX_APPROX_TOKENS = 1_000; +export const PROJECT_CONTEXT_MAX_COMMANDS = 6; +export const PROJECT_CONTEXT_MAX_WARNINGS = 3; +export const PROJECT_CONTEXT_FRAGMENT_PATH = ".hasna/instructions/project-context.md"; +export const PROJECT_CONTEXT_MANIFEST_PATH = ".hasna/project-context-manifest.json"; +export const PROJECT_CONTEXT_CACHE_PATH = ".hasna/project-context-cache.json"; +export const PROJECT_CONTEXT_LOCK_PATH = ".hasna/project-context.lock"; +export const PROJECT_CONTEXT_SNAPSHOT_DIR = ".hasna/project-context-snapshots"; +export const PROJECT_CONTEXT_CACHE_SCHEMA = "hasna.instructions.project-context-cache/v1" as const; +export const PROJECT_CONTEXT_MANAGED_COMMENT = "Managed by @hasna/configs project context"; +const SESSION_COMPATIBILITY_MANIFEST_MAX_BYTES = 8 * 1024 * 1024; +const PROJECT_CONTEXT_LOCK_STALE_MS = 5 * 60 * 1_000; +export const LEGACY_CONFIGS_PACKAGE = "@hasna/configs" as const; +export const LEGACY_CONFIGS_COMPAT_VERSION = "0.2.45" as const; +export const LEGACY_CONFIGS_EXECUTABLE = "configs" as const; + +const PROJECT_KINDS = [ + "open-source", + "internal-app", + "platform", + "company-website", + "scaffold", + "community", + "project", + "experiment", + "docs", + "remote-only", + "generic", +] as const; +const PROJECT_STATUSES = ["active", "archived", "deleted"] as const; +const LINK_STATES = ["linked", "partial", "unlinked"] as const; +const RESOLUTION_SOURCES = ["marker", "path", "id-or-slug", "name"] as const; +const safeId = z.string().min(1).max(512).regex(/^[A-Za-z0-9][A-Za-z0-9._:@+-]*$/); +const nullableId = safeId.nullable(); +const producerSlug = z.string().min(1).max(512); +const producerName = z.string().max(PROJECT_CONTEXT_MAX_INPUT_BYTES); +const safeOptionalDisplay = z.string().min(1).max(512).refine(isSafeSingleLine, "must be a safe single-line value").nullable(); +const isoTimestamp = z.string().min(20).max(40).refine(isStrictIsoTimestamp, "must be a strict ISO timestamp with timezone"); +const revisionSchema = z.string().min(1).max(512).refine((value) => revisionKey(value) !== null, "must be a monotonic rev-N or timestamp revision"); +const hashSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/); +const absolutePath = z.string().min(1).max(4_096).refine((value) => isAbsolute(value), "must be absolute").refine(isSafeSingleLine, "must be safe").nullable(); +const commandArg = z.string().min(1).max(1_024).refine((value) => isSafeCommandArgument(value), "unsafe argv item"); + +const commandSchema = z.object({ + name: z.enum(["show", "context", "why", "context-bundle"]), + argv: z.array(commandArg).min(1).max(8), +}).strict(); + +const projectContextBundleSchema = z.object({ + schema: z.literal(PROJECT_CONTEXT_SCHEMA), + generated_at: isoTimestamp, + hash: hashSchema, + revision: revisionSchema, + freshness: z.enum(["fresh", "stale", "unknown"]), + resolution: z.object({ + source: z.enum(RESOLUTION_SOURCES), + conflict: z.boolean(), + create_allowed: z.boolean(), + }).strict(), + authority: z.object({ + owner: z.literal("projects"), + mode: z.enum(["local", "api"]), + storage: z.enum(["sqlite", "cloud", "self-hosted"]), + availability: z.enum(["available", "unavailable"]), + }).strict(), + project: z.object({ + id: safeId, + slug: producerSlug, + name: producerName, + kind: z.enum(PROJECT_KINDS), + status: z.enum(PROJECT_STATUSES), + path: absolutePath, + updated_at: isoTimestamp, + }).strict(), + links: z.object({ + todos: z.object({ + state: z.enum(LINK_STATES), + project_id: nullableId, + task_list_id: nullableId, + }).strict(), + conversations: z.object({ + state: z.enum(LINK_STATES), + channel: safeOptionalDisplay, + }).strict(), + mementos: z.object({ + state: z.enum(LINK_STATES), + project_id: nullableId, + scope: safeOptionalDisplay, + }).strict(), + }).strict(), + station: z.object({ + station_id: nullableId, + machine_id: nullableId, + }).strict().nullable(), + commands: z.array(commandSchema).max(PROJECT_CONTEXT_MAX_COMMANDS), +}).strict(); + +const storedManifestProjectContextSchema = z.object({ + schema: z.literal(PROJECT_CONTEXT_SCHEMA), + projectId: safeId, + revision: revisionSchema, + hash: hashSchema, + status: z.enum(["fresh", "stale-source", "stale-cache"]), + ageSeconds: z.number().int().nonnegative(), + cachePath: z.string().min(1).max(1_024).refine(isSafeSingleLine, "must be safe"), + fragmentPath: z.string().min(1).max(1_024).refine(isSafeSingleLine, "must be safe"), +}).strict(); + +const storedManifestFileSchema = z.object({ + path: z.string().min(1).max(1_024).refine(isSafeSingleLine, "must be safe"), + relativePath: z.enum([ + PROJECT_CONTEXT_FRAGMENT_PATH, + "CLAUDE.md", + ".codewith/CODEWITH.md", + "AGENTS.md", + ]), + role: z.enum(["fragment", "index"]), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + sourceIds: z.tuple([z.literal("project-context-bundle")]), +}).strict(); + +const storedManifestObservationSchema = z.object({ + schema: z.literal(SESSION_RENDER_SCHEMA), + kind: z.literal("project-context"), + tool: z.enum(["claude", "codewith", "codex"]), + adapterMode: z.enum(["native-import", "managed-block"]), + projectContext: storedManifestProjectContextSchema, + files: z.array(storedManifestFileSchema).min(1).max(2), +}).passthrough().superRefine((value, context) => { + const fragments = value.files.filter((file) => file.relativePath === PROJECT_CONTEXT_FRAGMENT_PATH && file.role === "fragment"); + const indexes = value.files.filter((file) => file.role === "index"); + const uniquePaths = new Set(value.files.map((file) => file.relativePath)); + if (fragments.length !== 1 || indexes.length !== 1 || uniquePaths.size !== value.files.length) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["files"], + message: "manifest must contain one canonical fragment and one unique runtime index", + }); + } +}); + +const projectContextCacheSchema = z.object({ + schema: z.literal(PROJECT_CONTEXT_CACHE_SCHEMA), + cached_at: isoTimestamp, + project_id: safeId, + revision: revisionSchema, + hash: hashSchema, + bundle: projectContextBundleSchema, +}).strict(); + +export type ProjectContextBundleV1 = z.infer; +export type ProjectContextRuntime = "claude" | "codewith" | "agents"; +export type ProjectContextStatus = "fresh" | "stale-source" | "stale-cache"; +export type ProjectContextPhase = "before-compare" | "after-fragment" | "after-target" | "before-manifest"; + +export interface ProjectContextPlanInput { + workspace_root: string; + runtime: ProjectContextRuntime; + bundle: ProjectContextBundleV1; + source_path?: string; + status?: ProjectContextStatus; + age_seconds?: number; + now?: Date; + force?: boolean; + codewith_native_imports?: boolean; +} + +export interface ProjectContextPlan { + workspace_root: string; + runtime: ProjectContextRuntime; + target_path: string; + target_relative_path: string; + fragment_path: string; + manifest_path: string; + cache_path: string; + source_path: string; + bundle: ProjectContextBundleV1; + fragment: string; + managed_block: string; + target_content: string; + target_previous_content: string | null; + status: ProjectContextStatus; + age_seconds: number; + warnings: string[]; + included_commands: number; + native_imports: boolean; + marker: ManagedBlock | null; + legacy_migration: boolean; + expected_hashes: Map; +} + +export interface ProjectContextApplyOptions { + workspace_root: string; + runtime: ProjectContextRuntime; + bundle_json?: string; + bundle?: unknown; + source_path?: string; + expected_project_id?: string; + allow_stale_cache?: boolean; + max_stale_age_seconds?: number; + now?: Date; + force?: boolean; + dry_run?: boolean; + codewith_native_imports?: boolean; + test_hooks?: { + after_lock_open?: () => void; + atomic_exchange_unavailable?: boolean; + portable_create_only?: boolean; + before_stale_lock_remove?: (lockPath: string) => void; + before_compare?: (context: { attempt: number; plan: ProjectContextPlan }) => void; + after_fragment?: (context: { attempt: number; plan: ProjectContextPlan }) => void; + before_target_install?: (context: { attempt: number; plan: ProjectContextPlan; temp_path: string }) => void; + after_target_exchange?: (context: { attempt: number; plan: ProjectContextPlan }) => void; + after_target?: (context: { attempt: number; plan: ProjectContextPlan }) => void; + before_manifest?: (context: { attempt: number; plan: ProjectContextPlan }) => void; + process_start_identity?: (pid: number) => string | null; + }; +} + +export interface ProjectContextApplyResult { + applied: boolean; + dry_run: boolean; + workspace_root: string; + runtime: ProjectContextRuntime; + project_id: string; + revision: string; + hash: string; + status: ProjectContextStatus; + age_seconds: number; + race_retries: number; + target_path: string; + fragment_path: string; + manifest_path: string; + cache_path: string; + snapshot_path: string | null; + warnings: string[]; +} + +interface ManagedBlock { + start: number; + end: number; + id: string; + revision: string; + hash: string; + legacy: boolean; +} + +interface ProjectContextCache { + schema: typeof PROJECT_CONTEXT_CACHE_SCHEMA; + cached_at: string; + project_id: string; + revision: string; + hash: string; + bundle: ProjectContextBundleV1; +} + +interface ProjectContextManifestObservation { + tool: "claude" | "codewith" | "codex"; + adapterMode: "native-import" | "managed-block"; + projectContext: z.infer; + files: Array>; +} + +export interface ProjectContextSessionRenderInput { + tool: SessionRenderTool; + adapter_mode: SessionRenderMode; + target_home: string; + project_root?: string; + files: SessionRenderFile[]; +} + +export interface ProjectContextSessionRenderComposition { + files: SessionRenderFile[]; + source: SessionRenderManifest["sources"][number]; + project_context: NonNullable; + compatibility: Record; + guard: ProjectContextSessionGuard; +} + +export interface ProjectContextSessionGuard { + workspace_root: string; + runtime: ProjectContextRuntime; + observed_hashes: Array<{ + path: string; + sha256: string | null; + }>; +} + +export interface ProjectContextWriteCoordination { + workspace_root: string; + assert_held: () => void; +} + +interface WorkspaceLock { + fd: number; + contentHash: string; + identity: { dev: number; ino: number }; +} + +interface ProjectContextManifest { + schema: typeof SESSION_RENDER_SCHEMA; + kind: "project-context"; + tool: "claude" | "codewith" | "codex"; + adapterMode: "native-import" | "managed-block"; + profile: "project-context"; + sessionId: null; + targetHome: string; + targetKind: "project-root"; + targetOwner: { + kind: "project"; + tool: "claude" | "codewith" | "codex"; + profile: "project-context"; + targetHome: string; + projectRoot: string; + ownedBy: "open-configs"; + canonicalOwner: "instructions"; + reason: string; + }; + writable: true; + blocked: false; + blockers: []; + generatedAt: string; + env: Record; + sourceHash: string; + sources: Array<{ + id: "project-context-bundle"; + label: "Project Context Bundle"; + layer: "repo"; + merge: "replace"; + order: 0; + path: string; + targetProviders: string[]; + owner: { kind: "package"; id: "@hasna/projects" }; + sourcePaths: []; + hash: string; + nonOverridable: true; + replacementScope: "project-context"; + rules: []; + provenance: { + schema: typeof PROJECT_CONTEXT_SCHEMA; + projectId: string; + revision: string; + hash: string; + }; + }>; + skippedSources: []; + files: Array<{ + path: string; + relativePath: string; + role: "fragment" | "index"; + sha256: string; + sourceIds: ["project-context-bundle"]; + }>; + warnings: string[]; + projectContext: { + schema: typeof PROJECT_CONTEXT_SCHEMA; + projectId: string; + revision: string; + hash: string; + status: ProjectContextStatus; + ageSeconds: number; + cachePath: string; + fragmentPath: string; + }; + compatibility: { + legacyPackage: typeof LEGACY_CONFIGS_PACKAGE; + legacyVersion: typeof LEGACY_CONFIGS_COMPAT_VERSION; + legacyExecutable: typeof LEGACY_CONFIGS_EXECUTABLE; + manifestSchema: typeof SESSION_RENDER_SCHEMA; + managedBy: "@hasna/configs"; + ownedBy: "open-configs"; + canonicalOwner: "instructions"; + }; +} + +export class ProjectContextError extends Error { + readonly code: string; + readonly details: Record | undefined; + + constructor(code: string, message: string, details?: Record) { + super(`${code}: ${message}`); + this.name = "ProjectContextError"; + this.code = code; + this.details = details; + } +} + +class ProjectContextHashRace extends Error {} + +export function computeProjectContextSourceHash(value: unknown): string { + const normalized = removeHashForFingerprint(value); + return `sha256:${sha256(stableStringify(normalized))}`; +} + +export function parseProjectContextBundle(input: string | unknown): ProjectContextBundleV1 { + let encoded: string; + try { + const serialized = typeof input === "string" ? input : JSON.stringify(input); + if (typeof serialized !== "string") throw new Error("not JSON-serializable"); + encoded = serialized; + } catch { + throw new ProjectContextError("PROJECT_CONTEXT_INVALID", "bundle is not JSON-serializable"); + } + if (Buffer.byteLength(encoded, "utf8") > PROJECT_CONTEXT_MAX_INPUT_BYTES) { + throw new ProjectContextError("PROJECT_CONTEXT_INPUT_TOO_LARGE", `bundle exceeds ${PROJECT_CONTEXT_MAX_INPUT_BYTES} bytes`); + } + + let value: unknown; + try { + value = typeof input === "string" ? JSON.parse(input) : input; + } catch { + throw new ProjectContextError("PROJECT_CONTEXT_INVALID", "bundle is not valid JSON"); + } + const candidateSchema = isRecord(value) ? value["schema"] : undefined; + if (typeof candidateSchema === "string" && candidateSchema !== PROJECT_CONTEXT_SCHEMA) { + if (/^hasna\.projects\.project_context_bundle\.v[0-9]+$/.test(candidateSchema)) { + throw new ProjectContextError("PROJECT_CONTEXT_UNSUPPORTED_VERSION", `unsupported bundle schema ${candidateSchema}`); + } + } + + const result = projectContextBundleSchema.safeParse(value); + if (!result.success) { + throw new ProjectContextError("PROJECT_CONTEXT_INVALID", "bundle does not match the strict v1 schema", { + issues: result.error.issues.map((issue) => ({ path: issue.path.join("."), code: issue.code, message: issue.message })), + }); + } + const bundle = result.data; + validateLinkConsistency(bundle); + validateCommands(bundle); + validateIdentityConsistency(bundle); + rejectCredentialLikeBundle(bundle); + const expected = computeProjectContextSourceHash(bundle); + if (bundle.hash !== expected) { + throw new ProjectContextError("PROJECT_CONTEXT_HASH_MISMATCH", "bundle hash does not match its canonical allowlisted payload"); + } + return bundle; +} + +export function planProjectContext(input: ProjectContextPlanInput): ProjectContextPlan { + const workspaceRoot = assertSafeWorkspaceRoot(input.workspace_root); + const bundle = parseProjectContextBundle(input.bundle); + if (bundle.resolution.conflict) { + throw new ProjectContextError("PROJECT_CONTEXT_IDENTITY_CONFLICT", "Projects reported a conflicting identity resolution"); + } + const paths = runtimePaths(workspaceRoot, input.runtime); + assertCodewithTargetIsConsumed(workspaceRoot, input.runtime); + for (const path of [paths.target, paths.fragment, paths.manifest, paths.cache]) { + assertNoSymlinkSegments(workspaceRoot, path); + } + + const now = input.now ?? new Date(); + const status = input.status ?? (bundle.freshness === "fresh" ? "fresh" : "stale-source"); + const generatedAgeSeconds = ageInSeconds(bundle.generated_at, now); + const ageSeconds = input.age_seconds ?? generatedAgeSeconds; + if (!Number.isInteger(ageSeconds) || ageSeconds < 0) { + throw new ProjectContextError("PROJECT_CONTEXT_INVALID", "project context age must be a non-negative integer"); + } + const nativeImports = runtimeUsesNativeImports(input.runtime, input.codewith_native_imports); + const inlineMarkerOverhead = nativeImports ? 0 : Buffer.byteLength(buildManagedBlock(bundle, "", "\n"), "utf8"); + const generated = buildCanonicalFragment( + bundle, + status, + ageSeconds, + PROJECT_CONTEXT_MAX_RENDERED_BYTES - Math.max(320, inlineMarkerOverhead), + PROJECT_CONTEXT_MAX_APPROX_TOKENS - Math.max(80, Math.ceil(inlineMarkerOverhead / 4)), + ); + const previousTargetContent = existsSync(paths.target) ? readUtf8RegularFile(paths.target, workspaceRoot) : null; + const markerParse = parseManagedBlock(previousTargetContent ?? "", input.force === true); + if (markerParse.block && markerParse.block.id !== bundle.project.id) { + throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "managed block belongs to a different project"); + } + const eol = preferredEol(previousTargetContent ?? ""); + const body = nativeImports + ? `@${input.runtime === "codewith" ? "../" : ""}${PROJECT_CONTEXT_FRAGMENT_PATH}` + : generated.fragment.trimEnd(); + const managedBlock = buildManagedBlock(bundle, body, eol); + if (Buffer.byteLength(managedBlock, "utf8") > PROJECT_CONTEXT_MAX_RENDERED_BYTES || Math.ceil(managedBlock.length / 4) > PROJECT_CONTEXT_MAX_APPROX_TOKENS) { + throw new ProjectContextError("PROJECT_CONTEXT_RENDER_TOO_LARGE", "managed provider block exceeds its bounded render budget"); + } + + const legacy = markerParse.block === null + ? findLegacyCodewithWorkspaceSection(workspaceRoot, input.runtime, previousTargetContent, bundle) + : null; + const targetContent = replaceOrAppendManagedBlock( + previousTargetContent ?? "", + managedBlock, + markerParse, + legacy, + ); + scanGeneratedContent(generated.fragment); + scanGeneratedContent(managedBlock); + + const expectedHashes = new Map(); + for (const path of [paths.fragment, paths.target, paths.cache, paths.manifest, paths.sessionManifest]) { + if (!path) continue; + expectedHashes.set(path, currentFileHash(path, workspaceRoot)); + } + + return { + workspace_root: workspaceRoot, + runtime: input.runtime, + target_path: paths.target, + target_relative_path: relativePosix(workspaceRoot, paths.target), + fragment_path: paths.fragment, + manifest_path: paths.manifest, + cache_path: paths.cache, + source_path: paths.cache, + bundle, + fragment: generated.fragment, + managed_block: managedBlock, + target_content: targetContent, + target_previous_content: previousTargetContent, + status, + age_seconds: ageSeconds, + warnings: generated.warnings, + included_commands: generated.includedCommands, + native_imports: nativeImports, + marker: markerParse.block, + legacy_migration: legacy !== null, + expected_hashes: expectedHashes, + }; +} + +export function composeProjectContextSessionRender( + input: ProjectContextSessionRenderInput, +): ProjectContextSessionRenderComposition | null { + const guard = observeProjectContextSessionGuard(input); + if (guard === null) return null; + const { runtime, workspace_root: workspaceRoot, observed_hashes: observedHashes } = guard; + const paths = runtimePaths(workspaceRoot, runtime); + if (!existsSync(paths.manifest)) return null; + assertCodewithTargetIsConsumed(workspaceRoot, runtime); + + const manifest = readProjectContextManifest(paths.manifest, workspaceRoot); + if (!manifest) return null; + if (manifest.tool !== manifestTool(runtime)) return null; + const nativeImports = input.adapter_mode === "native-imports"; + const expectedAdapterMode = nativeImports ? "native-import" : "managed-block"; + if (manifest.adapterMode !== expectedAdapterMode) { + throw new ProjectContextError( + "PROJECT_CONTEXT_ADAPTER_MISMATCH", + "the active project-context adapter mode differs from the selected session runtime mode", + ); + } + const targetEntries = manifest.files.filter((file) => file.role === "index"); + const fragmentEntry = manifest.files.find((file) => file.relativePath === PROJECT_CONTEXT_FRAGMENT_PATH && file.role === "fragment"); + if ( + targetEntries.length !== 1 || + targetEntries[0]!.path !== paths.target || + fragmentEntry?.path !== paths.fragment + ) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "project-context manifest paths do not match the selected runtime workspace"); + } + + const cache = readProjectContextCache(paths.cache, workspaceRoot); + if (!cache) throw new ProjectContextError("PROJECT_CONTEXT_CACHE_MISSING", "durable project-context cache is missing"); + if ( + cache.project_id !== manifest.projectContext.projectId || + cache.revision !== manifest.projectContext.revision || + cache.hash !== manifest.projectContext.hash + ) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "project-context manifest and durable cache identities differ"); + } + if (currentFileHash(paths.fragment, workspaceRoot) !== fragmentEntry.sha256 || !fragmentMatchesBundle(paths.fragment, cache.bundle, workspaceRoot)) { + throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "canonical project-context fragment differs from its durable manifest"); + } + const fragment = readUtf8RegularFile(paths.fragment, workspaceRoot, PROJECT_CONTEXT_MAX_RENDERED_BYTES); + scanGeneratedContent(fragment); + + if (!existsSync(paths.target)) { + throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "project-context provider target is missing while durable context is active"); + } + const currentTarget = readUtf8RegularFile(paths.target, workspaceRoot); + const currentMarkers = parseManagedBlock(currentTarget, false); + if (!currentMarkers.block) { + throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "project-context provider target lost its managed block"); + } + if ( + currentMarkers.block.id !== cache.project_id || + currentMarkers.block.revision !== cache.revision || + currentMarkers.block.hash !== cache.hash + ) { + throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "project-context provider markers differ from the durable cache"); + } + + const plannedIndexes = input.files.filter((file) => file.role === "index" && resolve(file.path) === paths.target); + if (plannedIndexes.length !== 1) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "session renderer does not own the selected project-context provider target"); + } + const index = plannedIndexes[0]!; + const baseMarkers = parseManagedBlock(index.content, false); + const body = nativeImports + ? `@${runtime === "codewith" ? "../" : ""}${PROJECT_CONTEXT_FRAGMENT_PATH}` + : fragment.trimEnd(); + const managedBlock = buildManagedBlock(cache.bundle, body, preferredEol(index.content)); + scanGeneratedContent(managedBlock); + const content = ensureTrailingNewline(replaceOrAppendManagedBlock(index.content, managedBlock, baseMarkers, null)); + const files = input.files.map((file) => file === index + ? { + ...file, + content, + sha256: sha256(content), + sourceIds: [...new Set([...file.sourceIds, "project-context-bundle"])], + } + : file); + if (observedHashes.some((observed) => currentFileHash(observed.path, workspaceRoot) !== observed.sha256)) { + throw new ProjectContextError( + "PROJECT_CONTEXT_SESSION_STALE", + "durable project context changed while the session plan was being created; create a fresh session render plan", + ); + } + + return { + files, + source: projectContextManifestSource(paths.cache, runtime, cache.bundle), + project_context: { + ...manifest.projectContext, + }, + compatibility: manifestCompatibility(), + guard, + }; +} + +export function observeProjectContextSessionGuard( + input: Pick, +): ProjectContextSessionGuard | null { + const runtime = projectContextRuntimeForSessionTool(input.tool); + if (runtime === null) return null; + const workspaceRoot = projectContextWorkspaceForSession(input, runtime); + if (workspaceRoot === null) return null; + const paths = runtimePaths(workspaceRoot, runtime); + return { + workspace_root: workspaceRoot, + runtime, + observed_hashes: projectContextSessionGuardPaths(paths, runtime) + .map((path) => ({ path, sha256: currentFileHash(path, workspaceRoot) })), + }; +} + +export function withProjectContextSessionGuard( + guard: ProjectContextSessionGuard | undefined, + action: (coordination: ProjectContextWriteCoordination | null) => T, + options: { dry_run?: boolean } = {}, +): T { + if (!guard) return action(null); + const validated = validateProjectContextSessionGuard(guard); + const verify = () => { + for (const observed of validated.observed_hashes) { + if (currentFileHash(observed.path, validated.workspace_root) !== observed.sha256) { + throw new ProjectContextError( + "PROJECT_CONTEXT_SESSION_STALE", + "durable project context changed after the session plan was created; create a fresh session render plan", + ); + } + } + }; + if (options.dry_run) { + verify(); + return action(null); + } + + const lockPath = resolve(validated.workspace_root, ...PROJECT_CONTEXT_LOCK_PATH.split("/")); + const lock = acquireWorkspaceLock(validated.workspace_root, lockPath); + try { + verify(); + const assertHeld = () => assertWorkspaceLockHeld(lockPath, lock, validated.workspace_root); + assertHeld(); + return action({ workspace_root: validated.workspace_root, assert_held: assertHeld }); + } finally { + releaseWorkspaceLock(lockPath, lock, validated.workspace_root); + } +} + +function validateProjectContextSessionGuard(guard: ProjectContextSessionGuard): ProjectContextSessionGuard { + const workspaceRoot = assertSafeWorkspaceRoot(guard.workspace_root); + if (!(["claude", "codewith", "agents"] as const).includes(guard.runtime)) { + throw new ProjectContextError("PROJECT_CONTEXT_SESSION_STALE", "session project-context guard has an invalid runtime"); + } + const paths = runtimePaths(workspaceRoot, guard.runtime); + const allowedPaths = new Set(projectContextSessionGuardPaths(paths, guard.runtime)); + if (!Array.isArray(guard.observed_hashes) || guard.observed_hashes.length !== allowedPaths.size) { + throw new ProjectContextError("PROJECT_CONTEXT_SESSION_STALE", "session project-context guard has an incomplete hash inventory"); + } + const observedPaths = new Set(); + const observedHashes = guard.observed_hashes.map((observed) => { + if (!isRecord(observed) || typeof observed.path !== "string") { + throw new ProjectContextError("PROJECT_CONTEXT_SESSION_STALE", "session project-context guard contains malformed hash metadata"); + } + const path = resolve(observed.path); + if (!allowedPaths.has(path) || observedPaths.has(path)) { + throw new ProjectContextError("PROJECT_CONTEXT_SESSION_STALE", "session project-context guard contains an unexpected or duplicate path"); + } + if (observed.sha256 !== null && (typeof observed.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(observed.sha256))) { + throw new ProjectContextError("PROJECT_CONTEXT_SESSION_STALE", "session project-context guard contains an invalid hash"); + } + observedPaths.add(path); + return { path, sha256: observed.sha256 }; + }); + if (observedPaths.size !== allowedPaths.size) { + throw new ProjectContextError("PROJECT_CONTEXT_SESSION_STALE", "session project-context guard does not cover every durable context path"); + } + return { + workspace_root: workspaceRoot, + runtime: guard.runtime, + observed_hashes: observedHashes, + }; +} + +export function applyProjectContext(options: ProjectContextApplyOptions): ProjectContextApplyResult { + const workspaceRoot = assertSafeWorkspaceRoot(options.workspace_root); + const now = options.now ?? new Date(); + const lockPath = resolve(workspaceRoot, ...PROJECT_CONTEXT_LOCK_PATH.split("/")); + const lock = options.dry_run + ? null + : acquireWorkspaceLock( + workspaceRoot, + lockPath, + options.test_hooks?.after_lock_open, + options.test_hooks?.before_stale_lock_remove, + options.test_hooks?.process_start_identity, + ); + try { + const resolved = resolveBundleForApply(options, workspaceRoot, now); + let raceRetries = 0; + let snapshotPath: string | null = null; + for (let attempt = 0; attempt < 2; attempt++) { + const plan = planProjectContext({ + workspace_root: workspaceRoot, + runtime: options.runtime, + bundle: resolved.bundle, + source_path: resolved.sourcePath, + status: resolved.status, + age_seconds: resolved.ageSeconds, + now, + force: options.force, + codewith_native_imports: options.codewith_native_imports, + }); + assertRevisionOrdering(plan, options.force === true); + const cacheContent = `${JSON.stringify(buildCache(plan, now), null, 2)}\n`; + const sessionManifest = buildSessionCompatibilityManifest(plan, now); + const sessionOutput = { + path: runtimePaths(workspaceRoot, plan.runtime).sessionManifest, + content: `${JSON.stringify(sessionManifest, null, 2)}\n`, + }; + options.test_hooks?.before_compare?.({ attempt, plan }); + if (!hashesStillMatch(plan.expected_hashes, workspaceRoot)) { + if (attempt === 0) { + raceRetries++; + continue; + } + throw new ProjectContextError("PROJECT_CONTEXT_HASH_RACE", "workspace files changed during both compare-and-render attempts"); + } + if (options.dry_run) return resultForPlan(plan, true, raceRetries, null); + + assertWorkspaceLockHeld(lockPath, lock!, workspaceRoot); + try { + snapshotPath = writeMetadataSnapshot(plan, now); + atomicWriteFile( + plan.fragment_path, + plan.fragment, + workspaceRoot, + 0o644, + expectedPlanHash(plan, plan.fragment_path), + undefined, + options.test_hooks?.atomic_exchange_unavailable, + undefined, + options.test_hooks?.portable_create_only, + ); + options.test_hooks?.after_fragment?.({ attempt, plan }); + assertWorkspaceLockHeld(lockPath, lock!, workspaceRoot); + atomicWriteFile( + plan.target_path, + plan.target_content, + workspaceRoot, + 0o644, + expectedPlanHash(plan, plan.target_path), + () => options.test_hooks?.after_target_exchange?.({ attempt, plan }), + options.test_hooks?.atomic_exchange_unavailable, + (tempPath) => options.test_hooks?.before_target_install?.({ attempt, plan, temp_path: tempPath }), + options.test_hooks?.portable_create_only, + ); + options.test_hooks?.after_target?.({ attempt, plan }); + assertWorkspaceLockHeld(lockPath, lock!, workspaceRoot); + + atomicWriteFile( + plan.cache_path, + cacheContent, + workspaceRoot, + 0o600, + expectedPlanHash(plan, plan.cache_path), + undefined, + options.test_hooks?.atomic_exchange_unavailable, + undefined, + options.test_hooks?.portable_create_only, + ); + assertWorkspaceLockHeld(lockPath, lock!, workspaceRoot); + + atomicWriteFile( + sessionOutput.path, + sessionOutput.content, + workspaceRoot, + 0o600, + expectedPlanHash(plan, sessionOutput.path), + undefined, + options.test_hooks?.atomic_exchange_unavailable, + undefined, + options.test_hooks?.portable_create_only, + ); + assertWorkspaceLockHeld(lockPath, lock!, workspaceRoot); + + options.test_hooks?.before_manifest?.({ attempt, plan }); + assertWorkspaceLockHeld(lockPath, lock!, workspaceRoot); + assertRenderedOutputsStable(plan, cacheContent, sessionOutput); + const manifest = buildManifest(plan, now); + atomicWriteFile( + plan.manifest_path, + `${JSON.stringify(manifest, null, 2)}\n`, + workspaceRoot, + 0o600, + expectedPlanHash(plan, plan.manifest_path), + undefined, + options.test_hooks?.atomic_exchange_unavailable, + undefined, + options.test_hooks?.portable_create_only, + ); + return resultForPlan(plan, false, raceRetries, snapshotPath); + } catch (error) { + if (error instanceof ProjectContextHashRace && attempt === 0) { + raceRetries++; + continue; + } + if (error instanceof ProjectContextHashRace) { + throw new ProjectContextError("PROJECT_CONTEXT_HASH_RACE", "workspace files changed during both compare-and-render attempts"); + } + throw error; + } + } + throw new ProjectContextError("PROJECT_CONTEXT_HASH_RACE", "workspace files changed during both compare-and-render attempts"); + } finally { + if (lock !== null) releaseWorkspaceLock(lockPath, lock, workspaceRoot); + } +} + +function resultForPlan( + plan: ProjectContextPlan, + dryRun: boolean, + raceRetries: number, + snapshotPath: string | null, +): ProjectContextApplyResult { + return { + applied: !dryRun, + dry_run: dryRun, + workspace_root: plan.workspace_root, + runtime: plan.runtime, + project_id: plan.bundle.project.id, + revision: plan.bundle.revision, + hash: plan.bundle.hash, + status: plan.status, + age_seconds: plan.age_seconds, + race_retries: raceRetries, + target_path: plan.target_path, + fragment_path: plan.fragment_path, + manifest_path: plan.manifest_path, + cache_path: plan.cache_path, + snapshot_path: snapshotPath, + warnings: plan.warnings, + }; +} + +function expectedPlanHash(plan: ProjectContextPlan, path: string): string | null { + if (!plan.expected_hashes.has(path)) { + throw new ProjectContextError("PROJECT_CONTEXT_INVALID", `missing expected hash for managed path ${relativePosix(plan.workspace_root, path)}`); + } + return plan.expected_hashes.get(path) ?? null; +} + +function assertRenderedOutputsStable( + plan: ProjectContextPlan, + cacheContent: string, + sessionOutput: { path: string; content: string }, +): void { + const outputs = [ + { path: plan.fragment_path, content: plan.fragment }, + { path: plan.target_path, content: plan.target_content }, + { path: plan.cache_path, content: cacheContent }, + sessionOutput, + ]; + for (const output of outputs) { + if (currentFileHash(output.path, plan.workspace_root) !== sha256(output.content)) { + throw new ProjectContextHashRace(`managed path changed before manifest commit: ${relativePosix(plan.workspace_root, output.path)}`); + } + } +} + +function resolveBundleForApply( + options: ProjectContextApplyOptions, + workspaceRoot: string, + now: Date, +): { bundle: ProjectContextBundleV1; status: ProjectContextStatus; ageSeconds: number; sourcePath: string } { + const hasInput = options.bundle_json !== undefined || options.bundle !== undefined; + if (hasInput) { + try { + const bundle = parseProjectContextBundle(options.bundle_json ?? options.bundle); + if (options.expected_project_id && bundle.project.id !== options.expected_project_id) { + throw new ProjectContextError("PROJECT_CONTEXT_IDENTITY_CONFLICT", "bundle project ID differs from the expected project ID"); + } + return { + bundle, + status: bundle.freshness === "fresh" ? "fresh" : "stale-source", + ageSeconds: ageInSeconds(bundle.generated_at, now), + sourcePath: durableSourcePath(options.source_path, workspaceRoot), + }; + } catch (error) { + if (!(error instanceof ProjectContextError) || error.code !== "PROJECT_CONTEXT_UNSUPPORTED_VERSION" || !options.allow_stale_cache) { + throw error; + } + } + } + + if (!options.allow_stale_cache) { + throw new ProjectContextError("PROJECT_CONTEXT_INPUT_REQUIRED", "a v1 bundle is required unless stale-cache fallback is explicit"); + } + if (!options.expected_project_id) { + throw new ProjectContextError("PROJECT_CONTEXT_CACHE_ID_REQUIRED", "expected_project_id is required for stale-cache fallback"); + } + const cachePath = resolve(workspaceRoot, ...PROJECT_CONTEXT_CACHE_PATH.split("/")); + const cache = readProjectContextCache(cachePath, workspaceRoot); + if (!cache) throw new ProjectContextError("PROJECT_CONTEXT_CACHE_MISSING", "no last-known-good project context cache exists"); + if (cache.project_id !== options.expected_project_id || cache.bundle.project.id !== options.expected_project_id) { + throw new ProjectContextError("PROJECT_CONTEXT_CACHE_ID_MISMATCH", "cached project context belongs to a different project"); + } + const bundle = parseProjectContextBundle(cache.bundle); + if (bundle.revision !== cache.revision || bundle.hash !== cache.hash) { + throw new ProjectContextError("PROJECT_CONTEXT_CACHE_INVALID", "cached revision or hash metadata is inconsistent"); + } + const ageSeconds = Math.max( + staleCacheAgeInSeconds(bundle.generated_at, now, "bundle generated_at"), + staleCacheAgeInSeconds(cache.cached_at, now, "cache cached_at"), + ); + const maxAge = normalizeMaxStaleAge(options.max_stale_age_seconds); + if (ageSeconds > maxAge) { + throw new ProjectContextError("PROJECT_CONTEXT_CACHE_EXPIRED", `cached project context age ${ageSeconds}s exceeds ${maxAge}s`); + } + return { bundle, status: "stale-cache", ageSeconds, sourcePath: cachePath }; +} + +function buildCanonicalFragment( + bundle: ProjectContextBundleV1, + status: ProjectContextStatus, + ageSeconds: number, + maxBytes: number, + maxTokens: number, +): { fragment: string; warnings: string[]; includedCommands: number } { + const warnings = boundedWarnings(bundle, status, ageSeconds); + const commands = [...bundle.commands]; + let fragment = ""; + do { + fragment = renderFragment(bundle, status, ageSeconds, warnings, commands); + const bytes = Buffer.byteLength(fragment, "utf8"); + const tokens = Math.ceil(fragment.length / 4); + if (bytes <= maxBytes && tokens <= maxTokens) break; + if (commands.length === 0) { + throw new ProjectContextError("PROJECT_CONTEXT_RENDER_TOO_LARGE", "core project identity exceeds the bounded fragment budget"); + } + commands.pop(); + } while (true); + scanGeneratedContent(fragment); + return { fragment, warnings, includedCommands: commands.length }; +} + +function renderFragment( + bundle: ProjectContextBundleV1, + status: ProjectContextStatus, + ageSeconds: number, + warnings: string[], + commands: ProjectContextBundleV1["commands"], +): string { + const project = bundle.project; + const lines = [ + ``, + "# Managed Project Context", + "", + `Context: ${statusLabel(status, ageSeconds)}`, + `Project: ${inlineCode(project.name)} (${inlineCode(project.slug)})`, + `ID: \`${project.id}\``, + `Kind: \`${project.kind}\``, + `Status: \`${project.status}\``, + `Revision: \`${bundle.revision}\``, + `Authority: \`${bundle.authority.owner}\` / \`${bundle.authority.mode}\` / \`${bundle.authority.storage}\` / \`${bundle.authority.availability}\``, + `Resolution: \`${bundle.resolution.source}\`; create allowed: \`${String(bundle.resolution.create_allowed)}\``, + `Path: ${project.path ? inlineCode(project.path) : "`none`"}`, + `Updated: \`${project.updated_at}\``, + "", + "## Linked Systems", + "", + `- Todos (\`${bundle.links.todos.state}\`): project ${inlineNullable(bundle.links.todos.project_id)}, task list ${inlineNullable(bundle.links.todos.task_list_id)}`, + `- Conversations (\`${bundle.links.conversations.state}\`): channel ${inlineNullable(bundle.links.conversations.channel)}`, + `- Mementos (\`${bundle.links.mementos.state}\`): project ${inlineNullable(bundle.links.mementos.project_id)}, scope ${inlineNullable(bundle.links.mementos.scope)}`, + `- Station: ${bundle.station ? `${inlineNullable(bundle.station.station_id)}; machine ${inlineNullable(bundle.station.machine_id)}` : "`unknown`"}`, + ]; + if (warnings.length > 0) { + lines.push("", "## Warnings", "", ...warnings.map((warning) => `- ${warning}`)); + } + if (commands.length > 0) { + lines.push("", "## Safe Next Commands", ""); + for (const command of commands) { + lines.push(`- ${escapeText(command.name)}: \`${command.argv.map(shellQuote).join(" ")}\``); + } + } + return `${lines.join("\n")}\n`; +} + +function boundedWarnings(bundle: ProjectContextBundleV1, status: ProjectContextStatus, ageSeconds: number): string[] { + const warnings: string[] = []; + if (status === "stale-cache") warnings.push(`Using a bounded last-known-good cache aged ${ageSeconds}s; refresh from Projects before mutation.`); + else if (status === "stale-source") warnings.push(`Projects marked this context stale; source age is ${ageSeconds}s.`); + if (bundle.freshness === "unknown") warnings.push("Projects could not establish source freshness for this bundle."); + if (bundle.authority.availability === "unavailable") warnings.push("The Projects authority was unavailable when this bundle was produced."); + if (bundle.links.todos.state === "partial" || bundle.links.conversations.state === "partial" || bundle.links.mementos.state === "partial") { + warnings.push("One or more linked-system identities are partial."); + } + return warnings.slice(0, PROJECT_CONTEXT_MAX_WARNINGS); +} + +function buildManagedBlock(bundle: ProjectContextBundleV1, body: string, eol: string): string { + const revision = encodeURIComponent(bundle.revision); + const begin = ``; + const end = ``; + return `${begin}${eol}${body.replace(/\r?\n/g, eol).trimEnd()}${eol}${end}`; +} + +function parseManagedBlock(content: string, force: boolean): { block: ManagedBlock | null; forceRange: { start: number; end: number } | null } { + const lines = linesWithOffsets(content); + const markerLines = lines.filter((line) => + ( + line.text.includes(PROJECT_CONTEXT_MANAGED_COMMENT) || + /@hasna\/configs project context/i.test(line.text) + ) && !line.text.includes(`${PROJECT_CONTEXT_MANAGED_COMMENT} fragment.`) + ); + if (markerLines.length === 0) return { block: null, forceRange: null }; + + const parsed = markerLines.map((line) => ({ ...line, marker: parseMarkerLine(line.text) })); + const malformed = parsed.some((line) => line.marker === null); + const starts = parsed.filter((line) => line.marker?.kind === "BEGIN"); + const ends = parsed.filter((line) => line.marker?.kind === "END"); + const structurallyInvalid = malformed || starts.length !== 1 || ends.length !== 1 || starts[0]!.start >= ends[0]!.start; + if (structurallyInvalid) { + if (!force) throw new ProjectContextError("MANAGED_BLOCK_INVALID", "managed project-context markers are duplicate, nested, malformed, or unbalanced"); + return { + block: null, + forceRange: { + start: markerLines[0]!.start, + end: lineContentEnd(markerLines[markerLines.length - 1]!), + }, + }; + } + + const begin = starts[0]!; + const end = ends[0]!; + const a = begin.marker!; + const b = end.marker!; + if (a.id !== b.id || a.revision !== b.revision || a.hash !== b.hash) { + if (!force) throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "managed project-context marker metadata is inconsistent"); + return { block: null, forceRange: { start: begin.start, end: lineContentEnd(end) } }; + } + const nested = parsed.some((line) => line.start > begin.start && line.start < end.start); + if (nested) { + if (!force) throw new ProjectContextError("MANAGED_BLOCK_INVALID", "nested managed project-context markers are not supported"); + return { block: null, forceRange: { start: begin.start, end: lineContentEnd(end) } }; + } + return { + block: { + start: begin.start, + end: lineContentEnd(end), + id: a.id, + revision: a.revision, + hash: a.hash, + legacy: a.legacy || b.legacy, + }, + forceRange: null, + }; +} + +function parseMarkerLine(text: string): { kind: "BEGIN" | "END"; id: string; revision: string; hash: string; legacy: boolean } | null { + const line = text.replace(/[\r\n]+$/, ""); + const canonical = line.match(/^$/); + if (canonical) { + try { + const revision = decodeURIComponent(canonical[3]!); + if (!revisionSchema.safeParse(revision).success) return null; + return { kind: canonical[1] as "BEGIN" | "END", id: canonical[2]!, revision, hash: canonical[4]!, legacy: false }; + } catch { + return null; + } + } + const legacy = line.match(/^$/); + if (legacy) { + return { kind: legacy[1] as "BEGIN" | "END", id: legacy[2]!, revision: legacy[3]!, hash: legacy[4]!, legacy: true }; + } + return null; +} + +function replaceOrAppendManagedBlock( + content: string, + block: string, + parsed: ReturnType, + legacy: { start: number; end: number } | null, +): string { + const range = parsed.block ?? parsed.forceRange ?? legacy; + if (range) return `${content.slice(0, range.start)}${block}${content.slice(range.end)}`; + if (!content) return `${block}\n`; + const eol = preferredEol(content); + const separator = content.endsWith("\n") || content.endsWith("\r") ? eol : `${eol}${eol}`; + return `${content}${separator}${block}${eol}`; +} + +function findLegacyCodewithWorkspaceSection( + workspaceRoot: string, + runtime: ProjectContextRuntime, + content: string | null, + bundle: ProjectContextBundleV1, +): { start: number; end: number } | null { + if (runtime !== "codewith" || !content) return null; + const sessionManifestPath = runtimePaths(workspaceRoot, runtime).sessionManifest; + if (!existsSync(sessionManifestPath)) return null; + const manifest = readSessionManifestRecord(sessionManifestPath, workspaceRoot); + if (!manifest || manifest["schema"] !== SESSION_RENDER_SCHEMA) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "legacy Codewith session manifest is malformed or incompatible"); + } + const sources = Array.isArray(manifest["sources"]) ? manifest["sources"] : []; + const hasFdSource = sources.some((source) => isRecord(source) && typeof source["path"] === "string" && source["path"].startsWith("/dev/fd/")); + if (!hasFdSource) return null; + const files = Array.isArray(manifest["files"]) ? manifest["files"] : []; + const codewith = files.find((file) => isRecord(file) && file["relativePath"] === "CODEWITH.md"); + if (!isRecord(codewith) || codewith["sha256"] !== sha256(content)) { + throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "legacy /dev/fd session manifest does not match CODEWITH.md"); + } + const section = /^## Workspace\r?\n/gm.exec(content); + if (!section) return null; + const restStart = section.index + section[0].length; + const next = /^## [^\r\n]+\r?\n/gm; + next.lastIndex = restStart; + const nextMatch = next.exec(content); + const end = nextMatch?.index ?? content.length; + const body = content.slice(section.index, end); + if (!body.includes(".project.json") || !body.includes(bundle.project.id)) { + throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "legacy project section cannot be tied to the incoming canonical project ID"); + } + return { start: section.index, end }; +} + +function assertRevisionOrdering(plan: ProjectContextPlan, force: boolean): void { + const observations: Array<{ source: string; id: string; revision: string; hash: string }> = []; + const manifest = readProjectContextManifest(plan.manifest_path, plan.workspace_root); + if (manifest) { + observations.push({ + source: "manifest", + id: manifest.projectContext.projectId, + revision: manifest.projectContext.revision, + hash: manifest.projectContext.hash, + }); + const fragmentEntry = manifest.files.find((file) => file.relativePath === PROJECT_CONTEXT_FRAGMENT_PATH); + if (fragmentEntry && existsSync(plan.fragment_path)) { + const actual = currentFileHash(plan.fragment_path, plan.workspace_root); + if (actual !== fragmentEntry.sha256 && !fragmentMatchesBundle(plan.fragment_path, plan.bundle, plan.workspace_root) && !force) { + throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "canonical project-context fragment changed outside Instructions"); + } + } + } + const cache = readProjectContextCache(plan.cache_path, plan.workspace_root); + if (cache) observations.push({ source: "cache", id: cache.project_id, revision: cache.revision, hash: cache.hash }); + if (plan.marker) observations.push({ source: "marker", id: plan.marker.id, revision: plan.marker.revision, hash: plan.marker.hash }); + + for (const observation of observations) { + if (observation.id !== plan.bundle.project.id) { + throw new ProjectContextError("PROJECT_CONTEXT_IDENTITY_CONFLICT", `${observation.source} belongs to another project`); + } + const ordering = compareRevisions(plan.bundle.revision, observation.revision); + if (ordering < 0) { + throw new ProjectContextError("PROJECT_CONTEXT_REVISION_STALE", `incoming revision ${plan.bundle.revision} is older than ${observation.source} revision ${observation.revision}`); + } + if (ordering === 0 && plan.bundle.hash !== observation.hash) { + throw new ProjectContextError("PROJECT_CONTEXT_REVISION_CONFLICT", `revision ${plan.bundle.revision} has a different hash than ${observation.source}`); + } + } +} + +function buildCache(plan: ProjectContextPlan, now: Date): ProjectContextCache { + return { + schema: PROJECT_CONTEXT_CACHE_SCHEMA, + cached_at: now.toISOString(), + project_id: plan.bundle.project.id, + revision: plan.bundle.revision, + hash: plan.bundle.hash, + bundle: plan.bundle, + }; +} + +function buildManifest(plan: ProjectContextPlan, now: Date): ProjectContextManifest { + const tool = manifestTool(plan.runtime); + const files: ProjectContextManifest["files"] = [ + { + path: plan.fragment_path, + relativePath: PROJECT_CONTEXT_FRAGMENT_PATH, + role: "fragment", + sha256: sha256(plan.fragment), + sourceIds: ["project-context-bundle"], + }, + { + path: plan.target_path, + relativePath: plan.target_relative_path, + role: "index", + sha256: sha256(plan.target_content), + sourceIds: ["project-context-bundle"], + }, + ]; + return { + schema: SESSION_RENDER_SCHEMA, + kind: "project-context", + tool, + adapterMode: plan.native_imports ? "native-import" : "managed-block", + profile: "project-context", + sessionId: null, + targetHome: plan.workspace_root, + targetKind: "project-root", + targetOwner: { + kind: "project", + tool, + profile: "project-context", + targetHome: plan.workspace_root, + projectRoot: plan.workspace_root, + ownedBy: "open-configs", + canonicalOwner: "instructions", + reason: "project context is emitted by Projects and written exclusively by Instructions", + }, + writable: true, + blocked: false, + blockers: [], + generatedAt: now.toISOString(), + env: {}, + sourceHash: plan.bundle.hash, + sources: [manifestSource(plan)], + skippedSources: [], + files, + warnings: plan.warnings, + projectContext: manifestProjectContext(plan), + compatibility: manifestCompatibility(), + }; +} + +function buildSessionCompatibilityManifest(plan: ProjectContextPlan, now: Date): Record { + const paths = runtimePaths(plan.workspace_root, plan.runtime); + const tool = manifestTool(plan.runtime); + const targetHome = plan.runtime === "codewith" ? resolve(plan.workspace_root, ".codewith") : plan.workspace_root; + const targetRelativePath = sessionTargetRelativePath(plan.runtime); + const existing = existsSync(paths.sessionManifest) + ? readSessionManifestRecord(paths.sessionManifest, plan.workspace_root) + : { + schema: SESSION_RENDER_SCHEMA, + tool, + adapterMode: plan.native_imports ? "native-imports" : "flattened-markdown", + profile: "project-context", + sessionId: null, + targetHome, + targetKind: "session-home", + targetOwner: {}, + env: {}, + sourceHash: null, + sources: [], + skippedSources: [], + files: [], + warnings: [], + }; + if (!existing || existing["schema"] !== SESSION_RENDER_SCHEMA || (existing["tool"] !== undefined && existing["tool"] !== tool)) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session manifest is malformed or incompatible"); + } + const existingTargetHome = safeLegacyMetadataString(existing["targetHome"], null); + if (existingTargetHome !== null && resolve(existingTargetHome) !== targetHome) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session manifest targets a different workspace"); + } + const sources = sanitizeLegacySources(existing["sources"]) + .filter((source) => source["id"] !== "project-context-bundle"); + sources.push(manifestSource(plan)); + const files = sanitizeLegacyFiles(existing["files"]); + const targetIndexes = files.filter((file) => file["relativePath"] === targetRelativePath); + if (targetIndexes.length > 1) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", `provider session manifest contains duplicate ${targetRelativePath} entries`); + } + const previousSourceIds = targetIndexes[0]?.["sourceIds"] as string[] | undefined; + const updatedTarget = { + path: plan.target_path, + relativePath: targetRelativePath, + role: "index", + sha256: sha256(plan.target_content), + sourceIds: [...new Set([...(previousSourceIds ?? []), "project-context-bundle"])], + }; + const targetOwner = isRecord(existing["targetOwner"]) ? existing["targetOwner"] : {}; + const adapterMode = plan.native_imports ? "native-imports" : "flattened-markdown"; + return credentialSafeSessionManifest({ + schema: SESSION_RENDER_SCHEMA, + tool, + adapterMode, + profile: safeLegacyMetadataString(existing["profile"], "project-context"), + sessionId: existing["sessionId"] === null ? null : safeLegacyMetadataString(existing["sessionId"], null), + targetHome, + targetKind: existing["targetKind"] === "project-root" ? "project-root" : "session-home", + targetOwner: { + kind: targetOwner["kind"] === "project" ? "project" : "provider-profile", + tool, + profile: safeLegacyMetadataString(targetOwner["profile"], safeLegacyMetadataString(existing["profile"], "project-context")), + targetHome, + projectRoot: plan.workspace_root, + ownedBy: "open-configs", + canonicalOwner: "instructions", + reason: "provider session manifest retained for additive Instructions project-context compatibility", + }, + writable: true, + blocked: false, + blockers: [], + generatedAt: now.toISOString(), + env: sanitizeLegacyEnvironment(existing["env"]), + sourceHash: sha256(stableStringify({ previous: typeof existing["sourceHash"] === "string" ? existing["sourceHash"] : null, projectContext: plan.bundle.hash })), + sources, + skippedSources: sanitizeLegacySkippedSources(existing["skippedSources"]), + files: [...files.filter((file) => file["relativePath"] !== targetRelativePath), updatedTarget], + warnings: [...new Set([...sanitizeLegacyWarnings(existing["warnings"]), ...plan.warnings])].slice(0, 64), + projectContext: manifestProjectContext(plan), + compatibility: manifestCompatibility(), + }); +} + +function credentialSafeSessionManifest(manifest: Record): Record { + const encoded = JSON.stringify(manifest, null, 2); + if ( + scanSecrets(encoded, "json").length > 0 || + /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:password|passwd|api[_-]?key|access[_-]?token|client[_-]?secret)\s*["']?\s*[:=]|https?:\/\//i.test(encoded) + ) { + throw new ProjectContextError( + "PROJECT_CONTEXT_MANIFEST_INVALID", + "provider session manifest contains credential-like metadata", + ); + } + return manifest; +} + +function sanitizeLegacySources(value: unknown): Array> { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 64 || value.some((entry) => !isRecord(entry))) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session source inventory is malformed"); + } + return value.map((entry, index) => { + const source = entry as Record; + const id = safeLegacyMetadataString(source["id"], `legacy-source-${index}`); + const layer = typeof source["layer"] === "string" && SESSION_INSTRUCTION_LAYERS.includes(source["layer"] as typeof SESSION_INSTRUCTION_LAYERS[number]) + ? source["layer"] + : "local"; + const merge = source["merge"] === "replace" ? "replace" : "append"; + const owner = isRecord(source["owner"]) + ? { + kind: safeLegacyMetadataString(source["owner"]["kind"], "unknown"), + id: safeLegacyMetadataString(source["owner"]["id"], id), + } + : null; + const sourcePaths = Array.isArray(source["sourcePaths"]) + ? source["sourcePaths"].slice(0, 64).filter(isRecord).map((item) => ({ + path: safeLegacyMetadataString(item["path"], "unknown"), + ...(typeof item["editable"] === "boolean" ? { editable: item["editable"] } : {}), + ...(typeof item["required"] === "boolean" ? { required: item["required"] } : {}), + ...(typeof item["hash"] === "string" ? { hash: safeLegacyMetadataString(item["hash"], "") } : {}), + })) + : []; + const rules = Array.isArray(source["rules"]) + ? source["rules"].slice(0, 64).filter(isRecord).map((rule, ruleIndex) => ({ + id: safeLegacyMetadataString(rule["id"], `${id}-rule-${ruleIndex}`), + label: safeLegacyMetadataString(rule["label"], `${id} rule ${ruleIndex + 1}`), + path: safeLegacyMetadataString(rule["path"], "unknown"), + globs: safeLegacyStringArray(rule["globs"], 64), + hash: typeof rule["hash"] === "string" ? safeLegacyMetadataString(rule["hash"], null) : null, + })) + : []; + return { + id, + label: safeLegacyMetadataString(source["label"], id), + layer, + merge, + order: Number.isSafeInteger(source["order"]) ? Number(source["order"]) : index, + path: typeof source["path"] === "string" ? safeLegacyMetadataString(source["path"], null) : null, + targetProviders: safeLegacyStringArray(source["targetProviders"], 16), + owner, + sourcePaths, + hash: typeof source["hash"] === "string" ? safeLegacyMetadataString(source["hash"], null) : null, + nonOverridable: source["nonOverridable"] === true, + replacementScope: typeof source["replacementScope"] === "string" ? safeLegacyMetadataString(source["replacementScope"], null) : null, + rules, + provenance: sanitizeLegacyProvenance(source["provenance"]), + }; + }); +} + +function sanitizeLegacyEnvironment(value: unknown): Record { + if (value === undefined) return {}; + if (!isRecord(value) || Object.keys(value).length > 8) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session environment metadata is malformed"); + } + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(key) || typeof item !== "string" || !isAbsolute(item) || !isSafeSingleLine(item) || item.length > 4_096) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session environment metadata contains an unsafe entry"); + } + if (scanSecrets(`${key}=${item}`, "text").length > 0) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session environment metadata contains credential-like content"); + } + result[key] = item; + } + return result; +} + +function sanitizeLegacyWarnings(value: unknown): string[] { + return safeLegacyStringArray(value, 64); +} + +function sanitizeLegacyProvenance(value: unknown): Record | null { + if (value === undefined || value === null) return null; + if (!isRecord(value)) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session source provenance is malformed"); + } + const state = { nodes: 0 }; + const sanitized = sanitizeBoundedJsonMetadata(value, 0, state); + if (!isRecord(sanitized)) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session source provenance is malformed"); + } + const encoded = JSON.stringify(sanitized); + if (Buffer.byteLength(encoded, "utf8") > PROJECT_CONTEXT_MAX_INPUT_BYTES || scanSecrets(encoded, "text").length > 0) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session source provenance exceeds its bound or contains credential-like content"); + } + return sanitized; +} + +function sanitizeBoundedJsonMetadata(value: unknown, depth: number, state: { nodes: number }): unknown { + state.nodes++; + if (state.nodes > 128 || depth > 6) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session source provenance exceeds its structural bound"); + } + if (value === null || typeof value === "boolean") return value; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session source provenance contains an invalid number"); + return value; + } + if (typeof value === "string") { + if (value.length > 4_096 || !isSafeSingleLine(value)) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session source provenance contains an unsafe string"); + } + return value; + } + if (Array.isArray(value)) { + if (value.length > 64) throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session source provenance contains an oversized array"); + return value.map((item) => sanitizeBoundedJsonMetadata(item, depth + 1, state)); + } + if (isRecord(value)) { + const entries = Object.entries(value); + if (entries.length > 64) throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session source provenance contains an oversized object"); + const result: Record = {}; + for (const [key, item] of entries) { + if (key.length === 0 || key.length > 256 || !isSafeSingleLine(key) || key === "__proto__" || key === "constructor" || key === "prototype") { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session source provenance contains an unsafe key"); + } + result[key] = sanitizeBoundedJsonMetadata(item, depth + 1, state); + } + return result; + } + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session source provenance contains an unsupported value"); +} + +function sanitizeLegacyFiles(value: unknown): Array> { + if (!Array.isArray(value) || value.length > 64 || value.some((entry) => !isRecord(entry))) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session file inventory is malformed"); + } + return value.map((entry) => { + const file = entry as Record; + const relativePath = safeLegacyMetadataString(file["relativePath"], ""); + if (!relativePath || isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith("../") || relativePath.includes("/../")) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session file inventory contains an unsafe relative path"); + } + const sha = safeLegacyMetadataString(file["sha256"], ""); + if (!/^[a-f0-9]{64}$/.test(sha)) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session file inventory contains an invalid hash"); + } + const role = typeof file["role"] === "string" && ["index", "fragment", "rule", "config", "manifest"].includes(file["role"]) + ? file["role"] + : "index"; + return { + path: safeLegacyMetadataString(file["path"], ""), + relativePath, + role, + sha256: sha, + sourceIds: safeLegacyStringArray(file["sourceIds"], 64), + }; + }); +} + +function sanitizeLegacySkippedSources(value: unknown): Array> { + if (!Array.isArray(value)) return []; + return value.slice(0, 64).filter(isRecord).map((entry, index) => ({ + id: safeLegacyMetadataString(entry["id"], `skipped-${index}`), + label: safeLegacyMetadataString(entry["label"], `Skipped source ${index + 1}`), + targetProviders: safeLegacyStringArray(entry["targetProviders"], 16), + reason: safeLegacyMetadataString(entry["reason"], "unknown"), + })); +} + +function safeLegacyStringArray(value: unknown, maxItems: number): string[] { + if (!Array.isArray(value)) return []; + if (value.length > maxItems || value.some((item) => typeof item !== "string")) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "provider session manifest contains malformed string metadata"); + } + return value + .map((item) => safeLegacyMetadataString(item, "")) + .filter((item): item is string => item.length > 0); +} + +function safeLegacyMetadataString(value: unknown, fallback: string): string; +function safeLegacyMetadataString(value: unknown, fallback: null): string | null; +function safeLegacyMetadataString(value: unknown, fallback: string | null): string | null { + if (typeof value !== "string" || value.length === 0 || value.length > 4_096 || !isSafeSingleLine(value)) return fallback; + return value; +} + +function manifestSource(plan: ProjectContextPlan): ProjectContextManifest["sources"][number] { + return projectContextManifestSource(plan.cache_path, plan.runtime, plan.bundle); +} + +function projectContextManifestSource( + cachePath: string, + runtime: ProjectContextRuntime, + bundle: ProjectContextBundleV1, +): ProjectContextManifest["sources"][number] { + return { + id: "project-context-bundle", + label: "Project Context Bundle", + layer: "repo", + merge: "replace", + order: 0, + path: cachePath, + targetProviders: [manifestTool(runtime)], + owner: { kind: "package", id: "@hasna/projects" }, + sourcePaths: [], + hash: bundle.hash, + nonOverridable: true, + replacementScope: "project-context", + rules: [], + provenance: { + schema: PROJECT_CONTEXT_SCHEMA, + projectId: bundle.project.id, + revision: bundle.revision, + hash: bundle.hash, + }, + }; +} + +function manifestProjectContext(plan: ProjectContextPlan): ProjectContextManifest["projectContext"] { + return { + schema: PROJECT_CONTEXT_SCHEMA, + projectId: plan.bundle.project.id, + revision: plan.bundle.revision, + hash: plan.bundle.hash, + status: plan.status, + ageSeconds: plan.age_seconds, + cachePath: plan.cache_path, + fragmentPath: plan.fragment_path, + }; +} + +function manifestCompatibility(): ProjectContextManifest["compatibility"] { + return { + legacyPackage: LEGACY_CONFIGS_PACKAGE, + legacyVersion: LEGACY_CONFIGS_COMPAT_VERSION, + legacyExecutable: LEGACY_CONFIGS_EXECUTABLE, + manifestSchema: SESSION_RENDER_SCHEMA, + managedBy: "@hasna/configs", + ownedBy: "open-configs", + canonicalOwner: "instructions", + }; +} + +function writeMetadataSnapshot(plan: ProjectContextPlan, now: Date): string | null { + const previous = readProjectContextManifest(plan.manifest_path, plan.workspace_root); + if (!previous || (previous.projectContext.revision === plan.bundle.revision && previous.projectContext.hash === plan.bundle.hash)) return null; + const snapshotDir = resolve(plan.workspace_root, ...PROJECT_CONTEXT_SNAPSHOT_DIR.split("/")); + ensureSafeDirectory(snapshotDir, plan.workspace_root, 0o700); + const snapshotPath = resolve(snapshotDir, `${safeFilename(previous.projectContext.revision)}-${previous.projectContext.hash.slice(-12)}.json`); + const snapshot = { + schema: "hasna.configs.session-render-snapshot/v1", + kind: "project-context-metadata", + createdAt: now.toISOString(), + projectId: previous.projectContext.projectId, + revision: previous.projectContext.revision, + hash: previous.projectContext.hash, + status: previous.projectContext.status, + files: previous.files.map((file) => ({ relativePath: file.relativePath, role: file.role, sha256: file.sha256 })), + }; + atomicWriteFile(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, plan.workspace_root, 0o600); + return snapshotPath; +} + +function readProjectContextManifest(path: string, workspaceRoot: string): ProjectContextManifestObservation | null { + if (!existsSync(path)) return null; + const record = readJsonRecord(path, workspaceRoot); + const result = storedManifestObservationSchema.safeParse(record); + if (!result.success) { + throw new ProjectContextError("PROJECT_CONTEXT_MANIFEST_INVALID", "existing project-context manifest is malformed"); + } + return { + tool: result.data.tool, + adapterMode: result.data.adapterMode, + projectContext: result.data.projectContext, + files: result.data.files, + }; +} + +function readProjectContextCache(path: string, workspaceRoot: string): ProjectContextCache | null { + if (!existsSync(path)) return null; + const record = readJsonRecord(path, workspaceRoot); + const result = projectContextCacheSchema.safeParse(record); + if (!result.success) { + throw new ProjectContextError("PROJECT_CONTEXT_CACHE_INVALID", "cache is malformed or incompatible"); + } + const bundle = parseProjectContextBundle(result.data.bundle); + if ( + result.data.project_id !== bundle.project.id || + result.data.revision !== bundle.revision || + result.data.hash !== bundle.hash + ) { + throw new ProjectContextError("PROJECT_CONTEXT_CACHE_INVALID", "cache metadata does not match its bundle"); + } + return { ...result.data, bundle }; +} + +function readJsonRecord(path: string, workspaceRoot: string): Record | null { + const content = readUtf8RegularFile(path, workspaceRoot, PROJECT_CONTEXT_MAX_INPUT_BYTES * 4); + try { + const parsed = JSON.parse(content) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function readSessionManifestRecord(path: string, workspaceRoot: string): Record | null { + const content = readUtf8RegularFile(path, workspaceRoot, SESSION_COMPATIBILITY_MANIFEST_MAX_BYTES); + try { + const parsed = JSON.parse(content) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function atomicWriteFile( + path: string, + content: string, + workspaceRoot: string, + defaultMode: number, + expectedHash?: string | null, + afterExchange?: () => void, + atomicExchangeUnavailable = false, + beforeInstall?: (tempPath: string) => void, + portableCreateOnly = false, + maxObservedBytes?: number | null, + allowPortableReplacement = false, +): void { + const dir = resolve(path, ".."); + ensureSafeDirectory(dir, workspaceRoot, 0o700); + assertNoSymlinkSegments(workspaceRoot, path); + const anchoredOps = portableCreateOnly ? null : resolveAnchoredFsOps(); + if (!anchoredOps) { + atomicWritePortable( + path, + content, + workspaceRoot, + defaultMode, + expectedHash, + beforeInstall, + maxObservedBytes, + allowPortableReplacement, + ); + return; + } + if ( + allowPortableReplacement && + typeof expectedHash === "string" && + (atomicExchangeUnavailable || resolveAtomicExchange() === null) + ) { + atomicWritePortable( + path, + content, + workspaceRoot, + defaultMode, + expectedHash, + beforeInstall, + maxObservedBytes, + true, + ); + return; + } + const directory = openAnchoredDirectory(dir, workspaceRoot, anchoredOps, maxObservedBytes); + const targetName = basename(path); + const previous = anchoredFileObservation(directory, targetName); + const previousMode = previous?.mode ?? defaultMode; + const tempName = `.project-context-${randomUUID()}.tmp`; + const tempPath = join(dir, tempName); + let fd: number | null = null; + let preserveTemp = false; + let directoryChanged = false; + const desiredHash = sha256(content); + try { + fd = anchoredOpenExclusive(directory, tempName, previousMode); + writeFileSync(fd, content, { encoding: "utf8" }); + fsyncSync(fd); + closeSync(fd); + fd = null; + beforeInstall?.(tempPath); + assertManagedDirectoryStable(dir, workspaceRoot, directory.identity); + if (anchoredFileHash(directory, tempName) !== desiredHash) { + throw new ProjectContextHashRace(`prepared bytes changed before installation: ${relativePosix(workspaceRoot, path)}`); + } + if (expectedHash === undefined) { + if (!directory.ops.renameat(directory.fd, tempName, directory.fd, targetName)) { + throw new ProjectContextHashRace(`managed path changed before installation: ${relativePosix(workspaceRoot, path)}`); + } + directoryChanged = true; + } else if (expectedHash === null) { + const prepared = anchoredFileObservation(directory, tempName); + if (!prepared || anchoredFileObservation(directory, targetName) !== null) { + throw new ProjectContextHashRace(`managed path appeared before creation: ${relativePosix(workspaceRoot, path)}`); + } + if (!directory.ops.linkat(directory.fd, tempName, directory.fd, targetName)) { + throw new ProjectContextHashRace(`managed path appeared before creation: ${relativePosix(workspaceRoot, path)}`); + } + directoryChanged = true; + const installed = anchoredFileObservation(directory, targetName); + if ( + !installed || + installed.dev !== prepared.dev || + installed.ino !== prepared.ino || + anchoredFileHash(directory, tempName) !== desiredHash || + installed.hash !== desiredHash + ) { + // The target may now contain an ordinary concurrent edit. Preserve the + // installed path and remove only our extra hard link before retrying. + directory.ops.unlinkat(directory.fd, tempName); + preserveTemp = true; + throw new ProjectContextHashRace(`prepared bytes changed during creation: ${relativePosix(workspaceRoot, path)}`); + } + if (!directory.ops.unlinkat(directory.fd, tempName)) preserveTemp = true; + } else { + if (anchoredFileObservation(directory, targetName) === null) { + throw new ProjectContextHashRace(`managed path disappeared before replacement: ${relativePosix(workspaceRoot, path)}`); + } + if (atomicExchangeUnavailable) { + throw new ProjectContextError( + "PROJECT_CONTEXT_ATOMIC_REPLACE_UNAVAILABLE", + "the platform could not provide an atomic exchange for compare-and-swap replacement", + ); + } + if (anchoredFileHash(directory, targetName) !== expectedHash) { + throw new ProjectContextHashRace(`managed path changed before atomic replacement: ${relativePosix(workspaceRoot, path)}`); + } + if (anchoredFileHash(directory, tempName) !== desiredHash) { + throw new ProjectContextHashRace(`prepared bytes changed before atomic replacement: ${relativePosix(workspaceRoot, path)}`); + } + atomicExchangeEntries(directory.fd, tempName, targetName); + directoryChanged = true; + let exchanged = true; + try { + const displacedAtExchange = anchoredFileHash(directory, tempName); + const installedAtExchange = anchoredFileHash(directory, targetName); + if (displacedAtExchange !== expectedHash) { + preserveTemp = true; + exchanged = false; + throw new ProjectContextError( + "PROJECT_CONTEXT_ATOMIC_REPLACE_CONFLICT", + `the displaced managed file changed before exchange validation: ${relativePosix(workspaceRoot, path)}`, + ); + } + if (installedAtExchange !== desiredHash) { + atomicExchangeEntries(directory.fd, tempName, targetName); + exchanged = false; + throw new ProjectContextHashRace(`prepared bytes changed during atomic replacement: ${relativePosix(workspaceRoot, path)}`); + } + afterExchange?.(); + const replacedHash = anchoredFileHash(directory, tempName); + const replacementHash = anchoredFileHash(directory, targetName); + if (replacedHash !== expectedHash) { + preserveTemp = true; + exchanged = false; + throw new ProjectContextError( + "PROJECT_CONTEXT_ATOMIC_REPLACE_CONFLICT", + `the displaced managed file changed during atomic replacement: ${relativePosix(workspaceRoot, path)}`, + ); + } + if (replacementHash !== desiredHash) { + directory.ops.unlinkat(directory.fd, tempName); + exchanged = false; + throw new ProjectContextHashRace(`managed path changed immediately after atomic replacement: ${relativePosix(workspaceRoot, path)}`); + } + if (!directory.ops.unlinkat(directory.fd, tempName)) preserveTemp = true; + exchanged = false; + } catch (error) { + if (exchanged) { + try { + if ( + anchoredFileHash(directory, targetName) === desiredHash && + anchoredFileHash(directory, tempName) === expectedHash + ) { + atomicExchangeEntries(directory.fd, tempName, targetName); + exchanged = false; + } else { + preserveTemp = true; + } + } catch { + preserveTemp = true; + } + } + throw error; + } + } + assertManagedDirectoryStable(dir, workspaceRoot, directory.identity); + fsyncSync(directory.fd); + } catch (error) { + if (fd !== null) closeSync(fd); + if (!preserveTemp) directory.ops.unlinkat(directory.fd, tempName); + if (directoryChanged) fsyncSync(directory.fd); + throw error; + } finally { + closeSync(directory.fd); + } +} + +function atomicWritePortable( + path: string, + content: string, + workspaceRoot: string, + defaultMode: number, + expectedHash: string | null | undefined, + beforeInstall?: (tempPath: string) => void, + maxObservedBytes?: number | null, + allowReplacement = false, +): void { + const desiredHash = sha256(content); + const currentHash = portableFileHash(path, workspaceRoot, maxObservedBytes); + if (expectedHash === undefined && currentHash === desiredHash) return; + if (typeof expectedHash === "string" && allowReplacement) { + atomicWritePortableReplacement( + path, + content, + workspaceRoot, + expectedHash, + beforeInstall, + maxObservedBytes, + ); + return; + } + if (expectedHash !== null && !(expectedHash === undefined && currentHash === null)) { + throw new ProjectContextError( + "PROJECT_CONTEXT_ATOMIC_REPLACE_UNAVAILABLE", + "the platform can create new managed files but cannot safely replace existing files", + ); + } + if (currentHash !== null) { + throw new ProjectContextHashRace(`managed path appeared before portable creation: ${relativePosix(workspaceRoot, path)}`); + } + + const dir = dirname(path); + const directoryIdentity = captureManagedDirectoryIdentity(dir, workspaceRoot); + const tempPath = join(dir, `.project-context-${randomUUID()}.tmp`); + let fd: number | null = null; + let tempIdentity: { dev: number; ino: number } | null = null; + try { + fd = openSync( + tempPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + defaultMode, + ); + const opened = fstatSync(fd); + tempIdentity = { dev: opened.dev, ino: opened.ino }; + writeFileSync(fd, content, { encoding: "utf8" }); + fsyncSync(fd); + closeSync(fd); + fd = null; + beforeInstall?.(tempPath); + assertManagedDirectoryStable(dir, workspaceRoot, directoryIdentity); + assertNoSymlinkSegments(workspaceRoot, tempPath); + assertNoSymlinkSegments(workspaceRoot, path); + if (portableFileHash(tempPath, workspaceRoot, maxObservedBytes) !== desiredHash || portableFileHash(path, workspaceRoot, maxObservedBytes) !== null) { + throw new ProjectContextHashRace(`managed path changed before portable creation: ${relativePosix(workspaceRoot, path)}`); + } + const prepared = lstatSync(tempPath); + try { + linkSync(tempPath, path); + } catch { + throw new ProjectContextHashRace(`managed path appeared during portable creation: ${relativePosix(workspaceRoot, path)}`); + } + const installed = lstatSync(path); + if ( + installed.isSymbolicLink() || + installed.dev !== prepared.dev || + installed.ino !== prepared.ino || + portableFileHash(path, workspaceRoot, maxObservedBytes) !== desiredHash + ) { + throw new ProjectContextHashRace(`managed path changed during portable creation: ${relativePosix(workspaceRoot, path)}`); + } + rmSync(tempPath); + tempIdentity = null; + assertManagedDirectoryStable(dir, workspaceRoot, directoryIdentity); + fsyncDirectory(dir); + } catch (error) { + if (fd !== null) closeSync(fd); + if (tempIdentity && managedDirectoryMatches(dir, workspaceRoot, directoryIdentity)) { + try { + const current = lstatSync(tempPath); + if (!current.isSymbolicLink() && current.dev === tempIdentity.dev && current.ino === tempIdentity.ino) { + rmSync(tempPath); + } + } catch { + // Preserve an uncertain temp rather than following a replaced directory. + } + } + throw error; + } +} + +function atomicWritePortableReplacement( + path: string, + content: string, + workspaceRoot: string, + expectedHash: string, + beforeInstall?: (tempPath: string) => void, + maxObservedBytes?: number | null, +): void { + const currentHash = portableFileHash(path, workspaceRoot, maxObservedBytes); + if (currentHash !== expectedHash) { + throw new ProjectContextHashRace(`managed path changed before portable replacement: ${relativePosix(workspaceRoot, path)}`); + } + const current = lstatSync(path); + if (current.isSymbolicLink() || !current.isFile()) { + throw new ProjectContextHashRace(`managed path is not a regular file: ${relativePosix(workspaceRoot, path)}`); + } + + const dir = dirname(path); + const directoryIdentity = captureManagedDirectoryIdentity(dir, workspaceRoot); + const tempPath = join(dir, `.project-context-${randomUUID()}.tmp`); + const desiredHash = sha256(content); + let fd: number | null = null; + let tempIdentity: { dev: number; ino: number } | null = null; + try { + fd = openSync( + tempPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + current.mode & 0o777, + ); + const opened = fstatSync(fd); + tempIdentity = { dev: opened.dev, ino: opened.ino }; + writeFileSync(fd, content, { encoding: "utf8" }); + fsyncSync(fd); + closeSync(fd); + fd = null; + beforeInstall?.(tempPath); + assertManagedDirectoryStable(dir, workspaceRoot, directoryIdentity); + assertNoSymlinkSegments(workspaceRoot, tempPath); + assertNoSymlinkSegments(workspaceRoot, path); + if ( + portableFileHash(path, workspaceRoot, maxObservedBytes) !== expectedHash || + portableFileHash(tempPath, workspaceRoot, maxObservedBytes) !== desiredHash + ) { + throw new ProjectContextHashRace(`managed path changed before portable replacement: ${relativePosix(workspaceRoot, path)}`); + } + renameSync(tempPath, path); + tempIdentity = null; + assertManagedDirectoryStable(dir, workspaceRoot, directoryIdentity); + if (portableFileHash(path, workspaceRoot, maxObservedBytes) !== desiredHash) { + throw new ProjectContextHashRace(`managed path changed during portable replacement: ${relativePosix(workspaceRoot, path)}`); + } + fsyncDirectory(dir); + } catch (error) { + if (fd !== null) closeSync(fd); + if (tempIdentity && managedDirectoryMatches(dir, workspaceRoot, directoryIdentity)) { + try { + const candidate = lstatSync(tempPath); + if (!candidate.isSymbolicLink() && candidate.dev === tempIdentity.dev && candidate.ino === tempIdentity.ino) { + rmSync(tempPath); + } + } catch { + // Preserve an uncertain temp rather than following a replaced directory. + } + } + throw error; + } +} + +function portableFileHash( + path: string, + workspaceRoot: string, + maxObservedBytes?: number | null, +): string | null { + if (maxObservedBytes === undefined) return currentFileHash(path, workspaceRoot); + if (!existsSync(path)) return null; + assertNoSymlinkSegments(workspaceRoot, path); + const stat = lstatSync(path); + if (!stat.isFile()) throw new ProjectContextHashRace("managed output is not a regular file"); + if (maxObservedBytes !== null && stat.size > maxObservedBytes) { + throw new ProjectContextHashRace(`managed output exceeds the safe read limit: ${relativePosix(workspaceRoot, path)}`); + } + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +export function writeProjectContextCoordinatedFile(input: { + path: string; + content: string; + workspace_root: string; + default_mode?: number; + expected_hash: string | null | undefined; + max_observed_bytes?: number | null; + allow_portable_replacement?: boolean; + force_portable_file_ops?: boolean; +}): void { + atomicWriteFile( + resolve(input.path), + input.content, + assertSafeWorkspaceRoot(input.workspace_root), + input.default_mode ?? 0o644, + input.expected_hash, + undefined, + false, + undefined, + input.force_portable_file_ops ?? false, + input.max_observed_bytes, + input.allow_portable_replacement ?? false, + ); +} + +export function removeProjectContextCoordinatedFile(input: { + path: string; + workspace_root: string; + expected_hash: string; + max_observed_bytes?: number | null; + allow_portable_removal?: boolean; + force_portable_file_ops?: boolean; + test_hooks?: { + after_displace?: (displacedPath: string) => void; + }; +}): void { + const workspaceRoot = assertSafeWorkspaceRoot(input.workspace_root); + const path = resolve(input.path); + assertNoSymlinkSegments(workspaceRoot, path); + const dir = dirname(path); + const anchoredOps = input.force_portable_file_ops ? null : resolveAnchoredFsOps(); + if (!anchoredOps) { + if (!input.allow_portable_removal) { + throw new ProjectContextError( + "PROJECT_CONTEXT_ATOMIC_REPLACE_UNAVAILABLE", + "the platform could not provide directory-anchored managed-file removal", + ); + } + removePortableCoordinatedFile( + path, + workspaceRoot, + input.expected_hash, + input.max_observed_bytes, + input.test_hooks?.after_displace, + ); + return; + } + const directory = openAnchoredDirectory(dir, workspaceRoot, anchoredOps, input.max_observed_bytes); + const targetName = basename(path); + const displacedName = `.project-context-delete-${randomUUID()}.tmp`; + let displaced = false; + let expectedObservation: AnchoredFileObservation | null = null; + try { + const observed = anchoredFileObservation(directory, targetName); + if (!observed || observed.hash !== input.expected_hash) { + throw new ProjectContextHashRace(`managed path changed before deletion: ${relativePosix(workspaceRoot, path)}`); + } + expectedObservation = observed; + if (!directory.ops.renameat(directory.fd, targetName, directory.fd, displacedName)) { + throw new ProjectContextHashRace(`managed path changed during deletion: ${relativePosix(workspaceRoot, path)}`); + } + displaced = true; + input.test_hooks?.after_displace?.(join(dir, displacedName)); + const moved = anchoredFileObservation(directory, displacedName); + if ( + !moved || + moved.dev !== observed.dev || + moved.ino !== observed.ino || + moved.hash !== input.expected_hash || + anchoredFileObservation(directory, targetName) !== null + ) { + throw new ProjectContextHashRace(`managed path changed during deletion validation: ${relativePosix(workspaceRoot, path)}`); + } + if (!directory.ops.unlinkat(directory.fd, displacedName)) { + throw new ProjectContextHashRace(`managed path could not be removed safely: ${relativePosix(workspaceRoot, path)}`); + } + displaced = false; + assertManagedDirectoryStable(dir, workspaceRoot, directory.identity); + fsyncSync(directory.fd); + } catch (error) { + if ( + displaced && + expectedObservation && + restoreAnchoredDisplacedFile(directory, displacedName, targetName, expectedObservation) + ) { + displaced = false; + } + throw error; + } finally { + closeSync(directory.fd); + } +} + +function restoreAnchoredDisplacedFile( + directory: AnchoredDirectory, + displacedName: string, + targetName: string, + expected: AnchoredFileObservation, +): boolean { + if (!directory.ops.linkat(directory.fd, displacedName, directory.fd, targetName)) return false; + try { + const displaced = anchoredFileObservation(directory, displacedName); + const installed = anchoredFileObservation(directory, targetName); + if ( + !displaced || + !installed || + displaced.dev !== expected.dev || + displaced.ino !== expected.ino || + displaced.dev !== installed.dev || + displaced.ino !== installed.ino || + displaced.hash !== expected.hash || + installed.hash !== expected.hash + ) return false; + if (!directory.ops.unlinkat(directory.fd, displacedName)) return false; + fsyncSync(directory.fd); + return true; + } catch { + return false; + } +} + +function removePortableCoordinatedFile( + path: string, + workspaceRoot: string, + expectedHash: string, + maxObservedBytes?: number | null, + afterDisplace?: (displacedPath: string) => void, +): void { + if (portableFileHash(path, workspaceRoot, maxObservedBytes) !== expectedHash) { + throw new ProjectContextHashRace(`managed path changed before portable deletion: ${relativePosix(workspaceRoot, path)}`); + } + const observed = lstatSync(path); + if (observed.isSymbolicLink() || !observed.isFile()) { + throw new ProjectContextHashRace(`managed path is not a regular file: ${relativePosix(workspaceRoot, path)}`); + } + const dir = dirname(path); + const directoryIdentity = captureManagedDirectoryIdentity(dir, workspaceRoot); + const displacedPath = join(dir, `.project-context-delete-${randomUUID()}.tmp`); + let displaced = false; + try { + assertManagedDirectoryStable(dir, workspaceRoot, directoryIdentity); + if (portableFileHash(path, workspaceRoot, maxObservedBytes) !== expectedHash) { + throw new ProjectContextHashRace(`managed path changed before portable deletion: ${relativePosix(workspaceRoot, path)}`); + } + renameSync(path, displacedPath); + displaced = true; + afterDisplace?.(displacedPath); + const moved = lstatSync(displacedPath); + if ( + moved.isSymbolicLink() || + !moved.isFile() || + moved.dev !== observed.dev || + moved.ino !== observed.ino || + portableFileHash(displacedPath, workspaceRoot, maxObservedBytes) !== expectedHash || + existsSync(path) + ) { + throw new ProjectContextHashRace(`managed path changed during portable deletion: ${relativePosix(workspaceRoot, path)}`); + } + rmSync(displacedPath); + displaced = false; + assertManagedDirectoryStable(dir, workspaceRoot, directoryIdentity); + fsyncDirectory(dir); + } catch (error) { + if ( + displaced && + managedDirectoryMatches(dir, workspaceRoot, directoryIdentity) && + restorePortableDisplacedFile(displacedPath, path, workspaceRoot, observed, expectedHash, maxObservedBytes) + ) { + displaced = false; + } + throw error; + } +} + +function restorePortableDisplacedFile( + displacedPath: string, + path: string, + workspaceRoot: string, + expected: { dev: number; ino: number }, + expectedHash: string, + maxObservedBytes?: number | null, +): boolean { + try { + linkSync(displacedPath, path); + } catch { + return false; + } + try { + const displaced = lstatSync(displacedPath); + const installed = lstatSync(path); + if ( + displaced.isSymbolicLink() || + installed.isSymbolicLink() || + !displaced.isFile() || + !installed.isFile() || + displaced.dev !== expected.dev || + displaced.ino !== expected.ino || + displaced.dev !== installed.dev || + displaced.ino !== installed.ino || + portableFileHash(displacedPath, workspaceRoot, maxObservedBytes) !== expectedHash || + portableFileHash(path, workspaceRoot, maxObservedBytes) !== expectedHash + ) return false; + rmSync(displacedPath); + fsyncDirectory(dirname(path)); + return true; + } catch { + return false; + } +} + +interface AnchoredDirectory { + fd: number; + path: string; + workspaceRoot: string; + identity: { dev: number; ino: number }; + ops: AnchoredFsOps; + maxObservedBytes: number | null | undefined; +} + +interface AnchoredFileObservation { + dev: number; + ino: number; + hash: string; + mode: number; +} + +interface AnchoredFsOps { + openat: (directoryFd: number, name: string, flags: number, mode: number) => number; + renameat: (leftDirectoryFd: number, left: string, rightDirectoryFd: number, right: string) => boolean; + linkat: (leftDirectoryFd: number, left: string, rightDirectoryFd: number, right: string) => boolean; + unlinkat: (directoryFd: number, name: string) => boolean; +} + +let anchoredFsOps: AnchoredFsOps | null | undefined; + +function openAnchoredDirectory( + path: string, + workspaceRoot: string, + providedOps?: AnchoredFsOps, + maxObservedBytes?: number | null, +): AnchoredDirectory { + const ops = providedOps ?? resolveAnchoredFsOps(); + if (!ops) { + throw new ProjectContextError( + "PROJECT_CONTEXT_ATOMIC_REPLACE_UNAVAILABLE", + "the platform could not provide directory-anchored managed-file operations", + ); + } + const identity = captureManagedDirectoryIdentity(path, workspaceRoot); + let fd: number; + try { + fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + } catch { + throw new ProjectContextHashRace(`managed parent directory changed while opening: ${relativePosix(workspaceRoot, path)}`); + } + try { + const opened = fstatSync(fd); + if (!opened.isDirectory() || opened.dev !== identity.dev || opened.ino !== identity.ino) { + throw new ProjectContextHashRace(`managed parent directory changed while opening: ${relativePosix(workspaceRoot, path)}`); + } + assertManagedDirectoryStable(path, workspaceRoot, identity); + return { fd, path, workspaceRoot, identity, ops, maxObservedBytes }; + } catch (error) { + closeSync(fd); + throw error; + } +} + +function anchoredOpenExclusive(directory: AnchoredDirectory, name: string, mode: number): number { + const fd = directory.ops.openat( + directory.fd, + name, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + mode, + ); + if (fd < 0) throw new ProjectContextHashRace(`could not create prepared managed file in ${relativePosix(directory.workspaceRoot, directory.path)}`); + const opened = fstatSync(fd); + if (!opened.isFile()) { + closeSync(fd); + throw new ProjectContextHashRace("prepared managed output is not a regular file"); + } + return fd; +} + +function anchoredFileObservation(directory: AnchoredDirectory, name: string): AnchoredFileObservation | null { + const fd = directory.ops.openat(directory.fd, name, constants.O_RDONLY | constants.O_NOFOLLOW, 0); + if (fd < 0) return null; + try { + const stat = fstatSync(fd); + if (!stat.isFile()) throw new ProjectContextHashRace("managed output is not a regular file"); + const relativePath = relativePosix(directory.workspaceRoot, join(directory.path, name)); + const maxBytes = directory.maxObservedBytes === undefined + ? relativePath === ".hasna/session-render-manifest.json" || relativePath === ".codewith/.hasna/session-render-manifest.json" + ? SESSION_COMPATIBILITY_MANIFEST_MAX_BYTES + : 256 * 1024 + : directory.maxObservedBytes; + if (maxBytes !== null && stat.size > maxBytes) { + throw new ProjectContextHashRace(`managed output exceeds the safe read limit: ${relativePath}`); + } + return { + dev: stat.dev, + ino: stat.ino, + hash: createHash("sha256").update(readFileSync(fd)).digest("hex"), + mode: stat.mode & 0o777, + }; + } finally { + closeSync(fd); + } +} + +function anchoredFileHash(directory: AnchoredDirectory, name: string): string | null { + return anchoredFileObservation(directory, name)?.hash ?? null; +} + +function captureManagedDirectoryIdentity( + path: string, + workspaceRoot: string, +): { dev: number; ino: number } { + assertNoSymlinkSegments(workspaceRoot, join(path, ".project-context-directory-guard")); + let stat: ReturnType; + try { + stat = lstatSync(path); + } catch { + throw new ProjectContextHashRace(`managed parent directory disappeared: ${relativePosix(workspaceRoot, path)}`); + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new ProjectContextError("PROJECT_CONTEXT_PATH_INVALID", `managed parent is not a stable directory: ${path}`); + } + return { dev: stat.dev, ino: stat.ino }; +} + +function assertManagedDirectoryStable( + path: string, + workspaceRoot: string, + expected: { dev: number; ino: number }, +): void { + assertNoSymlinkSegments(workspaceRoot, join(path, ".project-context-directory-guard")); + let current: ReturnType; + try { + current = lstatSync(path); + } catch { + throw new ProjectContextHashRace(`managed parent directory disappeared during write: ${relativePosix(workspaceRoot, path)}`); + } + if (current.isSymbolicLink() || !current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) { + throw new ProjectContextHashRace(`managed parent directory changed during write: ${relativePosix(workspaceRoot, path)}`); + } +} + +function managedDirectoryMatches( + path: string, + workspaceRoot: string, + expected: { dev: number; ino: number }, +): boolean { + try { + assertManagedDirectoryStable(path, workspaceRoot, expected); + return true; + } catch { + return false; + } +} + +type AtomicExchange = ( + leftDirectoryFd: number, + left: string, + rightDirectoryFd: number, + right: string, +) => boolean; + +let atomicExchange: AtomicExchange | null | undefined; +const atomicExchangeLibraries: Array> = []; + +function atomicExchangePaths(left: string, right: string): void { + const exchange = resolveAtomicExchange(); + const atFdcwd = process.platform === "darwin" ? -2 : -100; + if (!exchange || !exchange(atFdcwd, left, atFdcwd, right)) { + throw new ProjectContextError( + "PROJECT_CONTEXT_ATOMIC_REPLACE_UNAVAILABLE", + "the platform could not provide an atomic exchange for compare-and-swap replacement", + ); + } +} + +function atomicExchangeEntries(directoryFd: number, left: string, right: string): void { + const exchange = resolveAtomicExchange(); + if (!exchange || !exchange(directoryFd, left, directoryFd, right)) { + throw new ProjectContextError( + "PROJECT_CONTEXT_ATOMIC_REPLACE_UNAVAILABLE", + "the platform could not provide a directory-anchored atomic exchange", + ); + } +} + +function resolveAtomicExchange(): AtomicExchange | null { + if (atomicExchange !== undefined) return atomicExchange; + if (process.platform === "linux") { + const muslArch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : null; + const candidates = [ + "libc.so.6", + "libc.so", + ...(muslArch ? [`/lib/ld-musl-${muslArch}.so.1`, `/lib/libc.musl-${muslArch}.so.1`] : []), + ]; + for (const candidate of candidates) { + try { + const library = dlopen(candidate, { + renameat2: { + args: [FFIType.i32, FFIType.cstring, FFIType.i32, FFIType.cstring, FFIType.u32], + returns: FFIType.i32, + }, + }); + atomicExchangeLibraries.push(library); + const renameat2 = library.symbols.renameat2; + atomicExchange = (leftDirectoryFd, left, rightDirectoryFd, right) => renameat2( + leftDirectoryFd, + Buffer.from(`${left}\0`), + rightDirectoryFd, + Buffer.from(`${right}\0`), + 2, + ) === 0; + return atomicExchange; + } catch { + // Older glibc and some musl builds expose the kernel call only through syscall(2). + } + } + const renameat2Syscall = process.arch === "arm64" ? 276 : process.arch === "x64" ? 316 : null; + if (renameat2Syscall !== null) { + for (const candidate of candidates) { + try { + const library = dlopen(candidate, { + syscall: { + args: [FFIType.i64, FFIType.i64, FFIType.cstring, FFIType.i64, FFIType.cstring, FFIType.u64], + returns: FFIType.i64, + }, + }); + atomicExchangeLibraries.push(library); + const syscall = library.symbols.syscall; + atomicExchange = (leftDirectoryFd, left, rightDirectoryFd, right) => Number(syscall( + renameat2Syscall, + leftDirectoryFd, + Buffer.from(`${left}\0`), + rightDirectoryFd, + Buffer.from(`${right}\0`), + 2, + )) === 0; + return atomicExchange; + } catch { + // Try the next libc location before failing closed. + } + } + } + } + if (process.platform === "darwin") { + try { + const library = dlopen("/usr/lib/libSystem.B.dylib", { + renameatx_np: { + args: [FFIType.i32, FFIType.cstring, FFIType.i32, FFIType.cstring, FFIType.u32], + returns: FFIType.i32, + }, + }); + atomicExchangeLibraries.push(library); + const renameatx = library.symbols.renameatx_np; + atomicExchange = (leftDirectoryFd, left, rightDirectoryFd, right) => renameatx( + leftDirectoryFd, + Buffer.from(`${left}\0`), + rightDirectoryFd, + Buffer.from(`${right}\0`), + 2, + ) === 0; + return atomicExchange; + } catch { + // Fall through to a fail-closed unsupported result. + } + } + // Windows ReplaceFileW can atomically install the prepared file, but it cannot + // atomically materialize the displaced target back at the temp path. Without a + // journaled recovery protocol that is not a true exchange, so updates fail closed. + atomicExchange = null; + return null; +} + +function resolveAnchoredFsOps(): AnchoredFsOps | null { + if (anchoredFsOps !== undefined) return anchoredFsOps; + const candidates = process.platform === "linux" + ? [ + "libc.so.6", + "libc.so", + ...(process.arch === "arm64" + ? ["/lib/ld-musl-aarch64.so.1", "/lib/libc.musl-aarch64.so.1"] + : process.arch === "x64" + ? ["/lib/ld-musl-x86_64.so.1", "/lib/libc.musl-x86_64.so.1"] + : []), + ] + : process.platform === "darwin" + ? ["/usr/lib/libSystem.B.dylib"] + : []; + for (const candidate of candidates) { + try { + const library = dlopen(candidate, { + openat: { + args: [FFIType.i32, FFIType.cstring, FFIType.i32, FFIType.u32], + returns: FFIType.i32, + }, + renameat: { + args: [FFIType.i32, FFIType.cstring, FFIType.i32, FFIType.cstring], + returns: FFIType.i32, + }, + linkat: { + args: [FFIType.i32, FFIType.cstring, FFIType.i32, FFIType.cstring, FFIType.i32], + returns: FFIType.i32, + }, + unlinkat: { + args: [FFIType.i32, FFIType.cstring, FFIType.i32], + returns: FFIType.i32, + }, + }); + atomicExchangeLibraries.push(library); + anchoredFsOps = { + openat: (directoryFd, name, flags, mode) => Number(library.symbols.openat( + directoryFd, + Buffer.from(`${name}\0`), + flags, + mode, + )), + renameat: (leftDirectoryFd, left, rightDirectoryFd, right) => library.symbols.renameat( + leftDirectoryFd, + Buffer.from(`${left}\0`), + rightDirectoryFd, + Buffer.from(`${right}\0`), + ) === 0, + linkat: (leftDirectoryFd, left, rightDirectoryFd, right) => library.symbols.linkat( + leftDirectoryFd, + Buffer.from(`${left}\0`), + rightDirectoryFd, + Buffer.from(`${right}\0`), + 0, + ) === 0, + unlinkat: (directoryFd, name) => library.symbols.unlinkat( + directoryFd, + Buffer.from(`${name}\0`), + 0, + ) === 0, + }; + return anchoredFsOps; + } catch { + // Try the next libc location before failing closed. + } + } + anchoredFsOps = null; + return null; +} + +function acquireWorkspaceLock( + workspaceRoot: string, + lockPath: string, + afterOpen?: () => void, + beforeStaleRemove?: (lockPath: string) => void, + processStartIdentityLookup: (pid: number) => string | null = processStartIdentity, +): WorkspaceLock { + const lockDirectory = resolve(lockPath, ".."); + ensureSafeDirectory(lockDirectory, workspaceRoot, 0o700); + assertNoSymlinkSegments(workspaceRoot, lockPath); + const tempPath = join(lockDirectory, `.project-context-lock-${randomUUID()}.tmp`); + let fd: number | null = null; + let openedIdentity: { dev: number; ino: number } | null = null; + let openedContentHash: string | null = null; + let linked = false; + let preserveTemp = false; + try { + fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + const opened = fstatSync(fd); + openedIdentity = { dev: opened.dev, ino: opened.ino }; + const content = `${JSON.stringify({ + schema: "hasna.instructions.project-context-lock/v1", + pid: process.pid, + nonce: randomUUID(), + created_at: new Date().toISOString(), + process_start_id: processStartIdentityLookup(process.pid), + })}\n`; + openedContentHash = sha256(content); + writeFileSync(fd, content); + fsyncSync(fd); + try { + linkSync(tempPath, lockPath); + linked = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const takeover = tryTakeoverStaleWorkspaceLock( + tempPath, + lockPath, + workspaceRoot, + openedIdentity, + openedContentHash, + beforeStaleRemove, + processStartIdentityLookup, + ); + if (!takeover) { + throw new ProjectContextError("PROJECT_CONTEXT_LOCKED", "another renderer holds the workspace project-context lock"); + } + linked = true; + } + fsyncDirectory(lockDirectory); + if (existsSync(tempPath)) { + rmSync(tempPath); + fsyncDirectory(lockDirectory); + } + const held = lstatSync(lockPath); + if ( + held.isSymbolicLink() || + held.dev !== openedIdentity.dev || + held.ino !== openedIdentity.ino || + currentFileHash(lockPath, workspaceRoot) !== openedContentHash + ) { + throw new ProjectContextError("PROJECT_CONTEXT_LOCK_LOST", "workspace project-context lock changed during initialization"); + } + afterOpen?.(); + return { fd, contentHash: openedContentHash, identity: openedIdentity }; + } catch (error) { + preserveTemp = error instanceof ProjectContextError && error.code === "PROJECT_CONTEXT_LOCK_LOST"; + if (linked && openedIdentity && openedContentHash) { + removeOwnedLockByInode(lockPath, openedIdentity, openedContentHash); + } + if (!preserveTemp && existsSync(tempPath)) { + try { rmSync(tempPath); } catch { /* leave an unreferenced temp for later cleanup */ } + } + if (fd !== null) { + try { closeSync(fd); } catch { /* already closed */ } + } + throw error; + } +} + +function removeOwnedLockByInode( + lockPath: string, + identity: { dev: number; ino: number }, + expectedHash?: string, +): void { + try { + if (!existsSync(lockPath)) return; + const current = lstatSync(lockPath); + if (current.isSymbolicLink() || current.dev !== identity.dev || current.ino !== identity.ino) return; + if (expectedHash !== undefined && sha256(readFileSync(lockPath, "utf8")) !== expectedHash) return; + rmSync(lockPath); + fsyncDirectory(resolve(lockPath, "..")); + } catch { + // Leave an uncertain lock in place rather than deleting another owner's file. + } +} + +function observeStaleWorkspaceLock( + lockPath: string, + workspaceRoot: string, + processStartIdentityLookup: (pid: number) => string | null = processStartIdentity, +): { identity: { dev: number; ino: number }; contentHash: string } | null { + let content: string; + let observed: ReturnType; + try { + content = readUtf8RegularFile(lockPath, workspaceRoot, 2_048); + observed = lstatSync(lockPath); + if (observed.isSymbolicLink() || !observed.isFile()) return null; + } catch { + return null; + } + const contentHash = sha256(content); + if (currentFileHash(lockPath, workspaceRoot) !== contentHash) return null; + let pid: number | null = null; + let createdAtMs: number | null = null; + let recordedProcessStart: string | null = null; + try { + const value = JSON.parse(content) as unknown; + if (isRecord(value)) { + if (Number.isSafeInteger(value["pid"]) && Number(value["pid"]) > 0) pid = Number(value["pid"]); + if (typeof value["created_at"] === "string" && isStrictIsoTimestamp(value["created_at"])) { + const parsedCreatedAt = Date.parse(value["created_at"]); + if (parsedCreatedAt <= Date.now()) createdAtMs = parsedCreatedAt; + } + if (typeof value["process_start_id"] === "string" && value["process_start_id"].length <= 512 && isSafeSingleLine(value["process_start_id"])) { + recordedProcessStart = value["process_start_id"]; + } + } + } catch { + if (Date.now() - observed.mtimeMs < PROJECT_CONTEXT_LOCK_STALE_MS) return null; + } + const observedStartMs = Math.min(observed.mtimeMs, createdAtMs ?? observed.mtimeMs); + const staleByAge = Date.now() - observedStartMs >= PROJECT_CONTEXT_LOCK_STALE_MS; + if (pid !== null && processIsAlive(pid)) { + const currentProcessStart = processStartIdentityLookup(pid); + if (recordedProcessStart !== null) { + if (currentProcessStart === recordedProcessStart) return null; + if (currentProcessStart === null && !staleByAge) return null; + } else if (!staleByAge) { + return null; + } + } + if (pid === null && !staleByAge) return null; + return { + identity: { dev: observed.dev, ino: observed.ino }, + contentHash, + }; +} + +function tryTakeoverStaleWorkspaceLock( + candidatePath: string, + lockPath: string, + workspaceRoot: string, + candidateIdentity: { dev: number; ino: number }, + candidateHash: string, + beforeTakeover?: (lockPath: string) => void, + processStartIdentityLookup: (pid: number) => string | null = processStartIdentity, +): boolean { + const stale = observeStaleWorkspaceLock(lockPath, workspaceRoot, processStartIdentityLookup); + if (!stale) return false; + beforeTakeover?.(lockPath); + atomicExchangePaths(candidatePath, lockPath); + let exchanged = true; + try { + const current = lstatSync(lockPath); + const displaced = lstatSync(candidatePath); + const candidateInstalled = ( + !current.isSymbolicLink() && + current.dev === candidateIdentity.dev && + current.ino === candidateIdentity.ino && + currentFileHash(lockPath, workspaceRoot) === candidateHash + ); + const staleDisplaced = ( + !displaced.isSymbolicLink() && + displaced.dev === stale.identity.dev && + displaced.ino === stale.identity.ino && + currentFileHash(candidatePath, workspaceRoot) === stale.contentHash + ); + if (!candidateInstalled || !staleDisplaced) { + if (candidateInstalled && existsSync(candidatePath)) { + atomicExchangePaths(candidatePath, lockPath); + exchanged = false; + return false; + } + throw new ProjectContextError( + "PROJECT_CONTEXT_LOCK_LOST", + "workspace lock changed during stale-lock takeover and could not be restored safely", + ); + } + rmSync(candidatePath); + fsyncDirectory(resolve(lockPath, "..")); + exchanged = false; + return true; + } catch (error) { + if (exchanged) { + try { + if (currentFileHash(lockPath, workspaceRoot) === candidateHash && existsSync(candidatePath)) { + atomicExchangePaths(candidatePath, lockPath); + exchanged = false; + } + } catch { + // Preserve both paths for bounded recovery instead of deleting uncertain ownership. + } + } + if (exchanged) { + throw new ProjectContextError( + "PROJECT_CONTEXT_LOCK_LOST", + "workspace lock takeover could not be completed or rolled back safely", + ); + } + throw error; + } +} + +function assertWorkspaceLockHeld(lockPath: string, lock: WorkspaceLock, workspaceRoot: string): void { + if (!existsSync(lockPath)) { + throw new ProjectContextError("PROJECT_CONTEXT_LOCK_LOST", "workspace project-context lock changed during render"); + } + const current = lstatSync(lockPath); + if ( + current.isSymbolicLink() || + current.dev !== lock.identity.dev || + current.ino !== lock.identity.ino || + currentFileHash(lockPath, workspaceRoot) !== lock.contentHash + ) { + throw new ProjectContextError("PROJECT_CONTEXT_LOCK_LOST", "workspace project-context lock changed during render"); + } +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function processStartIdentity(pid: number): string | null { + if (!Number.isSafeInteger(pid) || pid <= 0) return null; + if (process.platform === "linux") { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const close = stat.lastIndexOf(")"); + if (close < 0) return null; + const fields = stat.slice(close + 2).trim().split(/\s+/); + const startTicks = fields[19]; + if (!startTicks || !/^[0-9]+$/.test(startTicks)) return null; + const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(); + return /^[a-f0-9-]{36}$/i.test(bootId) ? `linux:${bootId}:${startTicks}` : null; + } catch { + return null; + } + } + if (process.platform === "darwin") { + try { + const started = execFileSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1_000, + }).trim(); + return started ? `darwin:${started}` : null; + } catch { + return null; + } + } + return null; +} + +function releaseWorkspaceLock(lockPath: string, lock: WorkspaceLock, workspaceRoot: string): void { + if (!resolveAtomicExchange()) { + try { + removeOwnedLockByInode(lockPath, lock.identity, lock.contentHash); + } finally { + try { closeSync(lock.fd); } catch { /* already closed */ } + } + return; + } + const lockDirectory = resolve(lockPath, ".."); + const releasePath = join(lockDirectory, `.project-context-release-${randomUUID()}.tmp`); + let releaseFd: number | null = null; + let releaseIdentity: { dev: number; ino: number } | null = null; + let releaseHash: string | null = null; + let exchanged = false; + try { + releaseFd = openSync(releasePath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + const opened = fstatSync(releaseFd); + releaseIdentity = { dev: opened.dev, ino: opened.ino }; + const releaseContent = `${JSON.stringify({ + schema: "hasna.instructions.project-context-lock/v1", + pid: process.pid, + nonce: randomUUID(), + state: "releasing", + created_at: new Date().toISOString(), + })}\n`; + releaseHash = sha256(releaseContent); + writeFileSync(releaseFd, releaseContent); + fsyncSync(releaseFd); + closeSync(releaseFd); + releaseFd = null; + + atomicExchangePaths(releasePath, lockPath); + exchanged = true; + const installed = lstatSync(lockPath); + const displaced = lstatSync(releasePath); + const releaseInstalled = ( + !installed.isSymbolicLink() && + installed.dev === releaseIdentity.dev && + installed.ino === releaseIdentity.ino && + currentFileHash(lockPath, workspaceRoot) === releaseHash + ); + const ownedDisplaced = ( + !displaced.isSymbolicLink() && + displaced.dev === lock.identity.dev && + displaced.ino === lock.identity.ino && + currentFileHash(releasePath, workspaceRoot) === lock.contentHash + ); + if (!releaseInstalled || !ownedDisplaced) { + if (releaseInstalled && existsSync(releasePath)) { + atomicExchangePaths(releasePath, lockPath); + exchanged = false; + } + return; + } + rmSync(releasePath); + removeOwnedLockByInode(lockPath, releaseIdentity, releaseHash); + fsyncDirectory(lockDirectory); + exchanged = false; + } catch { + if (exchanged) { + try { + if (releaseHash && currentFileHash(lockPath, workspaceRoot) === releaseHash && existsSync(releasePath)) { + atomicExchangePaths(releasePath, lockPath); + exchanged = false; + } + } catch { + // Leave both paths in place rather than deleting uncertain lock ownership. + } + } + } finally { + if (releaseFd !== null) { + try { closeSync(releaseFd); } catch { /* already closed */ } + } + if (!exchanged && existsSync(releasePath)) { + try { rmSync(releasePath); } catch { /* preserve an uncertain release marker */ } + } + try { closeSync(lock.fd); } catch { /* already closed */ } + } +} + +function fsyncDirectory(path: string): void { + const fd = openSync(path, constants.O_RDONLY); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function ensureSafeDirectory(path: string, workspaceRoot: string, mode: number): void { + const rel = relative(workspaceRoot, path); + if (rel === ".." || rel.startsWith("../") || isAbsolute(rel)) { + throw new ProjectContextError("PROJECT_CONTEXT_PATH_ESCAPE", "managed directory escapes the workspace root"); + } + const segments = rel.split(/[\\/]+/).filter(Boolean); + let current = workspaceRoot; + for (const segment of segments) { + current = join(current, segment); + if (existsSync(current)) { + if (lstatSync(current).isSymbolicLink()) throw new ProjectContextError("PROJECT_CONTEXT_SYMLINK_REJECTED", `managed path uses a symlink: ${current}`); + if (!statSync(current).isDirectory()) throw new ProjectContextError("PROJECT_CONTEXT_PATH_INVALID", `managed path is not a directory: ${current}`); + } else { + mkdirSync(current, { mode }); + fsyncDirectory(resolve(current, "..")); + } + } +} + +function validateLinkConsistency(bundle: ProjectContextBundleV1): void { + const todos = bundle.links.todos; + const todosCount = Number(todos.project_id !== null) + Number(todos.task_list_id !== null); + if ( + (todos.state === "linked" && todosCount !== 2) || + (todos.state === "partial" && todosCount !== 1) || + (todos.state === "unlinked" && todosCount !== 0) + ) throw new ProjectContextError("PROJECT_CONTEXT_INVALID", "Todos link state is inconsistent with its explicit IDs"); + validateSingleLink("Conversations", bundle.links.conversations.state, bundle.links.conversations.channel); + const mementosCount = Number(bundle.links.mementos.project_id !== null) + Number(bundle.links.mementos.scope !== null); + if ( + (bundle.links.mementos.state === "linked" && mementosCount !== 2) || + (bundle.links.mementos.state === "partial" && mementosCount !== 1) || + (bundle.links.mementos.state === "unlinked" && mementosCount !== 0) + ) throw new ProjectContextError("PROJECT_CONTEXT_INVALID", "Mementos link state is inconsistent with its explicit IDs"); +} + +function validateSingleLink(label: string, state: "linked" | "partial" | "unlinked", value: string | null): void { + if (state === "linked" && value === null) throw new ProjectContextError("PROJECT_CONTEXT_INVALID", `${label} linked state requires an identifier`); + if (state === "unlinked" && value !== null) throw new ProjectContextError("PROJECT_CONTEXT_INVALID", `${label} unlinked state forbids an identifier`); + if (state === "partial" && value === null) throw new ProjectContextError("PROJECT_CONTEXT_INVALID", `${label} partial state requires its available identifier`); +} + +function validateCommands(bundle: ProjectContextBundleV1): void { + for (const command of bundle.commands) { + const [executable, subcommand, projectId, format, ...rest] = command.argv; + if ( + executable !== "projects" || + subcommand !== command.name || + projectId !== bundle.project.id || + format !== "--json" || + rest.length !== 0 + ) { + throw new ProjectContextError("PROJECT_CONTEXT_INVALID", "bundle contains a command outside the fixed argv allowlist"); + } + } +} + +function validateIdentityConsistency(bundle: ProjectContextBundleV1): void { + if (bundle.project.status !== "active" && bundle.resolution.create_allowed) { + throw new ProjectContextError("PROJECT_CONTEXT_INVALID", "archived or deleted projects cannot allow creation"); + } +} + +function rejectCredentialLikeBundle(bundle: ProjectContextBundleV1): void { + const encoded = JSON.stringify(bundle); + const credentialShape = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:password|passwd|api[_-]?key|access[_-]?token|client[_-]?secret)\s*[:=]|\$\{|https?:\/\//i; + if (credentialShape.test(encoded) || scanSecrets(encoded, "text").length > 0) { + throw new ProjectContextError("PROJECT_CONTEXT_SECRET_REJECTED", "credential-like or URL content is forbidden in project context"); + } +} + +function scanGeneratedContent(content: string): void { + if (Buffer.byteLength(content, "utf8") > PROJECT_CONTEXT_MAX_RENDERED_BYTES) { + throw new ProjectContextError("PROJECT_CONTEXT_RENDER_TOO_LARGE", "generated project context exceeds 4 KiB"); + } + if (Math.ceil(content.length / 4) > PROJECT_CONTEXT_MAX_APPROX_TOKENS) { + throw new ProjectContextError("PROJECT_CONTEXT_RENDER_TOO_LARGE", "generated project context exceeds the approximate token budget"); + } + if (scanSecrets(content, "markdown").length > 0 || /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:password|passwd|api[_-]?key|access[_-]?token|client[_-]?secret)\s*[:=]|https?:\/\//i.test(content)) { + throw new ProjectContextError("PROJECT_CONTEXT_SECRET_REJECTED", "generated project context contains credential-like content"); + } +} + +function runtimePaths(workspaceRoot: string, runtime: ProjectContextRuntime): { + target: string; + fragment: string; + manifest: string; + cache: string; + sessionManifest: string; +} { + const relativeTarget = runtime === "claude" ? "CLAUDE.md" : runtime === "codewith" ? ".codewith/CODEWITH.md" : "AGENTS.md"; + return { + target: resolve(workspaceRoot, ...relativeTarget.split("/")), + fragment: resolve(workspaceRoot, ...PROJECT_CONTEXT_FRAGMENT_PATH.split("/")), + manifest: resolve(workspaceRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")), + cache: resolve(workspaceRoot, ...PROJECT_CONTEXT_CACHE_PATH.split("/")), + sessionManifest: runtime === "codewith" + ? resolve(workspaceRoot, ".codewith", ".hasna", "session-render-manifest.json") + : resolve(workspaceRoot, ".hasna", "session-render-manifest.json"), + }; +} + +function projectContextSessionGuardPaths( + paths: ReturnType, + runtime: ProjectContextRuntime, +): string[] { + return [ + paths.manifest, + paths.cache, + paths.fragment, + paths.target, + paths.sessionManifest, + ...(runtime === "codewith" ? [resolve(paths.target, "..", "CODEWITH.override.md")] : []), + ]; +} + +function sessionTargetRelativePath(runtime: ProjectContextRuntime): string { + if (runtime === "claude") return "CLAUDE.md"; + if (runtime === "codewith") return "CODEWITH.md"; + return "AGENTS.md"; +} + +function projectContextRuntimeForSessionTool(tool: SessionRenderTool): ProjectContextRuntime | null { + if (tool === "claude") return "claude"; + if (tool === "codewith") return "codewith"; + if (tool === "codex") return "agents"; + return null; +} + +function projectContextWorkspaceForSession( + input: Pick, + runtime: ProjectContextRuntime, +): string | null { + const targetHome = resolve(input.target_home); + if (runtime === "codewith") { + const workspaceRoot = basename(targetHome) === ".codewith" ? dirname(targetHome) : null; + if (!workspaceRoot) return null; + if (input.project_root && resolve(input.project_root) !== workspaceRoot) { + throw new ProjectContextError( + "PROJECT_CONTEXT_PATH_INVALID", + "Codewith project_root must be the parent workspace of target_home", + ); + } + if (!existsSync(workspaceRoot) || !lstatSync(workspaceRoot).isDirectory()) return null; + return assertSafeWorkspaceRoot(workspaceRoot); + } + if (!existsSync(targetHome) || !lstatSync(targetHome).isDirectory()) return null; + return assertSafeWorkspaceRoot(targetHome); +} + +function assertCodewithTargetIsConsumed(workspaceRoot: string, runtime: ProjectContextRuntime): void { + if (runtime !== "codewith") return; + const override = resolve(workspaceRoot, ".codewith", "CODEWITH.override.md"); + if (!existsSync(override)) return; + assertNoSymlinkSegments(workspaceRoot, override); + if (!lstatSync(override).isFile()) throw new ProjectContextError("PROJECT_CONTEXT_PATH_INVALID", "Codewith override is not a regular file"); + throw new ProjectContextError("PROJECT_CONTEXT_SHADOWED", ".codewith/CODEWITH.override.md shadows .codewith/CODEWITH.md"); +} + +function assertSafeWorkspaceRoot(path: string): string { + if (!isAbsolute(path)) throw new ProjectContextError("PROJECT_CONTEXT_PATH_INVALID", "workspace root must be absolute"); + const normalized = resolve(path); + if (normalized === parse(normalized).root) throw new ProjectContextError("PROJECT_CONTEXT_PATH_INVALID", "workspace root cannot be the filesystem root"); + if (!existsSync(normalized) || !lstatSync(normalized).isDirectory()) throw new ProjectContextError("PROJECT_CONTEXT_PATH_INVALID", "workspace root must be an existing directory"); + assertNoSymlinkAncestors(normalized); + if (lstatSync(normalized).isSymbolicLink()) throw new ProjectContextError("PROJECT_CONTEXT_SYMLINK_REJECTED", "workspace root cannot be a symlink"); + return normalized; +} + +function assertNoSymlinkSegments(root: string, target: string): void { + const rel = relative(root, target); + if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) { + throw new ProjectContextError("PROJECT_CONTEXT_PATH_ESCAPE", "managed path escapes workspace root"); + } + let current = root; + for (const segment of rel.split(/[\\/]+/).filter(Boolean)) { + current = join(current, segment); + if (existsSync(current) && lstatSync(current).isSymbolicLink()) { + throw new ProjectContextError("PROJECT_CONTEXT_SYMLINK_REJECTED", `managed path uses a symlink: ${current}`); + } + } +} + +function assertNoSymlinkAncestors(path: string): void { + const normalized = resolve(path); + let current = parse(normalized).root; + for (const segment of relative(current, normalized).split(/[\\/]+/).filter(Boolean)) { + current = join(current, segment); + if (!existsSync(current)) return; + if (lstatSync(current).isSymbolicLink()) throw new ProjectContextError("PROJECT_CONTEXT_SYMLINK_REJECTED", `workspace ancestor is a symlink: ${current}`); + } +} + +function readUtf8RegularFile(path: string, workspaceRoot: string, maxBytes = 256 * 1024): string { + assertNoSymlinkSegments(workspaceRoot, path); + const stat = lstatSync(path); + if (!stat.isFile()) throw new ProjectContextError("PROJECT_CONTEXT_PATH_INVALID", `managed path is not a regular file: ${path}`); + if (stat.size > maxBytes) throw new ProjectContextError("PROJECT_CONTEXT_INPUT_TOO_LARGE", `managed input exceeds ${maxBytes} bytes`); + return readFileSync(path, "utf8"); +} + +function currentFileHash(path: string, workspaceRoot: string): string | null { + if (!existsSync(path)) return null; + const relativePath = relativePosix(workspaceRoot, path); + const maxBytes = relativePath === ".hasna/session-render-manifest.json" || relativePath === ".codewith/.hasna/session-render-manifest.json" + ? SESSION_COMPATIBILITY_MANIFEST_MAX_BYTES + : 256 * 1024; + return sha256(readUtf8RegularFile(path, workspaceRoot, maxBytes)); +} + +function hashesStillMatch(expected: Map, workspaceRoot: string): boolean { + for (const [path, hash] of expected) { + if (currentFileHash(path, workspaceRoot) !== hash) return false; + } + return true; +} + +function fragmentMatchesBundle(path: string, bundle: ProjectContextBundleV1, workspaceRoot: string): boolean { + const content = readUtf8RegularFile(path, workspaceRoot); + const first = content.split(/\r?\n/, 1)[0] ?? ""; + return first.includes(`id=${bundle.project.id}`) && first.includes(`revision=${bundle.revision}`) && first.includes(`hash=${bundle.hash}`); +} + +function durableSourcePath(path: string | undefined, workspaceRoot: string): string { + if (!path || path.startsWith("/dev/fd/")) return resolve(workspaceRoot, ...PROJECT_CONTEXT_CACHE_PATH.split("/")); + const normalized = isAbsolute(path) ? resolve(path) : resolve(workspaceRoot, path); + if (normalized.startsWith("/dev/fd/")) return resolve(workspaceRoot, ...PROJECT_CONTEXT_CACHE_PATH.split("/")); + return normalized; +} + +function compareRevisions(incoming: string, previous: string): number { + const a = revisionKey(incoming); + const b = revisionKey(previous); + if (!a || !b || a.kind !== b.kind) { + throw new ProjectContextError("PROJECT_CONTEXT_REVISION_INCOMPARABLE", "project-context revisions use incompatible ordering schemes"); + } + return a.value < b.value ? -1 : a.value > b.value ? 1 : 0; +} + +function revisionKey(value: string): { kind: "sequence" | "timestamp"; value: bigint } | null { + const sequence = value.match(/^(?:rev-)?([0-9]+)$/); + if (sequence) return { kind: "sequence", value: BigInt(sequence[1]!) }; + if (!/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/.test(value)) return null; + const normalized = value.includes("T") || /(?:Z|[+-]\d{2}:?\d{2})$/.test(value) + ? value.replace(" ", "T") + : `${value.replace(" ", "T")}Z`; + const timestamp = Date.parse(normalized); + return Number.isFinite(timestamp) ? { kind: "timestamp", value: BigInt(timestamp) } : null; +} + +function normalizeMaxStaleAge(value: number | undefined): number { + const result = value ?? 3_600; + if (!Number.isInteger(result) || result < 1 || result > 7 * 24 * 3_600) { + throw new ProjectContextError("PROJECT_CONTEXT_INVALID", "max stale age must be an integer between 1 second and 7 days"); + } + return result; +} + +function manifestTool(runtime: ProjectContextRuntime): "claude" | "codewith" | "codex" { + return runtime === "agents" ? "codex" : runtime; +} + +function runtimeUsesNativeImports(runtime: ProjectContextRuntime, codewithNativeImports: boolean | undefined): boolean { + if (runtime === "claude") return true; + if (runtime === "agents") return false; + return codewithNativeImports === true || process.env[CODEWITH_NATIVE_IMPORTS_ENV] === "1" || process.env[CODEWITH_NATIVE_IMPORTS_ENV] === "true"; +} + +function ageInSeconds(generatedAt: string, now: Date): number { + const deltaMs = now.getTime() - Date.parse(generatedAt); + if (deltaMs < 0) { + throw new ProjectContextError("PROJECT_CONTEXT_INVALID", "bundle generated_at is in the future"); + } + return Math.floor(deltaMs / 1_000); +} + +function staleCacheAgeInSeconds(timestamp: string, now: Date, field: string): number { + const deltaMs = now.getTime() - Date.parse(timestamp); + if (deltaMs < 0) { + throw new ProjectContextError("PROJECT_CONTEXT_CACHE_INVALID", `${field} is in the future`); + } + return Math.floor(deltaMs / 1_000); +} + +function statusLabel(status: ProjectContextStatus, ageSeconds: number): string { + if (status === "fresh") return "fresh"; + if (status === "stale-cache") return `stale cache (age ${ageSeconds}s)`; + return `stale source (age ${ageSeconds}s)`; +} + +function shellQuote(value: string): string { + return /^[A-Za-z0-9_./:@+=,-]+$/.test(value) ? value : `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function inlineNullable(value: string | null): string { + return value === null ? "`none`" : inlineCode(value); +} + +function inlineCode(value: string): string { + const encoded = JSON.stringify(value).slice(1, -1).replace(/`/g, "\\u0060"); + return `\`${encoded}\``; +} + +function escapeText(value: string): string { + return value.replace(/[<>]/g, ""); +} + +function preferredEol(content: string): string { + return content.includes("\r\n") ? "\r\n" : "\n"; +} + +function relativePosix(root: string, path: string): string { + return relative(root, path).split("\\").join("/"); +} + +function ensureTrailingNewline(content: string): string { + return content.endsWith("\n") ? content : `${content}\n`; +} + +function safeFilename(value: string): string { + return value.replace(/[^A-Za-z0-9._-]/g, "-"); +} + +function linesWithOffsets(content: string): Array<{ text: string; start: number; end: number }> { + const result: Array<{ text: string; start: number; end: number }> = []; + const re = /[^\r\n]*(?:\r\n|\n|\r|$)/g; + let match: RegExpExecArray | null; + while ((match = re.exec(content)) !== null) { + if (match[0] === "" && match.index === content.length) break; + result.push({ text: match[0], start: match.index, end: match.index + match[0].length }); + } + return result; +} + +function lineContentEnd(line: { text: string; start: number; end: number }): number { + if (line.text.endsWith("\r\n")) return line.end - 2; + if (line.text.endsWith("\n") || line.text.endsWith("\r")) return line.end - 1; + return line.end; +} + +function isStrictIsoTimestamp(value: string): boolean { + const match = value.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-](\d{2}):(\d{2}))$/, + ); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[9] === undefined ? 0 : Number(match[9]); + const offsetMinute = match[10] === undefined ? 0 : Number(match[10]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const monthDays = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return ( + month >= 1 && month <= 12 && + day >= 1 && day <= monthDays[month - 1]! && + hour <= 23 && minute <= 59 && second <= 59 && + offsetHour <= 23 && offsetMinute <= 59 && + Number.isFinite(Date.parse(value)) + ); +} + +function isSafeSingleLine(value: string): boolean { + return !/[\u0000-\u001f\u007f\r\n]/.test(value) && !value.includes("") && !value.includes("`"); +} + +function isSafeCommandArgument(value: string): boolean { + return ( + (/^[A-Za-z0-9_./:@+=,-]+$/.test(value) && !value.includes("://") && !value.startsWith("-")) || + /^--[a-z][a-z0-9-]*$/.test(value) + ); +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (isRecord(value)) { + return `{${Object.keys(value).sort((left, right) => left.localeCompare(right)).map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function removeHashForFingerprint(value: unknown): unknown { + if (!isRecord(value)) return value; + const copy: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (key === "hash") continue; + copy[key] = item; + } + return copy; +} + +function sha256(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/lib/session-apply.test.ts b/src/lib/session-apply.test.ts index 849294c..3743c94 100644 --- a/src/lib/session-apply.test.ts +++ b/src/lib/session-apply.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { applySessionRender, checkSessionRenderDrift } from "./session-apply"; @@ -109,6 +109,20 @@ describe("session apply writer", () => { } }); + test("preserves legacy support for session outputs larger than the project-context read cap", () => { + const targetHome = targetFor("codex-large-output"); + const largeContent = "x".repeat(300 * 1024); + const plan = planSessionRender({ + tool: "codex", + profile: "account999", + targetHome, + sources: [{ ...globalIdentity, content: largeContent }], + }); + + expect(applySessionRender(plan).applied).toBe(true); + expect(readFileSync(join(targetHome, "AGENTS.md"), "utf8")).toContain(largeContent); + }); + test("blocks unmanaged file conflicts unless forced", () => { const targetHome = targetFor("codex-conflict"); mkdirSync(targetHome, { recursive: true }); @@ -176,6 +190,39 @@ describe("session apply writer", () => { expect(readFileSync(join(targetHome, "AGENTS.md"), "utf-8")).toContain("Updated managed content."); }); + test("preserves portable session updates and removals when no project-context guard is active", () => { + const targetHome = targetFor("cursor-portable-rerender"); + const first = planSessionRender({ + tool: "cursor", + profile: "account999", + projectRoot: targetHome, + sources: [globalIdentity, agentIdentity], + generatedAt: "2026-07-01T00:00:00.000Z", + }); + expect(first.projectContextGuard).toBeUndefined(); + expect(applySessionRender(first, { + test_hooks: { force_portable_file_ops: true }, + }).applied).toBe(true); + + const stalePath = join(targetHome, ".cursor", "rules", "02-agent-marcus.mdc"); + const second = planSessionRender({ + tool: "cursor", + profile: "account999", + projectRoot: targetHome, + sources: [{ ...globalIdentity, content: "Portable managed update." }], + generatedAt: "2026-07-01T00:01:00.000Z", + }); + const result = applySessionRender(second, { + test_hooks: { force_portable_file_ops: true }, + }); + + expect(result.applied).toBe(true); + expect(result.files.find((file) => file.action === "update")).toBeDefined(); + expect(result.files.find((file) => file.action === "delete")?.relativePath).toBe(".cursor/rules/02-agent-marcus.mdc"); + expect(readFileSync(join(targetHome, ".cursor", "rules", "01-global-codewith.mdc"), "utf8")).toContain("Portable managed update."); + expect(existsSync(stalePath)).toBe(false); + }); + test("detects drift from previous manifest before apply", () => { const targetHome = targetFor("codex-drift"); const first = planSessionRender({ @@ -274,6 +321,31 @@ describe("session apply writer", () => { expect(existsSync(join(outside, "instructions"))).toBe(false); }); + test("rejects a target-home symlink swap after planning without writing outside", () => { + const targetHome = targetFor("codex-symlink-race"); + const displaced = targetFor("codex-symlink-race-displaced"); + const outside = targetFor("codex-symlink-race-outside"); + mkdirSync(targetHome, { recursive: true }); + mkdirSync(outside, { recursive: true }); + const plan = planSessionRender({ + tool: "codex", + profile: "account999", + targetHome, + sources: [globalIdentity], + }); + + expect(() => applySessionRender(plan, { + test_hooks: { + before_apply_writes: () => { + renameSync(targetHome, displaced); + symlinkSync(outside, targetHome, "dir"); + }, + }, + })).toThrow("symlink"); + expect(existsSync(join(outside, "AGENTS.md"))).toBe(false); + expect(existsSync(join(displaced, "AGENTS.md"))).toBe(false); + }); + test("writes identity export rules and provenance into manifest", () => { const targetHome = targetFor("claude-identity-export"); const sources = sourcesFromIdentityExport({ diff --git a/src/lib/session-apply.ts b/src/lib/session-apply.ts index 6dd1a41..0d29b89 100644 --- a/src/lib/session-apply.ts +++ b/src/lib/session-apply.ts @@ -4,11 +4,14 @@ import { lstatSync, mkdirSync, readFileSync, - renameSync, - rmSync, - writeFileSync, } from "node:fs"; -import { dirname, isAbsolute, join, parse, relative, resolve } from "node:path"; +import { isAbsolute, join, parse, relative, resolve } from "node:path"; +import { + removeProjectContextCoordinatedFile, + withProjectContextSessionGuard, + writeProjectContextCoordinatedFile, + type ProjectContextWriteCoordination, +} from "./project-context.js"; import { SESSION_RENDER_MANAGED_MARKER, SESSION_RENDER_SCHEMA, @@ -63,6 +66,13 @@ export interface SessionApplyResult { export interface SessionApplyOptions { dryRun?: boolean; force?: boolean; + test_hooks?: { + before_apply_writes?: (context: { + plan: SessionRenderPlan; + results: SessionApplyFileResult[]; + }) => void; + force_portable_file_ops?: boolean; + }; } export class SessionApplyError extends Error { @@ -75,6 +85,18 @@ export class SessionApplyError extends Error { export function applySessionRender( plan: SessionRenderPlan, options: SessionApplyOptions = {}, +): SessionApplyResult { + return withProjectContextSessionGuard( + plan.projectContextGuard, + (coordination) => applySessionRenderUnlocked(plan, options, coordination), + { dry_run: options.dryRun }, + ); +} + +function applySessionRenderUnlocked( + plan: SessionRenderPlan, + options: SessionApplyOptions, + coordination: ProjectContextWriteCoordination | null, ): SessionApplyResult { if (plan.blocked || !plan.writable) { throw new SessionApplyError(`Session render plan is blocked: ${plan.blockers.join("; ")}`); @@ -111,18 +133,55 @@ export function applySessionRender( let snapshotPath: string | null = null; if (!options.dryRun) { - snapshotPath = writeSessionSnapshot(plan, targetHome, manifestPath, results, previousManifest); - for (const file of files) { - const target = resolvePlannedFilePath(plan, file, targetHome); - const existingContent = existsSync(target) ? readFileSync(target, "utf-8") : null; - if (existingContent === file.content) continue; - writePlannedFile(target, file.content, targetHome); + const allowPortableFallback = coordination === null; + const forcePortableFileOps = options.test_hooks?.force_portable_file_ops ?? false; + ensureSessionTargetHome(targetHome); + snapshotPath = writeSessionSnapshot( + plan, + targetHome, + manifestPath, + results, + previousManifest, + coordination, + allowPortableFallback, + forcePortableFileOps, + ); + options.test_hooks?.before_apply_writes?.({ plan, results }); + const resultsByPath = new Map(results.map((result) => [result.path, result])); + for (const file of plan.files) { + applyPlannedFile( + plan, + file, + targetHome, + resultsByPath, + coordination, + allowPortableFallback, + forcePortableFileOps, + ); } for (const result of results) { if (result.action !== "delete") continue; - assertNoSymlinkSegments(targetHome, result.path); - if (existsSync(result.path)) rmSync(result.path); + coordination?.assert_held(); + assertExpectedSessionFileHash(result.path, targetHome, result.previousSha256); + removeProjectContextCoordinatedFile({ + path: result.path, + workspace_root: targetHome, + expected_hash: requiredPreviousHash(result), + max_observed_bytes: null, + allow_portable_removal: allowPortableFallback, + force_portable_file_ops: forcePortableFileOps, + }); + coordination?.assert_held(); } + applyPlannedFile( + plan, + plan.manifestFile, + targetHome, + resultsByPath, + coordination, + allowPortableFallback, + forcePortableFileOps, + ); } return { @@ -138,6 +197,11 @@ export function applySessionRender( }; } +function ensureSessionTargetHome(targetHome: string): void { + if (!existsSync(targetHome)) mkdirSync(targetHome, { recursive: true, mode: 0o700 }); + assertSafeTargetHome(targetHome); +} + export function checkSessionRenderDrift(targetHome: string, manifestPath?: string): SessionDriftCheck { const safeTargetHome = assertSafeTargetHome(targetHome); const resolvedManifestPath = manifestPath @@ -388,13 +452,60 @@ function readPreviousManifest(path: string): SessionRenderManifest | null { } } -function writePlannedFile(path: string, content: string, targetHome: string): void { - const dir = dirname(path); - mkdirSync(dir, { recursive: true }); +function applyPlannedFile( + plan: SessionRenderPlan, + file: SessionRenderFile, + targetHome: string, + resultsByPath: Map, + coordination: ProjectContextWriteCoordination | null, + allowPortableFallback: boolean, + forcePortableFileOps: boolean, +): void { + const target = resolvePlannedFilePath(plan, file, targetHome); + const result = resultsByPath.get(target); + if (!result) throw new SessionApplyError(`Session apply result is missing for ${file.relativePath}`); + coordination?.assert_held(); + assertExpectedSessionFileHash(target, targetHome, result.previousSha256); + if (currentSessionFileHash(target, targetHome) === file.sha256) return; + writeProjectContextCoordinatedFile({ + path: target, + content: file.content, + workspace_root: targetHome, + default_mode: 0o644, + expected_hash: result.previousSha256, + max_observed_bytes: null, + allow_portable_replacement: allowPortableFallback, + force_portable_file_ops: forcePortableFileOps, + }); + coordination?.assert_held(); +} + +function assertExpectedSessionFileHash( + path: string, + targetHome: string, + expectedHash: string | null, +): void { + const actualHash = currentSessionFileHash(path, targetHome); + if (actualHash !== expectedHash) { + throw new SessionApplyError(`Session apply path changed after planning: ${relative(targetHome, path)}`); + } +} + +function currentSessionFileHash(path: string, targetHome: string): string | null { assertNoSymlinkSegments(targetHome, path); - const tmp = join(dir, `.session-${randomUUID()}.tmp`); - writeFileSync(tmp, content, "utf-8"); - renameSync(tmp, path); + if (!existsSync(path)) return null; + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new SessionApplyError(`Session apply path is not a regular file: ${path}`); + } + return sha256(readFileSync(path, "utf-8")); +} + +function requiredPreviousHash(result: SessionApplyFileResult): string { + if (result.previousSha256 === null) { + throw new SessionApplyError(`Session delete has no previous hash: ${result.relativePath}`); + } + return result.previousSha256; } function writeSessionSnapshot( @@ -403,6 +514,9 @@ function writeSessionSnapshot( manifestPath: string, results: SessionApplyFileResult[], previousManifest: SessionRenderManifest | null, + coordination: ProjectContextWriteCoordination | null, + allowPortableFallback: boolean, + forcePortableFileOps: boolean, ): string | null { const existingFiles = results .filter((result) => result.action === "update" || result.action === "delete") @@ -436,7 +550,18 @@ function writeSessionSnapshot( previousManifest, files: existingFiles, }; - writePlannedFile(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, targetHome); + coordination?.assert_held(); + writeProjectContextCoordinatedFile({ + path: snapshotPath, + content: `${JSON.stringify(snapshot, null, 2)}\n`, + workspace_root: targetHome, + default_mode: 0o600, + expected_hash: null, + max_observed_bytes: null, + allow_portable_replacement: allowPortableFallback, + force_portable_file_ops: forcePortableFileOps, + }); + coordination?.assert_held(); return snapshotPath; } diff --git a/src/lib/session-render-contract.ts b/src/lib/session-render-contract.ts new file mode 100644 index 0000000..1d20d2f --- /dev/null +++ b/src/lib/session-render-contract.ts @@ -0,0 +1,17 @@ +export const CODEWITH_NATIVE_IMPORTS_ENV = "HASNA_CONFIGS_CODEWITH_NATIVE_IMPORTS"; +export const SESSION_RENDER_MANAGED_MARKER = "Managed by @hasna/configs session render"; +export const SESSION_RENDER_SCHEMA = "hasna.configs.session-render/v1"; + +export const SESSION_INSTRUCTION_LAYERS = [ + "global", + "tool", + "account", + "machine", + "division", + "workspace", + "repo", + "path", + "agent", + "session", + "local", +] as const; diff --git a/src/lib/session-render.ts b/src/lib/session-render.ts index b85928b..13b5683 100644 --- a/src/lib/session-render.ts +++ b/src/lib/session-render.ts @@ -3,10 +3,24 @@ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, extname, isAbsolute, join, parse, posix, relative, resolve } from "node:path"; import type { Config } from "../types/index.js"; - -export const CODEWITH_NATIVE_IMPORTS_ENV = "HASNA_CONFIGS_CODEWITH_NATIVE_IMPORTS"; -export const SESSION_RENDER_MANAGED_MARKER = "Managed by @hasna/configs session render"; -export const SESSION_RENDER_SCHEMA = "hasna.configs.session-render/v1"; +import { + composeProjectContextSessionRender, + observeProjectContextSessionGuard, + type ProjectContextSessionGuard, +} from "./project-context.js"; +import { + CODEWITH_NATIVE_IMPORTS_ENV, + SESSION_INSTRUCTION_LAYERS, + SESSION_RENDER_MANAGED_MARKER, + SESSION_RENDER_SCHEMA, +} from "./session-render-contract.js"; + +export { + CODEWITH_NATIVE_IMPORTS_ENV, + SESSION_INSTRUCTION_LAYERS, + SESSION_RENDER_MANAGED_MARKER, + SESSION_RENDER_SCHEMA, +} from "./session-render-contract.js"; export const RAW_STORE_ROOT_ENV = "HASNA_CONFIGS_HOME"; export const ANTIGRAVITY_RULE_FILE_CHAR_LIMIT = 12_000; @@ -23,19 +37,6 @@ export const SESSION_RENDER_TOOLS = [ export type SessionRenderTool = (typeof SESSION_RENDER_TOOLS)[number]; export type SessionRenderMode = "native-imports" | "flattened-markdown" | "cursor-mdc" | "opencode-instructions" | "antigravity-rules"; -export const SESSION_INSTRUCTION_LAYERS = [ - "global", - "tool", - "account", - "machine", - "division", - "workspace", - "repo", - "path", - "agent", - "session", - "local", -] as const; export type SessionInstructionLayer = (typeof SESSION_INSTRUCTION_LAYERS)[number]; export type SessionInstructionLayerAlias = SessionInstructionLayer | "provider" | "identity" | "project"; export type SessionInstructionMerge = "append" | "replace"; @@ -195,6 +196,17 @@ export interface SessionRenderManifest { sourceIds: string[]; }>; warnings: string[]; + projectContext?: { + schema: string; + projectId: string; + revision: string; + hash: string; + status: string; + ageSeconds: number; + cachePath: string; + fragmentPath: string; + }; + compatibility?: Record; } export interface SessionRenderPlan { @@ -215,6 +227,7 @@ export interface SessionRenderPlan { manifestFile: SessionRenderFile; allFiles: SessionRenderFile[]; warnings: string[]; + projectContextGuard?: ProjectContextSessionGuard; } const CODEWITH_FLATTENED_ADAPTER: SessionToolAdapter = { @@ -851,7 +864,27 @@ export function planSessionRender(input: SessionRenderInput): SessionRenderPlan ...(orderedSources.length === 0 ? ["No instruction sources were provided."] : []), ...blockers, ]; - const files = blocked ? [] : buildFiles(targetHome, adapter, input.profile, orderedSources); + const baseFiles = blocked ? [] : buildFiles(targetHome, adapter, input.profile, orderedSources); + const projectContext = blocked + ? null + : composeProjectContextSessionRender({ + tool: input.tool, + adapter_mode: adapter.mode, + target_home: targetHome, + project_root: input.projectRoot, + files: baseFiles, + }); + const projectContextGuard = blocked + ? null + : projectContext?.guard ?? observeProjectContextSessionGuard({ + tool: input.tool, + target_home: targetHome, + project_root: input.projectRoot, + }); + if (projectContext && orderedSources.some((source) => source.id === projectContext.source.id)) { + throw new Error(`Session source ${projectContext.source.id} is reserved for the durable Instructions project-context renderer.`); + } + const files = projectContext?.files ?? baseFiles; rejectDuplicateRenderPaths(files); const manifest: SessionRenderManifest = { @@ -868,37 +901,53 @@ export function planSessionRender(input: SessionRenderInput): SessionRenderPlan blockers, generatedAt, env, - sourceHash: fingerprint(orderedSources.map((source) => ({ - id: source.id, - layer: source.resolvedLayer, - order: source.resolvedOrder, - merge: source.resolvedMerge, - content: source.content, - rules: source.resolvedRules.map((rule) => ({ id: rule.id, path: rule.resolvedPath, content: rule.content })), - hash: source.hash ?? null, - }))), - sources: orderedSources.map((source) => ({ - id: source.id, - label: source.resolvedLabel, - layer: source.resolvedLayer, - merge: source.resolvedMerge, - order: source.resolvedOrder, - path: source.path ?? null, - targetProviders: source.targetProviders ?? [], - owner: source.owner ?? null, - sourcePaths: source.sourcePaths ?? [], - hash: source.hash ?? null, - nonOverridable: source.nonOverridable === true, - replacementScope: source.replacementScope ?? null, - rules: source.resolvedRules.map((rule) => ({ - id: rule.id, - label: rule.resolvedLabel, - path: rule.resolvedPath, - globs: rule.globs ?? [], - hash: rule.hash ?? null, + sourceHash: fingerprint(projectContext + ? { + sources: orderedSources.map((source) => ({ + id: source.id, + layer: source.resolvedLayer, + order: source.resolvedOrder, + merge: source.resolvedMerge, + content: source.content, + rules: source.resolvedRules.map((rule) => ({ id: rule.id, path: rule.resolvedPath, content: rule.content })), + hash: source.hash ?? null, + })), + projectContext: projectContext.project_context, + } + : orderedSources.map((source) => ({ + id: source.id, + layer: source.resolvedLayer, + order: source.resolvedOrder, + merge: source.resolvedMerge, + content: source.content, + rules: source.resolvedRules.map((rule) => ({ id: rule.id, path: rule.resolvedPath, content: rule.content })), + hash: source.hash ?? null, + }))), + sources: [ + ...orderedSources.map((source) => ({ + id: source.id, + label: source.resolvedLabel, + layer: source.resolvedLayer, + merge: source.resolvedMerge, + order: source.resolvedOrder, + path: source.path ?? null, + targetProviders: source.targetProviders ?? [], + owner: source.owner ?? null, + sourcePaths: source.sourcePaths ?? [], + hash: source.hash ?? null, + nonOverridable: source.nonOverridable === true, + replacementScope: source.replacementScope ?? null, + rules: source.resolvedRules.map((rule) => ({ + id: rule.id, + label: rule.resolvedLabel, + path: rule.resolvedPath, + globs: rule.globs ?? [], + hash: rule.hash ?? null, + })), + provenance: source.provenance ?? null, })), - provenance: source.provenance ?? null, - })), + ...(projectContext ? [projectContext.source] : []), + ], skippedSources: [], files: files.map((file) => ({ path: file.path, @@ -908,6 +957,12 @@ export function planSessionRender(input: SessionRenderInput): SessionRenderPlan sourceIds: file.sourceIds, })), warnings, + ...(projectContext + ? { + projectContext: projectContext.project_context, + compatibility: projectContext.compatibility, + } + : {}), }; const manifestFile = makeFile( targetHome, @@ -935,6 +990,7 @@ export function planSessionRender(input: SessionRenderInput): SessionRenderPlan manifestFile, allFiles: [...files, manifestFile], warnings, + ...(projectContextGuard ? { projectContextGuard } : {}), }; }