sprag-cli 3.59.0 → 3.61.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 -0
- package/README.md +23 -0
- package/bin/cli.js +51 -3
- package/docs/CODEX.ko.md +69 -6
- package/docs/CODEX.md +84 -12
- package/package.json +1 -1
- package/src/agent.js +9 -0
- package/src/caps-cache.js +3 -3
- package/src/codex-brief.js +4 -5
- package/src/codex-cache-policy.js +3 -3
- package/src/codex-delegation.js +114 -15
- package/src/codex-doc2md-ledger.js +3 -3
- package/src/codex-doctor.js +2 -2
- package/src/codex-hooks.js +4 -5
- package/src/codex-installer.js +3 -3
- package/src/codex-ledger.js +25 -10
- package/src/codex-panel-session.js +3 -3
- package/src/codex-panel-state.js +51 -5
- package/src/codex-route-scan.js +12 -7
- package/src/commands/brief.js +1 -1
- package/src/commands/codex-delegate.js +81 -6
- package/src/commands/codex.js +8 -2
- package/src/commands/compact-window.js +6 -2
- package/src/commands/install.js +4 -1
- package/src/commands/korean.js +69 -3
- package/src/commands/route-scan.js +6 -4
- package/src/commands/saved.js +40 -1
- package/src/ctx-window-cache.js +14 -0
- package/src/formatters/statusline.js +167 -18
- package/src/harness-analyzer.cjs +8 -2
- package/src/harness.js +32 -23
- package/src/installer.js +52 -0
- package/src/model-alias.js +3 -3
- package/src/model-rules.js +14 -9
- package/src/preset-ratchet.js +3 -3
- package/src/reply-language.cjs +167 -0
- package/src/route-inject.js +57 -8
- package/src/route-scan.js +51 -11
- package/src/savings-ledger.js +3 -2
- package/src/seed-rules.js +32 -23
- package/src/session-cache.js +12 -4
- package/src/shared-model-rules.js +73 -0
- package/src/state-file.js +99 -2
- package/src/statusline-data.js +97 -0
- package/src/update-check.js +46 -6
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* reply-language — catches an English reply to a Korean prompt.
|
|
5
|
+
*
|
|
6
|
+
* The model sometimes answers a Korean prompt in English, typically right
|
|
7
|
+
* after a long stretch of English work (commit messages, release notes). The
|
|
8
|
+
* slip is decidable by counting letters, so it gets a Stop hook instead of
|
|
9
|
+
* another sentence in the guidance.
|
|
10
|
+
*
|
|
11
|
+
* The thresholds were measured on 2,204 Korean-prompt turns from 14 days of
|
|
12
|
+
* real transcripts: 8 flagged, all 8 true positives, no false positives.
|
|
13
|
+
* Loosening any of them reopens false positives on replies that are Korean
|
|
14
|
+
* prose around code and English identifiers, so change them only with a new
|
|
15
|
+
* measurement.
|
|
16
|
+
*
|
|
17
|
+
* CommonJS, no dependencies: this runs on every Stop, so it must stay cheap.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { openSync, readSync, fstatSync, closeSync } = require('node:fs');
|
|
21
|
+
|
|
22
|
+
// Only the tail of the transcript matters, and transcripts grow to hundreds
|
|
23
|
+
// of megabytes. If the last real prompt is further back than this, the turn is
|
|
24
|
+
// a long agentic run and we skip it rather than pay for reading it all.
|
|
25
|
+
const TAIL_BYTES = 2 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
const ENGLISH_REQUESTED = /영어로|영문으로|in English/i;
|
|
28
|
+
|
|
29
|
+
// A user entry that is not something the person typed.
|
|
30
|
+
const NOT_A_PROMPT_PREFIX = ['<command-name', '<local-command', '<command-message', '<task-notification', 'Caveat:'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Strip what is legitimately not prose in the reader's language: injected
|
|
34
|
+
* reminders, code, URLs and paths. Code and paths are Latin by nature, so
|
|
35
|
+
* counting them would flag every Korean reply that quotes a command.
|
|
36
|
+
*/
|
|
37
|
+
function cleanText(t) {
|
|
38
|
+
return String(t ?? '')
|
|
39
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
|
|
40
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
41
|
+
.replace(/`[^`]*`/g, ' ')
|
|
42
|
+
.replace(/https?:\/\/\S+/g, ' ')
|
|
43
|
+
.replace(/(?:~|\.{0,2})?\/[\w.\-/]+/g, ' ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function letterCounts(text) {
|
|
47
|
+
const cleaned = cleanText(text);
|
|
48
|
+
const h = (cleaned.match(/[가-힣]/g) || []).length;
|
|
49
|
+
const l = (cleaned.match(/[A-Za-z]/g) || []).length;
|
|
50
|
+
return { h, l, ratio: h + l === 0 ? 0 : h / (h + l) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isKoreanPrompt(text) {
|
|
54
|
+
const { h, ratio } = letterCounts(text);
|
|
55
|
+
return h >= 4 && ratio >= 0.3;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isEnglishReply(text) {
|
|
59
|
+
const { l, ratio } = letterCounts(text);
|
|
60
|
+
return l >= 80 && ratio < 0.15;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function textBlocks(content) {
|
|
64
|
+
if (typeof content === 'string') return content;
|
|
65
|
+
if (!Array.isArray(content)) return '';
|
|
66
|
+
return content
|
|
67
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
68
|
+
.map((b) => b.text)
|
|
69
|
+
.join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The text of a real user prompt, or null when the entry is a tool result, a
|
|
74
|
+
* meta entry, or machinery posing as a user message.
|
|
75
|
+
*/
|
|
76
|
+
function realPromptText(entry) {
|
|
77
|
+
if (entry.type !== 'user' || entry.isMeta) return null;
|
|
78
|
+
const content = entry.message?.content;
|
|
79
|
+
if (Array.isArray(content) && content.some((b) => b && b.type === 'tool_result')) return null;
|
|
80
|
+
if (typeof content !== 'string' && !Array.isArray(content)) return null;
|
|
81
|
+
const text = textBlocks(content);
|
|
82
|
+
const head = text.trimStart();
|
|
83
|
+
if (NOT_A_PROMPT_PREFIX.some((p) => head.startsWith(p))) return null;
|
|
84
|
+
if (text.includes('[Subagent hand-back]') || text.includes('<agent-message')) return null;
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The last turn in a list of JSONL lines: the final real user prompt and the
|
|
90
|
+
* assistant text the user sees after it. A tool_result resets the reply,
|
|
91
|
+
* because text written before a tool call is narration, not the answer.
|
|
92
|
+
* Returns null when there is no prompt. Unparsable lines and sidechain
|
|
93
|
+
* (subagent) entries are skipped.
|
|
94
|
+
*/
|
|
95
|
+
function lastTurn(lines) {
|
|
96
|
+
let prompt = null;
|
|
97
|
+
let reply = [];
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
if (!line) continue;
|
|
100
|
+
let entry;
|
|
101
|
+
try {
|
|
102
|
+
entry = JSON.parse(line);
|
|
103
|
+
} catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!entry || typeof entry !== 'object' || entry.isSidechain) continue;
|
|
107
|
+
if (entry.type === 'user') {
|
|
108
|
+
const content = entry.message?.content;
|
|
109
|
+
if (Array.isArray(content) && content.some((b) => b && b.type === 'tool_result')) {
|
|
110
|
+
reply = [];
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const text = realPromptText(entry);
|
|
114
|
+
if (text !== null) {
|
|
115
|
+
prompt = text;
|
|
116
|
+
reply = [];
|
|
117
|
+
}
|
|
118
|
+
} else if (entry.type === 'assistant') {
|
|
119
|
+
const text = textBlocks(entry.message?.content);
|
|
120
|
+
if (text) reply.push(text);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (prompt === null) return null;
|
|
124
|
+
return { prompt, reply: reply.join('\n') };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readTailLines(path) {
|
|
128
|
+
const fd = openSync(path, 'r');
|
|
129
|
+
try {
|
|
130
|
+
const size = fstatSync(fd).size;
|
|
131
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
132
|
+
const buf = Buffer.alloc(size - start);
|
|
133
|
+
let got = 0;
|
|
134
|
+
while (got < buf.length) {
|
|
135
|
+
const n = readSync(fd, buf, got, buf.length - got, start + got);
|
|
136
|
+
if (n <= 0) break;
|
|
137
|
+
got += n;
|
|
138
|
+
}
|
|
139
|
+
const lines = buf.subarray(0, got).toString('utf8').split('\n');
|
|
140
|
+
// A window that starts mid-file begins inside a line, so drop that stub.
|
|
141
|
+
if (start > 0) lines.shift();
|
|
142
|
+
return lines;
|
|
143
|
+
} finally {
|
|
144
|
+
closeSync(fd);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* null when the reply is fine (or cannot be judged), otherwise the measured
|
|
150
|
+
* ratios so a caller can report them. Never throws.
|
|
151
|
+
*/
|
|
152
|
+
function checkReplyLanguage(transcriptPath) {
|
|
153
|
+
try {
|
|
154
|
+
if (!transcriptPath) return null;
|
|
155
|
+
const turn = lastTurn(readTailLines(transcriptPath));
|
|
156
|
+
if (!turn) return null;
|
|
157
|
+
if (ENGLISH_REQUESTED.test(turn.prompt)) return null;
|
|
158
|
+
if (!isKoreanPrompt(turn.prompt) || !isEnglishReply(turn.reply)) return null;
|
|
159
|
+
const u = letterCounts(turn.prompt);
|
|
160
|
+
const r = letterCounts(turn.reply);
|
|
161
|
+
return { userHangulRatio: u.ratio, replyHangulRatio: r.ratio, replyLatin: r.l };
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { checkReplyLanguage, cleanText, isKoreanPrompt, isEnglishReply, lastTurn };
|
package/src/route-inject.js
CHANGED
|
@@ -22,11 +22,18 @@
|
|
|
22
22
|
* - the text trips EDIT_RE (implement, add, fix …): editing is not delegable
|
|
23
23
|
* however much the sentence also says "install" or "check"
|
|
24
24
|
* - no registered rule covers that category
|
|
25
|
+
* - the request is an explanation question ("어떻게 동작해", "뭐야", "how does
|
|
26
|
+
* …") whose only category evidence is one weight-1 keyword. "sprag 설치만으로
|
|
27
|
+
* … 도구에서 어떻게 쓰이는거지?" scored run on the bare word 설치 and was told
|
|
28
|
+
* to hand a question about how the tool behaves to a command-running haiku.
|
|
29
|
+
* Answering it takes the main model's knowledge, not a command. The read
|
|
30
|
+
* category is exempt: explaining is what it is for.
|
|
25
31
|
* A hint that fires on the wrong request costs more than one that stays quiet.
|
|
26
32
|
*/
|
|
27
33
|
|
|
28
|
-
import {
|
|
34
|
+
import { categorizeScored, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
|
|
29
35
|
import { loadModelRules, budgetCapPhrase, ruleBudget } from './model-rules.js';
|
|
36
|
+
import { sharedProjectRoots, realProjectRoot, scopeRank } from './shared-model-rules.js';
|
|
30
37
|
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
31
38
|
import { userLanguage } from './config.js';
|
|
32
39
|
import { aliasForRole, resolveModelAlias } from './model-alias.js';
|
|
@@ -44,6 +51,34 @@ export function looksPasted(text) {
|
|
|
44
51
|
/** Shortest text worth classifying; below this a prompt is an ack, not a task. */
|
|
45
52
|
const MIN_LEN = 8;
|
|
46
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A question that asks for an explanation ("어떻게 쓰이는거지?", "뭐야", "how
|
|
56
|
+
* does it work"). "왜" is not here: ESCALATE_RE already routes it to the top
|
|
57
|
+
* tier. Kept to the shapes that ask HOW or WHAT something is — a bare "?" or a
|
|
58
|
+
* sentence-final "-지?" is too common in ordinary commands to count.
|
|
59
|
+
*/
|
|
60
|
+
const EXPLAIN_Q_RE = /어떻게\s*(?:쓰|사용|동작|작동|해야|하면|하는|하지|하죠|되는\s*(?:거|건|걸|것))|어떤\s*식으로|어떨\s*때|뭐야|뭐지|무엇|무슨\s*(?:의미|뜻)|\bhow\s+(?:does|do)\b|\bwhat\s+(?:is|does)\b/i;
|
|
61
|
+
/**
|
|
62
|
+
* "어떻게 돼 / 됐 / 되고 있" asks for the current state of something, which is
|
|
63
|
+
* a check request however it is phrased. EXPLAIN_Q_RE does not list these, and
|
|
64
|
+
* this guard keeps it that way if "어떻게 되는 거" is ever matched next to one.
|
|
65
|
+
*/
|
|
66
|
+
const STATE_Q_RE = /어떻게\s*(?:돼|됐|됬|되어\s*있|되고\s*있)/;
|
|
67
|
+
/** The category rests on exactly one weight-1 keyword. */
|
|
68
|
+
const WEAK_SCORE = 1;
|
|
69
|
+
/**
|
|
70
|
+
* The category whose job is to explain. Its weight-1 keywords (설명, 알려줘,
|
|
71
|
+
* 뭐야 …) share one pattern, so a question shape adds no evidence against it,
|
|
72
|
+
* and the registered rule names "4번 보정이 뭐야" as its own example.
|
|
73
|
+
*/
|
|
74
|
+
const EXPLAINING_CATEGORY = 'read';
|
|
75
|
+
|
|
76
|
+
/** True for a question that asks how something works or what it is. */
|
|
77
|
+
export function isExplainQuestion(text) {
|
|
78
|
+
const t = String(text || '');
|
|
79
|
+
return EXPLAIN_Q_RE.test(t) && !STATE_Q_RE.test(t);
|
|
80
|
+
}
|
|
81
|
+
|
|
47
82
|
/**
|
|
48
83
|
* Price rank of whatever model is answering this session, or null when it
|
|
49
84
|
* cannot be established.
|
|
@@ -87,7 +122,7 @@ export function sessionModelRank({ env = process.env, transcriptPath = null, sna
|
|
|
87
122
|
* model filters nothing.
|
|
88
123
|
* @returns {{cat: object, t2: object|undefined, t1: object|undefined}|null}
|
|
89
124
|
*/
|
|
90
|
-
export function routeMatch(text, { rules, sessionRank = null } = {}) {
|
|
125
|
+
export function routeMatch(text, { rules, sessionRank = null, root } = {}) {
|
|
91
126
|
const t = String(text || '').trim();
|
|
92
127
|
if (t.length < MIN_LEN) return null;
|
|
93
128
|
// Judgement and irreversible work stay on the top tier. This check comes
|
|
@@ -98,8 +133,14 @@ export function routeMatch(text, { rules, sessionRank = null } = {}) {
|
|
|
98
133
|
// delegable, and with no tool calls yet only the wording can say so.
|
|
99
134
|
if (EDIT_RE.test(t)) return null;
|
|
100
135
|
|
|
101
|
-
const
|
|
102
|
-
if (!
|
|
136
|
+
const scored = categorizeScored(t, null);
|
|
137
|
+
if (!scored) return null;
|
|
138
|
+
const cat = scored.cat;
|
|
139
|
+
// A category carried by one weak keyword is a guess, and an explanation
|
|
140
|
+
// question is the case where the guess is most often wrong: "설치" inside a
|
|
141
|
+
// question about how a tool works is not a request to install anything.
|
|
142
|
+
// Two or more points (a command, or several keywords) keep the old verdict.
|
|
143
|
+
if (scored.score === WEAK_SCORE && cat.id !== EXPLAINING_CATEGORY && isExplainQuestion(t)) return null;
|
|
103
144
|
// Length alone makes a paste offline, where the tool mix confirms it. At
|
|
104
145
|
// prompt time a long text is as often a written spec as a pasted log, and a
|
|
105
146
|
// spec is not haiku work — so it must also look pasted: many lines, or the
|
|
@@ -108,7 +149,13 @@ export function routeMatch(text, { rules, sessionRank = null } = {}) {
|
|
|
108
149
|
if (cat.id === 'paste' && !looksPasted(t)) return null;
|
|
109
150
|
|
|
110
151
|
const all = rules || loadModelRules().rules || [];
|
|
111
|
-
|
|
152
|
+
// The registry is shared with Codex, so a project rule approved in one
|
|
153
|
+
// project must not route another. A session whose directory is unknown gets
|
|
154
|
+
// global rules only.
|
|
155
|
+
const roots = root ? sharedProjectRoots(root) : [];
|
|
156
|
+
const inScope = (r) => r.scope !== 'project' ||
|
|
157
|
+
(typeof r.targetRoot === 'string' && roots.includes(realProjectRoot(r.targetRoot)));
|
|
158
|
+
const matched = all.filter((r) => r && r.category === cat.id && inScope(r));
|
|
112
159
|
if (!matched.length) return null;
|
|
113
160
|
// A rule only saves anything when its target tier is cheaper than the model
|
|
114
161
|
// reading the hint. A T1 rule states "delegate to model: sonnet", which in a
|
|
@@ -121,8 +168,10 @@ export function routeMatch(text, { rules, sessionRank = null } = {}) {
|
|
|
121
168
|
: matched.filter((r) => worthDelegating(r.tier, sessionRank));
|
|
122
169
|
if (!usable.length) return null;
|
|
123
170
|
|
|
124
|
-
|
|
125
|
-
const
|
|
171
|
+
// The closest scope states the cap, whatever order the registry holds.
|
|
172
|
+
const nearest = [...usable].sort((a, b) => scopeRank(b) - scopeRank(a));
|
|
173
|
+
const t2 = nearest.find((r) => r.tier === 'T2');
|
|
174
|
+
const t1 = nearest.find((r) => r.tier === 'T1');
|
|
126
175
|
return t2 || t1 ? { cat, t2, t1 } : null;
|
|
127
176
|
}
|
|
128
177
|
|
|
@@ -146,7 +195,7 @@ export function matchCaps(match) {
|
|
|
146
195
|
* @returns {string|null} the line to inject, or null to stay quiet
|
|
147
196
|
*/
|
|
148
197
|
export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null, match } = {}) {
|
|
149
|
-
const found = match === undefined ? routeMatch(text, { rules, sessionRank }) : match;
|
|
198
|
+
const found = match === undefined ? routeMatch(text, { rules, sessionRank, root }) : match;
|
|
150
199
|
if (!found) return null;
|
|
151
200
|
const { cat, t2, t1 } = found;
|
|
152
201
|
const ko = lang === 'ko';
|
package/src/route-scan.js
CHANGED
|
@@ -29,6 +29,7 @@ import { modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from '.
|
|
|
29
29
|
import { sessionCost } from './claude-price.js';
|
|
30
30
|
import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
|
|
31
31
|
import { modelRuleBaseText } from './model-rules.js';
|
|
32
|
+
import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
|
|
32
33
|
|
|
33
34
|
// ── Tier bands (docs/TIER_CRITERIA.md §3) ────────────────────────────────
|
|
34
35
|
// T2 (haiku): finished in few calls, tiny output, near-zero mutation, no
|
|
@@ -235,7 +236,21 @@ export function mungeProjectPath(p) {
|
|
|
235
236
|
* id); no behavior AND no keyword hit → null (nothing delegable to name).
|
|
236
237
|
*/
|
|
237
238
|
export function categorize(text, toolCounts) {
|
|
238
|
-
|
|
239
|
+
const scored = categorizeScored(text, toolCounts);
|
|
240
|
+
return scored ? scored.cat : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* categorize() plus the winning category's keyword score, for a caller that
|
|
245
|
+
* must tell a request the keywords claim firmly from one that a single
|
|
246
|
+
* weight-1 word dragged into a category. `score` is 0 when the category came
|
|
247
|
+
* from the paste gate or from the tool-mix fallback, where no keyword won.
|
|
248
|
+
* categorize() delegates here, so the two can never disagree on the category.
|
|
249
|
+
*
|
|
250
|
+
* @returns {{cat: object, score: number}|null}
|
|
251
|
+
*/
|
|
252
|
+
export function categorizeScored(text, toolCounts) {
|
|
253
|
+
if (text.length >= PASTE_MIN_LEN) return { cat: CATEGORIES.find((c) => c.id === 'paste'), score: 0 };
|
|
239
254
|
const pool = behaviorPool(toolCounts);
|
|
240
255
|
const eligible = pool
|
|
241
256
|
? pool.ids.map((id) => CATEGORIES.find((c) => c.id === id))
|
|
@@ -246,8 +261,8 @@ export function categorize(text, toolCounts) {
|
|
|
246
261
|
const s = keywordScore(c, text);
|
|
247
262
|
if (s > bestScore) { best = c; bestScore = s; }
|
|
248
263
|
}
|
|
249
|
-
if (best) return best;
|
|
250
|
-
if (pool?.fallback) return CATEGORIES.find((c) => c.id === pool.fallback);
|
|
264
|
+
if (best) return { cat: best, score: bestScore };
|
|
265
|
+
if (pool?.fallback) return { cat: CATEGORIES.find((c) => c.id === pool.fallback), score: 0 };
|
|
251
266
|
return null;
|
|
252
267
|
}
|
|
253
268
|
|
|
@@ -465,6 +480,35 @@ export function windowBytes(files) {
|
|
|
465
480
|
return total;
|
|
466
481
|
}
|
|
467
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Fold one measured delegation into its aggregate (`stats`: key → { runs,
|
|
485
|
+
* errRuns, outTokens, savedUsd }).
|
|
486
|
+
*
|
|
487
|
+
* The two kinds of figure answer different questions. runs / errRuns / outTokens
|
|
488
|
+
* feed rule-health, which judges whether a rule keeps failing, so they cover the
|
|
489
|
+
* whole scan window whatever `sprag saved reset` did. savedUsd is money the user
|
|
490
|
+
* zeroed on purpose: it counts only runs dated at or after `resetMark` (the
|
|
491
|
+
* routing mark), by the same isBeforeReset the ledger totals use. The date is
|
|
492
|
+
* the one the ledger stamps on its event (end, else start), so a run that was
|
|
493
|
+
* still going when the user reset lands on the same side in both totals. A run
|
|
494
|
+
* with no usable time cannot be shown to postdate the reset, so it is left out
|
|
495
|
+
* too. An unpriced run (non-finite `saved`) adds nothing either way.
|
|
496
|
+
*
|
|
497
|
+
* @param {Map<string, object>} stats
|
|
498
|
+
* @param {string} key "tier|category|project"
|
|
499
|
+
* @param {{startedAt: number|null, endedAt?: number|null, out?: number}} run
|
|
500
|
+
* @param {number|null} saved price difference for this run
|
|
501
|
+
* @param {number|null} [resetMark] ms timestamp of the routing reset, or null
|
|
502
|
+
*/
|
|
503
|
+
export function tallyDelegatedRun(stats, key, run, saved, resetMark = null) {
|
|
504
|
+
const d = stats.get(key) || { runs: 0, errRuns: 0, outTokens: 0, savedUsd: 0 };
|
|
505
|
+
d.runs += 1;
|
|
506
|
+
if (isFailedRun(run)) d.errRuns += 1;
|
|
507
|
+
d.outTokens += run.out || 0;
|
|
508
|
+
if (Number.isFinite(saved) && !isBeforeReset(run.endedAt ?? run.startedAt, resetMark)) d.savedUsd += saved;
|
|
509
|
+
stats.set(key, d);
|
|
510
|
+
}
|
|
511
|
+
|
|
468
512
|
export async function runRouteScan({ days = 14 } = {}) {
|
|
469
513
|
const files = await discoverSessionFiles({ days });
|
|
470
514
|
|
|
@@ -599,14 +643,10 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
599
643
|
const delegatedStats = new Map(); // "tier|category|project" → outcome aggregate
|
|
600
644
|
let unresolvedRuns = 0; // delegated runs dropped for an unpriceable model id
|
|
601
645
|
const unresolvedModels = new Set();
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
d.outTokens += run.out || 0;
|
|
607
|
-
if (Number.isFinite(saved)) d.savedUsd += saved; // unpriced runs add nothing
|
|
608
|
-
delegatedStats.set(key, d);
|
|
609
|
-
};
|
|
646
|
+
// `sprag saved reset routing` zeroes the money, not the rule-health sample:
|
|
647
|
+
// see tallyDelegatedRun. readResetMarks never throws (no file = no reset).
|
|
648
|
+
const routingMark = readResetMarks(userDataDir()).routing;
|
|
649
|
+
const bumpDelegated = (key, run, saved) => tallyDelegatedRun(delegatedStats, key, run, saved, routingMark);
|
|
610
650
|
// Ledger events feed the statusline's weekly/monthly "Routing saved"
|
|
611
651
|
// totals. Keyed by run transcript path so overlapping re-scans upsert the
|
|
612
652
|
// same event instead of double-counting it.
|
package/src/savings-ledger.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* savings-ledger — per-run delegation saving events with timestamps.
|
|
3
3
|
*
|
|
4
|
-
* model-rules.json stores `savedUsd` as a scan-window snapshot per rule
|
|
5
|
-
*
|
|
4
|
+
* model-rules.json stores `savedUsd` as a scan-window snapshot per rule (only
|
|
5
|
+
* the runs dated after the last `sprag saved reset routing`), which is right
|
|
6
|
+
* for rule-health but cannot answer "how much did routing save this
|
|
6
7
|
* week / this month / ever". This ledger keeps one event per subagent run,
|
|
7
8
|
* keyed by the run's transcript path so re-scans over overlapping windows
|
|
8
9
|
* upsert instead of double-counting.
|
package/src/seed-rules.js
CHANGED
|
@@ -25,16 +25,16 @@
|
|
|
25
25
|
|
|
26
26
|
import { readFileSync } from 'node:fs';
|
|
27
27
|
import { CLI_NAME } from './cli-name.js';
|
|
28
|
-
import { join, dirname } from 'node:path';
|
|
28
|
+
import { join, dirname, resolve } from 'node:path';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
30
|
import { createHash } from 'node:crypto';
|
|
31
|
+
import { sharedModelRulesForProject, loadSharedModelRules, sharedProjectRoots, realProjectRoot } from './shared-model-rules.js';
|
|
31
32
|
import { userDataDir } from './paths.js';
|
|
32
33
|
import { userLanguage } from './config.js';
|
|
33
34
|
import { writeStateFile } from './state-file.js';
|
|
34
35
|
import {
|
|
35
36
|
addModelRule,
|
|
36
37
|
composeRuleText,
|
|
37
|
-
loadModelRules,
|
|
38
38
|
modelRuleBaseText,
|
|
39
39
|
syncAllFiles,
|
|
40
40
|
} from './model-rules.js';
|
|
@@ -102,22 +102,12 @@ const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/, '').
|
|
|
102
102
|
* Everything still worth asking about: presets the user has not answered and
|
|
103
103
|
* that are not already covered by a rule they have.
|
|
104
104
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* whose own route-scan already promoted that shape does not need ours, and
|
|
108
|
-
* offering it anyway would read as the tool failing to notice its own state.
|
|
105
|
+
* A shared policy covers its tier and category only within its approved scope.
|
|
106
|
+
* A decision in another project must not suppress this project's offer.
|
|
109
107
|
*/
|
|
110
108
|
export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(), agent = 'claude' } = {}) {
|
|
111
109
|
const { decided } = loadSeedState({ agent });
|
|
112
|
-
|
|
113
|
-
// Claude tier presets name models Codex does not run.
|
|
114
|
-
const registered = agent === 'codex' ? [] : (() => {
|
|
115
|
-
try {
|
|
116
|
-
return loadModelRules().rules;
|
|
117
|
-
} catch {
|
|
118
|
-
return [];
|
|
119
|
-
}
|
|
120
|
-
})();
|
|
110
|
+
const registered = sharedModelRulesForProject(loadSharedModelRules({ includeInactive: true }), root);
|
|
121
111
|
const haveModel = new Set(registered.map((r) => `${r.tier}|${r.category}`));
|
|
122
112
|
|
|
123
113
|
const haveText = new Set();
|
|
@@ -128,16 +118,23 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(),
|
|
|
128
118
|
}
|
|
129
119
|
|
|
130
120
|
const out = [];
|
|
131
|
-
|
|
132
|
-
|
|
121
|
+
const roots = sharedProjectRoots(root);
|
|
122
|
+
for (const p of modelPresets()) {
|
|
123
|
+
const decision = decided[p.id];
|
|
124
|
+
if (decision?.action === 'skipped') continue;
|
|
125
|
+
// An answer recorded before decisions kept targetRoot cannot name its
|
|
126
|
+
// project, so it stays decided everywhere, as it was when it was given.
|
|
127
|
+
if (decision?.action === 'accepted' && (decision.scope !== 'project' || !decision.targetRoot ||
|
|
128
|
+
roots.includes(realProjectRoot(decision.targetRoot)))) continue;
|
|
133
129
|
if (haveModel.has(`${p.tier}|${p.category}`)) continue;
|
|
134
130
|
out.push({
|
|
135
131
|
kind: 'model',
|
|
136
132
|
id: p.id,
|
|
137
133
|
tier: p.tier,
|
|
138
134
|
label: lang === 'ko' ? p.label : p.labelEn,
|
|
139
|
-
agent: p.agent,
|
|
140
|
-
ruleText:
|
|
135
|
+
agent: agent === 'codex' ? `Codex ${p.tier} mapping` : p.agent,
|
|
136
|
+
ruleText: agent === 'codex' ? codexSeedText(p, lang)
|
|
137
|
+
: composeRuleText(modelRuleBaseText(p, lang), { budget: p.budget }, lang),
|
|
141
138
|
preset: p,
|
|
142
139
|
});
|
|
143
140
|
}
|
|
@@ -151,6 +148,13 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(),
|
|
|
151
148
|
return out;
|
|
152
149
|
}
|
|
153
150
|
|
|
151
|
+
function codexSeedText(p, lang) {
|
|
152
|
+
const text = lang === 'ko'
|
|
153
|
+
? `"${p.label}" 유형의 ${p.tier === 'T2' ? '단순' : '중간 난도'} 작업은 ${p.tier}에 위임합니다. 실행 모델은 Codex 매핑에서 정합니다.`
|
|
154
|
+
: `${p.tier === 'T2' ? 'Simple' : 'Moderate'} "${p.labelEn}" tasks use ${p.tier}. The execution model comes from the Codex mapping.`;
|
|
155
|
+
return composeRuleText(text, p, lang);
|
|
156
|
+
}
|
|
157
|
+
|
|
154
158
|
/** One pending seed by id, or null. Ids are matched exactly. */
|
|
155
159
|
export function findSeed(id, opts = {}) {
|
|
156
160
|
return pendingSeeds(opts).find((s) => s.id === id) || null;
|
|
@@ -222,12 +226,16 @@ export async function acceptSeed(id, { scope, root = findProjectRoot(), lang = u
|
|
|
222
226
|
// Marks the rule as seeded rather than measured, so the rendered file does
|
|
223
227
|
// not report someone else's recurrence count as this user's evidence.
|
|
224
228
|
origin: 'preset',
|
|
229
|
+
approvedBy: agent,
|
|
225
230
|
promotedAt: today,
|
|
226
231
|
lastSeen: today,
|
|
227
232
|
});
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
233
|
+
// Codex-first installs need only the registry. Claude renders its view when
|
|
234
|
+
// it is installed or starts a session, without creating .claude here.
|
|
235
|
+
const written = agent === 'codex' ? [] : syncAllFiles();
|
|
236
|
+
recordDecision(id, 'accepted', { scope, targetRoot: scope === 'project' ? resolve(root) : null }, { agent });
|
|
237
|
+
return { id, kind: 'model', scope, paths: written,
|
|
238
|
+
rule: agent === 'codex' ? codexSeedText(p, lang) : composeRuleText(entry.rule, entry, lang) };
|
|
231
239
|
}
|
|
232
240
|
|
|
233
241
|
/**
|
|
@@ -277,13 +285,14 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
|
|
|
277
285
|
// Codex hook text stays English; the rule text itself follows the user's language.
|
|
278
286
|
const cmd = (s) => `${CLI_NAME} seed ${s} --agent codex`;
|
|
279
287
|
return [
|
|
280
|
-
`[Sprag seed] ${pending.length} recommended Codex ratchet rule(s) from the bundled presets are not registered yet.`,
|
|
288
|
+
`[Sprag seed] ${pending.length} recommended shared delegation and Codex ratchet rule(s) from the bundled presets are not registered yet.`,
|
|
281
289
|
'Ask in your first reply, after answering any request the user opened with. Offer these choices in this order:',
|
|
282
290
|
` 1. Register all, globally: ${cmd('accept all --global')}`,
|
|
283
291
|
` 2. Register all, this project only: ${cmd('accept all --project')}`,
|
|
284
292
|
' 3. Decide one at a time: ask about each rule below and run its command as soon as they answer',
|
|
285
293
|
` 4. Register none: ${cmd('skip all')} (never offered again)`,
|
|
286
294
|
'Always confirm the scope with the user before running an accept command.',
|
|
295
|
+
'Delegation policies are shared across Claude and Codex; approval retains the chosen scope. Codex execution also requires delegate on and explicit tier mappings: sprag delegate shared status --agent codex. Never assume Claude model names are Codex models.',
|
|
287
296
|
...pending.map((s) => ` [${s.id}] ${s.ruleText}`),
|
|
288
297
|
].join('\n');
|
|
289
298
|
}
|
package/src/session-cache.js
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
* and misses return identically shaped objects).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFileSync,
|
|
21
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
22
22
|
import { join } from 'node:path';
|
|
23
23
|
import { userDataDir } from './paths.js';
|
|
24
|
+
import { writeViaTmp, runHousekeeping, sweepStaleTmp } from './state-file.js';
|
|
24
25
|
import { debug } from './debug.js';
|
|
25
26
|
|
|
26
27
|
const CACHE_PATH = join(userDataDir(), 'session-cache.json');
|
|
@@ -185,12 +186,19 @@ export function saveCache(cache) {
|
|
|
185
186
|
for (const [path, e] of Object.entries(cache.entries)) {
|
|
186
187
|
if (e && typeof e.mtimeMs === 'number' && e.mtimeMs >= cutoff) entries[path] = e;
|
|
187
188
|
}
|
|
188
|
-
|
|
189
|
-
writeFileSync(tmp, JSON.stringify({ version: CACHE_VERSION, entries }) + '\n');
|
|
190
|
-
renameSync(tmp, CACHE_PATH);
|
|
189
|
+
writeViaTmp(`${CACHE_PATH}.${process.pid}.tmp`, CACHE_PATH, JSON.stringify({ version: CACHE_VERSION, entries }) + '\n');
|
|
191
190
|
} catch (e) {
|
|
192
191
|
debug('session-cache:save', e); // best-effort — never block a report
|
|
193
192
|
}
|
|
193
|
+
// This runs on nearly every statusline render, so it is the natural place to
|
|
194
|
+
// notice temp files that killed processes left in the data directory. The
|
|
195
|
+
// sweep itself runs at most once a day (see runHousekeeping).
|
|
196
|
+
try {
|
|
197
|
+
const dir = userDataDir();
|
|
198
|
+
runHousekeeping(dir, { 'stale-tmp': () => sweepStaleTmp(dir) });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
debug('session-cache:housekeeping', e);
|
|
201
|
+
}
|
|
194
202
|
}
|
|
195
203
|
|
|
196
204
|
export function sessionCachePath() {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { loadModelRules, ruleBudget } from './model-rules.js';
|
|
4
|
+
import { findProjectRoot } from './harness.js';
|
|
5
|
+
|
|
6
|
+
export const SHARED_RULE_TIERS = ['T2', 'T1'];
|
|
7
|
+
const CATEGORIES = new Set(['paste', 'translate', 'explore', 'read', 'check', 'run']);
|
|
8
|
+
|
|
9
|
+
/** A root as a real path, so a symlinked checkout or macOS /var names one directory once. */
|
|
10
|
+
export function realProjectRoot(path) {
|
|
11
|
+
try { return realpathSync(path); } catch { return resolve(path); }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every root either agent assigns to a working directory. Claude stops at
|
|
16
|
+
* CLAUDE.md and Codex at AGENTS.md, so a subdirectory holding only one of them
|
|
17
|
+
* gets a different root from each agent for the same project.
|
|
18
|
+
*/
|
|
19
|
+
export function sharedProjectRoots(start) {
|
|
20
|
+
const from = start || process.cwd();
|
|
21
|
+
return [...new Set(['claude', 'codex'].map((agent) => realProjectRoot(findProjectRoot(from, { agent }))))];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Read a policy view of the existing registry, never its model roles or statistics. */
|
|
25
|
+
export function loadSharedModelRules({ includeInactive = false, ...opts } = {}) {
|
|
26
|
+
return loadModelRules(opts).rules.flatMap((r) => {
|
|
27
|
+
if (!r || !(r.status === 'active' || (includeInactive && ['off', 'review'].includes(r.status))) ||
|
|
28
|
+
!SHARED_RULE_TIERS.includes(r.tier) || !CATEGORIES.has(r.category) ||
|
|
29
|
+
!['global', 'project'].includes(r.scope)) return [];
|
|
30
|
+
if (r.scope === 'project' && (typeof r.targetRoot !== 'string' || !isAbsolute(r.targetRoot))) return [];
|
|
31
|
+
const targetRoot = r.scope === 'project' ? realProjectRoot(r.targetRoot) : null;
|
|
32
|
+
const budget = ruleBudget(r);
|
|
33
|
+
return [{ category: r.category, tier: r.tier, scope: r.scope, targetRoot,
|
|
34
|
+
sharedSignature: typeof r.signature === 'string' && r.signature.length <= 512 ? r.signature
|
|
35
|
+
: `${r.tier}|${r.category}|${targetRoot || '*'}`,
|
|
36
|
+
budget: {
|
|
37
|
+
calls: Number.isSafeInteger(budget?.calls) && budget.calls > 0 ? budget.calls : null,
|
|
38
|
+
out: Number.isSafeInteger(budget?.out) && budget.out > 0 ? budget.out : null,
|
|
39
|
+
} }];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How close a rule's scope is to the working directory: a project rule ranks
|
|
45
|
+
* by the depth of its root, so in a nested project the inner root wins, and
|
|
46
|
+
* any project rule outranks a global one. Registry order never decides.
|
|
47
|
+
*/
|
|
48
|
+
export function scopeRank(rule) {
|
|
49
|
+
return rule.scope === 'project' && typeof rule.targetRoot === 'string' ? realProjectRoot(rule.targetRoot).length : -1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One policy per category and tier, the closest scope first (see scopeRank).
|
|
54
|
+
* `start` is the working directory when known; a project policy applies when
|
|
55
|
+
* its root is the root either agent assigns to that directory.
|
|
56
|
+
*/
|
|
57
|
+
export function sharedModelRulesForProject(rules, start) {
|
|
58
|
+
const roots = new Set(sharedProjectRoots(start));
|
|
59
|
+
const chosen = new Map();
|
|
60
|
+
for (const rule of rules) {
|
|
61
|
+
if (rule.scope !== 'global' && !roots.has(rule.targetRoot)) continue;
|
|
62
|
+
const key = `${rule.category}|${rule.tier}`;
|
|
63
|
+
if (!chosen.has(key) || scopeRank(rule) > scopeRank(chosen.get(key))) chosen.set(key, rule);
|
|
64
|
+
}
|
|
65
|
+
return [...chosen.values()];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function sharedRuleBounds(rule) {
|
|
69
|
+
const caps = [];
|
|
70
|
+
if (rule.budget?.calls) caps.push(`${rule.budget.calls} tool calls`);
|
|
71
|
+
if (rule.budget?.out) caps.push(`${rule.budget.out} output tokens`);
|
|
72
|
+
return caps.length ? `Caller-approved cap: ${caps.join(' / ')}. Stop and report partial work before exceeding it.` : '';
|
|
73
|
+
}
|