@first-tree-ai/context-tree 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import path, { basename, dirname, isAbsolute, join, parse, posix, relative, resolve, sep } from "node:path";
3
+ import path, { basename, delimiter, dirname, isAbsolute, join, parse, posix, relative, resolve, sep } from "node:path";
4
4
  import { EventEmitter } from "node:events";
5
- import childProcess, { spawnSync } from "node:child_process";
6
- import fs, { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import childProcess, { spawn, spawnSync } from "node:child_process";
6
+ import fs, { accessSync, chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync } from "node:fs";
7
7
  import process$1 from "node:process";
8
8
  import { stripVTControlCharacters } from "node:util";
9
+ import { createHash, randomUUID } from "node:crypto";
9
10
  import { homedir, tmpdir } from "node:os";
10
11
  import { fileURLToPath } from "node:url";
11
12
  //#region \0rolldown/runtime.js
@@ -7468,6 +7469,46 @@ object({
7468
7469
  ok: literal(false),
7469
7470
  schemaVersion: literal(1)
7470
7471
  }).strict();
7472
+ const cleanupAgentSchema = union([literal("codex"), literal("claude")]);
7473
+ const cleanupScheduleSchema = object({
7474
+ id: string$2().regex(/^[a-f0-9]{64}$/u),
7475
+ projectPath: string$2().refine(isAbsolute),
7476
+ identity: string$2(),
7477
+ agent: cleanupAgentSchema,
7478
+ model: string$2().min(1),
7479
+ everyMinutes: number().int().positive().max(525600),
7480
+ nodePath: string$2().refine(isAbsolute),
7481
+ cliPath: string$2().refine(isAbsolute),
7482
+ agentPath: string$2().refine(isAbsolute),
7483
+ searchPath: string$2(),
7484
+ enabled: boolean()
7485
+ }).strict();
7486
+ const cleanupOutcomeSchema = object({
7487
+ at: number(),
7488
+ outcome: union([
7489
+ literal("inactive"),
7490
+ literal("unchanged"),
7491
+ literal("noop"),
7492
+ literal("published"),
7493
+ literal("running"),
7494
+ literal("failed"),
7495
+ literal("cancelled"),
7496
+ literal("publication-uncertain")
7497
+ ]),
7498
+ worktreePath: string$2().optional(),
7499
+ sha: string$2().optional(),
7500
+ message: string$2().optional()
7501
+ }).strict();
7502
+ const cleanupResultSchema = object({
7503
+ schemaVersion: literal(1),
7504
+ schedule: cleanupScheduleSchema.nullable(),
7505
+ registered: boolean(),
7506
+ running: boolean(),
7507
+ inactive: boolean(),
7508
+ lastActivity: number().nullable(),
7509
+ latest: cleanupOutcomeSchema.nullable()
7510
+ }).strict();
7511
+ const cleanupRunResultSchema = cleanupOutcomeSchema.extend({ schemaVersion: literal(1) }).strict();
7471
7512
  //#endregion
7472
7513
  //#region src/core/internal/errors.ts
7473
7514
  /**
@@ -25700,164 +25741,969 @@ function readPackageVersion() {
25700
25741
  return manifest.version;
25701
25742
  }
25702
25743
  //#endregion
25703
- //#region src/core/scaffold.ts
25704
- function template(name, values) {
25705
- let result = readFileSync(resolvePackagedResource("templates", name), "utf8");
25706
- for (const [key, value] of Object.entries(values)) result = result.replaceAll(`{{${key}}}`, value);
25707
- return result;
25708
- }
25709
- /** Templated regular files, written before the CLAUDE.md -> AGENTS.md symlink. */
25710
- const TEMPLATED_FILES = [
25711
- ["NODE.md", "root-node.md"],
25712
- ["AGENTS.md", "AGENTS.md"],
25713
- [".github/workflows/validate-context-tree.yml", "validate-context-tree.yml"]
25714
- ];
25715
- const SCAFFOLD_FILES = [
25716
- "NODE.md",
25717
- "AGENTS.md",
25718
- "CLAUDE.md",
25719
- ".github/workflows/validate-context-tree.yml"
25720
- ];
25721
- function initializeGitRepository(root, runner) {
25722
- gitCommand([
25723
- "init",
25724
- "--quiet",
25725
- "--",
25726
- root
25744
+ //#region src/core/sync.ts
25745
+ /**
25746
+ * Local trees report their checked-out state without network access. GitHub
25747
+ * trees fast-forward the exact checked-out branch once, then revalidate.
25748
+ */
25749
+ function syncProject(projectPath, runner) {
25750
+ const connection = resolveConnectionRecord(projectPath, runner);
25751
+ const root = connection.tree.path;
25752
+ const branch = git(root, [
25753
+ "symbolic-ref",
25754
+ "--short",
25755
+ "HEAD"
25727
25756
  ], {
25728
- message: "Failed to initialize Git repository.",
25757
+ message: "Failed to resolve the checked-out branch.",
25729
25758
  runner
25730
25759
  });
25731
- return git(root, [
25760
+ if (connection.tree.kind === "github") {
25761
+ git(root, [
25762
+ "pull",
25763
+ "--ff-only",
25764
+ "origin",
25765
+ branch
25766
+ ], {
25767
+ message: "Fast-forwarding the Context Tree failed.",
25768
+ runner
25769
+ });
25770
+ validateStoredTreeState(connection.tree, runner);
25771
+ }
25772
+ return {
25773
+ branch,
25774
+ schemaVersion: 1,
25775
+ sha: git(root, ["rev-parse", "HEAD"], {
25776
+ message: "Failed to resolve the Context Tree commit.",
25777
+ runner
25778
+ }),
25779
+ tree: connection.tree
25780
+ };
25781
+ }
25782
+ //#endregion
25783
+ //#region src/core/write.ts
25784
+ const TASK_BRANCH_PREFIX = "context-tree/write/";
25785
+ /** A prepared worktree left untouched for longer than this is treated as abandoned. */
25786
+ const ABANDONED_WRITE_AGE_MS = 1440 * 60 * 1e3;
25787
+ /** Synchronize first, then create an isolated task worktree at the exact HEAD. */
25788
+ function prepareContextWrite(projectPath, runner) {
25789
+ const synchronized = syncProject(projectPath, runner);
25790
+ const root = synchronized.tree.path;
25791
+ reclaimAbandonedWrites(root, synchronized.branch, runner);
25792
+ const destination = mkdtempSync(join(tmpdir(), "context-tree-write-"));
25793
+ const taskBranch = `${TASK_BRANCH_PREFIX}${basename(destination)}`;
25794
+ try {
25795
+ git(root, [
25796
+ "worktree",
25797
+ "add",
25798
+ "--quiet",
25799
+ "-b",
25800
+ taskBranch,
25801
+ destination,
25802
+ synchronized.sha
25803
+ ], {
25804
+ message: "Creating the isolated write worktree failed.",
25805
+ runner
25806
+ });
25807
+ return {
25808
+ schemaVersion: 1,
25809
+ worktreePath: realDirectoryWithoutSymlinks(destination, "Write worktree")
25810
+ };
25811
+ } catch (error) {
25812
+ rmSync(destination, {
25813
+ force: true,
25814
+ recursive: true
25815
+ });
25816
+ throw error;
25817
+ }
25818
+ }
25819
+ /** Commit every pending change, then fast-forward locally or push once. */
25820
+ function finishContextWrite(options, runner) {
25821
+ const connection = resolveConnectionRecord(options.projectPath, runner);
25822
+ const root = connection.tree.path;
25823
+ const { taskBranch, worktreePath } = validatePreparedWorktree(root, options.worktreePath, runner);
25824
+ const branch = git(root, [
25732
25825
  "symbolic-ref",
25733
25826
  "--short",
25734
25827
  "HEAD"
25735
25828
  ], {
25736
- message: "Failed to resolve the initial Git branch during repository initialization.",
25829
+ message: "Failed to resolve the connected checkout branch.",
25737
25830
  runner
25738
25831
  });
25739
- }
25740
- function commitScaffold(root, runner) {
25741
- for (const file of SCAFFOLD_FILES) git(root, [
25742
- "add",
25743
- "--",
25744
- file
25832
+ if (git(worktreePath, [
25833
+ "status",
25834
+ "--porcelain",
25835
+ "--untracked-files=all"
25745
25836
  ], {
25746
- message: "Failed to stage the scaffold files.",
25837
+ message: "Failed to inspect the prepared worktree.",
25838
+ runner
25839
+ }).length === 0) throw new Error("The prepared worktree has no pending changes.");
25840
+ if (!verifyTree(worktreePath).ok) throw new ContextTreeError(CLI_ERROR_CODES.invalidTree, `Refusing to commit an invalid Context Tree; run context-tree verify --tree-path ${worktreePath}.`);
25841
+ git(worktreePath, ["add", "--all"], {
25842
+ message: "Staging the Context Tree changes failed.",
25747
25843
  runner
25748
25844
  });
25749
- git(root, [
25750
- "-c",
25751
- "user.name=Context Tree",
25752
- "-c",
25753
- "user.email=context-tree@localhost",
25845
+ git(worktreePath, [
25754
25846
  "-c",
25755
25847
  "commit.gpgsign=false",
25756
25848
  "commit",
25757
25849
  "--quiet",
25758
25850
  "-m",
25759
- "Initialize Context Tree"
25851
+ options.message
25760
25852
  ], {
25761
- message: "Failed to commit the scaffold.",
25853
+ message: "Committing the Context Tree changes failed.",
25762
25854
  runner
25763
25855
  });
25764
- return git(root, ["rev-parse", "HEAD"], {
25765
- message: "Failed to resolve the scaffold commit.",
25856
+ const sha = git(worktreePath, ["rev-parse", "HEAD"], {
25857
+ message: "Failed to resolve the write commit.",
25766
25858
  runner
25767
25859
  });
25768
- }
25769
- function scaffoldTree(options) {
25770
- const name = treeNameSchema.parse(options.name);
25771
- const root = resolve(options.path);
25772
- const destination = lstatSync(root, { throwIfNoEntry: false });
25773
- if (destination !== void 0) {
25774
- if (destination.isSymbolicLink() || !destination.isDirectory()) throw new Error(`Refusing to scaffold into a symlink or non-directory destination: ${root}`);
25775
- if (readdirSync(root).length > 0) throw new Error(`Refusing to scaffold into a non-empty directory: ${root}`);
25776
- }
25777
- const initialBranch = initializeGitRepository(root, options.runner);
25778
- const values = {
25779
- branchJson: JSON.stringify(initialBranch),
25780
- packageVersion: readPackageVersion(),
25781
- title: name,
25782
- titleJson: JSON.stringify(name)
25783
- };
25784
- for (const [relativePath, source] of TEMPLATED_FILES) {
25785
- const path = join(root, relativePath);
25786
- mkdirSync(dirname(path), { recursive: true });
25787
- writeFileSync(path, template(source, values), {
25788
- encoding: "utf8",
25789
- flag: "wx",
25790
- mode: 420
25860
+ try {
25861
+ if (connection.tree.kind === "local") git(root, [
25862
+ "merge",
25863
+ "--ff-only",
25864
+ taskBranch
25865
+ ], {
25866
+ message: "Fast-forwarding the local Context Tree failed.",
25867
+ runner
25868
+ });
25869
+ else git(worktreePath, [
25870
+ "push",
25871
+ "origin",
25872
+ `HEAD:refs/heads/${branch}`
25873
+ ], {
25874
+ message: "Publishing the Context Tree write failed.",
25875
+ runner
25791
25876
  });
25877
+ } catch (error) {
25878
+ if (isNonFastForward(error)) throw new ContextTreeError(CLI_ERROR_CODES.writeOutdated, `The Context Tree advanced; the prepared worktree is preserved at ${worktreePath}.`);
25879
+ throw error;
25792
25880
  }
25793
- symlinkSync("AGENTS.md", join(root, "CLAUDE.md"), "file");
25794
- if (!verifyTree(root).ok) throw new Error("Refusing to commit an invalid Context Tree scaffold.");
25881
+ removeWorktree(root, worktreePath, taskBranch, runner);
25795
25882
  return {
25796
- branch: initialBranch,
25797
- commit: commitScaffold(root, options.runner),
25798
- root
25883
+ branch,
25884
+ schemaVersion: 1,
25885
+ sha
25799
25886
  };
25800
25887
  }
25801
- //#endregion
25802
- //#region src/core/create.ts
25803
- function projectName(canonicalRoot) {
25804
- const normalized = basename(canonicalRoot).toLowerCase().replace(/[^a-z\d._-]+/gu, "-").replace(/-{2,}/gu, "-").replace(/^[-.]+/u, "").replace(/[-.]+$/u, "").slice(0, 40);
25805
- return /^[a-z\d]/u.test(normalized) ? normalized : "project";
25888
+ function gitCommonDirectory(root, runner) {
25889
+ const value = git(root, ["rev-parse", "--git-common-dir"], {
25890
+ message: "Failed to resolve the Git common directory.",
25891
+ runner
25892
+ });
25893
+ return realDirectoryWithoutSymlinks(isAbsolute(value) ? value : resolve(root, value), "Git common directory");
25806
25894
  }
25807
- function existingCreateResult(canonical, destination, runner) {
25808
- const branch = git(destination, [
25895
+ function validatePreparedWorktree(root, suppliedPath, runner) {
25896
+ const worktreePath = realDirectoryWithoutSymlinks(suppliedPath, "Prepared worktree");
25897
+ if (gitCommonDirectory(worktreePath, runner) !== gitCommonDirectory(root, runner)) throw new Error("The prepared worktree does not belong to the connected Context Tree.");
25898
+ const taskBranch = git(worktreePath, [
25809
25899
  "symbolic-ref",
25810
25900
  "--short",
25811
25901
  "HEAD"
25812
25902
  ], {
25813
- message: "Failed to resolve the managed tree branch.",
25814
- runner
25815
- });
25816
- const commitSha = git(destination, ["rev-parse", "HEAD"], {
25817
- message: "Failed to resolve the managed tree commit.",
25903
+ message: "Failed to resolve the worktree branch.",
25818
25904
  runner
25819
25905
  });
25820
- linkProjectInstructions(canonical);
25906
+ if (!taskBranch.startsWith(TASK_BRANCH_PREFIX)) throw new Error("The prepared worktree is not on a reserved Context Tree write branch.");
25821
25907
  return {
25822
- branch,
25823
- commitSha,
25824
- created: false,
25825
- schemaVersion: 1,
25826
- title: readRootNode(destination).frontmatter.title,
25827
- treePath: destination
25908
+ taskBranch,
25909
+ worktreePath
25828
25910
  };
25829
25911
  }
25830
- /** Create and connect the project's uniquely named managed local Context Tree. */
25831
- function createProject(projectPath, runner) {
25832
- const canonical = canonicalProjectRoot(projectPath, runner);
25833
- const name = treeNameSchema.parse(`${projectName(canonical)}-context-tree`);
25834
- const destination = join(managedTreesRoot(), name);
25835
- const current = findConnectionRecord(canonical, runner);
25836
- if (current !== void 0 && current.tree.path !== destination) throw new Error(`This project is already connected to a Context Tree at ${current.tree.path}; run context-tree connect ${name} to switch.`);
25837
- if (existsSync(destination)) {
25838
- const entry = lstatSync(destination);
25839
- if (entry.isSymbolicLink() || !entry.isDirectory() || current === void 0) throw new Error(`Managed Context Tree name ${name} is occupied; run context-tree connect ${name}.`);
25840
- return existingCreateResult(canonical, destination, runner);
25841
- }
25842
- mkdirSync(destination, { mode: 448 });
25843
- try {
25844
- const scaffold = scaffoldTree({
25845
- name,
25846
- path: destination,
25847
- runner
25848
- });
25849
- upsertConnection({
25850
- projectPath: canonical,
25851
- tree: {
25852
- kind: "local",
25853
- path: scaffold.root
25854
- }
25855
- }, runner);
25856
- linkProjectInstructions(canonical);
25857
- return {
25858
- branch: scaffold.branch,
25859
- commitSha: scaffold.commit,
25860
- created: true,
25912
+ function isNonFastForward(error) {
25913
+ return error instanceof CommandError && /non-fast-forward|fetch first|tip of your current branch is behind|not possible to fast-forward|diverg/i.test(error.stderr);
25914
+ }
25915
+ function removeWorktree(root, worktreePath, taskBranch, runner) {
25916
+ git(root, [
25917
+ "worktree",
25918
+ "remove",
25919
+ worktreePath
25920
+ ], {
25921
+ message: "Removing the write worktree failed.",
25922
+ runner
25923
+ });
25924
+ git(root, [
25925
+ "branch",
25926
+ "-D",
25927
+ taskBranch
25928
+ ], {
25929
+ message: "Deleting the write branch failed.",
25930
+ runner
25931
+ });
25932
+ }
25933
+ /** Map every reserved write branch that still has a registered worktree to its path. */
25934
+ function listWriteWorktrees(root, runner) {
25935
+ const paths = /* @__PURE__ */ new Map();
25936
+ const output = optionalGit(root, [
25937
+ "worktree",
25938
+ "list",
25939
+ "--porcelain"
25940
+ ], runner);
25941
+ if (output === void 0) return paths;
25942
+ let path;
25943
+ for (const record of output.split("\n")) {
25944
+ if (record.startsWith("worktree ")) {
25945
+ path = record.slice(9).trim();
25946
+ continue;
25947
+ }
25948
+ if (!record.startsWith("branch refs/heads/")) continue;
25949
+ const branch = record.slice(18).trim();
25950
+ if (path !== void 0 && branch.startsWith(TASK_BRANCH_PREFIX)) paths.set(branch, path);
25951
+ }
25952
+ return paths;
25953
+ }
25954
+ function millisecondsSinceModification(path) {
25955
+ try {
25956
+ return Date.now() - statSync(path).mtimeMs;
25957
+ } catch {
25958
+ return;
25959
+ }
25960
+ }
25961
+ /**
25962
+ * A preparation is abandoned only when it carries no commit the connected
25963
+ * checkout lacks, has no pending edits, and has gone untouched. Every unknown
25964
+ * answer preserves the worktree, so a `WRITE_OUTDATED` commit awaiting its
25965
+ * retry and a concurrent preparation both survive.
25966
+ */
25967
+ function isAbandonedWrite(root, branch, checkoutBranch, path, runner) {
25968
+ if (optionalGit(root, [
25969
+ "rev-list",
25970
+ "--count",
25971
+ branch,
25972
+ "--not",
25973
+ checkoutBranch
25974
+ ], runner) !== "0") return false;
25975
+ if (path === void 0) return true;
25976
+ const age = millisecondsSinceModification(path);
25977
+ if (age === void 0 || age < ABANDONED_WRITE_AGE_MS) return false;
25978
+ return optionalGit(path, [
25979
+ "status",
25980
+ "--porcelain",
25981
+ "--untracked-files=all"
25982
+ ], runner) === "";
25983
+ }
25984
+ /** Reclaim earlier preparations that were never finished. Every step is best effort. */
25985
+ function reclaimAbandonedWrites(root, checkoutBranch, runner) {
25986
+ optionalGit(root, ["worktree", "prune"], runner);
25987
+ const paths = listWriteWorktrees(root, runner);
25988
+ const branches = optionalGit(root, [
25989
+ "for-each-ref",
25990
+ "--format=%(refname:short)",
25991
+ `refs/heads/${TASK_BRANCH_PREFIX}`
25992
+ ], runner);
25993
+ if (branches === void 0) return;
25994
+ for (const branch of branches.split("\n").filter((value) => value.length > 0)) {
25995
+ const path = paths.get(branch);
25996
+ if (!isAbandonedWrite(root, branch, checkoutBranch, path, runner)) continue;
25997
+ if (path !== void 0) optionalGit(root, [
25998
+ "worktree",
25999
+ "remove",
26000
+ path
26001
+ ], runner);
26002
+ optionalGit(root, [
26003
+ "branch",
26004
+ "-D",
26005
+ branch
26006
+ ], runner);
26007
+ }
26008
+ }
26009
+ //#endregion
26010
+ //#region src/core/cleanup/agent.ts
26011
+ function agentArguments(config) {
26012
+ return config.agent === "codex" ? [
26013
+ "exec",
26014
+ "--sandbox",
26015
+ "workspace-write",
26016
+ "-c",
26017
+ "approval_policy=\"never\"",
26018
+ "-c",
26019
+ "model_reasoning_effort=\"low\"",
26020
+ "--model",
26021
+ config.model,
26022
+ "--ephemeral",
26023
+ "-"
26024
+ ] : [
26025
+ "-p",
26026
+ "--model",
26027
+ config.model,
26028
+ "--permission-mode",
26029
+ "acceptEdits",
26030
+ "--tools",
26031
+ "Read,Edit,Write,Glob,Grep",
26032
+ "--allowedTools",
26033
+ "Read,Edit,Write,Glob,Grep",
26034
+ "--no-session-persistence"
26035
+ ];
26036
+ }
26037
+ async function runAgent(config, worktree, prompt, signal, timeoutMs = 900 * 1e3) {
26038
+ if (signal.aborted) throw new Error("Cleanup cancelled.");
26039
+ await new Promise((resolve, reject) => {
26040
+ const child = spawn(config.agentPath, agentArguments(config), {
26041
+ cwd: worktree,
26042
+ env: {
26043
+ ...process.env,
26044
+ PATH: config.searchPath,
26045
+ CONTEXT_TREE_CLEANUP: "1"
26046
+ },
26047
+ stdio: [
26048
+ "pipe",
26049
+ "ignore",
26050
+ "ignore"
26051
+ ]
26052
+ });
26053
+ let failure;
26054
+ let termination;
26055
+ const stop = (reason) => {
26056
+ if (termination !== void 0) return;
26057
+ failure = reason;
26058
+ clearTimeout(timer);
26059
+ const descendants = child.pid === void 0 ? [] : childProcesses(child.pid);
26060
+ termination = new Promise((finished) => {
26061
+ setTimeout(() => {
26062
+ for (const pid of descendants) kill(pid, "SIGKILL");
26063
+ child.kill("SIGKILL");
26064
+ finished();
26065
+ }, 5e3);
26066
+ });
26067
+ for (const pid of descendants.reverse()) kill(pid, "SIGTERM");
26068
+ child.kill("SIGTERM");
26069
+ };
26070
+ const abort = () => stop("Cleanup cancelled.");
26071
+ const timer = setTimeout(() => stop("Cleanup agent exceeded its 15-minute timeout."), timeoutMs);
26072
+ signal.addEventListener("abort", abort, { once: true });
26073
+ child.stdin.on("error", () => void 0);
26074
+ child.stdin.end(prompt);
26075
+ child.on("error", () => {
26076
+ failure = "Unable to launch cleanup agent.";
26077
+ });
26078
+ child.on("close", async (code) => {
26079
+ clearTimeout(timer);
26080
+ signal.removeEventListener("abort", abort);
26081
+ await termination;
26082
+ if (failure || code !== 0) reject(new Error(failure ?? "Cleanup agent failed; check CLI authentication and model access."));
26083
+ else resolve();
26084
+ });
26085
+ });
26086
+ }
26087
+ function kill(pid, signal) {
26088
+ try {
26089
+ process.kill(pid, signal);
26090
+ } catch {}
26091
+ }
26092
+ function childProcesses(parent) {
26093
+ const pairs = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" }).stdout?.trim().split("\n").map((line) => line.trim().split(/\s+/u).map(Number)) ?? [];
26094
+ const children = [];
26095
+ const visit = (pid) => {
26096
+ for (const [child, owner] of pairs) if (owner === pid && child !== void 0 && !children.includes(child)) {
26097
+ children.push(child);
26098
+ visit(child);
26099
+ }
26100
+ };
26101
+ visit(parent);
26102
+ return children;
26103
+ }
26104
+ //#endregion
26105
+ //#region src/core/cleanup/store.ts
26106
+ function privateDirectory(path) {
26107
+ const parent = join(path, "..");
26108
+ if (!lstatSync(path, { throwIfNoEntry: false })) {
26109
+ privateDirectory(parent);
26110
+ mkdirSync(path, { mode: 448 });
26111
+ }
26112
+ return realDirectoryWithoutSymlinks(path, "Cleanup directory");
26113
+ }
26114
+ function cleanupRoot() {
26115
+ return privateDirectory(join(realpathSync(homedir()), ".context-tree", "cleanup"));
26116
+ }
26117
+ function statePath(id, name) {
26118
+ if (!/^[a-f0-9]{64}$/u.test(id) || !/^[a-z-]+$/u.test(name)) throw new Error("Invalid cleanup state key.");
26119
+ return join(cleanupRoot(), `${id}.${name}`);
26120
+ }
26121
+ function readState(path) {
26122
+ if (!lstatSync(dirname(path), { throwIfNoEntry: false })) return void 0;
26123
+ realDirectoryWithoutSymlinks(dirname(path), "Cleanup state parent");
26124
+ const entry = lstatSync(path, { throwIfNoEntry: false });
26125
+ if (!entry) return void 0;
26126
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("Cleanup state must be a regular file.");
26127
+ return JSON.parse(readFileSync(path, "utf8"));
26128
+ }
26129
+ function atomicState(path, value) {
26130
+ atomicFile(path, `${JSON.stringify(value)}\n`);
26131
+ }
26132
+ function atomicFile(path, value, mode = 384) {
26133
+ const entry = lstatSync(path, { throwIfNoEntry: false });
26134
+ if (entry && (!entry.isFile() || entry.isSymbolicLink())) throw new Error("Unsafe cleanup file.");
26135
+ const temporary = `${path}.${randomUUID()}.tmp`;
26136
+ writeFileSync(temporary, value, {
26137
+ mode,
26138
+ flag: "wx"
26139
+ });
26140
+ try {
26141
+ renameSync(temporary, path);
26142
+ } finally {
26143
+ rmSync(temporary, { force: true });
26144
+ }
26145
+ }
26146
+ function treeIdentity(tree) {
26147
+ return tree.kind === "github" ? `github:${tree.repository.toLowerCase()}` : `local:${realpathSync(tree.path)}`;
26148
+ }
26149
+ function identityId(identity) {
26150
+ return createHash("sha256").update(identity).digest("hex");
26151
+ }
26152
+ function schedules() {
26153
+ return readdirSync(cleanupRoot()).filter((name) => name.endsWith(".config")).map((name) => loadSchedule(name.slice(0, -7)));
26154
+ }
26155
+ function loadSchedule(id) {
26156
+ const config = cleanupScheduleSchema.parse(readState(statePath(id, "config")));
26157
+ if (config.id !== id || identityId(config.identity) !== id) throw new Error("Cleanup identity is corrupt.");
26158
+ return config;
26159
+ }
26160
+ function activity(id) {
26161
+ const parsed = number().finite().safeParse(readState(statePath(id, "activity")));
26162
+ return parsed.success ? parsed.data : null;
26163
+ }
26164
+ //#endregion
26165
+ //#region src/core/cleanup/scheduler.ts
26166
+ function xml(value) {
26167
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
26168
+ }
26169
+ function shellQuote(value) {
26170
+ if (value.includes("\0")) throw new Error("Unsupported control character in scheduler argument.");
26171
+ return `'${value.replaceAll("'", "'\\''")}'`;
26172
+ }
26173
+ function unitQuote(value) {
26174
+ if (/[\n\r\0]/u.test(value)) throw new Error("Unsupported control character in scheduler argument.");
26175
+ return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("%", "%%").replaceAll("$", "$$")}"`;
26176
+ }
26177
+ function nativeScheduler(platform = process.platform, home = homedir(), runner = defaultRunner) {
26178
+ if (platform !== "darwin" && platform !== "linux") throw new Error("Cleanup schedules require macOS LaunchAgents or Linux systemd user services.");
26179
+ const label = (config) => `ai.context-tree.cleanup.${config.id}`;
26180
+ const domain = `gui/${process.getuid?.() ?? 0}`;
26181
+ const command = (args, allowMissing = false) => {
26182
+ const result = runner(platform === "darwin" ? "launchctl" : "systemctl", platform === "darwin" ? args : ["--user", ...args]);
26183
+ if (result.status !== 0 && !(allowMissing && /could not find service|not loaded|not found|does not exist/i.test(result.stderr + result.stdout))) throw new Error("Native cleanup scheduler operation failed; inspect your user scheduler.");
26184
+ return result.status === 0 ? result.stdout : "";
26185
+ };
26186
+ const directory = () => privateDirectory(platform === "darwin" ? join(home, "Library", "LaunchAgents") : join(home, ".config", "systemd", "user"));
26187
+ const launcherDirectory = (config) => {
26188
+ if (!/^[a-f0-9]{64}$/u.test(config.id)) throw new Error("Invalid cleanup state key.");
26189
+ return privateDirectory(join(home, ".context-tree", "cleanup", "launchers", config.id));
26190
+ };
26191
+ const status = (config) => {
26192
+ if (platform === "darwin") {
26193
+ const output = command(["print", `${domain}/${label(config)}`], true);
26194
+ return {
26195
+ registered: output.length > 0,
26196
+ running: /state = running|pid = \d+/u.test(output)
26197
+ };
26198
+ }
26199
+ const timer = command([
26200
+ "show",
26201
+ `${label(config)}.timer`,
26202
+ "--property=LoadState,ActiveState"
26203
+ ], true);
26204
+ const service = command([
26205
+ "show",
26206
+ `${label(config)}.service`,
26207
+ "--property=ActiveState"
26208
+ ], true);
26209
+ return {
26210
+ registered: /ActiveState=active/u.test(timer),
26211
+ running: /ActiveState=(active|activating|deactivating)/u.test(service)
26212
+ };
26213
+ };
26214
+ return {
26215
+ status,
26216
+ install(config) {
26217
+ const args = [
26218
+ config.nodePath,
26219
+ config.cliPath,
26220
+ "cleanup",
26221
+ "run",
26222
+ "--schedule-id",
26223
+ config.id,
26224
+ "--project-path",
26225
+ config.projectPath,
26226
+ "--json"
26227
+ ];
26228
+ const name = label(config);
26229
+ if (platform === "darwin") {
26230
+ const file = join(directory(), `${name}.plist`);
26231
+ const launcher = join(launcherDirectory(config), "context-tree-cleanup");
26232
+ atomicFile(launcher, `#!/bin/sh\nexec ${args.map(shellQuote).join(" ")}\n`, 448);
26233
+ if (status(config).registered) command(["bootout", `${domain}/${name}`]);
26234
+ atomicFile(file, `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>${name}</string><key>ProgramArguments</key><array><string>${xml(launcher)}</string></array><key>StartInterval</key><integer>${config.everyMinutes * 60}</integer><key>RunAtLoad</key><false/><key>AbandonProcessGroup</key><false/><key>WorkingDirectory</key><string>${xml(config.projectPath)}</string><key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(config.searchPath)}</string></dict></dict></plist>`);
26235
+ command([
26236
+ "bootstrap",
26237
+ domain,
26238
+ file
26239
+ ]);
26240
+ } else {
26241
+ atomicFile(join(directory(), `${name}.service`), `[Unit]\nDescription=Context Tree cleanup\n[Service]\nType=oneshot\nExecStart=${args.map(unitQuote).join(" ")}\nEnvironment=${unitQuote(`PATH=${config.searchPath}`)}\nKillMode=control-group\nTimeoutStopSec=10\nTimeoutStartSec=30min\n`);
26242
+ atomicFile(join(directory(), `${name}.timer`), `[Unit]\nDescription=Context Tree cleanup timer\n[Timer]\nOnActiveSec=${config.everyMinutes}min\nOnUnitInactiveSec=${config.everyMinutes}min\nUnit=${name}.service\n[Install]\nWantedBy=timers.target\n`);
26243
+ command(["daemon-reload"]);
26244
+ command(["enable", `${name}.timer`]);
26245
+ command(["restart", `${name}.timer`]);
26246
+ }
26247
+ },
26248
+ remove(config) {
26249
+ const name = label(config);
26250
+ if (platform === "darwin") {
26251
+ command(["bootout", `${domain}/${name}`], true);
26252
+ const launcherDir = launcherDirectory(config);
26253
+ const launcher = join(launcherDir, "context-tree-cleanup");
26254
+ const entry = lstatSync(launcher, { throwIfNoEntry: false });
26255
+ if (entry && (!entry.isFile() || entry.isSymbolicLink())) throw new Error("Unsafe cleanup file.");
26256
+ rmSync(join(directory(), `${name}.plist`), { force: true });
26257
+ rmSync(launcher, { force: true });
26258
+ if (readdirSync(launcherDir).length === 0) rmdirSync(launcherDir);
26259
+ } else {
26260
+ command([
26261
+ "disable",
26262
+ "--now",
26263
+ `${name}.timer`
26264
+ ], true);
26265
+ command(["stop", `${name}.service`], true);
26266
+ for (const suffix of ["timer", "service"]) rmSync(join(directory(), `${name}.${suffix}`), { force: true });
26267
+ command(["daemon-reload"]);
26268
+ }
26269
+ }
26270
+ };
26271
+ }
26272
+ //#endregion
26273
+ //#region src/core/cleanup/index.ts
26274
+ function parseCleanupInterval(value = "1h") {
26275
+ const match = /^(\d+)(m|h|d)$/u.exec(value);
26276
+ const minutes = Number(match?.[1]) * (match?.[2] === "d" ? 1440 : match?.[2] === "h" ? 60 : 1);
26277
+ if (!match || !Number.isSafeInteger(minutes) || minutes < 1 || minutes > 525600) throw new Error("Cleanup cadence must be a positive whole-minute duration (for example 30m, 1h, 1d), at most 365d.");
26278
+ return minutes;
26279
+ }
26280
+ function executable(name) {
26281
+ for (const directory of (process.env.PATH ?? "").split(delimiter)) {
26282
+ if (!directory) continue;
26283
+ const path = resolve(directory, name);
26284
+ try {
26285
+ accessSync(path, constants.X_OK);
26286
+ if (lstatSync(realpathSync(path)).isFile()) return realpathSync(path);
26287
+ } catch {}
26288
+ }
26289
+ throw new Error(`Install ${name} on PATH before scheduling cleanup.`);
26290
+ }
26291
+ function findSchedule(project) {
26292
+ const canonical = canonicalProjectRoot(project);
26293
+ const owned = schedules().filter((config) => config.projectPath === canonical);
26294
+ if (owned.length > 1) throw new Error("Multiple saved cleanup identities for this project; remove the old schedule first.");
26295
+ if (owned[0]) return owned[0];
26296
+ const connection = findConnectionRecord(canonical);
26297
+ return connection ? schedules().find((config) => config.id === identityId(treeIdentity(connection.tree))) : void 0;
26298
+ }
26299
+ function cleanupStatus(project, scheduler = nativeScheduler()) {
26300
+ const config = findSchedule(project);
26301
+ if (!config) return {
26302
+ schemaVersion: 1,
26303
+ schedule: null,
26304
+ registered: false,
26305
+ running: false,
26306
+ inactive: true,
26307
+ lastActivity: null,
26308
+ latest: null
26309
+ };
26310
+ const lastActivity = activity(config.id);
26311
+ const latest = readState(statePath(config.id, "latest"));
26312
+ return cleanupResultSchema.parse({
26313
+ schemaVersion: 1,
26314
+ schedule: config,
26315
+ ...scheduler.status(config),
26316
+ inactive: lastActivity === null || Date.now() - lastActivity > 864e5,
26317
+ lastActivity,
26318
+ latest: latest === void 0 ? null : cleanupOutcomeSchema.parse(latest)
26319
+ });
26320
+ }
26321
+ function manage(operation) {
26322
+ const path = join(cleanupRoot(), ".management-lock");
26323
+ try {
26324
+ mkdirSync(path, { mode: 448 });
26325
+ } catch {
26326
+ throw new Error("Another cleanup management operation is in progress.");
26327
+ }
26328
+ try {
26329
+ return operation();
26330
+ } finally {
26331
+ rmSync(path, { recursive: true });
26332
+ }
26333
+ }
26334
+ function scheduleCleanup(options, scheduler = nativeScheduler()) {
26335
+ return manage(() => scheduleCleanupUnlocked(options, scheduler));
26336
+ }
26337
+ function removeCleanup(project, scheduler = nativeScheduler()) {
26338
+ return manage(() => removeCleanupUnlocked(project, scheduler));
26339
+ }
26340
+ function scheduleCleanupUnlocked(options, scheduler = nativeScheduler()) {
26341
+ const agent = cleanupAgentSchema.parse(options.agent);
26342
+ const connection = resolveConnectionRecord(options.projectPath);
26343
+ const identity = treeIdentity(connection.tree);
26344
+ const previous = findSchedule(connection.projectPath);
26345
+ if (previous?.enabled && previous.identity !== identity) throw new Error("Remove the previous cleanup schedule before scheduling a changed connection.");
26346
+ const id = identityId(identity);
26347
+ const config = cleanupScheduleSchema.parse({
26348
+ id,
26349
+ projectPath: connection.projectPath,
26350
+ identity,
26351
+ agent,
26352
+ model: options.model ?? (agent === "codex" ? "gpt-5.6-luna" : "claude-haiku-4-5"),
26353
+ everyMinutes: parseCleanupInterval(options.every),
26354
+ nodePath: realpathSync(process.execPath),
26355
+ cliPath: resolvePackagedResource("dist", "cli", "index.mjs"),
26356
+ agentPath: executable(agent),
26357
+ searchPath: process.env.PATH ?? "",
26358
+ enabled: true
26359
+ });
26360
+ if (scheduler.status(config).running || lstatSync(statePath(id, "lock"), { throwIfNoEntry: false })) throw new Error("Cleanup is running; remove it before changing the schedule.");
26361
+ if (previous && previous.id !== id) rmSync(statePath(previous.id, "config"));
26362
+ atomicState(statePath(id, "config"), config);
26363
+ atomicState(statePath(id, "activity"), Date.now());
26364
+ try {
26365
+ scheduler.install(config);
26366
+ } catch (error) {
26367
+ atomicState(statePath(id, "config"), {
26368
+ ...config,
26369
+ enabled: false
26370
+ });
26371
+ throw error;
26372
+ }
26373
+ return cleanupStatus(connection.projectPath, scheduler);
26374
+ }
26375
+ function removeCleanupUnlocked(project, scheduler = nativeScheduler()) {
26376
+ const config = findSchedule(project);
26377
+ if (!config) return cleanupStatus(project, scheduler);
26378
+ atomicState(statePath(config.id, "config"), {
26379
+ ...config,
26380
+ enabled: false
26381
+ });
26382
+ scheduler.remove(config);
26383
+ const latest = cleanupOutcomeSchema.safeParse(readState(statePath(config.id, "latest")));
26384
+ if (latest.success && latest.data.outcome === "running") atomicState(statePath(config.id, "latest"), {
26385
+ ...latest.data,
26386
+ at: Date.now(),
26387
+ outcome: latest.data.message === "publishing" ? "publication-uncertain" : "cancelled",
26388
+ message: "Stopped. Unfinished worktrees are preserved; publication already underway may have completed. No rollback or retry was attempted."
26389
+ });
26390
+ const owner = number().int().positive().safeParse(readState(join(statePath(config.id, "lock"), "owner")));
26391
+ if (owner.success && !alive(owner.data)) rmSync(statePath(config.id, "lock"), { recursive: true });
26392
+ return cleanupStatus(project, scheduler);
26393
+ }
26394
+ function alive(pid) {
26395
+ try {
26396
+ process.kill(pid, 0);
26397
+ return true;
26398
+ } catch (error) {
26399
+ return !(error instanceof Error && "code" in error && error.code === "ESRCH");
26400
+ }
26401
+ }
26402
+ /** CLI-only, best effort: the background process and all its child CLIs are excluded. */
26403
+ function recordCleanupActivity(options) {
26404
+ if (process.env.CONTEXT_TREE_CLEANUP === "1") return;
26405
+ try {
26406
+ const tree = options.projectPath ? findConnectionRecord(options.projectPath)?.tree : void 0;
26407
+ for (const config of schedules()) {
26408
+ if (!config.enabled) continue;
26409
+ let matches = tree !== void 0 && treeIdentity(tree) === config.identity;
26410
+ if (options.treePath) {
26411
+ const supplied = realpathSync(options.treePath);
26412
+ const connection = findConnectionRecord(config.projectPath);
26413
+ matches ||= connection !== void 0 && treeIdentity(connection.tree) === config.identity && realpathSync(connection.tree.path) === supplied;
26414
+ }
26415
+ if (matches) atomicState(statePath(config.id, "activity"), Date.now());
26416
+ }
26417
+ } catch {}
26418
+ }
26419
+ function snapshot(root) {
26420
+ const result = /* @__PURE__ */ new Map();
26421
+ const walk = (directory, prefix) => {
26422
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
26423
+ const name = prefix + entry.name;
26424
+ const path = join(directory, entry.name);
26425
+ if (entry.isDirectory()) walk(path, `${name}/`);
26426
+ else if (entry.isFile()) result.set(name, `${lstatSync(path).mode}:${createHash("sha256").update(readFileSync(path)).digest("hex")}`);
26427
+ else result.set(name, entry.isSymbolicLink() ? `symlink:${readlinkSync(path)}` : "unsupported");
26428
+ }
26429
+ };
26430
+ walk(root, "");
26431
+ return result;
26432
+ }
26433
+ function inspectEdits(root, before) {
26434
+ const after = snapshot(root);
26435
+ let changed = false;
26436
+ for (const name of new Set([...before.keys(), ...after.keys()])) {
26437
+ if (before.get(name) === after.get(name)) continue;
26438
+ if (!name.endsWith(".md") || classifyContextContent(name) === "repo-infra" || before.get(name) === "unsupported" || after.get(name) === "unsupported" || before.get(name)?.startsWith("symlink:") || after.get(name)?.startsWith("symlink:")) throw new Error("Cleanup agent changed infrastructure, a symlink, or unsupported content.");
26439
+ changed = true;
26440
+ }
26441
+ if (git(root, [
26442
+ "diff",
26443
+ "--cached",
26444
+ "--name-only",
26445
+ "-z"
26446
+ ]).length > 0) throw new Error("Cleanup agent staged changes; editorial workers must not stage.");
26447
+ return changed;
26448
+ }
26449
+ async function runCleanup(project, savedId, dependencies = {}) {
26450
+ const config = savedId ? loadSchedule(savedId) : findSchedule(project);
26451
+ if (!config) throw new Error("No cleanup schedule exists for this project.");
26452
+ const lock = statePath(config.id, "lock");
26453
+ manage(() => {
26454
+ const entry = lstatSync(lock, { throwIfNoEntry: false });
26455
+ if (entry) {
26456
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("Unsafe cleanup lock.");
26457
+ if (alive(number().int().positive().parse(readState(join(lock, "owner"))))) throw new Error("Cleanup already running.");
26458
+ rmSync(lock, { recursive: true });
26459
+ }
26460
+ mkdirSync(lock, { mode: 448 });
26461
+ atomicState(join(lock, "owner"), process.pid);
26462
+ });
26463
+ const controller = new AbortController();
26464
+ const cancel = () => controller.abort();
26465
+ process.on("SIGTERM", cancel);
26466
+ process.on("SIGINT", cancel);
26467
+ let worktreePath;
26468
+ let publishing = false;
26469
+ const record = (outcome, extra = {}) => {
26470
+ const value = cleanupOutcomeSchema.parse({
26471
+ at: Date.now(),
26472
+ outcome,
26473
+ ...worktreePath ? { worktreePath } : {},
26474
+ ...extra
26475
+ });
26476
+ atomicState(statePath(config.id, "latest"), value);
26477
+ return value;
26478
+ };
26479
+ const check = () => {
26480
+ const current = loadSchedule(config.id);
26481
+ if (controller.signal.aborted || !current.enabled || JSON.stringify(current) !== JSON.stringify(config)) throw new Error("Cleanup cancelled or schedule changed.");
26482
+ };
26483
+ const identityCheck = () => {
26484
+ check();
26485
+ const tree = resolveConnectionRecord(config.projectPath).tree;
26486
+ if (treeIdentity(tree) !== config.identity) throw new Error("Cleanup connection identity changed; reschedule explicitly.");
26487
+ return tree;
26488
+ };
26489
+ try {
26490
+ check();
26491
+ const lastActivity = activity(config.id);
26492
+ if (lastActivity === null || Date.now() - lastActivity > 864e5) return record("inactive");
26493
+ identityCheck();
26494
+ const synced = (dependencies.sync ?? syncProject)(config.projectPath);
26495
+ check();
26496
+ if (readState(statePath(config.id, "success")) === synced.sha) return record("unchanged", { sha: synced.sha });
26497
+ worktreePath = (dependencies.prepare ?? prepareContextWrite)(config.projectPath).worktreePath;
26498
+ check();
26499
+ const head = git(worktreePath, ["rev-parse", "HEAD"]);
26500
+ const before = snapshot(worktreePath);
26501
+ record("running");
26502
+ const editorial = readFileSync(resolvePackagedResource("skills", "context-tree-cleanup", "references", "editorial.md"), "utf8");
26503
+ const monitor = setInterval(() => {
26504
+ try {
26505
+ check();
26506
+ } catch {
26507
+ controller.abort();
26508
+ }
26509
+ }, 250);
26510
+ try {
26511
+ await (dependencies.agent ?? runAgent)(config, worktreePath, `${editorial}\n\nYou are the editorial worker in an already prepared isolated worktree. Read all normal and member Markdown content directly using file tools. Edit and check references only. Do not invoke Context Tree lifecycle commands, stage, commit, change Git configuration, or publish. Do not follow other skills that request those operations. Report unresolved issues outside tree files.`, controller.signal);
26512
+ } finally {
26513
+ clearInterval(monitor);
26514
+ }
26515
+ check();
26516
+ if (git(worktreePath, ["rev-parse", "HEAD"]) !== head) throw new Error("Cleanup agent committed changes.");
26517
+ const changed = inspectEdits(worktreePath, before);
26518
+ if (!verifyTree(worktreePath).ok) throw new Error("Cleanup agent produced an invalid tree.");
26519
+ identityCheck();
26520
+ if (!changed) {
26521
+ atomicState(statePath(config.id, "success"), head);
26522
+ return record("noop", { sha: head });
26523
+ }
26524
+ record("running", { message: "publishing" });
26525
+ check();
26526
+ publishing = true;
26527
+ const finished = (dependencies.finish ?? finishContextWrite)({
26528
+ projectPath: config.projectPath,
26529
+ worktreePath,
26530
+ message: "Clean up Context Tree content"
26531
+ });
26532
+ publishing = false;
26533
+ atomicState(statePath(config.id, "success"), finished.sha);
26534
+ return record("published", { sha: finished.sha });
26535
+ } catch (error) {
26536
+ const message = sanitizeCommandOutput(error instanceof Error ? error.message : "Cleanup failed.");
26537
+ const outdated = error instanceof Error && "code" in error && error.code === "WRITE_OUTDATED";
26538
+ return record(publishing && !outdated ? "publication-uncertain" : controller.signal.aborted || !loadSchedule(config.id).enabled ? "cancelled" : "failed", { message });
26539
+ } finally {
26540
+ process.removeListener("SIGTERM", cancel);
26541
+ process.removeListener("SIGINT", cancel);
26542
+ rmSync(lock, {
26543
+ recursive: true,
26544
+ force: true
26545
+ });
26546
+ }
26547
+ }
26548
+ //#endregion
26549
+ //#region src/core/scaffold.ts
26550
+ function template(name, values) {
26551
+ let result = readFileSync(resolvePackagedResource("templates", name), "utf8");
26552
+ for (const [key, value] of Object.entries(values)) result = result.replaceAll(`{{${key}}}`, value);
26553
+ return result;
26554
+ }
26555
+ /** Templated regular files, written before the CLAUDE.md -> AGENTS.md symlink. */
26556
+ const TEMPLATED_FILES = [
26557
+ ["NODE.md", "root-node.md"],
26558
+ ["AGENTS.md", "AGENTS.md"],
26559
+ [".github/workflows/validate-context-tree.yml", "validate-context-tree.yml"]
26560
+ ];
26561
+ const SCAFFOLD_FILES = [
26562
+ "NODE.md",
26563
+ "AGENTS.md",
26564
+ "CLAUDE.md",
26565
+ ".github/workflows/validate-context-tree.yml"
26566
+ ];
26567
+ function initializeGitRepository(root, runner) {
26568
+ gitCommand([
26569
+ "init",
26570
+ "--quiet",
26571
+ "--",
26572
+ root
26573
+ ], {
26574
+ message: "Failed to initialize Git repository.",
26575
+ runner
26576
+ });
26577
+ return git(root, [
26578
+ "symbolic-ref",
26579
+ "--short",
26580
+ "HEAD"
26581
+ ], {
26582
+ message: "Failed to resolve the initial Git branch during repository initialization.",
26583
+ runner
26584
+ });
26585
+ }
26586
+ function commitScaffold(root, runner) {
26587
+ for (const file of SCAFFOLD_FILES) git(root, [
26588
+ "add",
26589
+ "--",
26590
+ file
26591
+ ], {
26592
+ message: "Failed to stage the scaffold files.",
26593
+ runner
26594
+ });
26595
+ git(root, [
26596
+ "-c",
26597
+ "user.name=Context Tree",
26598
+ "-c",
26599
+ "user.email=context-tree@localhost",
26600
+ "-c",
26601
+ "commit.gpgsign=false",
26602
+ "commit",
26603
+ "--quiet",
26604
+ "-m",
26605
+ "Initialize Context Tree"
26606
+ ], {
26607
+ message: "Failed to commit the scaffold.",
26608
+ runner
26609
+ });
26610
+ return git(root, ["rev-parse", "HEAD"], {
26611
+ message: "Failed to resolve the scaffold commit.",
26612
+ runner
26613
+ });
26614
+ }
26615
+ function scaffoldTree(options) {
26616
+ const name = treeNameSchema.parse(options.name);
26617
+ const root = resolve(options.path);
26618
+ const destination = lstatSync(root, { throwIfNoEntry: false });
26619
+ if (destination !== void 0) {
26620
+ if (destination.isSymbolicLink() || !destination.isDirectory()) throw new Error(`Refusing to scaffold into a symlink or non-directory destination: ${root}`);
26621
+ if (readdirSync(root).length > 0) throw new Error(`Refusing to scaffold into a non-empty directory: ${root}`);
26622
+ }
26623
+ const initialBranch = initializeGitRepository(root, options.runner);
26624
+ const values = {
26625
+ branchJson: JSON.stringify(initialBranch),
26626
+ packageVersion: readPackageVersion(),
26627
+ title: name,
26628
+ titleJson: JSON.stringify(name)
26629
+ };
26630
+ for (const [relativePath, source] of TEMPLATED_FILES) {
26631
+ const path = join(root, relativePath);
26632
+ mkdirSync(dirname(path), { recursive: true });
26633
+ writeFileSync(path, template(source, values), {
26634
+ encoding: "utf8",
26635
+ flag: "wx",
26636
+ mode: 420
26637
+ });
26638
+ }
26639
+ symlinkSync("AGENTS.md", join(root, "CLAUDE.md"), "file");
26640
+ if (!verifyTree(root).ok) throw new Error("Refusing to commit an invalid Context Tree scaffold.");
26641
+ return {
26642
+ branch: initialBranch,
26643
+ commit: commitScaffold(root, options.runner),
26644
+ root
26645
+ };
26646
+ }
26647
+ //#endregion
26648
+ //#region src/core/create.ts
26649
+ function projectName(canonicalRoot) {
26650
+ const normalized = basename(canonicalRoot).toLowerCase().replace(/[^a-z\d._-]+/gu, "-").replace(/-{2,}/gu, "-").replace(/^[-.]+/u, "").replace(/[-.]+$/u, "").slice(0, 40);
26651
+ return /^[a-z\d]/u.test(normalized) ? normalized : "project";
26652
+ }
26653
+ function existingCreateResult(canonical, destination, runner) {
26654
+ const branch = git(destination, [
26655
+ "symbolic-ref",
26656
+ "--short",
26657
+ "HEAD"
26658
+ ], {
26659
+ message: "Failed to resolve the managed tree branch.",
26660
+ runner
26661
+ });
26662
+ const commitSha = git(destination, ["rev-parse", "HEAD"], {
26663
+ message: "Failed to resolve the managed tree commit.",
26664
+ runner
26665
+ });
26666
+ linkProjectInstructions(canonical);
26667
+ return {
26668
+ branch,
26669
+ commitSha,
26670
+ created: false,
26671
+ schemaVersion: 1,
26672
+ title: readRootNode(destination).frontmatter.title,
26673
+ treePath: destination
26674
+ };
26675
+ }
26676
+ /** Create and connect the project's uniquely named managed local Context Tree. */
26677
+ function createProject(projectPath, runner) {
26678
+ const canonical = canonicalProjectRoot(projectPath, runner);
26679
+ const name = treeNameSchema.parse(`${projectName(canonical)}-context-tree`);
26680
+ const destination = join(managedTreesRoot(), name);
26681
+ const current = findConnectionRecord(canonical, runner);
26682
+ if (current !== void 0 && current.tree.path !== destination) throw new Error(`This project is already connected to a Context Tree at ${current.tree.path}; run context-tree connect ${name} to switch.`);
26683
+ if (existsSync(destination)) {
26684
+ const entry = lstatSync(destination);
26685
+ if (entry.isSymbolicLink() || !entry.isDirectory() || current === void 0) throw new Error(`Managed Context Tree name ${name} is occupied; run context-tree connect ${name}.`);
26686
+ return existingCreateResult(canonical, destination, runner);
26687
+ }
26688
+ mkdirSync(destination, { mode: 448 });
26689
+ try {
26690
+ const scaffold = scaffoldTree({
26691
+ name,
26692
+ path: destination,
26693
+ runner
26694
+ });
26695
+ upsertConnection({
26696
+ projectPath: canonical,
26697
+ tree: {
26698
+ kind: "local",
26699
+ path: scaffold.root
26700
+ }
26701
+ }, runner);
26702
+ linkProjectInstructions(canonical);
26703
+ return {
26704
+ branch: scaffold.branch,
26705
+ commitSha: scaffold.commit,
26706
+ created: true,
25861
26707
  schemaVersion: 1,
25862
26708
  title: name,
25863
26709
  treePath: scaffold.root
@@ -26063,407 +26909,141 @@ function classifyCreationFailure(stderr) {
26063
26909
  if (/already exists/iu.test(stderr)) return new ContextTreeError(CLI_ERROR_CODES.repositoryExists, "A GitHub repository with this name already exists; choose an explicit OWNER/REPO override.");
26064
26910
  return new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub repository creation has an uncertain or partial result; do not retry automatically.");
26065
26911
  }
26066
- /**
26067
- * Publish a clean, valid local tree as a private GitHub repository. The
26068
- * default repository name derives from the authenticated account and managed
26069
- * tree name; OWNER/REPO is accepted only as an explicit override. The initial
26070
- * publication is one gh repo create operation, and the stored connection is
26071
- * updated atomically to the published tree state.
26072
- */
26073
- function publishProject(projectPath, options = {}, runner) {
26074
- const connection = resolveConnectionRecord(projectPath, runner);
26075
- const root = connection.tree.path;
26076
- if (connection.tree.kind === "github") throw new ContextTreeError(CLI_ERROR_CODES.failed, `The Context Tree is already published as ${connection.tree.repository}; writes publish new commits automatically.`);
26077
- if (optionalGit(root, [
26078
- "remote",
26079
- "get-url",
26080
- "origin"
26081
- ], runner) !== void 0) throw new ContextTreeError(CLI_ERROR_CODES.failed, "A local Context Tree must not already have an origin before publication.");
26082
- const branch = git(root, [
26083
- "symbolic-ref",
26084
- "--short",
26085
- "HEAD"
26086
- ], {
26087
- message: "Failed to resolve the checked-out branch.",
26088
- runner
26089
- });
26090
- const sha = git(root, ["rev-parse", "HEAD"], {
26091
- message: "Failed to resolve the Context Tree commit.",
26092
- runner
26093
- });
26094
- const repository = options.repository === void 0 ? `${authenticatedAccount(runner)}/${basename(root)}` : githubRepositoryIdentitySchema.parse(options.repository);
26095
- const url = canonicalGitHubRepositoryUrl(repository);
26096
- try {
26097
- gh([
26098
- "repo",
26099
- "create",
26100
- repository,
26101
- "--private",
26102
- "--source",
26103
- root,
26104
- "--remote",
26105
- "origin",
26106
- "--push"
26107
- ], {
26108
- message: "GitHub repository creation failed.",
26109
- runner
26110
- });
26111
- } catch (error) {
26112
- if (error instanceof CommandError) throw classifyCreationFailure(error.stderr);
26113
- throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub publication ended with an uncertain result.");
26114
- }
26115
- try {
26116
- updateConnectionTree(connection.projectPath, {
26117
- kind: "github",
26118
- path: root,
26119
- repository
26120
- }, runner);
26121
- } catch {
26122
- throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "The private repository was created, but updating the local connection failed.");
26123
- }
26124
- return {
26125
- branch,
26126
- repository,
26127
- schemaVersion: 1,
26128
- sha,
26129
- url
26130
- };
26131
- }
26132
- //#endregion
26133
- //#region src/core/read.ts
26134
- function normalizeTreeTarget(value) {
26135
- if (!value || value === ".") return "";
26136
- const normalized = posix.normalize(toPosixPath(value).replace(/^\.\//u, ""));
26137
- if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/")) throw new Error(`Read target is outside the Context Tree: ${value}`);
26138
- return normalized.replace(/\/$/u, "");
26139
- }
26140
- function canonicalTarget(root, path) {
26141
- const requested = normalizeTreeTarget(path);
26142
- const semanticPath = requested === "NODE.md" ? "" : requested.endsWith("/NODE.md") ? dirname(requested) : requested;
26143
- if (classifyContextContent(semanticPath) === "repo-infra") throw new Error(`Read target is repository infrastructure: ${requested || "."}`);
26144
- const absolutePath = resolve(root, semanticPath);
26145
- if (!isPathInside(root, absolutePath)) throw new Error("Read target escapes the Context Tree root.");
26146
- const entry = lstatSync(absolutePath);
26147
- if (entry.isSymbolicLink() || !entry.isDirectory() && !entry.isFile()) throw new Error(`Read target must be a real file or directory: ${requested || "."}`);
26148
- if (realpathSync(absolutePath) !== absolutePath) throw new Error(`Read target must not traverse a symlink: ${requested || "."}`);
26149
- const relativePath = toPosixPath(relative(root, absolutePath));
26150
- if (entry.isFile() && !absolutePath.endsWith(".md")) throw new Error(`Read target must be a Markdown file or indexed directory: ${requested || "."}`);
26151
- return {
26152
- absolutePath,
26153
- kind: entry.isDirectory() ? "directory" : "file",
26154
- relativePath
26155
- };
26156
- }
26157
- function readNode(path, relativePath, kind) {
26158
- const documentPath = kind === "directory" ? join(path, "NODE.md") : path;
26159
- const entry = lstatSync(documentPath);
26160
- if (entry.isSymbolicLink() || !entry.isFile()) throw new Error(`Context Tree document must be a regular file: ${relativePath || "NODE.md"}`);
26161
- const document = readNodeDocument(documentPath);
26162
- if (document === null) throw new Error(`Context Tree document has invalid or missing metadata: ${relativePath || "."}`);
26163
- return {
26164
- body: document.body,
26165
- contentClass: classifyContextContent(relativePath),
26166
- frontmatter: document.frontmatter,
26167
- kind,
26168
- path: relativePath || "."
26169
- };
26170
- }
26171
- function childSummary(root, parentPath, name) {
26172
- const absolutePath = join(parentPath, name);
26173
- const relativePath = toPosixPath(relative(root, absolutePath));
26174
- const contentClass = classifyContextContent(relativePath);
26175
- if (contentClass === "repo-infra") return null;
26176
- const entry = lstatSync(absolutePath);
26177
- if (entry.isSymbolicLink()) return null;
26178
- const kind = entry.isDirectory() ? "directory" : entry.isFile() && name.endsWith(".md") && name !== "NODE.md" ? "file" : null;
26179
- if (kind === null) return null;
26180
- const document = readNodeDocument(kind === "directory" ? join(absolutePath, "NODE.md") : absolutePath);
26181
- if (document === null) throw new Error(`Context Tree child has invalid or missing metadata: ${relativePath}`);
26182
- return {
26183
- contentClass,
26184
- ...document.description === void 0 ? {} : { description: document.description },
26185
- kind,
26186
- path: relativePath,
26187
- title: document.title
26188
- };
26189
- }
26190
- function readTree(treePath, path) {
26191
- const root = resolveTreeRoot(treePath);
26192
- const target = canonicalTarget(root, path);
26193
- const node = readNode(target.absolutePath, target.relativePath, target.kind);
26194
- return {
26195
- children: target.kind === "file" ? [] : readdirSync(target.absolutePath).map((name) => childSummary(root, target.absolutePath, name)).filter((child) => child !== null).sort((left, right) => left.path.localeCompare(right.path)),
26196
- node,
26197
- root,
26198
- schemaVersion: 1,
26199
- target: target.relativePath || "."
26200
- };
26201
- }
26202
- //#endregion
26203
- //#region src/core/sync.ts
26204
- /**
26205
- * Local trees report their checked-out state without network access. GitHub
26206
- * trees fast-forward the exact checked-out branch once, then revalidate.
26207
- */
26208
- function syncProject(projectPath, runner) {
26209
- const connection = resolveConnectionRecord(projectPath, runner);
26210
- const root = connection.tree.path;
26211
- const branch = git(root, [
26212
- "symbolic-ref",
26213
- "--short",
26214
- "HEAD"
26215
- ], {
26216
- message: "Failed to resolve the checked-out branch.",
26217
- runner
26218
- });
26219
- if (connection.tree.kind === "github") {
26220
- git(root, [
26221
- "pull",
26222
- "--ff-only",
26223
- "origin",
26224
- branch
26225
- ], {
26226
- message: "Fast-forwarding the Context Tree failed.",
26227
- runner
26228
- });
26229
- validateStoredTreeState(connection.tree, runner);
26230
- }
26231
- return {
26232
- branch,
26233
- schemaVersion: 1,
26234
- sha: git(root, ["rev-parse", "HEAD"], {
26235
- message: "Failed to resolve the Context Tree commit.",
26236
- runner
26237
- }),
26238
- tree: connection.tree
26239
- };
26240
- }
26241
- //#endregion
26242
- //#region src/core/write.ts
26243
- const TASK_BRANCH_PREFIX = "context-tree/write/";
26244
- /** A prepared worktree left untouched for longer than this is treated as abandoned. */
26245
- const ABANDONED_WRITE_AGE_MS = 1440 * 60 * 1e3;
26246
- /** Synchronize first, then create an isolated task worktree at the exact HEAD. */
26247
- function prepareContextWrite(projectPath, runner) {
26248
- const synchronized = syncProject(projectPath, runner);
26249
- const root = synchronized.tree.path;
26250
- reclaimAbandonedWrites(root, synchronized.branch, runner);
26251
- const destination = mkdtempSync(join(tmpdir(), "context-tree-write-"));
26252
- const taskBranch = `${TASK_BRANCH_PREFIX}${basename(destination)}`;
26253
- try {
26254
- git(root, [
26255
- "worktree",
26256
- "add",
26257
- "--quiet",
26258
- "-b",
26259
- taskBranch,
26260
- destination,
26261
- synchronized.sha
26262
- ], {
26263
- message: "Creating the isolated write worktree failed.",
26264
- runner
26265
- });
26266
- return {
26267
- schemaVersion: 1,
26268
- worktreePath: realDirectoryWithoutSymlinks(destination, "Write worktree")
26269
- };
26270
- } catch (error) {
26271
- rmSync(destination, {
26272
- force: true,
26273
- recursive: true
26274
- });
26275
- throw error;
26276
- }
26277
- }
26278
- /** Commit every pending change, then fast-forward locally or push once. */
26279
- function finishContextWrite(options, runner) {
26280
- const connection = resolveConnectionRecord(options.projectPath, runner);
26281
- const root = connection.tree.path;
26282
- const { taskBranch, worktreePath } = validatePreparedWorktree(root, options.worktreePath, runner);
26283
- const branch = git(root, [
26284
- "symbolic-ref",
26285
- "--short",
26286
- "HEAD"
26287
- ], {
26288
- message: "Failed to resolve the connected checkout branch.",
26289
- runner
26290
- });
26291
- if (git(worktreePath, [
26292
- "status",
26293
- "--porcelain",
26294
- "--untracked-files=all"
26295
- ], {
26296
- message: "Failed to inspect the prepared worktree.",
26297
- runner
26298
- }).length === 0) throw new Error("The prepared worktree has no pending changes.");
26299
- if (!verifyTree(worktreePath).ok) throw new ContextTreeError(CLI_ERROR_CODES.invalidTree, `Refusing to commit an invalid Context Tree; run context-tree verify --tree-path ${worktreePath}.`);
26300
- git(worktreePath, ["add", "--all"], {
26301
- message: "Staging the Context Tree changes failed.",
26302
- runner
26303
- });
26304
- git(worktreePath, [
26305
- "-c",
26306
- "commit.gpgsign=false",
26307
- "commit",
26308
- "--quiet",
26309
- "-m",
26310
- options.message
26912
+ /**
26913
+ * Publish a clean, valid local tree as a private GitHub repository. The
26914
+ * default repository name derives from the authenticated account and managed
26915
+ * tree name; OWNER/REPO is accepted only as an explicit override. The initial
26916
+ * publication is one gh repo create operation, and the stored connection is
26917
+ * updated atomically to the published tree state.
26918
+ */
26919
+ function publishProject(projectPath, options = {}, runner) {
26920
+ const connection = resolveConnectionRecord(projectPath, runner);
26921
+ const root = connection.tree.path;
26922
+ if (connection.tree.kind === "github") throw new ContextTreeError(CLI_ERROR_CODES.failed, `The Context Tree is already published as ${connection.tree.repository}; writes publish new commits automatically.`);
26923
+ if (optionalGit(root, [
26924
+ "remote",
26925
+ "get-url",
26926
+ "origin"
26927
+ ], runner) !== void 0) throw new ContextTreeError(CLI_ERROR_CODES.failed, "A local Context Tree must not already have an origin before publication.");
26928
+ const branch = git(root, [
26929
+ "symbolic-ref",
26930
+ "--short",
26931
+ "HEAD"
26311
26932
  ], {
26312
- message: "Committing the Context Tree changes failed.",
26933
+ message: "Failed to resolve the checked-out branch.",
26313
26934
  runner
26314
26935
  });
26315
- const sha = git(worktreePath, ["rev-parse", "HEAD"], {
26316
- message: "Failed to resolve the write commit.",
26936
+ const sha = git(root, ["rev-parse", "HEAD"], {
26937
+ message: "Failed to resolve the Context Tree commit.",
26317
26938
  runner
26318
26939
  });
26940
+ const repository = options.repository === void 0 ? `${authenticatedAccount(runner)}/${basename(root)}` : githubRepositoryIdentitySchema.parse(options.repository);
26941
+ const url = canonicalGitHubRepositoryUrl(repository);
26319
26942
  try {
26320
- if (connection.tree.kind === "local") git(root, [
26321
- "merge",
26322
- "--ff-only",
26323
- taskBranch
26324
- ], {
26325
- message: "Fast-forwarding the local Context Tree failed.",
26326
- runner
26327
- });
26328
- else git(worktreePath, [
26329
- "push",
26943
+ gh([
26944
+ "repo",
26945
+ "create",
26946
+ repository,
26947
+ "--private",
26948
+ "--source",
26949
+ root,
26950
+ "--remote",
26330
26951
  "origin",
26331
- `HEAD:refs/heads/${branch}`
26952
+ "--push"
26332
26953
  ], {
26333
- message: "Publishing the Context Tree write failed.",
26954
+ message: "GitHub repository creation failed.",
26334
26955
  runner
26335
26956
  });
26336
26957
  } catch (error) {
26337
- if (isNonFastForward(error)) throw new ContextTreeError(CLI_ERROR_CODES.writeOutdated, `The Context Tree advanced; the prepared worktree is preserved at ${worktreePath}.`);
26338
- throw error;
26958
+ if (error instanceof CommandError) throw classifyCreationFailure(error.stderr);
26959
+ throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub publication ended with an uncertain result.");
26960
+ }
26961
+ try {
26962
+ updateConnectionTree(connection.projectPath, {
26963
+ kind: "github",
26964
+ path: root,
26965
+ repository
26966
+ }, runner);
26967
+ } catch {
26968
+ throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "The private repository was created, but updating the local connection failed.");
26339
26969
  }
26340
- removeWorktree(root, worktreePath, taskBranch, runner);
26341
26970
  return {
26342
26971
  branch,
26972
+ repository,
26343
26973
  schemaVersion: 1,
26344
- sha
26974
+ sha,
26975
+ url
26345
26976
  };
26346
26977
  }
26347
- function gitCommonDirectory(root, runner) {
26348
- const value = git(root, ["rev-parse", "--git-common-dir"], {
26349
- message: "Failed to resolve the Git common directory.",
26350
- runner
26351
- });
26352
- return realDirectoryWithoutSymlinks(isAbsolute(value) ? value : resolve(root, value), "Git common directory");
26978
+ //#endregion
26979
+ //#region src/core/read.ts
26980
+ function normalizeTreeTarget(value) {
26981
+ if (!value || value === ".") return "";
26982
+ const normalized = posix.normalize(toPosixPath(value).replace(/^\.\//u, ""));
26983
+ if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/")) throw new Error(`Read target is outside the Context Tree: ${value}`);
26984
+ return normalized.replace(/\/$/u, "");
26353
26985
  }
26354
- function validatePreparedWorktree(root, suppliedPath, runner) {
26355
- const worktreePath = realDirectoryWithoutSymlinks(suppliedPath, "Prepared worktree");
26356
- if (gitCommonDirectory(worktreePath, runner) !== gitCommonDirectory(root, runner)) throw new Error("The prepared worktree does not belong to the connected Context Tree.");
26357
- const taskBranch = git(worktreePath, [
26358
- "symbolic-ref",
26359
- "--short",
26360
- "HEAD"
26361
- ], {
26362
- message: "Failed to resolve the worktree branch.",
26363
- runner
26364
- });
26365
- if (!taskBranch.startsWith(TASK_BRANCH_PREFIX)) throw new Error("The prepared worktree is not on a reserved Context Tree write branch.");
26986
+ function canonicalTarget(root, path) {
26987
+ const requested = normalizeTreeTarget(path);
26988
+ const semanticPath = requested === "NODE.md" ? "" : requested.endsWith("/NODE.md") ? dirname(requested) : requested;
26989
+ if (classifyContextContent(semanticPath) === "repo-infra") throw new Error(`Read target is repository infrastructure: ${requested || "."}`);
26990
+ const absolutePath = resolve(root, semanticPath);
26991
+ if (!isPathInside(root, absolutePath)) throw new Error("Read target escapes the Context Tree root.");
26992
+ const entry = lstatSync(absolutePath);
26993
+ if (entry.isSymbolicLink() || !entry.isDirectory() && !entry.isFile()) throw new Error(`Read target must be a real file or directory: ${requested || "."}`);
26994
+ if (realpathSync(absolutePath) !== absolutePath) throw new Error(`Read target must not traverse a symlink: ${requested || "."}`);
26995
+ const relativePath = toPosixPath(relative(root, absolutePath));
26996
+ if (entry.isFile() && !absolutePath.endsWith(".md")) throw new Error(`Read target must be a Markdown file or indexed directory: ${requested || "."}`);
26366
26997
  return {
26367
- taskBranch,
26368
- worktreePath
26998
+ absolutePath,
26999
+ kind: entry.isDirectory() ? "directory" : "file",
27000
+ relativePath
26369
27001
  };
26370
27002
  }
26371
- function isNonFastForward(error) {
26372
- return error instanceof CommandError && /non-fast-forward|fetch first|tip of your current branch is behind|not possible to fast-forward|diverg/i.test(error.stderr);
26373
- }
26374
- function removeWorktree(root, worktreePath, taskBranch, runner) {
26375
- git(root, [
26376
- "worktree",
26377
- "remove",
26378
- worktreePath
26379
- ], {
26380
- message: "Removing the write worktree failed.",
26381
- runner
26382
- });
26383
- git(root, [
26384
- "branch",
26385
- "-D",
26386
- taskBranch
26387
- ], {
26388
- message: "Deleting the write branch failed.",
26389
- runner
26390
- });
26391
- }
26392
- /** Map every reserved write branch that still has a registered worktree to its path. */
26393
- function listWriteWorktrees(root, runner) {
26394
- const paths = /* @__PURE__ */ new Map();
26395
- const output = optionalGit(root, [
26396
- "worktree",
26397
- "list",
26398
- "--porcelain"
26399
- ], runner);
26400
- if (output === void 0) return paths;
26401
- let path;
26402
- for (const record of output.split("\n")) {
26403
- if (record.startsWith("worktree ")) {
26404
- path = record.slice(9).trim();
26405
- continue;
26406
- }
26407
- if (!record.startsWith("branch refs/heads/")) continue;
26408
- const branch = record.slice(18).trim();
26409
- if (path !== void 0 && branch.startsWith(TASK_BRANCH_PREFIX)) paths.set(branch, path);
26410
- }
26411
- return paths;
26412
- }
26413
- function millisecondsSinceModification(path) {
26414
- try {
26415
- return Date.now() - statSync(path).mtimeMs;
26416
- } catch {
26417
- return;
26418
- }
27003
+ function readNode(path, relativePath, kind) {
27004
+ const documentPath = kind === "directory" ? join(path, "NODE.md") : path;
27005
+ const entry = lstatSync(documentPath);
27006
+ if (entry.isSymbolicLink() || !entry.isFile()) throw new Error(`Context Tree document must be a regular file: ${relativePath || "NODE.md"}`);
27007
+ const document = readNodeDocument(documentPath);
27008
+ if (document === null) throw new Error(`Context Tree document has invalid or missing metadata: ${relativePath || "."}`);
27009
+ return {
27010
+ body: document.body,
27011
+ contentClass: classifyContextContent(relativePath),
27012
+ frontmatter: document.frontmatter,
27013
+ kind,
27014
+ path: relativePath || "."
27015
+ };
26419
27016
  }
26420
- /**
26421
- * A preparation is abandoned only when it carries no commit the connected
26422
- * checkout lacks, has no pending edits, and has gone untouched. Every unknown
26423
- * answer preserves the worktree, so a `WRITE_OUTDATED` commit awaiting its
26424
- * retry and a concurrent preparation both survive.
26425
- */
26426
- function isAbandonedWrite(root, branch, checkoutBranch, path, runner) {
26427
- if (optionalGit(root, [
26428
- "rev-list",
26429
- "--count",
26430
- branch,
26431
- "--not",
26432
- checkoutBranch
26433
- ], runner) !== "0") return false;
26434
- if (path === void 0) return true;
26435
- const age = millisecondsSinceModification(path);
26436
- if (age === void 0 || age < ABANDONED_WRITE_AGE_MS) return false;
26437
- return optionalGit(path, [
26438
- "status",
26439
- "--porcelain",
26440
- "--untracked-files=all"
26441
- ], runner) === "";
27017
+ function childSummary(root, parentPath, name) {
27018
+ const absolutePath = join(parentPath, name);
27019
+ const relativePath = toPosixPath(relative(root, absolutePath));
27020
+ const contentClass = classifyContextContent(relativePath);
27021
+ if (contentClass === "repo-infra") return null;
27022
+ const entry = lstatSync(absolutePath);
27023
+ if (entry.isSymbolicLink()) return null;
27024
+ const kind = entry.isDirectory() ? "directory" : entry.isFile() && name.endsWith(".md") && name !== "NODE.md" ? "file" : null;
27025
+ if (kind === null) return null;
27026
+ const document = readNodeDocument(kind === "directory" ? join(absolutePath, "NODE.md") : absolutePath);
27027
+ if (document === null) throw new Error(`Context Tree child has invalid or missing metadata: ${relativePath}`);
27028
+ return {
27029
+ contentClass,
27030
+ ...document.description === void 0 ? {} : { description: document.description },
27031
+ kind,
27032
+ path: relativePath,
27033
+ title: document.title
27034
+ };
26442
27035
  }
26443
- /** Reclaim earlier preparations that were never finished. Every step is best effort. */
26444
- function reclaimAbandonedWrites(root, checkoutBranch, runner) {
26445
- optionalGit(root, ["worktree", "prune"], runner);
26446
- const paths = listWriteWorktrees(root, runner);
26447
- const branches = optionalGit(root, [
26448
- "for-each-ref",
26449
- "--format=%(refname:short)",
26450
- `refs/heads/${TASK_BRANCH_PREFIX}`
26451
- ], runner);
26452
- if (branches === void 0) return;
26453
- for (const branch of branches.split("\n").filter((value) => value.length > 0)) {
26454
- const path = paths.get(branch);
26455
- if (!isAbandonedWrite(root, branch, checkoutBranch, path, runner)) continue;
26456
- if (path !== void 0) optionalGit(root, [
26457
- "worktree",
26458
- "remove",
26459
- path
26460
- ], runner);
26461
- optionalGit(root, [
26462
- "branch",
26463
- "-D",
26464
- branch
26465
- ], runner);
26466
- }
27036
+ function readTree(treePath, path) {
27037
+ const root = resolveTreeRoot(treePath);
27038
+ const target = canonicalTarget(root, path);
27039
+ const node = readNode(target.absolutePath, target.relativePath, target.kind);
27040
+ return {
27041
+ children: target.kind === "file" ? [] : readdirSync(target.absolutePath).map((name) => childSummary(root, target.absolutePath, name)).filter((child) => child !== null).sort((left, right) => left.path.localeCompare(right.path)),
27042
+ node,
27043
+ root,
27044
+ schemaVersion: 1,
27045
+ target: target.relativePath || "."
27046
+ };
26467
27047
  }
26468
27048
  //#endregion
26469
27049
  //#region src/cli/format.ts
@@ -26545,7 +27125,8 @@ const TEXT_DEFAULT_COMMANDS = new Set([
26545
27125
  "resolve",
26546
27126
  "publish",
26547
27127
  "read",
26548
- "verify"
27128
+ "verify",
27129
+ "cleanup"
26549
27130
  ]);
26550
27131
  function line(io, value) {
26551
27132
  io.stdout(`${value}\n`);
@@ -26628,6 +27209,66 @@ function createContextTreeCli(io = defaultIo) {
26628
27209
  if (options.project !== void 0) request.projectPath = resolve(io.cwd(), options.project);
26629
27210
  line(io, JSON.stringify(uninstallSkills(request)));
26630
27211
  });
27212
+ const cleanup = program.command("cleanup").description("Schedule and manage CLI-based Context Tree cleanup.");
27213
+ for (const operation of [
27214
+ "schedule",
27215
+ "status",
27216
+ "remove",
27217
+ "run"
27218
+ ]) {
27219
+ const command = cleanup.command(operation).option("--project-path <path>", "project directory", ".").option(...jsonOption);
27220
+ if (operation === "schedule") command.requiredOption("--agent <agent>", "codex or claude").option("--model <model>", "explicit model override").option("--every <duration>", "positive whole-minute interval, e.g. 30m or 1h", "1h");
27221
+ if (operation === "run") command.addOption(new Option("--schedule-id <id>").hideHelp());
27222
+ command.action(async (options) => {
27223
+ const projectPath = resolve(io.cwd(), options.projectPath);
27224
+ if (operation === "run") {
27225
+ const result = await runCleanup(projectPath, options.scheduleId);
27226
+ const wireResult = cleanupRunResultSchema.parse({
27227
+ schemaVersion: 1,
27228
+ ...result
27229
+ });
27230
+ emit(io, options.json, wireResult, (value) => `Cleanup: ${value.outcome}.${value.message ? ` ${value.message}` : ""}${value.worktreePath ? `\n Worktree: ${value.worktreePath}` : ""}${value.sha ? `\n Commit: ${value.sha}` : ""}`);
27231
+ if ([
27232
+ "failed",
27233
+ "cancelled",
27234
+ "publication-uncertain"
27235
+ ].includes(result.outcome)) process.exitCode = 1;
27236
+ return;
27237
+ }
27238
+ const result = operation === "schedule" ? scheduleCleanup({
27239
+ ...options,
27240
+ projectPath,
27241
+ agent: options.agent ?? ""
27242
+ }) : operation === "remove" ? removeCleanup(projectPath) : cleanupStatus(projectPath);
27243
+ emit(io, options.json, result, (value) => {
27244
+ const config = value.schedule;
27245
+ if (!config) return "No cleanup schedule.";
27246
+ return [
27247
+ `Cleanup ${config.enabled ? "scheduled" : "removed"}.`,
27248
+ ` Registered: ${value.registered}; running: ${value.running}`,
27249
+ ` Project: ${config.projectPath}`,
27250
+ ` Every: ${config.everyMinutes} minutes`,
27251
+ ` Agent: ${config.agent}; model: ${config.model}`,
27252
+ ` Last activity: ${value.lastActivity === null ? "missing" : new Date(value.lastActivity).toISOString()}`,
27253
+ ` Inactivity: ${value.inactive ? "cleanup prevented (no activity within 24 hours)" : "cleanup permitted"}`,
27254
+ ` Latest: ${value.latest?.outcome ?? "none"}${value.latest?.message ? ` — ${value.latest.message}` : ""}`,
27255
+ ...value.latest?.worktreePath ? [` Worktree: ${value.latest.worktreePath}`] : []
27256
+ ].join("\n");
27257
+ });
27258
+ });
27259
+ }
27260
+ program.hook("postAction", (_command, action) => {
27261
+ if (![
27262
+ "create",
27263
+ "connect",
27264
+ "sync",
27265
+ "read",
27266
+ "prepare-write",
27267
+ "finish-write"
27268
+ ].includes(action.name())) return;
27269
+ const options = action.opts();
27270
+ recordCleanupActivity(action.name() === "read" ? { treePath: resolve(io.cwd(), options.treePath ?? ".") } : { projectPath: resolve(io.cwd(), options.projectPath ?? ".") });
27271
+ });
26631
27272
  return program;
26632
27273
  }
26633
27274
  async function runContextTreeCli(argv = process.argv, io = defaultIo) {