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

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 CHANGED
@@ -1,306 +1,126 @@
1
1
  # Context Tree
2
2
 
3
- `@first-tree-ai/context-tree` provides durable, structured project context for
4
- coding agents. It ships a portable core, CLI, templates, and seven
5
- framework-neutral skills.
6
-
7
- A Context Tree records current decisions, constraints, relationships, and their
8
- rationale. Source repositories still own implementation detail, task history,
9
- and credentials.
10
-
11
- ## Requirements
12
-
13
- - Node.js 22.13 or newer
14
- - Git
15
- - GitHub CLI (`gh`) only for connecting a GitHub tree or publishing
16
-
17
- Git and GitHub authentication remain owned by the host tools. Repository inputs
18
- are credential-free `OWNER/REPO` identities, never URLs containing credentials.
3
+ Context Tree gives coding agents lasting project memory: decisions, constraints,
4
+ and the reasons behind them. Use it to avoid repeating context in new sessions,
5
+ keep different agents aligned, or share knowledge across related repositories.
6
+ Context lives in a separate Git repository. Keep it local or share it through
7
+ private GitHub storage.
19
8
 
20
9
  ## Install
21
10
 
11
+ Requires **Node.js 22.13+** and **Git**. For GitHub sharing, also install and
12
+ sign in to the GitHub CLI (`gh auth login`).
13
+
22
14
  ```bash
23
15
  npm install --global @first-tree-ai/context-tree
24
16
  ```
25
17
 
26
- That installs the `context-tree` command and copies the eight skills into the
27
- skill directory of every agent you already have:
18
+ This installs the CLI and skills for installed **Codex, Claude Code, and Pi**
19
+ agents. Restart your agent to discover the skills.
28
20
 
29
- ```text
30
- ✓ claude → ~/.claude/skills/ (8 skills)
31
- ✓ codex → ~/.codex/skills/ (8 skills)
32
- ```
33
-
34
- Restart your agent so it discovers them, then try asking:
21
+ ## Get started
35
22
 
36
- > Set up a Context Tree for this project, then read the relevant context.
23
+ Open your project in your agent and ask:
37
24
 
38
- > Write this architectural decision to the Context Tree.
25
+ > Set up a local Context Tree for this project, then read the relevant context.
39
26
 
40
- Skill installation is a normal command, so you can re-run it after installing a
41
- new agent, or scope it to one project:
27
+ You can also ask to connect an existing tree or create a private GitHub tree.
28
+ To create a local tree yourself, run this from your project directory:
42
29
 
43
30
  ```bash
44
- context-tree install # every agent you have
45
- context-tree install --host codex # one agent
46
- context-tree install --project . # ./.claude/skills and ./.codex/skills
47
- context-tree uninstall # remove context-tree-* skills
31
+ context-tree create
48
32
  ```
49
33
 
50
- Install and uninstall own exactly the `context-tree-*` skill directories.
51
- Install never touches skills it does not own or creates a configuration directory
52
- for an agent that is not present; uninstall removes every owned-prefix directory
53
- and nothing else. Adding support for another agent is one entry in the host table
54
- in `src/core/install.ts`.
55
-
56
- `create` and `connect` leave project instructions unchanged. When a project has
57
- a regular `AGENTS.md` and no `CLAUDE.md` entry, they best-effort create a
58
- `CLAUDE.md` symlink to `AGENTS.md`. Connections are stored separately.
59
-
60
- ## Seven skills
34
+ The tree is saved under `~/.context-tree/trees` and connected to your project.
61
35
 
62
- ### Setup
36
+ ## Read and save context
63
37
 
64
- Read and write try their operation first. On `NO_CONNECTION`, they invoke
65
- `context-tree-setup` once, which offers an existing tree, a new local tree, a new
66
- private GitHub tree, or skipping Context Tree for this session. Existing targets
67
- can be managed names, GitHub `OWNER/REPO`, or exact checkout paths. Choices
68
- already supplied are reused without asking again. New private GitHub trees are
69
- created locally and then published; local-only creation needs no GitHub prompt.
38
+ Ask your agent to use the tree as you work:
70
39
 
71
- After successful setup, the pending read/write resumes once for the original
72
- project. Skipping continues the user's task without Context Tree and suppresses
73
- further read/write and setup attempts for that project during the session,
74
- unless the user reopens them. The preference stays in the conversation, not
75
- project configuration. Failure or an unanswered setup question defers the
76
- Context Tree operation without silently choosing a fallback or claiming success.
77
- An installed skill does not require the user to adopt Context Tree.
40
+ > Read the Context Tree before planning this change.
78
41
 
79
- ### Create
42
+ > Save our decision to use a single writer, including why we rejected multiple writers.
80
43
 
81
- ```bash
82
- context-tree create --project-path ./service
83
- ```
44
+ The read skill retrieves relevant context; the write skill updates it and commits
45
+ changes, pushing them when the tree is shared on GitHub. Save decisions and
46
+ constraints that future work should respect, with their rationale.
84
47
 
85
- `create` derives `<normalized-project-directory>-context-tree`, scaffolds and
86
- commits it under `~/.context-tree/trees`, then connects it atomically. It is
87
- idempotent only while the project remains connected to that managed tree.
48
+ To invoke a skill explicitly, use `$context-tree-read` in Codex,
49
+ `/context-tree-read` in Claude Code, or `/skill:context-tree-read` in Pi.
50
+ Replace `read` with `write`, `setup`, `create`, `connect`, `publish`, `cleanup`,
51
+ or `schedule-cleanup` for the other workflows.
88
52
 
89
- ### Connect
53
+ ## Share or connect an existing tree
90
54
 
91
- Connect to an existing managed tree by exact name:
55
+ Publish your local tree as a **new private GitHub repository**:
92
56
 
93
57
  ```bash
94
- context-tree connect shared-context-tree --project-path ./service
58
+ context-tree publish OWNER/REPO
95
59
  ```
96
60
 
97
- Or reuse or clone a GitHub tree by repository identity:
61
+ From another project or machine, connect to it:
98
62
 
99
63
  ```bash
100
- context-tree connect OWNER/REPO --project-path ./service
64
+ context-tree connect OWNER/REPO
101
65
  ```
102
66
 
103
- Or connect an existing checkout in place by exact disk path:
104
-
105
- ```bash
106
- context-tree connect --tree-path /path/to/a/tree --project-path ./service
107
- ```
108
-
109
- `connect --tree-path` requires an exact, clean, fully valid Git root with no
110
- symlink components. Trees without an origin connect as local state;
111
- credential-free GitHub origins connect as GitHub state. External disk trees
112
- are never copied, moved, or deleted.
113
-
114
- An identical connection is idempotent. An explicit connect automatically
115
- switches the project. GitHub checkouts use the repository's lowercase name in
116
- the same flat managed namespace as created trees.
117
-
118
- `context-tree list` reports valid, clean managed trees; `context-tree list --json`
119
- returns them as `{ schemaVersion: 1, trees: [{ name, tree }] }`, and a missing
120
- managed directory is an empty list.
121
-
122
- ### Read
67
+ You can also reuse a local tree by name or connect a checkout on disk:
123
68
 
124
69
  ```bash
125
- context-tree sync --project-path ./service
126
- context-tree read product/runtime.md --tree-path /path/from/sync
70
+ context-tree list
71
+ context-tree connect my-project-context-tree
72
+ context-tree connect --tree-path /absolute/path/to/tree
127
73
  ```
128
74
 
129
- Local trees report their checked-out branch and exact `HEAD` without network
130
- access. GitHub trees perform one fast-forward-only pull of the checked-out
131
- branch. Reads navigate from indexes to narrow, task-relevant children.
75
+ Connecting switches the current project's tree. Run these commands from the
76
+ project directory, or add `--project-path /path/to/project`.
132
77
 
133
- ### Write
78
+ ## Cleanup and scheduling
134
79
 
135
- ```bash
136
- context-tree prepare-write --project-path ./service
137
- # Edit only the returned worktreePath.
138
- context-tree finish-write --project-path ./service \
139
- --worktree-path /path/from/prepare \
140
- --message "Record runtime constraint"
141
- ```
142
-
143
- Preparation synchronizes first and creates a random isolated worktree at that
144
- exact commit. Finishing validates the worktree, stages every pending change,
145
- creates one unsigned commit using the host identity, and attempts one
146
- fast-forward merge for local trees or one non-force push for GitHub trees.
80
+ Ask your agent to remove outdated clutter and consolidate duplicate context:
147
81
 
148
- If the destination advanced, `finish-write` returns `WRITE_OUTDATED` and
149
- preserves the worktree. Prepare again, reread affected nodes and their current
150
- placement, and adapt the intended semantic change once to any moved or
151
- consolidated content; there is no automatic rebase, retry loop, or pull-request fallback.
82
+ > Run the context-tree-cleanup skill for this project and publish the changes.
152
83
 
153
- A preserved or abandoned write leaves its temporary worktree on disk and a
154
- `context-tree/write/<name>` branch in the tree. The next `prepare-write` reclaims
155
- one of these only when it holds no commit your checkout lacks, has no pending
156
- change, and has gone untouched for twenty-four hours, so a worktree you are still
157
- editing and a `WRITE_OUTDATED` worktree awaiting its retry are both left alone.
158
- Those keep their pending edits until you clear them with
159
- `git worktree remove <path>` and `git branch -D <branch>` in the connected tree.
160
-
161
- ### Publish
84
+ For recurring cleanup, use the schedule-cleanup skill or run:
162
85
 
163
86
  ```bash
164
- context-tree publish --project-path ./service
165
- # or: context-tree publish OWNER/REPO --project-path ./service
87
+ context-tree cleanup schedule --agent codex --every 2h
88
+ context-tree cleanup status
89
+ context-tree cleanup logs
90
+ context-tree cleanup remove
166
91
  ```
167
92
 
168
- Publishing requires a clean, valid local tree with no `origin`. It creates one
169
- new private GitHub repository, pushes the checkout, and then changes the stored
170
- connection to GitHub state. Those external and local changes are not atomic;
171
- uncertain or partial outcomes are reported as `PUBLISH_INCOMPLETE` and are not
172
- automatically inspected or repaired.
173
-
174
- ### Cleanup and scheduling
175
-
176
- `context-tree-cleanup` removes noise, consolidates duplicates, and improves
177
- placement across shared content and all member directories, then publishes one
178
- commit if anything changed. It preserves useful context and protected decisions.
179
-
180
- > Run `$context-tree-cleanup` for the project at `<absolute-project-path>`.
181
- > Clean the entire tree and publish the changes. If another writer advances it,
182
- > defer until the next run.
183
-
184
- Use `context-tree-schedule-cleanup` to create or update a persistent local
185
- cleanup task:
186
-
187
- ```text
188
- # Codex
189
- $context-tree-schedule-cleanup every 2 hours
93
+ Choose `codex`, `claude`, or `pi`; the agent CLI must be installed and authenticated.
94
+ Schedules run locally on macOS or Linux while the machine is awake, and skip trees
95
+ unused for 24 hours. Use one designated cleaner per shared tree.
96
+ `context-tree cleanup run` runs the configured cleanup now; `cleanup logs --list`
97
+ lists previous runs.
190
98
 
191
- # Claude Code
192
- /context-tree-schedule-cleanup every 2 hours
193
- ```
194
-
195
- The CLI manages one schedule per tree on this machine:
99
+ ## Useful commands
196
100
 
197
101
  ```bash
198
- context-tree cleanup schedule --project-path /absolute/project --agent codex
199
- context-tree cleanup schedule --project-path /absolute/project --agent claude --every 2h
200
- context-tree cleanup status --project-path /absolute/project
201
- context-tree cleanup run --project-path /absolute/project
202
- context-tree cleanup remove --project-path /absolute/project
102
+ context-tree resolve # show this project's tree
103
+ context-tree read --tree-path /path/to/tree # browse its root index
104
+ context-tree verify --tree-path /path/to/tree # check its structure
105
+ context-tree install # add skills for a new agent
106
+ context-tree install --project . # install skills for this project
107
+ context-tree uninstall # remove Context Tree skills
108
+ context-tree --help
203
109
  ```
204
110
 
205
- All four operations accept `--json`. Scheduling starts no immediate cleanup.
206
- Cadence defaults to one hour and accepts positive whole-minute durations (`30m`,
207
- `2h`, `1d`, up to `365d`). Repeating schedule updates the same tree's entry.
208
- Remove an active schedule before changing it. Local identity is the resolved
209
- path; GitHub identity is the repository, case-insensitively. Connection changes
210
- require explicit removal and rescheduling.
211
-
212
- macOS uses user LaunchAgents, each invoking an executable named
213
- `context-tree-cleanup` at `~/.context-tree/cleanup/launchers/<schedule-id>/`.
214
- This private launcher executes the configured Node cleanup command and is removed
215
- with the schedule. Linux uses systemd user timers and services. The
216
- machine must be awake and the user scheduler available. No desktop app, root
217
- installation, daemon, or Linux lingering is needed. Cancel any previously created
218
- Codex desktop task or Claude Desktop routine before replacing it: the CLI cannot
219
- inspect or remove those tasks. Keep one designated cleaner per tree across machines.
220
-
221
- Agents use existing CLI authentication. Defaults are `gpt-5.6-luna` with low
222
- reasoning effort and `claude-haiku-4-5`; `--model` selects an explicit override.
223
- Codex uses workspace-write sandboxing and Claude uses file-editing permissions
224
- with a restricted tool list. Permission and authentication failures stop the run;
225
- models are never silently substituted. See [Codex noninteractive mode](https://learn.chatgpt.com/docs/non-interactive-mode)
226
- and the [Claude CLI reference](https://code.claude.com/docs/en/cli-reference).
227
-
228
- Scheduling opens a 24-hour activity window. Successful ordinary `create`,
229
- `connect`, `sync`, `read`, `prepare-write`, and `finish-write` use refreshes it.
230
- Cleanup and status never do. Missing or older activity skips before network or
231
- model work; successfully inspected unchanged commits skip the model. Each run
232
- uses a fresh isolated worktree, one agent with a 15-minute timeout, shared
233
- editorial instructions, verification, and at most one publication attempt.
234
-
235
- Private atomic state in `~/.context-tree/cleanup` holds configuration, activity,
236
- the last successful commit, and only the latest outcome. `status` reports native
237
- registration/running state and whether inactivity prevents cleanup. `remove`
238
- disables future runs and stops the native scheduled process and its children,
239
- preserving unfinished worktrees. Publication already underway may have completed;
240
- uncertain outcomes are reported without rollback or retries. Failures and
241
- `WRITE_OUTDATED` never advance the successful-inspection checkpoint.
242
-
243
- ## Project identity
244
-
245
- Git project paths resolve to the exact root of that checkout. A clone or Git
246
- worktree is independent even if it shares an origin or Git common directory.
247
- Non-Git projects match only the exact connected directory; nested directories
248
- do not inherit the connection.
249
-
250
- Connection data is written atomically with mode `0600` at
251
- `~/.context-tree/connections.json`. Duplicate project records are corruption.
252
- Stored local/GitHub state is not reclassified from mutable remotes.
253
-
254
- Every command that touches a connected tree reports why it refused:
255
- `NO_CONNECTION` (nothing connected), `DIRTY_TREE` (your uncommitted edits —
256
- commit or discard them), `INVALID_TREE` (structure fails `verify`),
257
- `STALE_CONNECTION` (the stored path is gone; connect again), and
258
- `CORRUPT_CONNECTION` (unreadable or duplicated records).
259
-
260
- ## CLI plumbing
261
-
262
- The public command inventory is:
263
-
264
- ```text
265
- install uninstall create connect list resolve sync prepare-write
266
- finish-write publish read verify
267
- ```
268
-
269
- Setup, create, connect, read, write, publish, cleanup, and schedule-cleanup ship as
270
- eight skills; setup
271
- orchestrates the five concrete workflows. `install` is the distribution
272
- entry point, run for you by `npm install`; `uninstall` is its supported reverse.
273
- `resolve`, `sync`, `prepare-write`,
274
- `finish-write`, and `verify` are plumbing or diagnostic commands rather than
275
- separate user intentions; `list` backs setup's connect-target discovery.
276
-
277
- ### Output
278
-
279
- `create`, `connect`, `list`, `resolve`, `publish`, `read`, and `verify` print
280
- human-readable text by default and accept `--json` to emit their strict schema
281
- version `1` payload for scripts and agents; in text mode a failure prints a
282
- sanitized message to stderr with a non-zero exit code. The eight skills always
283
- pass `--json`. `sync`, `prepare-write`, `finish-write`, `install`, and `uninstall` are
284
- low-level plumbing and always emit that JSON (with the error envelope on stdout).
285
- `--help` and `--version` are always plain text.
286
-
287
- ```bash
288
- context-tree verify # human-readable report
289
- context-tree verify --json # { "ok": true, "schemaVersion": 1, ... }
290
- ```
111
+ For scripts and custom integrations, see the [CLI specification](docs/specification.md),
112
+ including synchronization, prepared writes, and JSON output.
291
113
 
292
- `verify` is intended for CI and diagnostics. Normal skills invoke it only after
293
- an operation reports invalid tree content.
114
+ ## Working with OpenTag
294
115
 
295
- ## Development
116
+ Create or publish a tree as above, then choose it on each OpenTag Computer:
296
117
 
297
118
  ```bash
298
- pnpm install
299
- pnpm check
300
- pnpm typecheck
301
- pnpm test
302
- pnpm check:package
119
+ opentag context-tree connect OWNER/REPO
120
+ # Or use a local tree:
121
+ opentag context-tree connect my-project-context-tree
303
122
  ```
304
123
 
305
- See [docs/specification.md](docs/specification.md) for contracts and safety
306
- invariants.
124
+ OpenTag connects each agent workspace to that tree when a session starts.
125
+ Use the same read, write, and cleanup prompts in those sessions. For a GitHub tree,
126
+ GitHub authentication must work on each Computer.
@@ -3,12 +3,13 @@ import { createRequire } from "node:module";
3
3
  import path, { basename, delimiter, dirname, isAbsolute, join, parse, posix, relative, resolve, sep } from "node:path";
4
4
  import { EventEmitter } from "node:events";
5
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";
6
+ import fs, { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
7
7
  import process$1 from "node:process";
8
8
  import { stripVTControlCharacters } from "node:util";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
10
  import { homedir, tmpdir } from "node:os";
11
11
  import { fileURLToPath } from "node:url";
12
+ import { StringDecoder } from "node:string_decoder";
12
13
  //#region \0rolldown/runtime.js
13
14
  var __defProp = Object.defineProperty;
14
15
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
@@ -7352,7 +7353,11 @@ const contextContentClassCountsSchema = object({
7352
7353
  member: number().int().nonnegative(),
7353
7354
  "repo-infra": number().int().nonnegative()
7354
7355
  }).strict();
7355
- const SKILL_HOSTS = ["claude", "codex"];
7356
+ const SKILL_HOSTS = [
7357
+ "claude",
7358
+ "codex",
7359
+ "pi"
7360
+ ];
7356
7361
  const skillHostSchema = _enum(SKILL_HOSTS);
7357
7362
  const skillInstallationSchema = object({
7358
7363
  host: skillHostSchema,
@@ -7469,13 +7474,17 @@ object({
7469
7474
  ok: literal(false),
7470
7475
  schemaVersion: literal(1)
7471
7476
  }).strict();
7472
- const cleanupAgentSchema = union([literal("codex"), literal("claude")]);
7477
+ const cleanupAgentSchema = union([
7478
+ literal("codex"),
7479
+ literal("claude"),
7480
+ literal("pi")
7481
+ ]);
7473
7482
  const cleanupScheduleSchema = object({
7474
7483
  id: string$2().regex(/^[a-f0-9]{64}$/u),
7475
7484
  projectPath: string$2().refine(isAbsolute),
7476
7485
  identity: string$2(),
7477
7486
  agent: cleanupAgentSchema,
7478
- model: string$2().min(1),
7487
+ model: string$2().min(1).optional(),
7479
7488
  everyMinutes: number().int().positive().max(525600),
7480
7489
  nodePath: string$2().refine(isAbsolute),
7481
7490
  cliPath: string$2().refine(isAbsolute),
@@ -7485,6 +7494,7 @@ const cleanupScheduleSchema = object({
7485
7494
  }).strict();
7486
7495
  const cleanupOutcomeSchema = object({
7487
7496
  at: number(),
7497
+ runId: string$2().uuid().optional(),
7488
7498
  outcome: union([
7489
7499
  literal("inactive"),
7490
7500
  literal("unchanged"),
@@ -7509,6 +7519,29 @@ const cleanupResultSchema = object({
7509
7519
  latest: cleanupOutcomeSchema.nullable()
7510
7520
  }).strict();
7511
7521
  const cleanupRunResultSchema = cleanupOutcomeSchema.extend({ schemaVersion: literal(1) }).strict();
7522
+ const cleanupLogEventSchema = object({
7523
+ at: number().finite(),
7524
+ source: union([
7525
+ literal("runner"),
7526
+ literal("stdout"),
7527
+ literal("stderr")
7528
+ ]),
7529
+ text: string$2()
7530
+ }).strict();
7531
+ const cleanupRunMetadataSchema = object({
7532
+ runId: string$2().uuid(),
7533
+ startedAt: number().finite(),
7534
+ agent: cleanupAgentSchema,
7535
+ model: string$2().optional(),
7536
+ truncated: boolean(),
7537
+ terminal: cleanupOutcomeSchema.optional()
7538
+ }).strict();
7539
+ const cleanupLogsResultSchema = object({
7540
+ schemaVersion: literal(1),
7541
+ runs: array(cleanupRunMetadataSchema),
7542
+ selectedRunId: string$2().uuid().nullable(),
7543
+ events: array(cleanupLogEventSchema)
7544
+ }).strict();
7512
7545
  //#endregion
7513
7546
  //#region src/core/internal/errors.ts
7514
7547
  /**
@@ -7525,18 +7558,27 @@ var ContextTreeError = class extends Error {
7525
7558
  };
7526
7559
  //#endregion
7527
7560
  //#region src/core/internal/git.ts
7528
- function defaultRunner(command, args) {
7561
+ function defaultRunner(command, args, timeoutMs = 12e4) {
7529
7562
  const result = spawnSync(command, args, {
7530
7563
  encoding: "utf8",
7531
7564
  stdio: [
7532
7565
  "ignore",
7533
7566
  "pipe",
7534
7567
  "pipe"
7535
- ]
7568
+ ],
7569
+ timeout: timeoutMs,
7570
+ killSignal: "SIGKILL",
7571
+ detached: process.platform !== "win32"
7536
7572
  });
7573
+ const timedOut = result.error !== void 0 && "code" in result.error && result.error.code === "ETIMEDOUT";
7574
+ if (timedOut && process.platform !== "win32" && result.pid) try {
7575
+ process.kill(-result.pid, "SIGKILL");
7576
+ } catch (error) {
7577
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) throw error;
7578
+ }
7537
7579
  return {
7538
7580
  status: result.status,
7539
- stderr: typeof result.stderr === "string" ? result.stderr : "",
7581
+ stderr: timedOut ? `Command timed out after ${timeoutMs} ms.` : result.error ? "Unable to start command." : typeof result.stderr === "string" ? result.stderr : "",
7540
7582
  stdout: typeof result.stdout === "string" ? result.stdout : ""
7541
7583
  };
7542
7584
  }
@@ -26008,23 +26050,36 @@ function reclaimAbandonedWrites(root, checkoutBranch, runner) {
26008
26050
  }
26009
26051
  //#endregion
26010
26052
  //#region src/core/cleanup/agent.ts
26053
+ /** Pi built-in tools mirroring the Claude editing allowlist; Bash stays disabled. */
26054
+ const PI_CLEANUP_TOOLS = "read,edit,write,grep,find,ls";
26011
26055
  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
- ] : [
26056
+ if (config.agent === "codex") {
26057
+ const args = [
26058
+ "exec",
26059
+ "--sandbox",
26060
+ "workspace-write",
26061
+ "-c",
26062
+ "approval_policy=\"never\"",
26063
+ "-c",
26064
+ "model_reasoning_effort=\"low\""
26065
+ ];
26066
+ if (config.model !== void 0) args.push("--model", config.model);
26067
+ args.push("--ephemeral", "-");
26068
+ return args;
26069
+ }
26070
+ if (config.agent === "pi") {
26071
+ const args = [
26072
+ "-p",
26073
+ "--tools",
26074
+ PI_CLEANUP_TOOLS,
26075
+ "--no-session",
26076
+ "--no-extensions"
26077
+ ];
26078
+ if (config.model !== void 0) args.push("--model", config.model);
26079
+ return args;
26080
+ }
26081
+ const args = [
26025
26082
  "-p",
26026
- "--model",
26027
- config.model,
26028
26083
  "--permission-mode",
26029
26084
  "acceptEdits",
26030
26085
  "--tools",
@@ -26033,8 +26088,10 @@ function agentArguments(config) {
26033
26088
  "Read,Edit,Write,Glob,Grep",
26034
26089
  "--no-session-persistence"
26035
26090
  ];
26091
+ if (config.model !== void 0) args.push("--model", config.model);
26092
+ return args;
26036
26093
  }
26037
- async function runAgent(config, worktree, prompt, signal, timeoutMs = 900 * 1e3) {
26094
+ async function runAgent(config, worktree, prompt, signal, timeoutMs = 900 * 1e3, output) {
26038
26095
  if (signal.aborted) throw new Error("Cleanup cancelled.");
26039
26096
  await new Promise((resolve, reject) => {
26040
26097
  const child = spawn(config.agentPath, agentArguments(config), {
@@ -26046,8 +26103,8 @@ async function runAgent(config, worktree, prompt, signal, timeoutMs = 900 * 1e3)
26046
26103
  },
26047
26104
  stdio: [
26048
26105
  "pipe",
26049
- "ignore",
26050
- "ignore"
26106
+ "pipe",
26107
+ "pipe"
26051
26108
  ]
26052
26109
  });
26053
26110
  let failure;
@@ -26067,6 +26124,39 @@ async function runAgent(config, worktree, prompt, signal, timeoutMs = 900 * 1e3)
26067
26124
  for (const pid of descendants.reverse()) kill(pid, "SIGTERM");
26068
26125
  child.kill("SIGTERM");
26069
26126
  };
26127
+ const promptLines = new Set(prompt.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean));
26128
+ const flushers = [];
26129
+ for (const source of ["stdout", "stderr"]) {
26130
+ const decoder = new StringDecoder("utf8");
26131
+ let pending = "";
26132
+ let oversized = false;
26133
+ const emit = (text) => {
26134
+ try {
26135
+ output?.(source, promptLines.has(text.trim()) ? "[Prompt line suppressed]" : text);
26136
+ } catch {
26137
+ stop("Unable to store cleanup logs; worktree preserved.");
26138
+ }
26139
+ };
26140
+ const consume = (text) => {
26141
+ for (const part of text.split(/(?<=\n)/u)) {
26142
+ if (!oversized) pending += part;
26143
+ if (pending.length > 16384) {
26144
+ pending = "";
26145
+ oversized = true;
26146
+ }
26147
+ if (part.endsWith("\n")) {
26148
+ emit(oversized ? "[Oversized output line suppressed]" : pending.slice(0, -1));
26149
+ pending = "";
26150
+ oversized = false;
26151
+ }
26152
+ }
26153
+ };
26154
+ child[source].on("data", (chunk) => consume(decoder.write(chunk)));
26155
+ flushers.push(() => {
26156
+ consume(decoder.end());
26157
+ if (oversized || pending) emit(oversized ? "[Oversized output line suppressed]" : pending);
26158
+ });
26159
+ }
26070
26160
  const abort = () => stop("Cleanup cancelled.");
26071
26161
  const timer = setTimeout(() => stop("Cleanup agent exceeded its 15-minute timeout."), timeoutMs);
26072
26162
  signal.addEventListener("abort", abort, { once: true });
@@ -26076,6 +26166,7 @@ async function runAgent(config, worktree, prompt, signal, timeoutMs = 900 * 1e3)
26076
26166
  failure = "Unable to launch cleanup agent.";
26077
26167
  });
26078
26168
  child.on("close", async (code) => {
26169
+ for (const flush of flushers) flush();
26079
26170
  clearTimeout(timer);
26080
26171
  signal.removeEventListener("abort", abort);
26081
26172
  await termination;
@@ -26161,6 +26252,128 @@ function activity(id) {
26161
26252
  const parsed = number().finite().safeParse(readState(statePath(id, "activity")));
26162
26253
  return parsed.success ? parsed.data : null;
26163
26254
  }
26255
+ function directory(treeId) {
26256
+ if (!/^[a-f0-9]{64}$/u.test(treeId)) throw new Error("Invalid cleanup tree ID.");
26257
+ return privateDirectory(join(cleanupRoot(), "logs", treeId));
26258
+ }
26259
+ function runDirectory(root, runId) {
26260
+ string$2().uuid().parse(runId);
26261
+ const path = join(root, runId);
26262
+ if (!lstatSync(path, { throwIfNoEntry: false })) throw new Error("Unknown cleanup run ID.");
26263
+ return privateDirectory(path);
26264
+ }
26265
+ function summaries(root) {
26266
+ return readdirSync(root).flatMap((id) => {
26267
+ const state = readState(join(runDirectory(root, id), "metadata"));
26268
+ if (state === void 0) return [];
26269
+ const metadata = cleanupRunMetadataSchema.parse(state);
26270
+ if (metadata.runId !== id) throw new Error("Cleanup log identity is corrupt.");
26271
+ return metadata;
26272
+ }).sort((a, b) => b.startedAt - a.startedAt || b.runId.localeCompare(a.runId));
26273
+ }
26274
+ function readEvents(path) {
26275
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
26276
+ try {
26277
+ const stat = fstatSync(fd);
26278
+ if (!stat.isFile() || stat.size > 5242880) throw new Error("Unsafe cleanup events file.");
26279
+ const lines = readFileSync(fd, "utf8").split("\n");
26280
+ lines.pop();
26281
+ return lines.map((line) => cleanupLogEventSchema.parse(JSON.parse(line)));
26282
+ } finally {
26283
+ closeSync(fd);
26284
+ }
26285
+ }
26286
+ function readCleanupHistory(treeId, options = {}) {
26287
+ if (options.list && options.run !== void 0) throw new Error("--list and --run are mutually exclusive.");
26288
+ if (options.run !== void 0) string$2().uuid().parse(options.run);
26289
+ const root = directory(treeId);
26290
+ const runs = summaries(root);
26291
+ const selected = options.list ? void 0 : options.run ?? runs[0]?.runId;
26292
+ if (options.run && !runs.some((run) => run.runId === options.run)) throw new Error("Unknown cleanup run ID.");
26293
+ return cleanupLogsResultSchema.parse({
26294
+ schemaVersion: 1,
26295
+ runs,
26296
+ selectedRunId: selected ?? null,
26297
+ events: selected ? readEvents(join(runDirectory(root, selected), "events.jsonl")) : []
26298
+ });
26299
+ }
26300
+ function sanitizeLogText(text) {
26301
+ return sanitizeCommandOutput(stripVTControlCharacters(text).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "")).replace(/\b(?:https?|ssh):\/\/[^\s]+/giu, "<redacted URL>").replace(/\b[A-Z_][A-Z0-9_]*=[^\s]+/gu, "<redacted assignment>");
26302
+ }
26303
+ var CleanupHistory = class {
26304
+ runId;
26305
+ path;
26306
+ metadata;
26307
+ bytes = 0;
26308
+ failed = false;
26309
+ constructor(config) {
26310
+ const root = directory(config.id);
26311
+ const previous = summaries(root);
26312
+ let count = previous.length;
26313
+ for (const run of [...previous].reverse()) {
26314
+ if (count < 50) break;
26315
+ if (!run.terminal) continue;
26316
+ rmSync(runDirectory(root, run.runId), { recursive: true });
26317
+ count--;
26318
+ }
26319
+ this.runId = randomUUID();
26320
+ this.path = privateDirectory(join(root, this.runId));
26321
+ this.metadata = {
26322
+ runId: this.runId,
26323
+ startedAt: Date.now(),
26324
+ agent: config.agent,
26325
+ ...config.model === void 0 ? {} : { model: sanitizeLogText(config.model) },
26326
+ truncated: false
26327
+ };
26328
+ closeSync(openSync(join(this.path, "events.jsonl"), constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 384));
26329
+ atomicState(join(this.path, "metadata"), this.metadata);
26330
+ }
26331
+ event(source, text) {
26332
+ this.assertHealthy();
26333
+ try {
26334
+ if (this.metadata.truncated) return;
26335
+ const line = `${JSON.stringify(cleanupLogEventSchema.parse({
26336
+ at: Date.now(),
26337
+ source,
26338
+ text: sanitizeLogText(text)
26339
+ }))}\n`;
26340
+ const size = Buffer.byteLength(line);
26341
+ if (this.bytes + size > 5242880) {
26342
+ this.metadata.truncated = true;
26343
+ this.save();
26344
+ return;
26345
+ }
26346
+ privateDirectory(this.path);
26347
+ const fd = openSync(join(this.path, "events.jsonl"), constants.O_APPEND | constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
26348
+ try {
26349
+ if (!fstatSync(fd).isFile()) throw new Error("Unsafe cleanup events file.");
26350
+ const buffer = Buffer.from(line);
26351
+ let written = 0;
26352
+ while (written < buffer.length) written += writeSync(fd, buffer, written, buffer.length - written);
26353
+ } finally {
26354
+ closeSync(fd);
26355
+ }
26356
+ this.bytes += size;
26357
+ } catch {
26358
+ this.failed = true;
26359
+ throw new Error("Unable to store cleanup logs; worktree preserved.");
26360
+ }
26361
+ }
26362
+ assertHealthy() {
26363
+ if (this.failed) throw new Error("Unable to store cleanup logs; worktree preserved.");
26364
+ }
26365
+ finish(outcome) {
26366
+ this.metadata.terminal = {
26367
+ ...outcome,
26368
+ ...outcome.message === void 0 ? {} : { message: sanitizeLogText(outcome.message) }
26369
+ };
26370
+ this.save();
26371
+ }
26372
+ save() {
26373
+ privateDirectory(this.path);
26374
+ atomicState(join(this.path, "metadata"), this.metadata);
26375
+ }
26376
+ };
26164
26377
  //#endregion
26165
26378
  //#region src/core/cleanup/scheduler.ts
26166
26379
  function xml(value) {
@@ -26248,7 +26461,11 @@ function nativeScheduler(platform = process.platform, home = homedir(), runner =
26248
26461
  remove(config) {
26249
26462
  const name = label(config);
26250
26463
  if (platform === "darwin") {
26251
- command(["bootout", `${domain}/${name}`], true);
26464
+ try {
26465
+ command(["bootout", `${domain}/${name}`], true);
26466
+ } catch (error) {
26467
+ if (status(config).registered) throw error;
26468
+ }
26252
26469
  const launcherDir = launcherDirectory(config);
26253
26470
  const launcher = join(launcherDir, "context-tree-cleanup");
26254
26471
  const entry = lstatSync(launcher, { throwIfNoEntry: false });
@@ -26271,6 +26488,11 @@ function nativeScheduler(platform = process.platform, home = homedir(), runner =
26271
26488
  }
26272
26489
  //#endregion
26273
26490
  //#region src/core/cleanup/index.ts
26491
+ /** Low-cost editorial default for agents that need an explicit model; `--model` overrides it. */
26492
+ const DEFAULT_CLEANUP_MODEL = {
26493
+ claude: "claude-haiku-4-5",
26494
+ codex: "gpt-5.6-luna"
26495
+ };
26274
26496
  function parseCleanupInterval(value = "1h") {
26275
26497
  const match = /^(\d+)(m|h|d)$/u.exec(value);
26276
26498
  const minutes = Number(match?.[1]) * (match?.[2] === "d" ? 1440 : match?.[2] === "h" ? 60 : 1);
@@ -26296,6 +26518,12 @@ function findSchedule(project) {
26296
26518
  const connection = findConnectionRecord(canonical);
26297
26519
  return connection ? schedules().find((config) => config.id === identityId(treeIdentity(connection.tree))) : void 0;
26298
26520
  }
26521
+ function cleanupLogs(project, options = {}) {
26522
+ const connection = findConnectionRecord(canonicalProjectRoot(project));
26523
+ const id = connection ? identityId(treeIdentity(connection.tree)) : findSchedule(project)?.id;
26524
+ if (!id) throw new Error("No Context Tree connection or cleanup schedule exists for this project.");
26525
+ return readCleanupHistory(id, options);
26526
+ }
26299
26527
  function cleanupStatus(project, scheduler = nativeScheduler()) {
26300
26528
  const config = findSchedule(project);
26301
26529
  if (!config) return {
@@ -26349,7 +26577,7 @@ function scheduleCleanupUnlocked(options, scheduler = nativeScheduler()) {
26349
26577
  projectPath: connection.projectPath,
26350
26578
  identity,
26351
26579
  agent,
26352
- model: options.model ?? (agent === "codex" ? "gpt-5.6-luna" : "claude-haiku-4-5"),
26580
+ model: options.model ?? DEFAULT_CLEANUP_MODEL[agent],
26353
26581
  everyMinutes: parseCleanupInterval(options.every),
26354
26582
  nodePath: realpathSync(process.execPath),
26355
26583
  cliPath: resolvePackagedResource("dist", "cli", "index.mjs"),
@@ -26466,14 +26694,30 @@ async function runCleanup(project, savedId, dependencies = {}) {
26466
26694
  process.on("SIGINT", cancel);
26467
26695
  let worktreePath;
26468
26696
  let publishing = false;
26697
+ let history;
26469
26698
  const record = (outcome, extra = {}) => {
26470
26699
  const value = cleanupOutcomeSchema.parse({
26471
26700
  at: Date.now(),
26701
+ runId: history?.runId,
26472
26702
  outcome,
26473
26703
  ...worktreePath ? { worktreePath } : {},
26474
26704
  ...extra
26475
26705
  });
26476
26706
  atomicState(statePath(config.id, "latest"), value);
26707
+ if (outcome !== "running") try {
26708
+ try {
26709
+ history?.event("runner", `${outcome}${value.message ? `: ${value.message}` : ""}`);
26710
+ } finally {
26711
+ history?.finish(value);
26712
+ }
26713
+ } catch {
26714
+ if (![
26715
+ "failed",
26716
+ "cancelled",
26717
+ "publication-uncertain"
26718
+ ].includes(outcome)) throw new Error("Unable to store cleanup logs; worktree preserved.");
26719
+ }
26720
+ else history?.event("runner", value.message ?? "Agent execution started.");
26477
26721
  return value;
26478
26722
  };
26479
26723
  const check = () => {
@@ -26487,13 +26731,17 @@ async function runCleanup(project, savedId, dependencies = {}) {
26487
26731
  return tree;
26488
26732
  };
26489
26733
  try {
26734
+ history = new CleanupHistory(config);
26735
+ history.event("runner", "Preparing cleanup.");
26490
26736
  check();
26491
26737
  const lastActivity = activity(config.id);
26492
26738
  if (lastActivity === null || Date.now() - lastActivity > 864e5) return record("inactive");
26493
26739
  identityCheck();
26740
+ record("running", { message: "Synchronizing Context Tree." });
26494
26741
  const synced = (dependencies.sync ?? syncProject)(config.projectPath);
26495
26742
  check();
26496
26743
  if (readState(statePath(config.id, "success")) === synced.sha) return record("unchanged", { sha: synced.sha });
26744
+ record("running", { message: "Preparing cleanup worktree." });
26497
26745
  worktreePath = (dependencies.prepare ?? prepareContextWrite)(config.projectPath).worktreePath;
26498
26746
  check();
26499
26747
  const head = git(worktreePath, ["rev-parse", "HEAD"]);
@@ -26508,10 +26756,12 @@ async function runCleanup(project, savedId, dependencies = {}) {
26508
26756
  }
26509
26757
  }, 250);
26510
26758
  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);
26759
+ 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, void 0, (source, text) => history?.event(source, text));
26512
26760
  } finally {
26513
26761
  clearInterval(monitor);
26514
26762
  }
26763
+ history.assertHealthy();
26764
+ history.event("runner", "Verifying cleanup edits.");
26515
26765
  check();
26516
26766
  if (git(worktreePath, ["rev-parse", "HEAD"]) !== head) throw new Error("Cleanup agent committed changes.");
26517
26767
  const changed = inspectEdits(worktreePath, before);
@@ -26529,7 +26779,6 @@ async function runCleanup(project, savedId, dependencies = {}) {
26529
26779
  worktreePath,
26530
26780
  message: "Clean up Context Tree content"
26531
26781
  });
26532
- publishing = false;
26533
26782
  atomicState(statePath(config.id, "success"), finished.sha);
26534
26783
  return record("published", { sha: finished.sha });
26535
26784
  } catch (error) {
@@ -26718,12 +26967,22 @@ function createProject(projectPath, runner) {
26718
26967
  }
26719
26968
  //#endregion
26720
26969
  //#region src/core/install.ts
26721
- /** Per-host configuration directory, relative to the home directory or to a project root. */
26722
- const HOST_CONFIG_DIRECTORY = {
26723
- claude: ".claude",
26724
- codex: ".codex"
26970
+ /** Home configuration paths detect hosts independently of their skills destinations. */
26971
+ const HOST_DIRECTORIES = {
26972
+ claude: {
26973
+ config: [".claude"],
26974
+ skills: ".claude"
26975
+ },
26976
+ codex: {
26977
+ config: [".codex"],
26978
+ skills: ".agents"
26979
+ },
26980
+ pi: {
26981
+ config: [".pi", "agent"],
26982
+ skills: ".agents"
26983
+ }
26725
26984
  };
26726
- /** Every supported host keeps user skills in the same subdirectory of its configuration directory. */
26985
+ /** Subdirectory below each skills destination. */
26727
26986
  const SKILLS_DIRECTORY = "skills";
26728
26987
  /** Only directories carrying this prefix are ever replaced or removed. */
26729
26988
  const OWNED_SKILL_PREFIX = "context-tree-";
@@ -26770,18 +27029,21 @@ function packagedSkillNames(skillsRoot) {
26770
27029
  }
26771
27030
  /** Resolve one host's destination, or the reason it was skipped. */
26772
27031
  function hostDestination(host, root, isProjectInstall) {
26773
- const configDirectory = HOST_CONFIG_DIRECTORY[host];
27032
+ const directories = HOST_DIRECTORIES[host];
26774
27033
  if (!isProjectInstall) {
26775
- const hostRoot = join(root, configDirectory);
26776
- const entry = lstatSync(hostRoot, { throwIfNoEntry: false });
26777
- if (entry === void 0) return { reason: `${hostRoot} does not exist; install ${host} first, then run context-tree install.` };
26778
- if (entry.isSymbolicLink() || !entry.isDirectory()) return { reason: `${hostRoot} is not a real directory.` };
27034
+ let hostRoot = root;
27035
+ for (const segment of directories.config) {
27036
+ hostRoot = join(hostRoot, segment);
27037
+ const entry = lstatSync(hostRoot, { throwIfNoEntry: false });
27038
+ if (entry === void 0) return { reason: `${hostRoot} does not exist; install ${host} first, then run context-tree install.` };
27039
+ if (entry.isSymbolicLink() || !entry.isDirectory()) return { reason: `${hostRoot} is not a real directory.` };
27040
+ }
26779
27041
  }
26780
- return { destination: ensureRealDirectory(root, [configDirectory, SKILLS_DIRECTORY]) };
27042
+ return { destination: ensureRealDirectory(root, [directories.skills, SKILLS_DIRECTORY]) };
26781
27043
  }
26782
27044
  /** Resolve one host's existing skills root without creating or following anything. */
26783
27045
  function hostSkillsRoot(host, root) {
26784
- const hostRoot = join(root, HOST_CONFIG_DIRECTORY[host]);
27046
+ const hostRoot = join(root, HOST_DIRECTORIES[host].skills);
26785
27047
  const hostEntry = lstatSync(hostRoot, { throwIfNoEntry: false });
26786
27048
  if (hostEntry === void 0) return { reason: `${hostRoot} does not exist; nothing to remove.` };
26787
27049
  if (hostEntry.isSymbolicLink() || !hostEntry.isDirectory()) return { reason: `${hostRoot} is not a real directory.` };
@@ -26807,6 +27069,7 @@ function installSkills(options = {}) {
26807
27069
  const root = projectRoot ?? realHome();
26808
27070
  const installed = [];
26809
27071
  const skipped = [];
27072
+ const written = /* @__PURE__ */ new Set();
26810
27073
  for (const host of hosts) {
26811
27074
  const resolved = hostDestination(host, root, projectRoot !== void 0);
26812
27075
  if ("reason" in resolved) {
@@ -26816,13 +27079,16 @@ function installSkills(options = {}) {
26816
27079
  });
26817
27080
  continue;
26818
27081
  }
26819
- for (const skill of skills) {
26820
- const target = join(resolved.destination, skill);
26821
- if (lstatSync(target, { throwIfNoEntry: false }) !== void 0) rmSync(target, {
26822
- force: true,
26823
- recursive: true
26824
- });
26825
- copyRealTree(join(skillsRoot, skill), target);
27082
+ if (!written.has(resolved.destination)) {
27083
+ for (const skill of skills) {
27084
+ const target = join(resolved.destination, skill);
27085
+ if (lstatSync(target, { throwIfNoEntry: false }) !== void 0) rmSync(target, {
27086
+ force: true,
27087
+ recursive: true
27088
+ });
27089
+ copyRealTree(join(skillsRoot, skill), target);
27090
+ }
27091
+ written.add(resolved.destination);
26826
27092
  }
26827
27093
  installed.push({
26828
27094
  host,
@@ -26843,6 +27109,7 @@ function uninstallSkills(options = {}) {
26843
27109
  const root = options.projectPath === void 0 ? realHome() : resolve(options.projectPath);
26844
27110
  const removed = [];
26845
27111
  const skipped = [];
27112
+ const removedByDestination = /* @__PURE__ */ new Map();
26846
27113
  for (const host of hosts) {
26847
27114
  const resolved = hostSkillsRoot(host, root);
26848
27115
  if ("reason" in resolved) {
@@ -26852,29 +27119,34 @@ function uninstallSkills(options = {}) {
26852
27119
  });
26853
27120
  continue;
26854
27121
  }
26855
- const skills = [];
26856
- for (const entry of readdirSync(resolved.destination, { withFileTypes: true })) {
26857
- if (!entry.name.startsWith(OWNED_SKILL_PREFIX)) continue;
26858
- const target = join(resolved.destination, entry.name);
26859
- const targetEntry = lstatSync(target, { throwIfNoEntry: false });
26860
- if (targetEntry === void 0) continue;
26861
- if (targetEntry.isSymbolicLink() || !targetEntry.isDirectory()) {
26862
- skipped.push({
26863
- host,
26864
- reason: `${target} is not a real directory.`
27122
+ let skills = removedByDestination.get(resolved.destination);
27123
+ if (skills === void 0) {
27124
+ skills = [];
27125
+ for (const entry of readdirSync(resolved.destination, { withFileTypes: true })) {
27126
+ if (!entry.name.startsWith(OWNED_SKILL_PREFIX)) continue;
27127
+ const target = join(resolved.destination, entry.name);
27128
+ const targetEntry = lstatSync(target, { throwIfNoEntry: false });
27129
+ if (targetEntry === void 0) continue;
27130
+ if (targetEntry.isSymbolicLink() || !targetEntry.isDirectory()) {
27131
+ skipped.push({
27132
+ host,
27133
+ reason: `${target} is not a real directory.`
27134
+ });
27135
+ continue;
27136
+ }
27137
+ rmSync(target, {
27138
+ force: true,
27139
+ recursive: true
26865
27140
  });
26866
- continue;
27141
+ skills.push(entry.name);
26867
27142
  }
26868
- rmSync(target, {
26869
- force: true,
26870
- recursive: true
26871
- });
26872
- skills.push(entry.name);
27143
+ skills.sort();
27144
+ removedByDestination.set(resolved.destination, skills);
26873
27145
  }
26874
27146
  removed.push({
26875
27147
  host,
26876
27148
  path: resolved.destination,
26877
- skills: skills.sort()
27149
+ skills
26878
27150
  });
26879
27151
  }
26880
27152
  return {
@@ -27197,13 +27469,13 @@ function createContextTreeCli(io = defaultIo) {
27197
27469
  emit(io, options.json, result, formatVerify);
27198
27470
  if (!result.ok) process.exitCode = 1;
27199
27471
  });
27200
- program.command("install").description("Install the packaged Context Tree skills into each agent's skill directory.").option("--host <host>", "restrict to one host: claude, codex, or all", "all").option("--project <path>", "install below this project root instead of the home directory").action((options) => {
27472
+ program.command("install").description("Install the packaged Context Tree skills into each agent's skill directory.").option("--host <host>", "restrict to one host: claude, codex, pi, or all", "all").option("--project <path>", "install below this project root instead of the home directory").action((options) => {
27201
27473
  const request = {};
27202
27474
  if (options.host !== "all") request.hosts = [skillHostSchema.parse(options.host)];
27203
27475
  if (options.project !== void 0) request.projectPath = resolve(io.cwd(), options.project);
27204
27476
  line(io, JSON.stringify(installSkills(request)));
27205
27477
  });
27206
- program.command("uninstall").description("Remove packaged Context Tree skills from each agent's skill directory.").option("--host <host>", "restrict to one host: claude, codex, or all", "all").option("--project <path>", "remove below this project root instead of the home directory").action((options) => {
27478
+ program.command("uninstall").description("Remove packaged Context Tree skills from each agent's skill directory.").option("--host <host>", "restrict to one host: claude, codex, pi, or all", "all").option("--project <path>", "remove below this project root instead of the home directory").action((options) => {
27207
27479
  const request = {};
27208
27480
  if (options.host !== "all") request.hosts = [skillHostSchema.parse(options.host)];
27209
27481
  if (options.project !== void 0) request.projectPath = resolve(io.cwd(), options.project);
@@ -27214,13 +27486,24 @@ function createContextTreeCli(io = defaultIo) {
27214
27486
  "schedule",
27215
27487
  "status",
27216
27488
  "remove",
27217
- "run"
27489
+ "run",
27490
+ "logs"
27218
27491
  ]) {
27219
27492
  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");
27493
+ if (operation === "schedule") command.requiredOption("--agent <agent>", "codex, claude, or pi").option("--model <model>", "explicit model override").option("--every <duration>", "positive whole-minute interval, e.g. 30m or 1h", "1h");
27494
+ if (operation === "logs") command.option("--list", "list recorded runs").option("--run <run-id>", "select a recorded run");
27221
27495
  if (operation === "run") command.addOption(new Option("--schedule-id <id>").hideHelp());
27222
27496
  command.action(async (options) => {
27223
27497
  const projectPath = resolve(io.cwd(), options.projectPath);
27498
+ if (operation === "logs") {
27499
+ const result = cleanupLogs(projectPath, options);
27500
+ emit(io, options.json, result, (value) => {
27501
+ if (!value.runs.length) return `No cleanup logs recorded for project ${projectPath}.\nUse --project-path <path> to query another project's Context Tree.`;
27502
+ if (options.list) return value.runs.map((run) => `${run.runId} ${new Date(run.startedAt).toISOString()} ${run.agent}/${run.model ?? "agent default"} ${run.terminal?.outcome ?? "incomplete"}${run.truncated ? " truncated" : ""}`).join("\n");
27503
+ return value.events.map((event) => `[${new Date(event.at).toISOString()}] [${event.source}] ${event.text}`).join("\n");
27504
+ });
27505
+ return;
27506
+ }
27224
27507
  if (operation === "run") {
27225
27508
  const result = await runCleanup(projectPath, options.scheduleId);
27226
27509
  const wireResult = cleanupRunResultSchema.parse({
@@ -27248,7 +27531,7 @@ function createContextTreeCli(io = defaultIo) {
27248
27531
  ` Registered: ${value.registered}; running: ${value.running}`,
27249
27532
  ` Project: ${config.projectPath}`,
27250
27533
  ` Every: ${config.everyMinutes} minutes`,
27251
- ` Agent: ${config.agent}; model: ${config.model}`,
27534
+ ` Agent: ${config.agent}; model: ${config.model ?? "agent default"}`,
27252
27535
  ` Last activity: ${value.lastActivity === null ? "missing" : new Date(value.lastActivity).toISOString()}`,
27253
27536
  ` Inactivity: ${value.inactive ? "cleanup prevented (no activity within 24 hours)" : "cleanup permitted"}`,
27254
27537
  ` Latest: ${value.latest?.outcome ?? "none"}${value.latest?.message ? ` — ${value.latest.message}` : ""}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@first-tree-ai/context-tree",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Durable, structured project context for coding agents: a CLI plus framework-neutral skills.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -50,7 +50,7 @@ const skipped = Array.isArray(payload?.skipped) ? payload.skipped : [];
50
50
 
51
51
  if (installed.length === 0) {
52
52
  process.stdout.write(
53
- "Context Tree: no agent directory found. Install Claude Code or Codex, then run `context-tree install`.\n",
53
+ "Context Tree: no agent directory found. Install Claude Code, Codex, or Pi, then run `context-tree install`.\n",
54
54
  );
55
55
  process.exit(0);
56
56
  }
@@ -19,11 +19,11 @@ runs cleanup immediately. Use the installed CLI on PATH; if missing, report
19
19
  desktop task using its existing controls before creating the CLI schedule.
20
20
  The CLI cannot discover or cancel old desktop tasks. Do not create a duplicate
21
21
  while cancellation is unconfirmed.
22
- 2. Select the requested installed agent, or the current host's CLI: `codex` or
23
- `claude`. Use the requested model if supplied; otherwise keep CLI defaults.
24
- Use a positive whole-minute cadence such as `30m`, `1h`, or `1d`; default to
25
- every hour. Do not silently approximate unsupported schedules.
26
- 3. Run `context-tree cleanup schedule --project-path "<absolute-project-path>" --agent <codex-or-claude> --every <duration> --json`.
22
+ 2. Select the requested installed agent, or the current host's CLI: `codex`,
23
+ `claude`, or `pi`. Use the requested model if supplied; otherwise keep CLI
24
+ defaults. Use a positive whole-minute cadence such as `30m`, `1h`, or `1d`;
25
+ default to every hour. Do not silently approximate unsupported schedules.
26
+ 3. Run `context-tree cleanup schedule --project-path "<absolute-project-path>" --agent <codex-claude-or-pi> --every <duration> --json`.
27
27
  Add `--model <model>` only for an explicit override. Quote real arguments
28
28
  safely. Connection or scheduler errors stop without setup or repair.
29
29
  4. Read back `context-tree cleanup status --project-path "<absolute-project-path>" --json`.
@@ -43,8 +43,10 @@ activity, unchanged commits, and overlap. Do not run it merely when scheduling.
43
43
  macOS uses user LaunchAgents; Linux uses systemd user timers/services. No desktop
44
44
  app, daemon, root installation, or Linux lingering is required. The machine must
45
45
  be awake and the user scheduler available; timing follows the native scheduler.
46
- Defaults are `gpt-5.6-luna` with low reasoning effort or `claude-haiku-4-5`, using
47
- existing CLI authentication. Do not change credentials, bypass permissions,
46
+ Defaults are `gpt-5.6-luna` with low reasoning effort for Codex and
47
+ `claude-haiku-4-5` for Claude, using existing CLI authentication. Pi uses its
48
+ configured default model. Pi runs ephemeral with extensions disabled; `--model`
49
+ may also name a `provider/model`. Do not change credentials, bypass permissions,
48
50
  silently switch models, or retry publication.
49
51
 
50
52
  Initial scheduling starts a 24-hour activity window. Successful ordinary create,
@@ -54,3 +56,19 @@ activity skips before network or model work; unchanged successfully cleaned
54
56
  commits skip the model. Failures preserve worktrees and success checkpoints.
55
57
  The runner owns preparation, verification, and publication; the fresh agent
56
58
  only edits using the cleanup skill's shared required editorial resource.
59
+
60
+ Inspect runner history with `context-tree cleanup logs --project-path <project> --json`.
61
+ Use `--list` for newest-first summaries or `--run <run-id>` for a particular run;
62
+ these selectors are mutually exclusive. The versioned result includes summaries
63
+ and the selected run's labeled runner/stdout/stderr events. Reads are snapshots,
64
+ do not refresh activity, and do not query the scheduler. Agent output varies by
65
+ CLI. Missing terminal outcomes mean incomplete runs. Outcomes include `runId`
66
+ when history was recorded.
67
+
68
+ History is local, shared across connected projects, and survives schedule removal.
69
+ Retention is 50 runs (oldest completed runs are pruned; incomplete entries are
70
+ protected), with 5 MiB of serialized output per run and an explicit truncation
71
+ flag. Final outcome metadata survives output truncation. Credentials and terminal
72
+ controls are sanitized; oversized lines are suppressed. Only runner attempts are
73
+ recorded, including inactivity and unchanged skips. Old transcripts and cleanup
74
+ performed directly through a host skill are unavailable.