sprag-cli 3.54.0 → 3.56.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/README.ko.md +17 -5
- package/README.md +23 -6
- package/bin/cli.js +6 -6
- package/docs/CODEX.ko.md +57 -10
- package/docs/CODEX.md +89 -20
- package/package.json +6 -3
- package/src/claude-price.js +28 -0
- package/src/codex-brief.js +20 -0
- package/src/codex-cache-policy.js +26 -41
- package/src/codex-delegation.js +45 -5
- package/src/codex-doctor.js +1 -1
- package/src/codex-harness.js +20 -9
- package/src/codex-hooks.js +114 -19
- package/src/codex-ledger.js +94 -16
- package/src/codex-panel-setup.js +98 -0
- package/src/codex-panel.js +12 -7
- package/src/codex-parser.js +41 -4
- package/src/codex-route-scan.js +14 -7
- package/src/codex-rule-health.js +18 -0
- package/src/commands/codex-delegate.js +12 -3
- package/src/commands/codex.js +41 -11
- package/src/commands/harness.js +13 -2
- package/src/commands/install.js +106 -10
- package/src/commands/route-scan.js +8 -3
- package/src/commands/seed.js +7 -4
- package/src/commands/uninstall.js +23 -0
- package/src/commands/upgrade.js +13 -0
- package/src/cost.js +57 -3
- package/src/formatters/statusline.js +18 -7
- package/src/formatters/table.js +1 -0
- package/src/gateway-prices.js +39 -0
- package/src/harness-templates.js +33 -1
- package/src/harness.js +26 -1
- package/src/litellm-models.js +72 -10
- package/src/model-rules.js +2 -1
- package/src/money.js +9 -0
- package/src/month-spend.js +10 -5
- package/src/openai-prices.js +33 -0
- package/src/preset-ratchet.js +133 -0
- package/src/route-scan.js +16 -8
- package/src/savings-ledger.js +10 -6
- package/src/seed-rules.js +32 -7
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sets up the Codex companion panel to run inline, inside the same terminal,
|
|
3
|
+
* instead of opening a separate macOS Terminal window. Codex's native footer
|
|
4
|
+
* cannot run a command, so the panel is the closest thing Codex has to a
|
|
5
|
+
* statusline; showing it below the Codex pane in tmux (via a zsh `codex()`
|
|
6
|
+
* wrapper) means a user does not get a new window per session. Install falls
|
|
7
|
+
* back to the separate window when tmux cannot be set up, the login shell is
|
|
8
|
+
* not zsh, or the user already has their own `codex` alias/function.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { basename, dirname, join } from 'node:path';
|
|
14
|
+
import { installPanelShell } from './codex-panel-shell.js';
|
|
15
|
+
|
|
16
|
+
/** Whether a tmux binary answers `-V`. */
|
|
17
|
+
export function tmuxAvailable({ exec = execFileSync } = {}) {
|
|
18
|
+
try { exec('tmux', ['-V'], { stdio: 'pipe' }); return true; }
|
|
19
|
+
catch { return false; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ensureTmux({ platform = process.platform, env = process.env, exec = execFileSync, exists = existsSync } = {}) {
|
|
23
|
+
// Checked first, before probing anything, so tests and CI never spawn brew.
|
|
24
|
+
if (env.CTS_NO_TMUX === '1' || (env.CI && env.CI !== 'false')) return { status: 'skipped' };
|
|
25
|
+
if (tmuxAvailable({ exec })) return { status: 'present' };
|
|
26
|
+
if (platform !== 'darwin') {
|
|
27
|
+
return { status: 'unavailable', detail: 'install tmux with your package manager (e.g. sudo apt install tmux)' };
|
|
28
|
+
}
|
|
29
|
+
let brew;
|
|
30
|
+
try { exec('brew', ['--prefix'], { stdio: 'pipe' }); brew = 'brew'; }
|
|
31
|
+
catch {
|
|
32
|
+
for (const p of ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']) {
|
|
33
|
+
if (exists(p)) { brew = p; break; }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (!brew) {
|
|
37
|
+
return { status: 'unavailable', detail: 'Homebrew not found; install tmux from https://github.com/tmux/tmux/wiki/Installing or MacPorts (sudo port install tmux)' };
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
// brew update alone can take minutes; postinstall cannot afford that here.
|
|
41
|
+
exec(brew, ['install', 'tmux'], { stdio: 'inherit', timeout: 600_000,
|
|
42
|
+
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1' } });
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return { status: 'failed', via: 'brew', detail: String(e.message).split('\n')[0] };
|
|
45
|
+
}
|
|
46
|
+
// No npm-package fallback: no official npm tmux exists, and a third-party
|
|
47
|
+
// prebuilt binary would be an unaudited supply-chain risk.
|
|
48
|
+
const fallback = join(dirname(brew === 'brew' ? '/opt/homebrew/bin/brew' : brew), 'tmux');
|
|
49
|
+
if (tmuxAvailable({ exec }) || exists(fallback)) return { status: 'installed', via: 'brew' };
|
|
50
|
+
return { status: 'failed', via: 'brew', detail: 'tmux not on PATH after brew install' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function setupCodexPanel({ platform = process.platform, env = process.env, config = {}, noPanel = false,
|
|
54
|
+
exec, exists, installShell = installPanelShell, tmux = ensureTmux } = {}) {
|
|
55
|
+
if (noPanel) return { panelAuto: false, mode: 'off' };
|
|
56
|
+
// Panel auto and the zsh integration are macOS-only today.
|
|
57
|
+
if (platform !== 'darwin') return { panelAuto: false, mode: 'off' };
|
|
58
|
+
const windowAuto = config?.codex?.panelAuto !== false;
|
|
59
|
+
if (config?.codex?.panelShell === false) {
|
|
60
|
+
return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: 'shell integration turned off' };
|
|
61
|
+
}
|
|
62
|
+
if (basename(env.SHELL || '') !== 'zsh') {
|
|
63
|
+
return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: 'login shell is not zsh' };
|
|
64
|
+
}
|
|
65
|
+
const t = tmux({ platform, env, ...(exec && { exec }), ...(exists && { exists }) });
|
|
66
|
+
if (!['present', 'installed'].includes(t.status)) {
|
|
67
|
+
return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: t.detail || t.status, tmux: t };
|
|
68
|
+
}
|
|
69
|
+
let shellFile;
|
|
70
|
+
try { shellFile = installShell(); }
|
|
71
|
+
catch (e) {
|
|
72
|
+
return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: e.message, tmux: t };
|
|
73
|
+
}
|
|
74
|
+
// The inline panel replaces the separate window by default; an explicit
|
|
75
|
+
// `panel auto on` keeps both.
|
|
76
|
+
return { panelAuto: config?.codex?.panelAuto === true, mode: 'inline', tmux: t, shellFile };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Formats a `setupCodexPanel` result as lines a caller prefixes and prints. */
|
|
80
|
+
export function describePanelSetup(result, lang = 'en') {
|
|
81
|
+
if (result.mode === 'off') return [];
|
|
82
|
+
if (result.mode === 'inline') {
|
|
83
|
+
const lines = [lang === 'ko'
|
|
84
|
+
? `패널: tmux 인라인 (${result.shellFile}). 새 터미널에서 codex 를 실행하면 같은 창 아래쪽에 패널이 붙습니다.`
|
|
85
|
+
: `panel: inline tmux (${result.shellFile}). Open a new terminal and run codex; the panel attaches below it.`];
|
|
86
|
+
if (result.tmux?.status === 'installed') {
|
|
87
|
+
lines.push(lang === 'ko' ? 'tmux 를 Homebrew 로 설치했습니다.' : 'installed tmux with Homebrew.');
|
|
88
|
+
}
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
const lines = [lang === 'ko'
|
|
92
|
+
? `패널: 별도 Terminal 창${result.reason ? ` (${result.reason})` : ''}`
|
|
93
|
+
: `panel: separate Terminal window${result.reason ? ` (${result.reason})` : ''}`];
|
|
94
|
+
if (result.tmux?.detail && ['unavailable', 'failed'].includes(result.tmux.status)) {
|
|
95
|
+
lines.push(`tmux: ${result.tmux.detail}`);
|
|
96
|
+
}
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
package/src/codex-panel.js
CHANGED
|
@@ -9,7 +9,7 @@ import { loadConfig, statuslineDefaults } from './config.js';
|
|
|
9
9
|
import { glyphsFor } from './glyphs.js';
|
|
10
10
|
import { gaugeBar, formatMoney } from './formatters/statusline.js';
|
|
11
11
|
import { codexCacheTimer } from './codex-cache.js';
|
|
12
|
-
import { readCodexCachePolicy, readCodexPrices } from './codex-cache-policy.js';
|
|
12
|
+
import { readCodexCachePolicy, readCodexPrices, isDirectOpenAI } from './codex-cache-policy.js';
|
|
13
13
|
import { codexIssues } from './codex-brief.js';
|
|
14
14
|
import { labelForKey } from './window-labels.js';
|
|
15
15
|
import { resolveLabelMode } from './statusline-mode.js';
|
|
@@ -28,20 +28,22 @@ export const CODEX_SEGMENT_ORDER = [
|
|
|
28
28
|
|
|
29
29
|
// Only cache full-session parses. A moving time cutoff would make an unchanged
|
|
30
30
|
// file's cached totals stale. The panel reports session totals, not window totals.
|
|
31
|
+
// Matches the candidate TTL: older runs could not contribute a candidate anyway.
|
|
32
|
+
const RATCHET_SIBLING_MS = 30 * 60000;
|
|
33
|
+
|
|
31
34
|
export function createPanelReader({ root = process.cwd(), home, sessionId: pinnedId, sessionFile, days = 7, readBudget = () => null,
|
|
32
35
|
readCachePolicy = (session) => readCodexCachePolicy(session, { home }), hasFlag = () => false } = {}) {
|
|
33
36
|
if (pinnedId && sessionFile) throw new Error('Choose --session or --session-file, not both.');
|
|
34
37
|
const cache = new Map();
|
|
35
38
|
const ratchetCache = new Map();
|
|
36
|
-
const ratchetFor = (session) => {
|
|
39
|
+
const ratchetFor = (session, siblings = []) => {
|
|
37
40
|
if (!session?.filePath) return [];
|
|
38
41
|
try {
|
|
39
|
-
const s = statSync(
|
|
40
|
-
const key = `${s.mtimeMs}:${s.size}`;
|
|
42
|
+
const key = [session.filePath, ...siblings].map((file) => { const s = statSync(file); return `${file}:${s.mtimeMs}:${s.size}`; }).join('|');
|
|
41
43
|
// Candidates expire by age, so a quiet file still needs a periodic re-read.
|
|
42
44
|
const hit = ratchetCache.get(session.filePath);
|
|
43
45
|
if (hit?.key === key && Date.now() - hit.at < 60000) return hit.value;
|
|
44
|
-
const value = codexRatchetCandidates(session.filePath);
|
|
46
|
+
const value = codexRatchetCandidates(session.filePath, { siblings });
|
|
45
47
|
ratchetCache.clear();
|
|
46
48
|
ratchetCache.set(session.filePath, { key, at: Date.now(), value });
|
|
47
49
|
return value;
|
|
@@ -65,6 +67,8 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
65
67
|
let selected = null;
|
|
66
68
|
let matches = 0;
|
|
67
69
|
const seen = new Set();
|
|
70
|
+
// Recent rollouts of the same project, for repeats across `codex exec` runs.
|
|
71
|
+
const recent = [];
|
|
68
72
|
for (const f of files.slice().reverse()) {
|
|
69
73
|
let entry = cache.get(f.path);
|
|
70
74
|
if (!entry || entry.mtime !== f.mtime || entry.size !== f.size) {
|
|
@@ -80,6 +84,7 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
80
84
|
if (seen.has(key)) continue;
|
|
81
85
|
seen.add(key);
|
|
82
86
|
matches++;
|
|
87
|
+
if (!sessionId && Date.now() - f.mtime < RATCHET_SIBLING_MS) recent.push(s.filePath);
|
|
83
88
|
// Activity timestamps survive archive moves that update filesystem mtime.
|
|
84
89
|
if (!selected || (s.lastActivity?.getTime() || 0) > (selected.lastActivity?.getTime() || 0)) selected = s;
|
|
85
90
|
}
|
|
@@ -98,10 +103,10 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
98
103
|
let route = [];
|
|
99
104
|
try { route = openCodexCandidates(readCodexRouteScan(), { root: project }); } catch { /* No scan yet. */ }
|
|
100
105
|
let routingSaved = null;
|
|
101
|
-
try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
|
|
106
|
+
try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: isDirectOpenAI(selected?.provider, home ? { home } : {}) || !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
|
|
102
107
|
catch { /* Unreadable ledger reads as n/a. */ }
|
|
103
108
|
return { session: selected, matches, root, sessionId, selection, bindingPending: !!sessionFile && !sessionId, days, harness, rules,
|
|
104
|
-
ratchet: ratchetFor(selected), route, routingSaved,
|
|
109
|
+
ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, routingSaved,
|
|
105
110
|
labelMode: resolveLabelMode({ cfg: statuslineDefaults(), hasFlag }).mode,
|
|
106
111
|
color: statuslineDefaults().color,
|
|
107
112
|
timer: !hasFlag('--no-timer') && (hasFlag('--timer') || statuslineDefaults().timer),
|
package/src/codex-parser.js
CHANGED
|
@@ -7,16 +7,45 @@ import { codexUserDir } from './agent.js';
|
|
|
7
7
|
const count = (n) => Number.isSafeInteger(n) && n >= 0 ? n : 0;
|
|
8
8
|
const emptyTotals = () => ({ input: 0, cacheRead: 0, cacheCreation: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 });
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A child spawned with fork_turns "all" writes its own session_meta first,
|
|
12
|
+
* then a copy of the parent's session_meta and history, then its own work.
|
|
13
|
+
* The copy belongs to the parent: identity comes from the first session_meta,
|
|
14
|
+
* and the copied lines are skipped so the parent's request is not counted a
|
|
15
|
+
* second time. The copy ends at the child's own thread_settings_applied, or
|
|
16
|
+
* at the first line written after the batch copy, whichever comes first.
|
|
17
|
+
*/
|
|
18
|
+
export function forkedCopyFilter() {
|
|
19
|
+
let ownId = null, copyStamp = null;
|
|
20
|
+
return (e) => {
|
|
21
|
+
const p = e?.payload;
|
|
22
|
+
if (e?.type === 'session_meta') {
|
|
23
|
+
const id = p?.id || p?.session_id || null;
|
|
24
|
+
if (ownId === null) { ownId = id; return false; }
|
|
25
|
+
// A resumed rollout can repeat its own meta; only another thread's is a copy.
|
|
26
|
+
if (id && id !== ownId) { copyStamp = e.timestamp ?? null; return true; }
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (copyStamp === null) return false;
|
|
30
|
+
const own = e?.type === 'event_msg' && p?.type === 'thread_settings_applied' && p.thread_id === ownId;
|
|
31
|
+
if (own || e?.timestamp !== copyStamp) { copyStamp = null; return false; }
|
|
32
|
+
return true;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
function updateSnapshot(session, e) {
|
|
11
37
|
const p = e?.payload;
|
|
12
38
|
if (!p || typeof p !== 'object') return;
|
|
13
39
|
const activity = Date.parse(e.timestamp);
|
|
14
40
|
if (Number.isFinite(activity)) session.lastActivity = new Date(activity);
|
|
15
41
|
if (e.type === 'session_meta') {
|
|
42
|
+
// Callers drop forked copies, so a later meta can only be this thread's own repeat.
|
|
43
|
+
if (session.sessionId) return;
|
|
16
44
|
session.sessionId = p.id || p.session_id || null;
|
|
17
45
|
session.projectDir = typeof p.cwd === 'string' ? p.cwd : '';
|
|
18
46
|
session.source = p.source ?? null;
|
|
19
47
|
session.isSubagent = typeof p.source === 'object' && p.source !== null && 'subagent' in p.source;
|
|
48
|
+
session.forkedFrom = typeof p.forked_from_id === 'string' ? p.forked_from_id : null;
|
|
20
49
|
session.branch = typeof p.git?.branch === 'string' ? p.git.branch : null;
|
|
21
50
|
session.provider = typeof p.model_provider === 'string' ? p.model_provider : null;
|
|
22
51
|
}
|
|
@@ -63,8 +92,12 @@ export function readCodexSnapshot(filePath, { tailBytes = 256 * 1024 } = {}) {
|
|
|
63
92
|
const lines = readCodexTailLines(filePath, tailBytes);
|
|
64
93
|
if (!lines) return null;
|
|
65
94
|
const snapshot = {};
|
|
95
|
+
const copied = forkedCopyFilter();
|
|
66
96
|
for (const line of lines) {
|
|
67
|
-
try {
|
|
97
|
+
try {
|
|
98
|
+
const e = JSON.parse(line);
|
|
99
|
+
if (!copied(e)) updateSnapshot(snapshot, e);
|
|
100
|
+
} catch { /* Partial or malformed record. */ }
|
|
68
101
|
}
|
|
69
102
|
return snapshot;
|
|
70
103
|
}
|
|
@@ -164,7 +197,8 @@ const emptyTurn = (turnId) => ({ turnId, startedAt: null, endedAt: null, aborted
|
|
|
164
197
|
* structured event described, so a call is never counted twice.
|
|
165
198
|
*/
|
|
166
199
|
export async function parseCodexTurns(filePath, { cutoffMs = -Infinity } = {}) {
|
|
167
|
-
const session = { filePath, sessionId: null, projectDir: '', isSubagent: false, parentThreadId: null, provider: null, turns: [] };
|
|
200
|
+
const session = { filePath, sessionId: null, projectDir: '', isSubagent: false, parentThreadId: null, forkedFrom: null, provider: null, turns: [] };
|
|
201
|
+
const copied = forkedCopyFilter();
|
|
168
202
|
const turns = new Map();
|
|
169
203
|
const turnOf = (id) => {
|
|
170
204
|
if (!id) return null;
|
|
@@ -184,12 +218,14 @@ export async function parseCodexTurns(filePath, { cutoffMs = -Infinity } = {}) {
|
|
|
184
218
|
let e;
|
|
185
219
|
try { e = JSON.parse(line); } catch { continue; }
|
|
186
220
|
const p = e?.payload;
|
|
187
|
-
if (!p || typeof p !== 'object') continue;
|
|
221
|
+
if (!p || typeof p !== 'object' || copied(e)) continue;
|
|
188
222
|
if (e.type === 'session_meta') {
|
|
223
|
+
if (session.sessionId) continue;
|
|
189
224
|
session.sessionId = p.id || p.session_id || null;
|
|
190
225
|
session.projectDir = typeof p.cwd === 'string' ? p.cwd : '';
|
|
191
226
|
session.isSubagent = typeof p.source === 'object' && p.source !== null && 'subagent' in p.source;
|
|
192
227
|
session.parentThreadId = p.source?.subagent?.thread_spawn?.parent_thread_id || null;
|
|
228
|
+
session.forkedFrom = typeof p.forked_from_id === 'string' ? p.forked_from_id : null;
|
|
193
229
|
session.provider = typeof p.model_provider === 'string' ? p.model_provider : null;
|
|
194
230
|
continue;
|
|
195
231
|
}
|
|
@@ -303,6 +339,7 @@ export async function parseCodexSessionFile(filePath, { cutoffMs = -Infinity } =
|
|
|
303
339
|
lastContextTokens: null, rateLimits: null, lastActivity: null, effort: null,
|
|
304
340
|
cacheActivity: null,
|
|
305
341
|
};
|
|
342
|
+
const copied = forkedCopyFilter();
|
|
306
343
|
let previous = null;
|
|
307
344
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
|
308
345
|
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
@@ -310,7 +347,7 @@ export async function parseCodexSessionFile(filePath, { cutoffMs = -Infinity } =
|
|
|
310
347
|
let e;
|
|
311
348
|
try { e = JSON.parse(line); } catch { continue; }
|
|
312
349
|
const p = e?.payload;
|
|
313
|
-
if (!p) continue;
|
|
350
|
+
if (!p || copied(e)) continue;
|
|
314
351
|
updateSnapshot(session, e);
|
|
315
352
|
if (e.type !== 'event_msg' || p.type !== 'token_count') continue;
|
|
316
353
|
if (!p.info) continue;
|
package/src/codex-route-scan.js
CHANGED
|
@@ -8,7 +8,8 @@ import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
|
|
|
8
8
|
import { categorize, isSkippable, tierOf, calibrateThresholds, MIN_RECURRENCE,
|
|
9
9
|
RESCAN_MIN_INTERVAL_MS, RESCAN_BIG_DELTA_BYTES, RESCAN_MAX_AGE_MS } from './route-scan.js';
|
|
10
10
|
import { codexPromptCategory, loadCodexModelRules } from './codex-delegation.js';
|
|
11
|
-
import { codexRunCost, readCodexPrices } from './codex-cache-policy.js';
|
|
11
|
+
import { codexRunCost, codexPriceBook, isDirectOpenAI, readCodexPrices } from './codex-cache-policy.js';
|
|
12
|
+
import { OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
|
|
12
13
|
import { refreshCodexLedger } from './codex-ledger.js';
|
|
13
14
|
|
|
14
15
|
/*
|
|
@@ -81,11 +82,12 @@ export async function shouldRescanCodex(cache, { home, days = 14, now = Date.now
|
|
|
81
82
|
* a rule target, so a gateway that also serves unrelated models (embeddings,
|
|
82
83
|
* other vendors) never has one suggested blind.
|
|
83
84
|
*/
|
|
84
|
-
function pickTarget(group, { prices, known }) {
|
|
85
|
-
const
|
|
85
|
+
function pickTarget(group, { prices, known, home }) {
|
|
86
|
+
const book = codexPriceBook(group.provider, prices, { home });
|
|
87
|
+
const cost = (model) => book ? codexRunCost(group.tokens, book.prices[model]) : null;
|
|
86
88
|
const parent = cost(group.from);
|
|
87
89
|
if (parent === null) return { suggestedModel: null, alternatives: [], estSavedUsd: null };
|
|
88
|
-
const cheaper = Object.keys(
|
|
90
|
+
const cheaper = Object.keys(book.prices).filter((m) => m !== group.from && book.prices[m])
|
|
89
91
|
.map((m) => ({ model: m, usd: cost(m) })).filter((x) => x.usd !== null && x.usd < parent)
|
|
90
92
|
.sort((a, b) => a.usd - b.usd);
|
|
91
93
|
const eligible = cheaper.filter((x) => known.has(`${group.provider}|${x.model}`));
|
|
@@ -151,7 +153,11 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
|
|
|
151
153
|
(!r.provider || r.provider === g.provider) &&
|
|
152
154
|
(r.scope === 'global' || r.targetRoot === g.projectRoot));
|
|
153
155
|
const known = new Set([...observed, ...rules.filter((r) => r.provider).map((r) => `${r.provider}|${r.model}`)]);
|
|
154
|
-
|
|
156
|
+
const target = cfg?.codex?.delegateTarget?.model;
|
|
157
|
+
if (target) {
|
|
158
|
+
if (prices?.provider) known.add(`${prices.provider}|${target}`);
|
|
159
|
+
if (isDirectOpenAI('openai', { home })) known.add(`openai|${target}`);
|
|
160
|
+
}
|
|
155
161
|
const prev = readCodexRouteScan({ dir });
|
|
156
162
|
const resolved = new Set(prev?.resolved || []);
|
|
157
163
|
const candidates = [...groups.values()]
|
|
@@ -160,9 +166,10 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
|
|
|
160
166
|
.slice(0, MAX_CANDIDATES)
|
|
161
167
|
.map((g, i) => ({ id: i + 1, signature: g.signature, category: g.category, label: g.label, labelEn: g.labelEn,
|
|
162
168
|
from: g.from, provider: g.provider, projectRoot: g.projectRoot, count: g.count, tiers: g.tiers, example: g.example.slice(0, 160),
|
|
163
|
-
lastSeen: g.lastSeen || null, suggestedScope: 'project', ...pickTarget(g, { prices, known }) }));
|
|
169
|
+
lastSeen: g.lastSeen || null, suggestedScope: 'project', ...pickTarget(g, { prices, known, home }) }));
|
|
170
|
+
const priceCheckedAt = prices?.checkedAt ?? ([...groups.values()].some((g) => isDirectOpenAI(g.provider, { home })) ? OPENAI_PRICES_CHECKED_AT : null);
|
|
164
171
|
const data = { version: 1, scannedAt: new Date(now).toISOString(), days, dataBytes, totalEpisodes: episodes.length,
|
|
165
|
-
thresholds, observedModels: [...observed], priceCheckedAt
|
|
172
|
+
thresholds, observedModels: [...observed], priceCheckedAt, candidates, resolved: [...resolved] };
|
|
166
173
|
writeCache(data, dir);
|
|
167
174
|
if (refreshLedger) {
|
|
168
175
|
try { await refreshCodexLedger({ dir, home, now, prices }); } catch { /* The ledger catches up on the next scan. */ }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { loadCodexLedger } from './codex-ledger.js';
|
|
2
|
+
import { isFailedRun } from './route-scan.js';
|
|
3
|
+
import { wilsonLowerBound, HEALTH_ERR_RATE, HEALTH_MIN_SAMPLE_DELEGATED } from './model-rules.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Measured outcome of one Codex rule, judged as Claude's rule health judges
|
|
7
|
+
* its delegations: a run fails when interrupted or when its tool-error density
|
|
8
|
+
* crosses isFailedRun's line, and the rule goes to review only when the Wilson
|
|
9
|
+
* lower bound of the failure rate clears HEALTH_ERR_RATE over enough runs.
|
|
10
|
+
*/
|
|
11
|
+
export function codexRuleHealth(rule, { events = Object.values(loadCodexLedger().events) } = {}) {
|
|
12
|
+
const runs = events.filter((e) => e && e.category === rule.category && e.from === rule.from && e.to === rule.model &&
|
|
13
|
+
(!rule.provider || !e.provider || e.provider === rule.provider) && (e.complete || e.aborted));
|
|
14
|
+
const errs = runs.filter((e) => e.aborted || isFailedRun({ toolErrors: e.toolErrors, calls: e.calls })).length;
|
|
15
|
+
const saved = runs.filter((e) => Number.isFinite(e.usd)).reduce((sum, e) => sum + e.usd, 0);
|
|
16
|
+
const review = runs.length >= HEALTH_MIN_SAMPLE_DELEGATED && wilsonLowerBound(errs, runs.length) > HEALTH_ERR_RATE;
|
|
17
|
+
return { runs: runs.length, errs, rate: runs.length ? errs / runs.length : 0, saved, status: review ? 'review' : 'active' };
|
|
18
|
+
}
|
|
@@ -2,7 +2,9 @@ import { loadConfig, saveConfig } from '../config.js';
|
|
|
2
2
|
import { configureCodexHooks } from '../codex-installer.js';
|
|
3
3
|
import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule } from '../codex-delegation.js';
|
|
4
4
|
import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
|
|
5
|
-
import { codexRoutingSavedTotals } from '../codex-ledger.js';
|
|
5
|
+
import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
|
|
6
|
+
import { codexRuleHealth } from '../codex-rule-health.js';
|
|
7
|
+
import { signedUsd } from '../money.js';
|
|
6
8
|
import { codexProviderName } from '../agent.js';
|
|
7
9
|
|
|
8
10
|
export function run({ args, getArg, hasFlag, root }) {
|
|
@@ -31,7 +33,14 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
31
33
|
else if (args[2]) throw new Error('Usage: delegate rules [add <category|R<N>> --from <model> --model <model> --global|--project | rm <N>]');
|
|
32
34
|
const rules = loadCodexModelRules();
|
|
33
35
|
if (!rules.length) console.log('No Codex model rules. Claude rules are not imported.');
|
|
34
|
-
|
|
36
|
+
let events = [];
|
|
37
|
+
try { events = Object.values(loadCodexLedger().events); } catch { /* No ledger yet: rules list without outcomes. */ }
|
|
38
|
+
rules.forEach((r, i) => {
|
|
39
|
+
const h = codexRuleHealth(r, { events });
|
|
40
|
+
const outcome = h.runs ? ` | measured x${h.runs}, err ${Math.round(h.rate * 100)}%, saved ${signedUsd(h.saved, 4)}` : '';
|
|
41
|
+
const warn = h.status === 'review' ? ` | rule-health: ${h.errs}/${h.runs} runs failed, narrow the condition or remove (delegate rules rm ${i + 1} --agent codex)` : '';
|
|
42
|
+
console.log(`#${i + 1} ${r.category} | ${r.from} -> ${r.model} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}${outcome}${warn}`);
|
|
43
|
+
});
|
|
35
44
|
return;
|
|
36
45
|
}
|
|
37
46
|
if (!['on', 'off', 'status', 'model'].includes(sub)) throw new Error('Usage: delegate on|off|status|model <model>|rules --agent codex');
|
|
@@ -54,6 +63,6 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
54
63
|
console.log('Explicit spawn models and custom roles are preserved. Targets must be available from your Codex provider.');
|
|
55
64
|
const saved = codexRoutingSavedTotals();
|
|
56
65
|
console.log(saved.priced
|
|
57
|
-
? `Routing saved: $${saved.total.toFixed(4)} over ${saved.priced} priced run(s). Details: sprag route-scan savings --agent codex`
|
|
66
|
+
? `Routing saved: ${saved.total < 0 ? '-' : ''}$${Math.abs(saved.total).toFixed(4)} over ${saved.priced} priced run(s). Details: sprag route-scan savings --agent codex`
|
|
58
67
|
: `Routing saved: n/a (${saved.runs ? `${saved.runs} attributed run(s), none priced` : 'no attributed runs yet'}). Review/trust the Sprag hooks in Codex /hooks.`);
|
|
59
68
|
}
|
package/src/commands/codex.js
CHANGED
|
@@ -4,6 +4,8 @@ import { initCodexHarness, uninitCodexHarness, codexHarnessStatus } from '../cod
|
|
|
4
4
|
import { findProjectRoot, harnessPromote, harnessListRules, harnessRmRule, harnessPrune } from '../harness.js';
|
|
5
5
|
import { loadConfig, saveConfig } from '../config.js';
|
|
6
6
|
import { resolve } from 'node:path';
|
|
7
|
+
import { debug } from '../debug.js';
|
|
8
|
+
import { signedUsd } from '../money.js';
|
|
7
9
|
|
|
8
10
|
const HELP = `Sprag / Codex
|
|
9
11
|
|
|
@@ -19,7 +21,7 @@ Usage: sprag <command> --agent codex [options]
|
|
|
19
21
|
doctor [--format json] Config, hooks, harness, logs, and capabilities
|
|
20
22
|
capabilities [--format json] Support matrix, including native/unsupported areas
|
|
21
23
|
brief [on|off|status] Current warnings or prompt-briefing preference
|
|
22
|
-
last / history
|
|
24
|
+
last / history Context/rate-limit warnings and handoffs; --days, --session, --project
|
|
23
25
|
handoff [--cwd path] Write Git state and Codex usage to HANDOFF-*.md
|
|
24
26
|
delegate on|off|status Native model routing and SubagentStart guidance
|
|
25
27
|
delegate model <model> Default target for spawns without an explicit model
|
|
@@ -155,7 +157,7 @@ export async function run({ args, version }) {
|
|
|
155
157
|
const money = (usd) => Number.isFinite(usd) ? `${usd < 0 ? '-' : ''}$${Math.abs(usd).toFixed(4)}` : 'n/a (unpriced)';
|
|
156
158
|
console.log(`Codex routing saved: ${totals.priced ? `${money(totals.week)} (7d) / ${money(totals.total)} total` : 'n/a'} | ${totals.runs} attributed run(s), ${totals.runs - totals.priced} unpriced`);
|
|
157
159
|
for (const e of events.slice(0, 20)) {
|
|
158
|
-
console.log(`${new Date(e.ts).toISOString().slice(0, 16)} ${e.source}${e.category ? `/${e.category}` : ''} ${e.from} -> ${e.to} ${money(e.usd)}${e.complete ? '' : ' (incomplete)'}`);
|
|
160
|
+
console.log(`${new Date(e.ts).toISOString().slice(0, 16)} ${e.source}${e.category ? `/${e.category}` : ''} ${e.from} -> ${e.to} ${money(e.usd)}${e.aborted ? ' (interrupted: whole cost counted as a loss)' : e.complete ? '' : ' (incomplete)'}`);
|
|
159
161
|
}
|
|
160
162
|
console.log('Counterfactual: the child run\'s own tokens priced at the parent model. Only Sprag-routed spawns with a recorded route id count.');
|
|
161
163
|
return;
|
|
@@ -172,7 +174,7 @@ export async function run({ args, version }) {
|
|
|
172
174
|
if (hasFlag('--refresh') || !cache) {
|
|
173
175
|
// Prices live in the gateway snapshot; refresh it when stale so candidates can name a target.
|
|
174
176
|
const { readCodexPrices, refreshCodexPrices } = await import('../codex-cache-policy.js');
|
|
175
|
-
if (!readCodexPrices()) { try { await refreshCodexPrices(); } catch {
|
|
177
|
+
if (!readCodexPrices()) { try { await refreshCodexPrices(); } catch (e) { debug('codex:prices', e); } }
|
|
176
178
|
cache = await rs.runCodexRouteScan({ days });
|
|
177
179
|
}
|
|
178
180
|
if (hasFlag('--quiet')) return;
|
|
@@ -181,7 +183,7 @@ export async function run({ args, version }) {
|
|
|
181
183
|
console.log(`Codex route-scan: ${cache.totalEpisodes} turns in ${cache.days} days, scanned ${cache.scannedAt}${cache.priceCheckedAt ? '' : ' | prices unavailable'}`);
|
|
182
184
|
if (!open.length) console.log('No recurring delegable pattern. Needs 3+ similar simple turns per project and parent model.');
|
|
183
185
|
for (const c of open) {
|
|
184
|
-
const target = c.suggestedModel ? `-> ${c.suggestedModel}${Number.isFinite(c.estSavedUsd) ? ` (
|
|
186
|
+
const target = c.suggestedModel ? `-> ${c.suggestedModel}${Number.isFinite(c.estSavedUsd) ? ` (~${signedUsd(c.estSavedUsd, 4)} over ${c.count} turns)` : ''}`
|
|
185
187
|
: `-> choose --model${c.alternatives.length ? ` (priced cheaper: ${c.alternatives.join(', ')})` : ''}`;
|
|
186
188
|
console.log(`R${c.id} ${c.category} x${c.count} [T2 ${c.tiers.T2} / T1 ${c.tiers.T1}] ${c.provider || 'unknown provider'} / ${c.from} ${target}`);
|
|
187
189
|
console.log(` ${c.projectRoot}`);
|
|
@@ -225,7 +227,7 @@ export async function run({ args, version }) {
|
|
|
225
227
|
sessionId: getArg('--session'), project: getArg('--project') || getArg('-p') });
|
|
226
228
|
if (cmd === 'last') events = events.slice(-1);
|
|
227
229
|
if (format === 'json') { console.log(JSON.stringify({ agent: 'codex', events }, null, 2)); return; }
|
|
228
|
-
if (!events.length) console.log('No Codex warning history in this window.
|
|
230
|
+
if (!events.length) console.log('No Codex warning history in this window. The trusted prompt hook records context and rate-limit warnings; handoffs are recorded when written.');
|
|
229
231
|
else if (hasFlag('--list')) console.log([...new Set(events.map((event) => event.at.slice(0, 10)))].reverse().join('\n'));
|
|
230
232
|
else for (const event of events) {
|
|
231
233
|
console.log(terminalText(`${event.at} [${event.sessionId}] ${event.message}`));
|
|
@@ -239,6 +241,11 @@ export async function run({ args, version }) {
|
|
|
239
241
|
const cwd = getArg('--cwd') || process.cwd();
|
|
240
242
|
const data = await createPanelReader({ root: findProjectRoot(resolve(cwd), { agent: 'codex' }), sessionId: getArg('--session'), days: numArg('--days', { dflt: 7, min: 0 }) })();
|
|
241
243
|
const result = writeHandoff({ cwd, agent: 'codex', session: data.session });
|
|
244
|
+
try {
|
|
245
|
+
const { recordCodexHistoryEvent } = await import('../codex-brief.js');
|
|
246
|
+
recordCodexHistoryEvent({ sessionId: data.session?.sessionId, project: cwd, key: 'handoff',
|
|
247
|
+
message: `Handoff written: ${result.path}` });
|
|
248
|
+
} catch (e) { debug('codex:handoff-record', e); }
|
|
242
249
|
console.log(`Handoff written: ${result.path}`);
|
|
243
250
|
console.log('Fill in the remaining-work sections, then ask the next Codex session to read this file.');
|
|
244
251
|
return;
|
|
@@ -272,6 +279,11 @@ export async function run({ args, version }) {
|
|
|
272
279
|
if (!['install', 'remove'].includes(args[2])) throw new Error('Usage: sprag panel shell install|remove --agent codex');
|
|
273
280
|
const { installPanelShell } = await import('../codex-panel-shell.js');
|
|
274
281
|
const file = installPanelShell({ remove: args[2] === 'remove' });
|
|
282
|
+
// install adds the integration by default, so a removal must be
|
|
283
|
+
// remembered or the next upgrade puts it back.
|
|
284
|
+
const cfg = loadConfig();
|
|
285
|
+
cfg.codex = { ...cfg.codex, panelShell: args[2] === 'install' };
|
|
286
|
+
saveConfig(cfg);
|
|
275
287
|
console.log(`Shell integration ${args[2]}: ${file}. Open a new terminal to apply it.`);
|
|
276
288
|
return;
|
|
277
289
|
}
|
|
@@ -319,7 +331,7 @@ export async function run({ args, version }) {
|
|
|
319
331
|
})() : { read: createCodexBudgetReader() };
|
|
320
332
|
if (hasFlag('--once')) {
|
|
321
333
|
const provider = codexBudgetProvider();
|
|
322
|
-
if (provider) { try { readBudget.set(await fetchCodexBudget(provider)); } catch {
|
|
334
|
+
if (provider) { try { readBudget.set(await fetchCodexBudget(provider)); } catch (e) { debug('codex:budget', e); } }
|
|
323
335
|
}
|
|
324
336
|
const { createInlinePanelResizer } = await import('../codex-panel-runner.js');
|
|
325
337
|
const rawInterval = getArg('--interval');
|
|
@@ -335,7 +347,7 @@ export async function run({ args, version }) {
|
|
|
335
347
|
readCachePolicy: hasFlag('--once') ? async (session) => {
|
|
336
348
|
const { readCodexCachePolicy } = await import('../codex-cache-policy.js');
|
|
337
349
|
try { return readCodexCachePolicy(session) || await refreshCodexCachePolicy(session); }
|
|
338
|
-
catch { return null; }
|
|
350
|
+
catch (e) { debug('codex:cache-policy', e); return null; }
|
|
339
351
|
} : createCodexCachePolicyReader(), hasFlag }),
|
|
340
352
|
interval: interval * 1000, once: hasFlag('--once'),
|
|
341
353
|
color: !hasFlag('--no-color') && !process.env.NO_COLOR && !!process.stdout.isTTY, version, compact: hasFlag('--compact'),
|
|
@@ -372,22 +384,30 @@ export async function run({ args, version }) {
|
|
|
372
384
|
}
|
|
373
385
|
const out = await codexHookOutput(getArg('--event'), payload);
|
|
374
386
|
if (out) console.log(JSON.stringify(out));
|
|
375
|
-
} catch { /* Hook failures must not break the agent loop. */ }
|
|
387
|
+
} catch (e) { debug('codex:hook', e); /* Hook failures must not break the agent loop. */ }
|
|
376
388
|
return;
|
|
377
389
|
}
|
|
378
390
|
if (cmd === 'install') {
|
|
379
391
|
const cfg = loadConfig();
|
|
380
|
-
const
|
|
392
|
+
const { setupCodexPanel, describePanelSetup } = await import('../codex-panel-setup.js');
|
|
393
|
+
const panel = setupCodexPanel({ config: cfg || {}, noPanel: hasFlag('--no-panel') });
|
|
394
|
+
const panelAuto = panel.panelAuto;
|
|
381
395
|
const hook = configureCodexHooks({ panelAuto });
|
|
382
396
|
console.log(`Codex hooks: ${hook.file} (${hook.action})`);
|
|
397
|
+
for (const line of describePanelSetup(panel)) console.log(`Codex ${line}`);
|
|
383
398
|
if (process.env.CTS_NO_HARNESS !== '1') {
|
|
384
399
|
const h = initCodexHarness({ root, scope: 'global' });
|
|
385
400
|
console.log(`Codex harness: ${h.file}`);
|
|
386
401
|
}
|
|
402
|
+
try {
|
|
403
|
+
const { preparePresetRatchet } = await import('../preset-ratchet.js');
|
|
404
|
+
const p = preparePresetRatchet({ agent: 'codex' });
|
|
405
|
+
console.log(`Codex preset rules: ${p.file} (${p.rules} registered${p.moved ? `, ${p.moved} moved from ratchet.md` : ''})`);
|
|
406
|
+
} catch (e) { debug('codex:preset', e); /* Presets are optional; `seed` still registers them. */ }
|
|
387
407
|
console.log('Open /hooks in Codex and review/trust the Sprag hooks before they can run.');
|
|
388
408
|
console.log(`Panel auto-start: ${panelAuto ? 'registered with the other hooks' : 'off'}. Hook registration is complete; runtime activation is not verified.`);
|
|
389
409
|
console.log('After approval, start or resume Codex and run: sprag panel doctor --agent codex');
|
|
390
|
-
if (
|
|
410
|
+
if (panel.mode === 'window') console.log('Some Codex versions defer SessionStart until the first prompt. For immediate opening on typing codex: sprag panel shell install --agent codex (optional zsh integration).');
|
|
391
411
|
console.log('Existing config.toml, Claude Code settings, and ratchet rules are preserved.');
|
|
392
412
|
console.log('Korean guidance is opt-in: sprag korean on --agent codex');
|
|
393
413
|
return;
|
|
@@ -444,6 +464,15 @@ export async function run({ args, version }) {
|
|
|
444
464
|
const r = harnessListRules(opts);
|
|
445
465
|
console.log(`Codex ratchet [${scope}]: ${r.path}`);
|
|
446
466
|
for (const rule of r.rules) console.log(`#${rule.index} ${rule.text}`);
|
|
467
|
+
if (scope === 'global') {
|
|
468
|
+
const { acceptedGlobalPresets, presetRatchetPath } = await import('../preset-ratchet.js');
|
|
469
|
+
const presets = acceptedGlobalPresets({ agent: 'codex' });
|
|
470
|
+
if (presets.length) {
|
|
471
|
+
console.log(`Codex preset rules [global]: ${presetRatchetPath({ agent: 'codex' })}`);
|
|
472
|
+
for (const p of presets) console.log(`${p.id} ${p.text}`);
|
|
473
|
+
console.log('Remove a preset with: sprag seed skip <id> --agent codex');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
447
476
|
} else if (sub === 'rm') {
|
|
448
477
|
const raw = args[2];
|
|
449
478
|
// Locate the index even when scope flags precede it.
|
|
@@ -486,7 +515,8 @@ export async function run({ args, version }) {
|
|
|
486
515
|
console.log(`Cache: ${doc2md.cacheDir()}`);
|
|
487
516
|
try {
|
|
488
517
|
for (const hook of codexHookStatus().filter((h) => ['PreToolUse', 'UserPromptSubmit'].includes(h.event))) console.log(`${hook.event}: ${hook.registered ? 'registered' : 'missing/outdated'}`);
|
|
489
|
-
} catch {
|
|
518
|
+
} catch (e) {
|
|
519
|
+
debug('codex:hook-status', e);
|
|
490
520
|
console.log('Hooks: cannot read Codex hooks.json; file left unchanged. Run sprag doctor --agent codex for diagnostics.');
|
|
491
521
|
}
|
|
492
522
|
console.log('Prompt conversion, supported reads, and document/cache write guards require trusted hooks. Review in Codex /hooks.');
|
package/src/commands/harness.js
CHANGED
|
@@ -337,7 +337,7 @@ export async function run({ args, hasFlag }) {
|
|
|
337
337
|
// rules, and a project ratchet inherits global anyway). Strictly opt-in:
|
|
338
338
|
// install/init never auto-injects rules.
|
|
339
339
|
const scope = parseHarnessScope(args.slice(2), 'global');
|
|
340
|
-
const r = harnessPull({ scope });
|
|
340
|
+
const r = await harnessPull({ scope });
|
|
341
341
|
console.log(`Curated preset rules → ${r.path} [${r.scope}]`);
|
|
342
342
|
if (r.added.length) {
|
|
343
343
|
console.log(`✅ ${r.added.length}/${r.presets} rule(s) registered:`);
|
|
@@ -366,7 +366,18 @@ export async function run({ args, hasFlag }) {
|
|
|
366
366
|
console.log('');
|
|
367
367
|
};
|
|
368
368
|
if (wantProject) print('project');
|
|
369
|
-
if (wantGlobal)
|
|
369
|
+
if (wantGlobal) {
|
|
370
|
+
print('global');
|
|
371
|
+
// Presets live in their own tool-owned file and are removed by id,
|
|
372
|
+
// so they are listed apart from the numbered rules `rm <N>` acts on.
|
|
373
|
+
const { acceptedGlobalPresets, presetRatchetPath } = await import('../preset-ratchet.js');
|
|
374
|
+
const presets = acceptedGlobalPresets();
|
|
375
|
+
if (presets.length) {
|
|
376
|
+
console.log(`📦 Preset rules [global] — ${presetRatchetPath()}\n`);
|
|
377
|
+
for (const p of presets) console.log(` ${p.id} ${p.text}`);
|
|
378
|
+
console.log('\nRemove a preset with: sprag seed skip <id>\n');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
370
381
|
console.log('Remove with: sprag harness rm [--global|--project] <N>');
|
|
371
382
|
console.log('Archive in bulk: sprag harness prune [--global] [--tag <t>] [--older-than <months>] [--dry-run]');
|
|
372
383
|
return;
|