@lyupro/codex-bridge 0.1.4 → 0.2.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.0] - 2026-08-06
8
+
9
+ ### Added
10
+
11
+ - `codex-bridge projects` gives the run store a way to look at itself: one line per project — runs, weight, tokens spent, how many are running right now, when the last one started — and one line per run under a project name, plus `--json` for scripts. The store had grown to 12 projects, 152 runs and 80 MB with no command that could answer "what has Codex been working on"; the only way in was a file manager. The count of live runs is computed by the same module the reply guard and the worktree lock use, because a second detector of a live run sitting next to the first would drift from it silently.
12
+ - `codex-bridge prune` reclaims that space, and it is built to be hard to fire by accident: without `-f` it prints a plan and deletes nothing, `-f` still asks before each step, and with no terminal it refuses outright — deletion is an operator action, and the bypass flag that would undo this is deliberately absent. A gentle prune removes transport only (event streams, stderr, the archived `raw.log` of older runs) and leaves accounting, reports and worktree snapshots in place, so a task can still be continued and still be read after its megabytes are gone; `--purge` is what takes a folder whole, and it is refused together with `--all-projects`, because deleting a dozen projects with one command has no second line of defence. A live run is never a target — it is recognised by its process, not by a status file, since the file that would be deleted is the verdict being written right now.
13
+ - Run transport is dropped automatically from runs older than 30 days when a new run starts, and the dispatcher's reply says how much was freed: a rare event that goes unexplained sends someone looking for a fault. Automatic cleanup can only shed weight — the code to remove `meta.json` or a folder does not exist on that path at all, so it cannot be switched on by mistake — and `retention` in `run-config.json` changes the age or turns it off. Both `install` and `doctor` say out loud that it is on, because an installer that quietly starts deleting files is a surprise, not a convenience.
14
+ - A fourth hook refuses `codex-bridge prune` in an agent's shell command before the deleting process exists. Mentioning the command is not calling it: heredoc bodies and quoted text are stripped before the check, after the guard rejected the very commit that introduced it over a command name in the message. `git prune` and `npm prune` are left alone — a guard that breaks unrelated work is a guard that gets removed, and then it protects nothing.
15
+ - `codex-bridge read <run>` renders a run's events as text on demand. Readability is now something the package produces when asked rather than a file it keeps, and a rendering that is not stored cannot disagree with the facts.
16
+
17
+ ### Changed
18
+
19
+ - Runs go through `codex exec --json`, and `events.jsonl` is the single source of the quota signal, the token count, the session id and the failure reason. The stream is written by the CLI, not by the model, so a quotation out of a source file can no longer reach it. `tokens` is input plus output with the CLI's whole `usage` object beside it, and `session_id` comes from `thread.started` instead of a line matched in a log.
20
+ - The reason for a `FAIL` is taken from the run's own error event rather than from `stderr.log`. A live probe showed `stderr.log` is not empty even for a clean run — 488 bytes of execpolicy refusals, repository paths included — which made the reason a random blocked call instead of the real one. The transport branch keeps the reason the CLI itself gave with its status code: a `LIMIT` whose reason does not mention the quota is the same class of defect.
21
+ - A run that never began is one with no events *and* no stderr. Stderr with no events is a CLI that did not understand a flag — a different diagnosis, calling for a different action, and the two no longer collapse into one.
22
+ - `log_bytes` in `meta.json` is replaced by `events_bytes` and `stderr_bytes`.
23
+
24
+ ### Removed
25
+
26
+ - `raw.log` is gone. Under `--json` it would have been a byte-for-byte copy of `events.jsonl` — a second source of truth with no role of its own — and the package already declared it ephemeral and safe to delete while resting the "abandoned at start" verdict on it. An artifact one is allowed to remove cannot be the ground of a verdict.
27
+ - The text detector of the quota signal is removed entirely rather than kept as a fallback. While the regular expressions live, so does the class of defect they caused.
28
+
29
+ ### Fixed
30
+
31
+ - `LIMIT` is set only by an error event from the CLI carrying `status: 429` or an error type naming the limit. A `codex-build` run that had finished its work grepped the package's own test fixtures, printed a line where `ERROR` and `rate limit` stand together, and was closed as quota-exhausted — the work was complete and the suite was green, and the verdict threw it away.
32
+ - A run killed by its time budget says so. It records `stopped_on_deadline` and answers `FAIL — run stopped on its deadline`, instead of inventing a reason out of whatever text was nearest.
33
+ - A log file that cannot be written no longer leaves Codex orphaned. An unhandled stream error used to kill the worker while the CLI kept burning quota with nothing left to close it; now either stream failing takes down the process tree, so the run dies with its process rather than instead of it.
34
+ - A run that exited honestly is no longer marked as killed by its deadline. The timer judged by `close`, which a grandchild process can hold open long after the run is over; it now watches `exit`.
35
+ - A verdict is never shortened to fit the terminal. In a narrow window `running` was printed as `…nning`, which reads as a damaged run rather than one still in flight; a verdict is one word from a known set, like a number or a date, and the run name is what a narrow table gives up instead.
36
+ - The CLI's own warnings are no longer read as the model's complaint about the order. `codex-cli` announces a deprecated configuration key through the same event shape the model uses for a content error, before the turn even opens — so a failed run on such a host would have answered "`[features].codex_hooks` is deprecated" when asked why it failed. Only events after the first `turn.started` are the model's; a complaint about the order cannot predate the model's first thought.
37
+
7
38
  ## [0.1.4] - 2026-08-05
8
39
 
9
40
  ### Added
package/README.md CHANGED
@@ -14,11 +14,13 @@ node bin/codex-bridge.mjs update # bring an installation up to the curren
14
14
  node bin/codex-bridge.mjs doctor # what is installed, where it points, is codex alive
15
15
  node bin/codex-bridge.mjs uninstall # removes exactly what was installed
16
16
  node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without hunting for a pid
17
+ node bin/codex-bridge.mjs read <run> # read a run: its events rendered as text, on demand
18
+ node bin/codex-bridge.mjs projects # what the run store holds: projects, runs, weight, tokens
19
+ node bin/codex-bridge.mjs prune <project> # reclaim disk space, transport only unless told otherwise
17
20
  ```
18
21
 
19
22
  `install` copies the package into the host's `agents/codex/`, its slash commands into
20
- `commands/codex/`, and registers both the stop hook and the pre-dispatch order gate in
21
- `settings.json` — **merged**, so hooks that
23
+ `commands/codex/`, and registers its four hooks in `settings.json` — **merged**, so hooks that
22
24
  are already there survive, and the file is backed up before every write. Run it twice and the
23
25
  second run does nothing. `--dry-run` prints the plan and touches nothing.
24
26
 
@@ -42,6 +44,16 @@ Run artifacts live in `codex-runs/<project>/`, one folder per repository, marked
42
44
  different `api` — therefore get `api` and `api-2` instead of one mixed history. `doctor` prints
43
45
  which folder the current repository writes to.
44
46
 
47
+ `projects` reads that store back: without an argument, one line per project — runs, weight, tokens
48
+ spent, how many are running right now; with a project name, one line per run. `prune` is the only
49
+ command in the package that destroys data, and it is built to be hard to fire by accident: without
50
+ `-f` it prints a plan and deletes nothing, `-f` still asks before each step, and with no terminal
51
+ it refuses outright, because deletion is an operator action and there is no flag around it.
52
+ By default it removes transport only — event streams and stderr, the megabytes — and leaves
53
+ accounting, reports and worktree snapshots alone; `--purge` is what takes a folder whole.
54
+ The same transport is dropped automatically from runs older than 30 days when a new run starts,
55
+ which the reply says out loud; `retention` in `run-config.json` changes the age or turns it off.
56
+
45
57
  ## What it gives you
46
58
 
47
59
  - **Three dispatcher agents** for Claude Code — reconnaissance (read-only), implementation
@@ -52,8 +64,10 @@ which folder the current repository writes to.
52
64
  - **A verdict that checks the report against the worktree.** The agent's own account of what
53
65
  it changed is compared with what git says actually changed, and a mismatch is a failed run
54
66
  rather than a nicer-sounding summary.
55
- - **A stop hook** that refuses a dispatcher reply which does not match the recorded state of
56
- the run.
67
+ - **Four hooks that hold the rules the prompts only ask for**: a reply is refused when it does not
68
+ match the recorded state of the run, a dispatcher call is refused when it carries no order id, a
69
+ file edit is refused inside a repository a live run holds, and deletion of the run store is
70
+ refused when an agent is the one asking.
57
71
 
58
72
  ## Requirements
59
73
 
@@ -5,7 +5,10 @@ import { fileURLToPath } from 'node:url';
5
5
  import { diagnose, renderDoctor } from '../cli/doctor.mjs';
6
6
  import { resolveHost } from '../cli/hosts.mjs';
7
7
  import { install } from '../cli/install.mjs';
8
+ import { projects } from '../cli/projects.mjs';
9
+ import { read } from '../cli/read.mjs';
8
10
  import { packageInfo } from '../cli/manifest.mjs';
11
+ import { prune } from '../cli/prune.mjs';
9
12
  import { stop } from '../cli/stop.mjs';
10
13
  import { uninstall } from '../cli/uninstall.mjs';
11
14
  import { update } from '../cli/update.mjs';
@@ -17,6 +20,10 @@ Usage:
17
20
  codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
18
21
  codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
19
22
  codex-bridge doctor [--scope user|project] [--host <path>]
23
+ codex-bridge projects [<name>] [--json]
24
+ codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
25
+ codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
26
+ codex-bridge read <run>
20
27
  codex-bridge stop <run>
21
28
  codex-bridge --help
22
29
  codex-bridge --version
@@ -26,6 +33,9 @@ Commands:
26
33
  update Update a recorded codex-bridge installation
27
34
  uninstall Remove installed files while preserving run artifacts
28
35
  doctor Diagnose the selected Claude Code host
36
+ projects List projects or runs from the run store
37
+ prune Remove archived transport, or purge selected run folders
38
+ read Render a run's structured event stream
29
39
  stop Stop a running Codex run and record FAIL`;
30
40
 
31
41
  function commandOptions(command, argv) {
@@ -76,6 +86,25 @@ export async function main(argv, io = console) {
76
86
  io.log(result.output);
77
87
  return result.exitCode;
78
88
  }
89
+ if (command === 'read') {
90
+ if (rest.length !== 1) {
91
+ io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
92
+ return 2;
93
+ }
94
+ const result = read({ run: rest[0] });
95
+ io.log(result.output);
96
+ return result.exitCode;
97
+ }
98
+ if (command === 'projects') {
99
+ const result = projects(rest);
100
+ io.log(result.output);
101
+ return result.exitCode;
102
+ }
103
+ if (command === 'prune') {
104
+ const result = await prune(rest);
105
+ io.log(result.output);
106
+ return result.exitCode;
107
+ }
79
108
  if (command === 'install' || command === 'update' || command === 'uninstall') {
80
109
  const options = commandOptions(command, rest);
81
110
  const host = resolveHost(options);
package/cli/doctor.mjs CHANGED
@@ -10,9 +10,13 @@ import {
10
10
  } from './manifest.mjs';
11
11
  import { commandFor, inspectHook } from './settings-merge.mjs';
12
12
  import { readRulesRegistry } from './rules-owners.mjs';
13
+ import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
13
14
  import { runsRoot } from '../src/runner/runs-root.mjs';
14
15
  import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
15
16
 
17
+ const WARNING = '\u001b[33m';
18
+ const RESET = '\u001b[0m';
19
+
16
20
  async function exists(target) {
17
21
  try {
18
22
  await fs.access(target);
@@ -34,6 +38,15 @@ function check(key, status, value) {
34
38
  return { key, status, value };
35
39
  }
36
40
 
41
+ function retentionCheck(host) {
42
+ try {
43
+ const notice = retentionNotice(readRunConfig(path.join(host.agentsDir, 'run-config.json')));
44
+ return check('retention', notice.enabled ? 'warn' : 'ok', notice.text);
45
+ } catch (err) {
46
+ return check('retention', 'fail', `invalid configuration: ${err.message}`);
47
+ }
48
+ }
49
+
37
50
  /**
38
51
  * The runner asks git for the repository root before it picks a runs folder, so doctor has to
39
52
  * ask the same question: run from `src/runner`, a plain cwd would name the folder `runner` and
@@ -158,6 +171,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
158
171
  const rules = await rulesCheck(host, record);
159
172
  checks.push(rules);
160
173
  checks.push(...await hookChecks(host, record));
174
+ const retention = retentionCheck(host);
175
+ checks.push(retention);
161
176
 
162
177
  const codex = codexProbe();
163
178
  checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
@@ -169,7 +184,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
169
184
 
170
185
  return {
171
186
  exitCode: !record || recordBroken || missingFiles.length || rules.status === 'fail'
172
- || projectRuns.status === 'fail' ? 1 : 0,
187
+ || retention.status === 'fail' || projectRuns.status === 'fail' ? 1 : 0,
173
188
  checks,
174
189
  record,
175
190
  missingFiles,
@@ -177,5 +192,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
177
192
  }
178
193
 
179
194
  export function renderDoctor(result) {
180
- return result.checks.map(({ key, status, value }) => `[${status}] ${key}: ${value}`).join('\n');
195
+ return result.checks.map(({ key, status, value }) => {
196
+ const rendered = `[${status}] ${key}: ${value}`;
197
+ return key === 'retention' && status === 'warn' ? `${WARNING}${rendered}${RESET}` : rendered;
198
+ }).join('\n');
181
199
  }
package/cli/install.mjs CHANGED
@@ -16,6 +16,10 @@ import {
16
16
  import { copyPlannedFile, targetMatches } from './copy.mjs';
17
17
  import { commandFor, inspectHook, mergeHook } from './settings-merge.mjs';
18
18
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
19
+ import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
20
+
21
+ const WARNING = '\u001b[33m';
22
+ const RESET = '\u001b[0m';
19
23
 
20
24
  async function targetExists(target) {
21
25
  try {
@@ -45,9 +49,19 @@ function recordHasHooks(record, targets) {
45
49
  hook.event === definition.event && hook.path === relative));
46
50
  }
47
51
 
52
+ function retentionLine(host) {
53
+ const notice = retentionNotice(readRunConfig(path.join(host.agentsDir, 'run-config.json')));
54
+ return notice.enabled ? `${WARNING}${notice.text}${RESET}` : notice.text;
55
+ }
56
+
57
+ function retentionOutput(line, output) {
58
+ return `${output}\n${line}`;
59
+ }
60
+
48
61
  export async function install({ host, dryRun = false, force = false, packageRoot } = {}) {
49
62
  // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
50
63
  await readRulesRegistry(host);
64
+ const configuredRetentionLine = retentionLine(host);
51
65
  const plan = await buildInstallPlan(host, packageRoot);
52
66
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
53
67
  const currentPackage = await packageInfo(packageRoot);
@@ -83,7 +97,7 @@ export async function install({ host, dryRun = false, force = false, packageRoot
83
97
  const files = conflicts.map(({ item }) => ` ${item.relativeToHost || item.name}`).join('\n');
84
98
  return {
85
99
  exitCode: 1,
86
- output: `Conflicting files:\n${files}\nRun install again with --force to overwrite them.`,
100
+ output: retentionOutput(configuredRetentionLine, `Conflicting files:\n${files}\nRun install again with --force to overwrite them.`),
87
101
  };
88
102
  }
89
103
 
@@ -95,7 +109,7 @@ export async function install({ host, dryRun = false, force = false, packageRoot
95
109
  if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
96
110
  && recordHasHooks(record, targets) && sameRecord) {
97
111
  if (!dryRun) await addRulesOwner(host);
98
- return { exitCode: 0, output: 'codex-bridge is already installed; nothing to do.' };
112
+ return { exitCode: 0, output: retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.') };
99
113
  }
100
114
 
101
115
  if (dryRun) {
@@ -111,7 +125,7 @@ export async function install({ host, dryRun = false, force = false, packageRoot
111
125
  : `Would register ${definition.event} hook for matcher ${definition.matcher}.`);
112
126
  });
113
127
  lines.push('Would write installation record.');
114
- return { exitCode: 0, output: lines.join('\n') };
128
+ return { exitCode: 0, output: retentionOutput(configuredRetentionLine, lines.join('\n')) };
115
129
  }
116
130
 
117
131
  // Claim ownership of the shared rules file before writing anything. Claiming it last meant a
@@ -146,6 +160,6 @@ export async function install({ host, dryRun = false, force = false, packageRoot
146
160
  });
147
161
  return {
148
162
  exitCode: 0,
149
- output: `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`,
163
+ output: retentionOutput(configuredRetentionLine, `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`),
150
164
  };
151
165
  }
@@ -0,0 +1,92 @@
1
+ /** Implements the projects command. */
2
+ import { listProjectRuns, listProjects } from './runs-inventory.mjs';
3
+ import { renderTable } from './table.mjs';
4
+
5
+ // The table renders whatever it is given; how a byte count or a timestamp reads is this command's
6
+ // business. Raw bytes and ISO milliseconds are technically exact and practically unreadable — an
7
+ // inventory exists to be scanned by eye, and 8468429 has to be counted digit by digit.
8
+ const SIZE_UNITS = [['GB', 1024 ** 3], ['MB', 1024 ** 2], ['KB', 1024]];
9
+
10
+ function formatSize(bytes) {
11
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return null;
12
+ for (const [unit, scale] of SIZE_UNITS) {
13
+ if (bytes >= scale) return `${(bytes / scale).toFixed(bytes >= 10 * scale ? 0 : 1)} ${unit}`;
14
+ }
15
+ return `${bytes} B`;
16
+ }
17
+
18
+ /** Both timestamp shapes in the store: an ISO stamp from artifacts, a folder name otherwise. */
19
+ function formatStamp(value) {
20
+ if (typeof value !== 'string' || !value.trim()) return null;
21
+ const iso = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
22
+ if (iso) return `${iso[1]} ${iso[2]}`;
23
+ const folder = /^(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})/.exec(value);
24
+ return folder ? `${folder[1]} ${folder[2]}:${folder[3]}` : value;
25
+ }
26
+
27
+ const PROJECT_COLUMNS = [
28
+ { key: 'project', header: 'project', truncate: 'start' },
29
+ { key: 'runs', header: 'runs', kind: 'number' },
30
+ { key: 'size', header: 'size', kind: 'number', value: (row) => formatSize(row.size) },
31
+ { key: 'totalTokens', header: 'total tokens', kind: 'number' },
32
+ { key: 'liveNow', header: 'live now', kind: 'number' },
33
+ { key: 'lastRun', header: 'last run', kind: 'date', value: (row) => formatStamp(row.lastRun) },
34
+ ];
35
+
36
+ // The verdict is never shortened, for the same reason numbers and dates are not: it is one whole
37
+ // word from a known set, and half of it is not half an answer. A narrow terminal turned `running`
38
+ // into `…nning`, which reads as damage rather than as a run in flight. The agent column keeps the
39
+ // tail because that is exactly what tells `codex-build` from `codex-scout`.
40
+ const RUN_COLUMNS = [
41
+ { key: 'run', header: 'run', truncate: 'start' },
42
+ { key: 'agent', header: 'agent', truncate: 'start' },
43
+ { key: 'verdict', header: 'verdict', fixed: true },
44
+ { key: 'tokens', header: 'tokens', kind: 'number' },
45
+ { key: 'size', header: 'size', kind: 'number', value: (row) => formatSize(row.size) },
46
+ ];
47
+
48
+ function parseArgs(args) {
49
+ const positional = [];
50
+ let json = false;
51
+ for (const arg of args) {
52
+ if (arg === '--json') {
53
+ json = true;
54
+ continue;
55
+ }
56
+ if (arg.startsWith('-')) return { error: `codex-bridge projects: unknown option "${arg}"` };
57
+ positional.push(arg);
58
+ }
59
+ if (positional.length > 1) {
60
+ return { error: 'codex-bridge projects accepts at most one project name.' };
61
+ }
62
+ return { json, projectName: positional[0] || null };
63
+ }
64
+
65
+ function output(rows, columns, options) {
66
+ if (options.json) return JSON.stringify(rows, null, 2);
67
+ return renderTable(columns, rows, options.terminalWidth);
68
+ }
69
+
70
+ /** Lists projects or runs for one project. */
71
+ export function projects(argv = [], options = {}) {
72
+ const parsed = parseArgs(argv);
73
+ if (parsed.error) return { exitCode: 2, output: parsed.error };
74
+
75
+ const { projectName } = parsed;
76
+ const root = options.runsRootPath;
77
+ const renderOptions = { json: parsed.json, terminalWidth: options.terminalWidth };
78
+
79
+ if (projectName) {
80
+ const rows = listProjectRuns(root, projectName);
81
+ if (rows === null) {
82
+ return {
83
+ exitCode: 1,
84
+ output: `codex-bridge projects: unknown project "${projectName}"`,
85
+ };
86
+ }
87
+ return { exitCode: 0, output: output(rows, RUN_COLUMNS, renderOptions) };
88
+ }
89
+
90
+ const rows = listProjects(root);
91
+ return { exitCode: 0, output: output(rows, PROJECT_COLUMNS, renderOptions) };
92
+ }
@@ -0,0 +1,133 @@
1
+ /** Parses prune scopes and refuses combinations that could widen destructive reach. */
2
+ import path from 'node:path';
3
+
4
+ const DURATION = /^(\d+)([dh])$/;
5
+ const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
6
+
7
+ function error(message) {
8
+ return { error: `codex-bridge prune: ${message}` };
9
+ }
10
+
11
+ function validCalendarDate(value) {
12
+ const match = DATE.exec(value);
13
+ if (!match) return false;
14
+ const [, year, month, day] = match;
15
+ const timestamp = Date.UTC(Number(year), Number(month) - 1, Number(day));
16
+ const date = new Date(timestamp);
17
+ return date.getUTCFullYear() === Number(year)
18
+ && date.getUTCMonth() === Number(month) - 1
19
+ && date.getUTCDate() === Number(day);
20
+ }
21
+
22
+ /** Parses 30d, 12h, or a calendar date without consulting the filesystem. */
23
+ export function parseOlderThan(value) {
24
+ const text = String(value ?? '').trim();
25
+ const duration = DURATION.exec(text);
26
+ if (duration) {
27
+ const amount = Number(duration[1]);
28
+ if (amount > 0 && Number.isSafeInteger(amount)) {
29
+ return { kind: 'duration', amount, unit: duration[2] };
30
+ }
31
+ }
32
+ if (validCalendarDate(text)) return { kind: 'date', date: text };
33
+ return { error: `--older-than must be a positive duration like 30d or 12h, or an exact date YYYY-MM-DD; got "${text}"` };
34
+ }
35
+
36
+ function validateName(label, value) {
37
+ if (!value || value === '.' || value === '..' || path.isAbsolute(value) || path.dirname(value) !== '.') {
38
+ return error(`${label} must be a bare folder name`);
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** Parses prune's positional scopes and safety flags. */
44
+ export function parsePruneArgs(argv = []) {
45
+ const positional = [];
46
+ let allProjects = false;
47
+ let purge = false;
48
+ let force = false;
49
+ let json = false;
50
+ let olderThan = null;
51
+ let olderThanExplicit = false;
52
+
53
+ for (let index = 0; index < argv.length; index += 1) {
54
+ const arg = argv[index];
55
+ if (arg === '--all-projects') {
56
+ allProjects = true;
57
+ continue;
58
+ }
59
+ if (arg === '--purge') {
60
+ purge = true;
61
+ continue;
62
+ }
63
+ if (arg === '--force' || arg === '-f') {
64
+ force = true;
65
+ continue;
66
+ }
67
+ if (arg === '--json') {
68
+ json = true;
69
+ continue;
70
+ }
71
+ if (arg === '--older-than' || arg === '--older-than=') {
72
+ const value = argv[index + 1];
73
+ if (value === undefined || value.startsWith('-')) return error('--older-than requires a value');
74
+ const parsed = parseOlderThan(value);
75
+ if (parsed.error) return error(parsed.error.replace(/^--older-than\s*/, '--older-than '));
76
+ olderThan = parsed;
77
+ olderThanExplicit = true;
78
+ index += 1;
79
+ continue;
80
+ }
81
+ if (arg.startsWith('--older-than=')) {
82
+ const value = arg.slice('--older-than='.length);
83
+ if (!value) return error('--older-than requires a value');
84
+ const parsed = parseOlderThan(value);
85
+ if (parsed.error) return error(parsed.error.replace(/^--older-than\s*/, '--older-than '));
86
+ olderThan = parsed;
87
+ olderThanExplicit = true;
88
+ continue;
89
+ }
90
+ if (arg.startsWith('-')) return error(`unknown option "${arg}"`);
91
+ positional.push(arg);
92
+ }
93
+
94
+ if (allProjects && purge) {
95
+ return error('--all-projects cannot be combined with --purge; all-projects is gentle only');
96
+ }
97
+ if (allProjects && positional.length) {
98
+ return error('--all-projects does not accept a project or run name');
99
+ }
100
+ if (!allProjects && (positional.length < 1 || positional.length > 2)) {
101
+ return error('expected <project>, optional <run>, or --all-projects');
102
+ }
103
+
104
+ const projectName = positional[0] || null;
105
+ const runName = positional[1] || null;
106
+ if (projectName) {
107
+ const invalid = validateName('project', projectName);
108
+ if (invalid) return invalid;
109
+ }
110
+ if (runName) {
111
+ const invalid = validateName('run', runName);
112
+ if (invalid) return invalid;
113
+ }
114
+
115
+ // The 30-day default belongs to the gentle sweep, which searches history for weight to shed.
116
+ // A purge is never implicitly aged: `prune sbx2 --purge` means that folder, now. Defaulting it
117
+ // made the plan's own fourth scenario unreachable — a young project answered "nothing to
118
+ // remove", which reads as a broken command, not as a refusal.
119
+ if (!olderThan && !purge && (allProjects || !runName)) {
120
+ olderThan = { kind: 'duration', amount: 30, unit: 'd' };
121
+ }
122
+
123
+ return {
124
+ allProjects,
125
+ projectName,
126
+ runName,
127
+ purge,
128
+ force,
129
+ json,
130
+ olderThan,
131
+ olderThanExplicit,
132
+ };
133
+ }