argus-decision-mcp 1.2.0 → 1.3.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 (82) hide show
  1. package/README.md +44 -60
  2. package/SECURITY.md +3 -3
  3. package/dist/a0/account-connect.js +180 -0
  4. package/dist/a0/account-credentials.js +86 -0
  5. package/dist/index.js +35 -1
  6. package/dist/lib/ambient-elicit.js +225 -0
  7. package/dist/lib/argus-dir.js +26 -8
  8. package/dist/lib/calendar.js +2 -2
  9. package/dist/lib/due-note.js +3 -3
  10. package/dist/lib/elicit.js +13 -1
  11. package/dist/lib/ledger-replay.js +19 -3
  12. package/dist/lib/locale-mismatch.js +52 -0
  13. package/dist/lib/locale.js +81 -10
  14. package/dist/lib/localize-result.js +120 -0
  15. package/dist/lib/localized-message.js +13 -0
  16. package/dist/lib/premises.js +0 -2
  17. package/dist/lib/push-account.js +4 -3
  18. package/dist/lib/review/extract-core.js +116 -0
  19. package/dist/lib/review/extract-file-node.js +145 -29
  20. package/dist/lib/review/index.js +1 -1
  21. package/dist/lib/review/ingest.js +84 -20
  22. package/dist/lib/review/lenses.js +18 -0
  23. package/dist/lib/review/prompts.js +199 -22
  24. package/dist/lib/review/reviewability.js +8 -7
  25. package/dist/lib/review/routing.js +38 -17
  26. package/dist/lib/review-path.js +2 -2
  27. package/dist/lib/spine.js +13 -21
  28. package/dist/lib/state-machine.js +12 -12
  29. package/dist/lib/surfaces.js +90 -94
  30. package/dist/lib/tool-presentation.js +35 -0
  31. package/dist/resources.js +52 -14
  32. package/dist/server.js +85 -31
  33. package/dist/tools/candidates.js +3 -3
  34. package/dist/tools/check-in.js +24 -12
  35. package/dist/tools/errors.js +17 -3
  36. package/dist/tools/index.js +34 -2
  37. package/dist/tools/init-config.js +39 -11
  38. package/dist/tools/open-decision.js +58 -25
  39. package/dist/tools/premises.js +18 -18
  40. package/dist/tools/public-tools.js +382 -0
  41. package/dist/tools/recall.js +99 -24
  42. package/dist/tools/recheck.js +3 -3
  43. package/dist/tools/review.js +133 -29
  44. package/dist/tools/seal.js +62 -26
  45. package/dist/tools/semantic-record.js +225 -0
  46. package/dist/tools/settle.js +49 -8
  47. package/dist/tools/sync.js +2 -2
  48. package/dist/tools/tool-types.js +174 -0
  49. package/dist/tools/watch.js +1 -1
  50. package/dist/v2/bridge.js +2 -2
  51. package/dist/v2/brief.js +1 -1
  52. package/dist/v2/candidate-capture.js +150 -0
  53. package/dist/v2/capture-cli.js +72 -0
  54. package/dist/v2/capture-runtime.js +73 -0
  55. package/dist/v2/connection-io.js +47 -0
  56. package/dist/v2/connection.js +93 -0
  57. package/dist/v2/evidence.js +5 -1
  58. package/dist/v2/harvest.js +26 -51
  59. package/dist/v2/lifecycle-cli.js +50 -0
  60. package/dist/v2/lifecycle.js +298 -2
  61. package/dist/v2/logbook.js +14 -14
  62. package/dist/v2/mirror.js +2 -2
  63. package/dist/v2/queue.js +71 -12
  64. package/dist/v2/v1-reader.js +6 -3
  65. package/dist/v3/fixtures/dkk-corpus.js +55 -0
  66. package/dist/v3/fixtures/p5-measurement-plan.js +42 -0
  67. package/dist/v3/index.js +5 -0
  68. package/dist/v3/legacy-v2.js +169 -0
  69. package/dist/v3/p5-gate.js +123 -0
  70. package/dist/v3/reducer.js +290 -0
  71. package/dist/v3/store.js +133 -0
  72. package/dist/v3/types.js +193 -0
  73. package/dist/v4/index.js +5 -0
  74. package/dist/v4/reducer.js +361 -0
  75. package/dist/v4/relation-validation.js +40 -0
  76. package/dist/v4/shadow.js +22 -0
  77. package/dist/v4/types.js +326 -0
  78. package/dist/v4/watch.js +18 -0
  79. package/package.json +6 -3
  80. package/dist/lib/discipline.js +0 -42
  81. package/dist/prompts.js +0 -72
  82. package/snippets/claude-code-watch.md +0 -47
@@ -0,0 +1,225 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { elicit, canElicit } from './elicit.js';
4
+ import { replayLedger } from './ledger-replay.js';
5
+ import { resolveToday } from './resolve-today.js';
6
+ import { resolveResponseLocale } from './surfaces.js';
7
+ import { resolveToolArgusDir } from './argus-dir.js';
8
+ import { configPath } from './layout.js';
9
+ import { atomicWriteJson } from './atomic-write.js';
10
+ import { logError } from './log.js';
11
+ import { sanitizeLine } from '../v2/sanitize.js';
12
+ /**
13
+ * Out-of-band ambient elicitation — the MCP-side firing gate (창업자 컨셉
14
+ * 2026-07-15, 스파이크 spikes/ambient-elicit/ 가 프로토콜 층을 실증).
15
+ *
16
+ * The wire is bidirectional: `elicitation/create` is a server→client request
17
+ * and nothing ties it to an in-flight tool call. So after a tool call ends and
18
+ * the user goes back to their main work, THIS module may ask them the one due
19
+ * question — the wait becomes judgment time, no plugin required.
20
+ *
21
+ * Gate BEFORE form (spine mirror clause — don't judge WHETHER to intervene in
22
+ * the user's stead; default is restraint):
23
+ *
24
+ * arm-time — host declared elicitation? not already spent? then debounce:
25
+ * each tool call re-arms one quiet-delay timer (fire only when
26
+ * Argus goes quiet — mid-conversation is not the wait).
27
+ * fire-time — EVERYTHING re-checked: capability, `ambient_mute: true` in
28
+ * config.yaml (the SAME escape hatch as the due-note tail — one
29
+ * mute, not two), cross-session cooldown (4h, state file), and
30
+ * due recomputed from the ledger (zero due = silence).
31
+ * budget — at most ONE ask per server process (one question per sitting);
32
+ * an argus_check_in call spends the budget (the user just saw
33
+ * their dues — asking again in a minute is nagging, the same
34
+ * principle as due-note's SKIP_TOOLS mark).
35
+ * decline — is an answer. Nothing written, budget stays spent, the state
36
+ * file's cooldown keeps the next session from insta-re-asking.
37
+ *
38
+ * Form (spine §9.2): only the settlement outcome — the ONE spine-SAFE
39
+ * structured pick (reality, not a verdict; same enum as argus_settle's
40
+ * in-band elicit). Premise re-checks and open questions are deliberately NOT
41
+ * asked out-of-band in v1: their honest form is free text, and free text
42
+ * without conversational context invites fabricated-feeling answers. The
43
+ * recording brain is NOT duplicated: an accepted answer is fed to the real
44
+ * settle handler (injected via init), which runs every guard, receipt, and
45
+ * dual-write exactly as an in-band settle would. still_pending composes for
46
+ * free — the settle handler's own defer elicitation runs over the same
47
+ * out-of-band channel.
48
+ *
49
+ * Honest limits: real-host rendering of out-of-band pickers is per-host
50
+ * empirical (spike README table); an unanswered ask times out to null and is
51
+ * treated as a decline. Any internal failure is swallowed — an ambient extra
52
+ * must never tax the session it rides on.
53
+ */
54
+ const COOLDOWN_MS = 4 * 60 * 60 * 1000; // 세션(연결)을 넘는 재발사 간격
55
+ const DEFAULT_ASK_TIMEOUT_MS = 120_000; // 렌더 안 하는 호스트에서 조용히 접는 시한
56
+ const DEFAULT_QUIET_MS = 45_000; // 마지막 툴 호출 뒤 이만큼 조용하면 발사
57
+ function askTimeoutMs() {
58
+ const raw = Number(process.env['ARGUS_AMBIENT_ASK_TIMEOUT_MS']);
59
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_ASK_TIMEOUT_MS;
60
+ }
61
+ let _settleHandler = null;
62
+ let _serialize = (fn) => fn();
63
+ let _timer = null;
64
+ let _spent = false; // 프로세스당 질문 1개 — 한 자리에 한 질문
65
+ let _inFlight = false;
66
+ /** server.ts가 시동 시 배선한다. 미배선이면 arm은 완전 no-op (정직한 미연결). */
67
+ export function initAmbientElicit(deps) {
68
+ _settleHandler = deps.settleHandler;
69
+ if (deps.serialize)
70
+ _serialize = deps.serialize;
71
+ }
72
+ /** 테스트 리셋 — 타이머·예산·배선을 모두 되돌린다. */
73
+ export function resetAmbientElicit() {
74
+ if (_timer)
75
+ clearTimeout(_timer);
76
+ _timer = null;
77
+ _spent = false;
78
+ _inFlight = false;
79
+ _settleHandler = null;
80
+ _serialize = (fn) => fn();
81
+ }
82
+ function quietMs() {
83
+ const raw = Number(process.env['ARGUS_AMBIENT_DELAY_MS']);
84
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_QUIET_MS;
85
+ }
86
+ function muted(dir) {
87
+ try {
88
+ return /^ambient_mute:\s*true\b/m.test(fs.readFileSync(configPath(dir), 'utf8'));
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ const statePath = (dir) => path.join(dir, 'ambient-elicit-state.json');
95
+ function underCooldown(dir) {
96
+ try {
97
+ const st = JSON.parse(fs.readFileSync(statePath(dir), 'utf8'));
98
+ return typeof st.last_fired_at === 'number' && Date.now() - st.last_fired_at < COOLDOWN_MS;
99
+ }
100
+ catch {
101
+ return false; // 상태 부재·파손 = 이력 없음
102
+ }
103
+ }
104
+ function withTimeout(p, ms) {
105
+ return new Promise((res) => {
106
+ const t = setTimeout(() => res(null), ms);
107
+ t.unref?.();
108
+ p.then((v) => { clearTimeout(t); res(v); }, () => { clearTimeout(t); res(null); });
109
+ });
110
+ }
111
+ /**
112
+ * 디스패처가 매 툴 호출 뒤에 부른다 (fire-and-forget, 절대 던지지 않음).
113
+ * 타이머는 디바운스 — 새 호출이 오면 리셋되어, Argus가 조용해진 뒤에만 발사.
114
+ */
115
+ export function armAmbientElicit(toolName, args) {
116
+ try {
117
+ if (!_settleHandler)
118
+ return; // 미배선 = no-op
119
+ if (toolName === 'argus_check_in') {
120
+ _spent = true;
121
+ return;
122
+ } // due를 방금 봤다 — 예산 소진
123
+ if (_spent || !canElicit())
124
+ return;
125
+ let dir;
126
+ try {
127
+ dir = resolveToolArgusDir(args['argus_dir']);
128
+ }
129
+ catch {
130
+ return;
131
+ }
132
+ const todayOverride = typeof args['today_override'] === 'string' ? args['today_override'] : undefined;
133
+ if (_timer)
134
+ clearTimeout(_timer);
135
+ _timer = setTimeout(() => { void fire(dir, todayOverride); }, quietMs());
136
+ _timer.unref?.();
137
+ }
138
+ catch { /* ambient는 절대 본 호출에 세금을 물리지 않는다 */ }
139
+ }
140
+ async function fire(dir, todayOverride) {
141
+ try {
142
+ if (_spent || _inFlight || !_settleHandler || !canElicit())
143
+ return;
144
+ if (muted(dir) || underCooldown(dir))
145
+ return;
146
+ const today = resolveToday({ override: todayOverride });
147
+ const state = replayLedger(dir, today);
148
+ const first = state.overdue[0];
149
+ if (!first)
150
+ return; // due 0건 = 침묵 (빈 질문은 표현 불가)
151
+ // 여기서부터는 시도 = 예산 소진. 상태 기록이 실패하면 발사하지 않는다 —
152
+ // 상한 없는 질문보다 한 번 거르는 쪽이 안전하다 (driver 훅과 같은 자세).
153
+ _spent = true;
154
+ _inFlight = true;
155
+ try {
156
+ await atomicWriteJson(statePath(dir), { last_fired_at: Date.now() });
157
+ }
158
+ catch {
159
+ return;
160
+ }
161
+ const ko = resolveResponseLocale(dir) === 'ko';
162
+ const text = sanitizeLine(first.text || first.id, 120);
163
+ // 물음 1 — 정산 outcome (spine-SAFE 구조 픽: 현실이지 평결이 아니다).
164
+ // 대화 맥락이 없는 사용자를 위해 그의 예측을 그대로 되비춘다 (우정 1조).
165
+ const picked = await withTimeout(elicit(ko
166
+ ? `Argus — 확인일이 지난 예측이 있어요: "${text}" (확인일 ${first.date}). 현실이 어떻게 답했나요? 지금 어려우면 닫아도 됩니다 — 다시 조르지 않아요.`
167
+ : `Argus — a prediction passed its check-by: "${text}" (due ${first.date}). What did reality do? Dismiss if now is a bad time — no re-asking.`, {
168
+ type: 'object',
169
+ required: ['outcome'],
170
+ properties: {
171
+ outcome: {
172
+ type: 'string',
173
+ enum: ['held', 'avoided', 'partial', 'still_pending', 'missed'],
174
+ enumNames: ko
175
+ ? ['그렇게 됐다 (held)', '피했다 (avoided)', '부분적으로 (partial)', '아직 불분명 (still pending)', '빗나갔다 (missed)']
176
+ : ['It held', 'Avoided it', 'Partially', 'Still pending', 'Missed — my read was wrong'],
177
+ description: ko ? '봉인한 예측에 현실이 어떻게 답했는지.' : 'What reality did to your sealed prediction.',
178
+ },
179
+ },
180
+ }), askTimeoutMs());
181
+ const outcome = picked?.['outcome'];
182
+ if (outcome !== 'held' && outcome !== 'avoided' && outcome !== 'partial' && outcome !== 'still_pending' && outcome !== 'missed') {
183
+ return; // 거절·시간초과·미렌더 = 답이다. 아무것도 쓰지 않는다.
184
+ }
185
+ // still_pending은 실제 settle 핸들러의 defer elicitation이 같은 채널로
186
+ // 이어서 묻는다 (두뇌 하나). 종결 outcome은 what_happened를 사용자의
187
+ // 말로 받아야만 기록한다 — 비면 기록하지 않는다 (날조 금지, 정직한 공백).
188
+ let whatHappened;
189
+ if (outcome !== 'still_pending') {
190
+ const wh = await withTimeout(elicit(ko
191
+ ? '실제로 무슨 일이 있었나요? 한 줄이면 됩니다 — 당신의 말 그대로 기록됩니다.'
192
+ : 'What actually happened, in one line? Recorded verbatim, in your words.', {
193
+ type: 'object',
194
+ required: ['what_happened'],
195
+ properties: {
196
+ what_happened: {
197
+ type: 'string', maxLength: 600,
198
+ description: ko ? '당신의 말, 그대로.' : 'Your words, verbatim.',
199
+ },
200
+ },
201
+ }), askTimeoutMs());
202
+ whatHappened = typeof wh?.['what_happened'] === 'string' ? wh['what_happened'].trim() : '';
203
+ if (!whatHappened)
204
+ return; // 현실 서술 없이는 종결 정산을 쓰지 않는다
205
+ }
206
+ // 기록은 실제 settle 핸들러로 — 가드·영수증·이중쓰기 전부 본 경로 그대로.
207
+ // 툴 호출 직렬화 사슬에 태워 원장 read-replay-append가 절대 끼어들지 않게.
208
+ const result = await _serialize(() => _settleHandler({
209
+ argus_dir: dir,
210
+ id: first.id,
211
+ outcome,
212
+ outcome_source: 'user_stated',
213
+ ...(whatHappened ? { what_happened: whatHappened } : {}),
214
+ ...(todayOverride ? { today_override: todayOverride } : {}),
215
+ }));
216
+ if (result.isError)
217
+ logError('[ambient-elicit] settle refused', result.structuredContent?.['error_code']);
218
+ }
219
+ catch (e) {
220
+ logError('[ambient-elicit] fire failed', e);
221
+ }
222
+ finally {
223
+ _inFlight = false;
224
+ }
225
+ }
@@ -3,17 +3,21 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { boundMarkerPath } from './layout.js';
5
5
  /**
6
- * argus_dir resolution + precedence (blueprint §4.0 + addendum A/B).
6
+ * argus_dir resolution + precedence (blueprint §4.0 + addendum A/B; §9.7 O1).
7
7
  *
8
8
  * The `${workspaceFolder}` config variable only expands in VS Code — Claude
9
9
  * Code expands `${CLAUDE_PROJECT_DIR}`, others expand nothing. So env
10
10
  * interpolation is the LEAST reliable channel and must come last. Tools always
11
11
  * receive a per-call `argus_dir`, which works on every host, so that wins.
12
12
  *
13
- * Precedence:
14
- * 1. per-call `argus_dir` argument (works on every host)
15
- * 2. last dir written to `.bound` by argus_init (read-side for Resources)
16
- * 3. ARGUS_DIR env (host-dependent interpolation)
13
+ * Precedence (tools and resources must tell the same storage story):
14
+ * tools: 1. per-call `argus_dir` 2. ARGUS_DIR env 3. ~/.argus default
15
+ * resources: (no per-call channel) 1. ARGUS_DIR env 2. ~/.argus default
16
+ *
17
+ * One deliberate asymmetry: an ARGUS_DIR that is SET but invalid makes a tool
18
+ * THROW (requireArgusDir) but makes a resource return null/unbound — resources
19
+ * can't error usefully, and silently reading a ledger the user didn't
20
+ * configure would be worse than degrading.
17
21
  */
18
22
  export class ArgusDirError extends Error {
19
23
  code = 'ARGUS_DIR_INVALID';
@@ -123,12 +127,26 @@ function readBoundList(argusDir) {
123
127
  }
124
128
  /**
125
129
  * Resolve argus_dir for a Resource read (no per-call arg available).
126
- * Returns null (unbound) rather than throwing — the Resource degrades cleanly.
130
+ *
131
+ * Mirrors the tool chain minus the per-call arg: ARGUS_DIR env, else the same
132
+ * zero-config `~/.argus` default that tools write to — so a zero-config
133
+ * install's passive attention surface reads the SAME ledger its tools use.
134
+ * (§9.7 O1 방2. Previously env-only, which left every zero-config install's
135
+ * `argus://attention` permanently `{unbound}` while argus_predict happily
136
+ * wrote to ~/.argus — the return loop's front door was dark.)
137
+ *
138
+ * Stays null (unbound, cleanly degraded) ONLY when ARGUS_DIR is set but
139
+ * invalid — unexpanded `${...}`/`%...%` or a relative path. Falling back to
140
+ * ~/.argus in that case would silently read a different ledger than the one
141
+ * the user configured; unbound-with-hint is the resource's honest-failure
142
+ * form of the requireArgusDir throw.
127
143
  */
128
144
  export function resolveArgusDirForResource() {
129
145
  const env = process.env['ARGUS_DIR'];
130
- if (typeof env === 'string' && env.length > 0 && path.isAbsolute(env)) {
146
+ if (typeof env === 'string' && env.length > 0) {
147
+ if (/\$\{[^}]*\}|%[A-Za-z_]+%/.test(env) || !path.isAbsolute(env))
148
+ return null;
131
149
  return path.resolve(env);
132
150
  }
133
- return null;
151
+ return path.join(os.homedir(), '.argus');
134
152
  }
@@ -35,9 +35,9 @@ export function renderReturnCalendarEvent(args) {
35
35
  const stamp = args.created_at.replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
36
36
  const summary = `Argus return: ${args.predicate.slice(0, 80)}`;
37
37
  const description = [
38
- 'Your sealed Argus decision is due today.',
38
+ 'An Argus prediction has reached its check date.',
39
39
  `Predicate: ${args.predicate}`,
40
- 'Open your terminal and run argus_check_in, then argus_settle if reality is clear.',
40
+ 'Open your terminal and run argus_check_in, then argus_resolve if reality is clear.',
41
41
  ].join('\n');
42
42
  const lines = [
43
43
  'BEGIN:VCALENDAR',
@@ -73,11 +73,11 @@ export function appendDueNote(toolName, args, result) {
73
73
  // ── channel 1: machine counts (host-choice; unchanged) ──
74
74
  const parts = [];
75
75
  if (due.premiseFactsDue > 0)
76
- parts.push(`${due.premiseFactsDue} premise fact(s) to re-check (argus_recheck)`);
76
+ parts.push(`${due.premiseFactsDue} premise fact(s) to re-check (argus_capture action=update_fact)`);
77
77
  if (due.openQuestionsDue > 0)
78
- parts.push(`${due.openQuestionsDue} open question(s) to reconsider (argus_premises)`);
78
+ parts.push(`${due.openQuestionsDue} open question(s) to reconsider (argus_capture)`);
79
79
  if (due.contractsDue > 0)
80
- parts.push(`${due.contractsDue} contract(s) to settle (argus_settle)`);
80
+ parts.push(`${due.contractsDue} prediction result(s) to record (argus_resolve)`);
81
81
  const data = (sc['data'] ??= {});
82
82
  if (!('due_note' in data))
83
83
  data['due_note'] = parts.join(' · ');
@@ -10,10 +10,22 @@
10
10
  * the text flow — no crash, no dead end.
11
11
  */
12
12
  let _elicit = null;
13
- export function setElicitor(fn) {
13
+ let _capable = null;
14
+ /** Wire the elicitor and, optionally, a live capability probe. The probe must
15
+ * reflect the ACTUAL host: the MCP spec (and the SDK) only permit elicitInput
16
+ * when the client DECLARED the `elicitation` capability at initialize — calling
17
+ * it otherwise throws. Without an accurate probe, `canElicit()` would report
18
+ * true on a host that will actually throw, and a caller that reads a null result
19
+ * as "the user declined" (rather than "no picker here") silently drops work.
20
+ * The probe closes that gap: no declared capability ⇒ callers skip the picker
21
+ * and take their text path. */
22
+ export function setElicitor(fn, capable) {
14
23
  _elicit = fn;
24
+ _capable = capable ?? null;
15
25
  }
16
26
  export function canElicit() {
27
+ if (_capable)
28
+ return _capable();
17
29
  return _elicit !== null;
18
30
  }
19
31
  /** Ask the user; returns null if unsupported / errored / not accepted. */
@@ -118,11 +118,18 @@ export function replayLedger(argusDir, today) {
118
118
  stats.total_settled++;
119
119
  const outcome = ev['outcome'];
120
120
  cur.outcome = outcome;
121
- if (typeof ev['ts'] === 'string' && ev['ts'].length >= 10)
122
- cur.settled_on = ev['ts'].slice(0, 10);
121
+ // Timestamp field is two-vocab across surfaces: this binary stamps `ts`,
122
+ // the plugin CLI stamps `at` — read both so a plugin-settled decision
123
+ // still gets its settled date on the receipt (O2 방1 finding ⑤).
124
+ const settledTs = typeof ev['ts'] === 'string' ? ev['ts'] : typeof ev['at'] === 'string' ? ev['at'] : undefined;
125
+ if (settledTs && settledTs.length >= 10)
126
+ cur.settled_on = settledTs.slice(0, 10);
123
127
  if (typeof ev['broken_premise_id'] === 'string')
124
128
  cur.broken_premise_id = ev['broken_premise_id'];
125
- if (outcome === 'held')
129
+ // `happened` is the plugin CLI's legacy spelling of `held` (pre plain-canon
130
+ // unification). Old ledgers keep their bytes — the alias lives at read time
131
+ // so the frequency buckets stay complete (total_settled == sum of buckets).
132
+ if (outcome === 'held' || outcome === 'happened')
126
133
  stats.held++;
127
134
  else if (outcome === 'avoided')
128
135
  stats.avoided++;
@@ -275,6 +282,15 @@ export function replayLedger(argusDir, today) {
275
282
  }
276
283
  case 'gate_input':
277
284
  break; // known meta event (over-fire gate audit) — not a state change, not corrupt
285
+ case 'wake':
286
+ // Plugin-side event (sail Step 7.5: the in-session 1st settlement of the
287
+ // BIND lean — did the user's own read hold once the reviewers were in?).
288
+ // Session-scoped, not part of the decision state machine; the plugin's
289
+ // own reducer folds it. Until 2026-07-17 this binary counted it as a
290
+ // DROPPED line (plugin events carried no `v` stamp), so a ledger shared
291
+ // between the plugin and the MCP raised a false corruption alarm on
292
+ // check_in (O2 방1 cross-surface finding ①).
293
+ break;
278
294
  // ── 당직 루프 (§9) — outside the decision state machine. No contract
279
295
  // entry is created; nothing touches stats. Anchor is a note, not a bet.
280
296
  case 'watch_anchor': {
@@ -0,0 +1,52 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { contentLocaleFromArgs } from './locale.js';
4
+ import { configLocale } from './surfaces.js';
5
+ /**
6
+ * Locale-mismatch once-note (§9.7 O1 product contract: "locale은 대화-언어
7
+ * 우선 — 설정과 대화 언어가 어긋나면 1회 확인, 감지 1회 영구 고착 금지").
8
+ *
9
+ * The escape hatch stays intact: an explicit config locale is never silently
10
+ * overridden (that guarantee is what makes the pin trustworthy). But until
11
+ * now the pin was also never QUESTIONED — the 2026-06-15 incident wrote
12
+ * `locale: en` into a real config during an English-driven eval run, and a
13
+ * Korean founder then got English surfaces for a month with nothing ever
14
+ * saying "your saved language disagrees with your words".
15
+ *
16
+ * Contract: when an explicit pin exists AND the user's own content confidently
17
+ * speaks the other language, append ONE note — a fact plus the argus_settings
18
+ * handle, in the language the user is speaking — to the next successful
19
+ * surface. Once per ledger, ever (marker file); declining by ignoring it is a
20
+ * valid answer and is never re-asked. No auto-update, no directive: the config
21
+ * change stays an authorial act the user performs.
22
+ */
23
+ const MARKER = 'locale-mismatch-noted';
24
+ export function appendLocaleMismatchNote(argusDir, args, result) {
25
+ try {
26
+ if (!argusDir)
27
+ return result;
28
+ const pinned = configLocale(argusDir);
29
+ if (!pinned)
30
+ return result; // no pin → the text-detection chain already speaks the user's language
31
+ const spoken = contentLocaleFromArgs(args);
32
+ if (!spoken || spoken === pinned)
33
+ return result;
34
+ const marker = path.join(argusDir, MARKER);
35
+ if (fs.existsSync(marker))
36
+ return result; // asked once already — silence forever
37
+ const sc = result.structuredContent;
38
+ if (!sc || sc['ok'] !== true || typeof sc['surface'] !== 'string')
39
+ return result; // never decorate errors
40
+ fs.writeFileSync(marker, new Date().toISOString());
41
+ const note = spoken === 'ko'
42
+ ? '\n(설정 언어가 English로 고정되어 있어요. 한국어로 바꾸려면 argus_settings에 locale="ko" 한 번이면 돼요 — 이 안내는 다시 나오지 않아요.)'
43
+ : '\n(Your saved language is Korean. To switch, call argus_settings with locale="en" once — this note will not appear again.)';
44
+ const next = { ...sc, surface: `${sc['surface']}${note}` };
45
+ result.structuredContent = next;
46
+ result.content = [{ type: 'text', text: JSON.stringify(next, null, 2) }];
47
+ return result;
48
+ }
49
+ catch {
50
+ return result; // a broken note must never wedge a tool call
51
+ }
52
+ }
@@ -1,10 +1,89 @@
1
1
  import fs from 'fs';
2
2
  import { detect } from 'tinyld/light';
3
3
  import { configPath } from './layout.js';
4
+ /** The user-authored text fields worth sniffing for the session's language.
5
+ * Incidental fields (ids, notes on machine actions) are excluded so a stray
6
+ * token can't flip a session. */
7
+ const CONTENT_FIELDS = [
8
+ 'decision', 'predicate', 'what_happened', 'finding', 'text', 'question',
9
+ 'human_judgment', 'observation_text', 'statement', 'review_question',
10
+ ];
11
+ /**
12
+ * Persist a Korean locale the FIRST time the user's own words are Korean, so the
13
+ * whole session stays Korean — including later surfaces with no text to sniff
14
+ * (validation errors, recall, check_in). This is content-driven, never env:
15
+ * - only a POSITIVE Korean content signal ever writes (English never pins);
16
+ * - never overrides an explicit locale already in config.
17
+ * Fixes mid-session English leaks on a Korean session (2026-07-14 locale sweep).
18
+ */
19
+ export function learnLocaleFromContent(argusDir, args) {
20
+ if (!argusDir)
21
+ return;
22
+ if (contentLocaleFromArgs(args) !== 'ko')
23
+ return;
24
+ try {
25
+ const p = configPath(argusDir);
26
+ let cfg = '';
27
+ try {
28
+ cfg = fs.readFileSync(p, 'utf8');
29
+ }
30
+ catch { /* no config yet → auto-init writes one before the handler returns */ }
31
+ if (/^locale:\s*(ko|en)\b/m.test(cfg))
32
+ return; // explicit locale already set — never override
33
+ const next = cfg
34
+ ? `${cfg.endsWith('\n') ? cfg : `${cfg}\n`}locale: ko\n`
35
+ : 'schema_version: 5\nlocale: ko\n';
36
+ fs.writeFileSync(p, next);
37
+ }
38
+ catch { /* best-effort: a failed persist just means the next Korean surface re-sniffs from content */ }
39
+ }
40
+ /** The language the user is ACTUALLY speaking in this call, judged only from
41
+ * their own content fields (never ids/env). Null when there is no confident
42
+ * signal. Shared by learnLocaleFromContent (pins ko on a fresh config) and
43
+ * the locale-mismatch once-note (§9.7 O1 — a pinned config that contradicts
44
+ * the conversation language must be surfaced once, not silently obeyed
45
+ * forever: the 2026-06-15 locale:en contamination stayed invisible for a
46
+ * month precisely because nothing ever said "your config disagrees with
47
+ * your words"). */
48
+ export function contentLocaleFromArgs(args) {
49
+ const sample = CONTENT_FIELDS
50
+ .map((k) => args[k])
51
+ .filter((v) => typeof v === 'string' && v.trim().length > 0)
52
+ .join('\n');
53
+ return sample ? detectLocaleFromText(sample) : null;
54
+ }
55
+ /**
56
+ * osLocaleHint — THE one OS-environment locale probe (§9.7 O1 방1: one env
57
+ * resolver, consumed by every locale chain).
58
+ *
59
+ * Until 2026-07-16 this env→Intl chain lived in two copies (detectLocale here,
60
+ * resolveResponseLocale in surfaces.ts) and both treated env as a KO-DETECTOR
61
+ * ONLY: `LANG=en_US` didn't pin en — the chain fell through to Intl and a
62
+ * Korean-locale OS still resolved ko. That asymmetry is why the release suite
63
+ * was machine-dependent (4 reds on a ko OS; overhaul review §10) and why an
64
+ * explicit English env could never assert itself.
65
+ *
66
+ * Rule: a NON-EMPTY LANG/LC_ALL names the user's working language explicitly —
67
+ * ko iff it starts with ko, else en (Argus is binary ko/en). Only an EMPTY env
68
+ * falls through to the OS Intl locale. Explicit config wins ABOVE this at
69
+ * every call site, and Korean CONTENT re-claims ko later regardless (the text
70
+ * step / learnLocaleFromContent) — so an en hint never locks a Korean user out.
71
+ */
72
+ export function osLocaleHint() {
73
+ const env = process.env['LANG'] || process.env['LC_ALL'] || '';
74
+ if (env)
75
+ return /^ko/i.test(env) ? 'ko' : 'en';
76
+ try {
77
+ if (/^ko/i.test(Intl.DateTimeFormat().resolvedOptions().locale))
78
+ return 'ko';
79
+ }
80
+ catch { /* Intl unavailable */ }
81
+ return 'en';
82
+ }
4
83
  /**
5
84
  * detectLocale — CONFIG/ENV detection seeded at argus_init write time.
6
85
  *
7
- * Chain (write-time): explicit config > env (LANG/LC_ALL) > Intl > 'en'.
86
+ * Chain (write-time): explicit config > osLocaleHint (env, then Intl) > 'en'.
8
87
  * The runtime input-text step lives in resolveResponseLocale (surfaces.ts),
9
88
  * which layers text detection on TOP of a persisted config. This function
10
89
  * stays env-only so a fresh dir on a KST machine seeds locale:ko once.
@@ -17,15 +96,7 @@ export function detectLocale(argusDir) {
17
96
  return m[1];
18
97
  }
19
98
  catch { /* no config */ }
20
- const env = process.env['LANG'] || process.env['LC_ALL'] || '';
21
- if (/^ko/i.test(env))
22
- return 'ko';
23
- try {
24
- if (/^ko/i.test(Intl.DateTimeFormat().resolvedOptions().locale))
25
- return 'ko';
26
- }
27
- catch { /* Intl unavailable */ }
28
- return 'en';
99
+ return osLocaleHint();
29
100
  }
30
101
  /**
31
102
  * detectLocaleFromText — the runtime language sniff (M4, spec §4).