@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.
- package/README.md +64 -8
- package/dist/cli/index.mjs +1144 -503
- package/package.json +1 -1
- package/skills/context-tree-cleanup/SKILL.md +19 -13
- package/skills/context-tree-cleanup/references/editorial.md +27 -0
- package/skills/context-tree-schedule-cleanup/SKILL.md +56 -0
- package/skills/context-tree-schedule-cleanup/agents/openai.yaml +4 -0
package/dist/cli/index.mjs
CHANGED
|
@@ -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/
|
|
25704
|
-
|
|
25705
|
-
|
|
25706
|
-
|
|
25707
|
-
|
|
25708
|
-
|
|
25709
|
-
|
|
25710
|
-
const
|
|
25711
|
-
|
|
25712
|
-
|
|
25713
|
-
|
|
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
|
|
25757
|
+
message: "Failed to resolve the checked-out branch.",
|
|
25729
25758
|
runner
|
|
25730
25759
|
});
|
|
25731
|
-
|
|
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
|
|
25829
|
+
message: "Failed to resolve the connected checkout branch.",
|
|
25737
25830
|
runner
|
|
25738
25831
|
});
|
|
25739
|
-
|
|
25740
|
-
|
|
25741
|
-
|
|
25742
|
-
"
|
|
25743
|
-
"--",
|
|
25744
|
-
file
|
|
25832
|
+
if (git(worktreePath, [
|
|
25833
|
+
"status",
|
|
25834
|
+
"--porcelain",
|
|
25835
|
+
"--untracked-files=all"
|
|
25745
25836
|
], {
|
|
25746
|
-
message: "Failed to
|
|
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(
|
|
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
|
-
|
|
25851
|
+
options.message
|
|
25760
25852
|
], {
|
|
25761
|
-
message: "
|
|
25853
|
+
message: "Committing the Context Tree changes failed.",
|
|
25762
25854
|
runner
|
|
25763
25855
|
});
|
|
25764
|
-
|
|
25765
|
-
message: "Failed to resolve the
|
|
25856
|
+
const sha = git(worktreePath, ["rev-parse", "HEAD"], {
|
|
25857
|
+
message: "Failed to resolve the write commit.",
|
|
25766
25858
|
runner
|
|
25767
25859
|
});
|
|
25768
|
-
|
|
25769
|
-
|
|
25770
|
-
|
|
25771
|
-
|
|
25772
|
-
|
|
25773
|
-
|
|
25774
|
-
|
|
25775
|
-
|
|
25776
|
-
|
|
25777
|
-
|
|
25778
|
-
|
|
25779
|
-
|
|
25780
|
-
|
|
25781
|
-
|
|
25782
|
-
|
|
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
|
-
|
|
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
|
|
25797
|
-
|
|
25798
|
-
|
|
25883
|
+
branch,
|
|
25884
|
+
schemaVersion: 1,
|
|
25885
|
+
sha
|
|
25799
25886
|
};
|
|
25800
25887
|
}
|
|
25801
|
-
|
|
25802
|
-
|
|
25803
|
-
|
|
25804
|
-
|
|
25805
|
-
|
|
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
|
|
25808
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
25823
|
-
|
|
25824
|
-
created: false,
|
|
25825
|
-
schemaVersion: 1,
|
|
25826
|
-
title: readRootNode(destination).frontmatter.title,
|
|
25827
|
-
treePath: destination
|
|
25908
|
+
taskBranch,
|
|
25909
|
+
worktreePath
|
|
25828
25910
|
};
|
|
25829
25911
|
}
|
|
25830
|
-
|
|
25831
|
-
|
|
25832
|
-
|
|
25833
|
-
|
|
25834
|
-
|
|
25835
|
-
|
|
25836
|
-
|
|
25837
|
-
|
|
25838
|
-
|
|
25839
|
-
|
|
25840
|
-
|
|
25841
|
-
}
|
|
25842
|
-
|
|
25843
|
-
|
|
25844
|
-
|
|
25845
|
-
|
|
25846
|
-
|
|
25847
|
-
|
|
25848
|
-
|
|
25849
|
-
|
|
25850
|
-
|
|
25851
|
-
|
|
25852
|
-
|
|
25853
|
-
|
|
25854
|
-
|
|
25855
|
-
|
|
25856
|
-
|
|
25857
|
-
|
|
25858
|
-
|
|
25859
|
-
|
|
25860
|
-
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
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: "
|
|
26933
|
+
message: "Failed to resolve the checked-out branch.",
|
|
26313
26934
|
runner
|
|
26314
26935
|
});
|
|
26315
|
-
const sha = git(
|
|
26316
|
-
message: "Failed to resolve the
|
|
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
|
-
|
|
26321
|
-
"
|
|
26322
|
-
"
|
|
26323
|
-
|
|
26324
|
-
|
|
26325
|
-
|
|
26326
|
-
|
|
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
|
-
|
|
26952
|
+
"--push"
|
|
26332
26953
|
], {
|
|
26333
|
-
message: "
|
|
26954
|
+
message: "GitHub repository creation failed.",
|
|
26334
26955
|
runner
|
|
26335
26956
|
});
|
|
26336
26957
|
} catch (error) {
|
|
26337
|
-
if (
|
|
26338
|
-
throw
|
|
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
|
-
|
|
26348
|
-
|
|
26349
|
-
|
|
26350
|
-
|
|
26351
|
-
|
|
26352
|
-
|
|
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
|
|
26355
|
-
const
|
|
26356
|
-
|
|
26357
|
-
|
|
26358
|
-
|
|
26359
|
-
|
|
26360
|
-
|
|
26361
|
-
|
|
26362
|
-
|
|
26363
|
-
|
|
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
|
-
|
|
26368
|
-
|
|
26998
|
+
absolutePath,
|
|
26999
|
+
kind: entry.isDirectory() ? "directory" : "file",
|
|
27000
|
+
relativePath
|
|
26369
27001
|
};
|
|
26370
27002
|
}
|
|
26371
|
-
function
|
|
26372
|
-
|
|
26373
|
-
|
|
26374
|
-
|
|
26375
|
-
|
|
26376
|
-
|
|
26377
|
-
|
|
26378
|
-
|
|
26379
|
-
|
|
26380
|
-
|
|
26381
|
-
|
|
26382
|
-
|
|
26383
|
-
|
|
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
|
-
|
|
26422
|
-
|
|
26423
|
-
|
|
26424
|
-
|
|
26425
|
-
|
|
26426
|
-
|
|
26427
|
-
|
|
26428
|
-
|
|
26429
|
-
|
|
26430
|
-
|
|
26431
|
-
|
|
26432
|
-
|
|
26433
|
-
|
|
26434
|
-
|
|
26435
|
-
|
|
26436
|
-
|
|
26437
|
-
|
|
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
|
-
|
|
26444
|
-
|
|
26445
|
-
|
|
26446
|
-
const
|
|
26447
|
-
|
|
26448
|
-
"
|
|
26449
|
-
|
|
26450
|
-
|
|
26451
|
-
|
|
26452
|
-
|
|
26453
|
-
|
|
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) {
|