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.
Files changed (45) hide show
  1. package/README.ko.md +17 -0
  2. package/README.md +23 -0
  3. package/bin/cli.js +51 -3
  4. package/docs/CODEX.ko.md +69 -6
  5. package/docs/CODEX.md +84 -12
  6. package/package.json +1 -1
  7. package/src/agent.js +9 -0
  8. package/src/caps-cache.js +3 -3
  9. package/src/codex-brief.js +4 -5
  10. package/src/codex-cache-policy.js +3 -3
  11. package/src/codex-delegation.js +114 -15
  12. package/src/codex-doc2md-ledger.js +3 -3
  13. package/src/codex-doctor.js +2 -2
  14. package/src/codex-hooks.js +4 -5
  15. package/src/codex-installer.js +3 -3
  16. package/src/codex-ledger.js +25 -10
  17. package/src/codex-panel-session.js +3 -3
  18. package/src/codex-panel-state.js +51 -5
  19. package/src/codex-route-scan.js +12 -7
  20. package/src/commands/brief.js +1 -1
  21. package/src/commands/codex-delegate.js +81 -6
  22. package/src/commands/codex.js +8 -2
  23. package/src/commands/compact-window.js +6 -2
  24. package/src/commands/install.js +4 -1
  25. package/src/commands/korean.js +69 -3
  26. package/src/commands/route-scan.js +6 -4
  27. package/src/commands/saved.js +40 -1
  28. package/src/ctx-window-cache.js +14 -0
  29. package/src/formatters/statusline.js +167 -18
  30. package/src/harness-analyzer.cjs +8 -2
  31. package/src/harness.js +32 -23
  32. package/src/installer.js +52 -0
  33. package/src/model-alias.js +3 -3
  34. package/src/model-rules.js +14 -9
  35. package/src/preset-ratchet.js +3 -3
  36. package/src/reply-language.cjs +167 -0
  37. package/src/route-inject.js +57 -8
  38. package/src/route-scan.js +51 -11
  39. package/src/savings-ledger.js +3 -2
  40. package/src/seed-rules.js +32 -23
  41. package/src/session-cache.js +12 -4
  42. package/src/shared-model-rules.js +73 -0
  43. package/src/state-file.js +99 -2
  44. package/src/statusline-data.js +97 -0
  45. 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 };
@@ -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 { categorize, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
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 cat = categorize(t, null);
102
- if (!cat) return null;
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
- const matched = all.filter((r) => r && r.category === cat.id);
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
- const t2 = usable.find((r) => r.tier === 'T2');
125
- const t1 = usable.find((r) => r.tier === 'T1');
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
- if (text.length >= PASTE_MIN_LEN) return CATEGORIES.find((c) => c.id === 'paste');
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
- const bumpDelegated = (key, run, saved) => {
603
- const d = delegatedStats.get(key) || { runs: 0, errRuns: 0, outTokens: 0, savedUsd: 0 };
604
- d.runs += 1;
605
- if (isFailedRun(run)) d.errRuns += 1;
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.
@@ -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, which
5
- * is right for rule-health but cannot answer "how much did routing save this
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
- * "Already covered" is deliberately loose for model presets — any registered
106
- * rule for the same tier and category wins, whatever its scope or agent. A user
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
- // Codex model routing comes from its own measured route-scan candidates;
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
- for (const p of agent === 'codex' ? [] : modelPresets()) {
132
- if (decided[p.id]) continue;
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: composeRuleText(modelRuleBaseText(p, lang), { budget: p.budget }, lang),
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
- const written = syncAllFiles();
229
- recordDecision(id, 'accepted', { scope }, { agent });
230
- return { id, kind: 'model', scope, paths: written, rule: composeRuleText(entry.rule, entry, lang) };
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
  }
@@ -18,9 +18,10 @@
18
18
  * and misses return identically shaped objects).
19
19
  */
20
20
 
21
- import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
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
- const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
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
+ }