argus-decision-mcp 1.4.6 → 1.5.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 (38) hide show
  1. package/dist/lib/ambient-elicit.js +7 -5
  2. package/dist/lib/calendar.js +0 -0
  3. package/dist/lib/continuity.js +2 -2
  4. package/dist/lib/detect-signals.js +151 -0
  5. package/dist/lib/ledger-append.js +11 -1
  6. package/dist/lib/ledger-replay.js +51 -33
  7. package/dist/lib/locale-mismatch.js +2 -2
  8. package/dist/lib/localize-result.js +157 -10
  9. package/dist/lib/numeric-drift.js +13 -13
  10. package/dist/lib/render-receipt.js +79 -50
  11. package/dist/lib/resolve-today.js +14 -0
  12. package/dist/lib/review/extract-file-node.js +2 -2
  13. package/dist/lib/review/render.js +6 -6
  14. package/dist/lib/review/routing.js +6 -6
  15. package/dist/lib/review/schema.js +9 -1
  16. package/dist/lib/spine.js +30 -15
  17. package/dist/lib/state-machine.js +2 -2
  18. package/dist/lib/surfaces.js +56 -40
  19. package/dist/lib/telemetry.js +3 -3
  20. package/dist/lib/tool-presentation.js +3 -3
  21. package/dist/lib/validate-crux.js +3 -3
  22. package/dist/lib/validate-seal.js +20 -4
  23. package/dist/tools/amend-dismiss.js +10 -5
  24. package/dist/tools/candidates.js +1 -1
  25. package/dist/tools/check-in.js +22 -5
  26. package/dist/tools/errors.js +15 -1
  27. package/dist/tools/open-decision.js +6 -5
  28. package/dist/tools/premises.js +7 -5
  29. package/dist/tools/public-tools.js +17 -14
  30. package/dist/tools/recall.js +34 -12
  31. package/dist/tools/recheck.js +34 -15
  32. package/dist/tools/review.js +6 -6
  33. package/dist/tools/seal.js +7 -9
  34. package/dist/tools/settle.js +32 -18
  35. package/dist/tools/tool-types.js +4 -4
  36. package/dist/v2/lifecycle-cli.js +23 -8
  37. package/dist/v2/lifecycle.js +27 -0
  38. package/package.json +1 -1
@@ -163,17 +163,19 @@ async function fire(dir, todayOverride) {
163
163
  // 물음 1 — 정산 outcome (spine-SAFE 구조 픽: 현실이지 평결이 아니다).
164
164
  // 대화 맥락이 없는 사용자를 위해 그의 예측을 그대로 되비춘다 (우정 1조).
165
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.`, {
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
168
  type: 'object',
169
169
  required: ['outcome'],
170
170
  properties: {
171
171
  outcome: {
172
172
  type: 'string',
173
173
  enum: ['held', 'avoided', 'partial', 'still_pending', 'missed'],
174
+ // Same labels as the in-band settle picker (settle.ts) — 풀어쓰기.
175
+ // (Duplicated across the two picker sites; keep them in lockstep.)
174
176
  enumNames: ko
175
- ? ['그렇게 됐다 (held)', '피했다 (avoided)', '부분적으로 (partial)', '아직 불분명 (still pending)', '빗나갔다 (missed)']
176
- : ['It held', 'Avoided it', 'Partially', 'Still pending', 'Missed my read was wrong'],
177
+ ? ['예측대로 됐다', '걱정한 일은 안 일어났다', '일부만 맞았다', '아직 불분명', '예측이 빗나갔다']
178
+ : ['It held', 'Avoided', 'Partially', 'Still unclear', 'Missed: my read was wrong'],
177
179
  description: ko ? '봉인한 예측에 현실이 어떻게 답했는지.' : 'What reality did to your sealed prediction.',
178
180
  },
179
181
  },
@@ -188,7 +190,7 @@ async function fire(dir, todayOverride) {
188
190
  let whatHappened;
189
191
  if (outcome !== 'still_pending') {
190
192
  const wh = await withTimeout(elicit(ko
191
- ? '실제로 무슨 일이 있었나요? 한 줄이면 됩니다 당신의 말 그대로 기록됩니다.'
193
+ ? '실제로 무슨 일이 있었나요? 한 줄이면 됩니다. 당신의 말 그대로 기록됩니다.'
192
194
  : 'What actually happened, in one line? Recorded verbatim, in your words.', {
193
195
  type: 'object',
194
196
  required: ['what_happened'],
Binary file
@@ -21,11 +21,11 @@ export function computeContinuity(argusDir, relatedIds) {
21
21
  }
22
22
  const frequency_statement = settled === 0
23
23
  ? 'No settled history among the decisions you tagged as similar.'
24
- : `Of ${settled} similar decision(s) you settled: ${held} held, ${settled - held} did not.`;
24
+ : `Of ${settled} similar ${settled === 1 ? 'decision' : 'decisions'} you settled: ${held} held, ${settled - held} did not.`;
25
25
  return {
26
26
  related,
27
27
  frequency_statement,
28
28
  sample_size: settled,
29
- sample_size_caveat: settled > 0 && settled < 10 ? 'Sample is small read this as history, not a pattern about you.' : undefined,
29
+ sample_size_caveat: settled > 0 && settled < 10 ? 'Sample is small. Read this as history, not a pattern about you.' : undefined,
30
30
  };
31
31
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Layer-2 deterministic signal detection (blueprint continuation of the plugin's
3
+ * `decision-signals.js`, whose comment reads "Layer 2 widens precision — later").
4
+ *
5
+ * WHY THIS EXISTS (the trust answer, 2026-07-20). In an MCP the *noticing* of a
6
+ * passing prediction / a surfacing outcome / a load-bearing assumption is, by the
7
+ * protocol, the host model's act — the server never sees the conversation, and
8
+ * Claude does not yet support server-driven `sampling` (feature req #1785). So
9
+ * "the model will notice" is a goodwill dependency, not a guarantee. This module
10
+ * turns the part that CAN be code into code: a pure, dependency-free, unit-tested
11
+ * RULE scan of a turn's text. It does not replace the model — it RAISES RECALL so
12
+ * the model is handed a specific candidate in the user's own words instead of
13
+ * being trusted to spot it from scratch.
14
+ *
15
+ * LLM-glue invariant: because this is rules (not a model), a test can pin it —
16
+ * "plausible" cannot masquerade as "correct". Every detected span is the user's
17
+ * OWN words (a verbatim slice), never a fabricated stand-in.
18
+ *
19
+ * SPINE (mirror clause — max detect, min fire): this detector leans HIGH-RECALL.
20
+ * Restraint lives in the FIRING gate around it (the hook's once-per-session +
21
+ * cooldown, and the model's freedom to stay silent on a flat case), NOT here.
22
+ * Detection ≠ interruption: a caller may detect and still choose to say nothing.
23
+ */
24
+ /* ── Cue groups (named so tests + the plugin mirror can pin them) ──────────── */
25
+ /** Future / modal markers: the claim points at a not-yet-settled state. */
26
+ const FUTURE = [
27
+ /\bwill\b/i, /\bwon'?t\b/i, /\bgoing to\b/i, /\bgonna\b/i, /\bshall\b/i,
28
+ /\bexpect(s|ed|ing)?\b/i, /\bshould\b/i, /\blikely\b/i, /\bplan(ning)? to\b/i,
29
+ /\bby (mon|tue|wed|thu|fri|sat|sun)\w*/i,
30
+ /\bby (jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*/i,
31
+ /\bby \d/i, /\bwithin \d+\s*(day|week|month|quarter)/i,
32
+ /\bnext (week|month|quarter|year|sprint)\b/i,
33
+ // Korean futures / commitments / thresholds-as-future
34
+ /(할|될|갈|낼|올|줄)\s*(것|거|게|걸)/, /하겠|되겠|시키겠/, /(ㄹ|를|을)\s*거(다|예요|야|임)/,
35
+ /예상|전망|목표|계획|할 예정|될 예정/, /까지(는|\b|\s)/, /안에|이내(에)?/,
36
+ /다음\s*(주|달|분기|해|스프린트)/, /(유지|달성|돌파|출시|완료)(할|될|하겠|되겠|한다|된다)/,
37
+ // Korean prospective endings — "-ㄹ 거예요 / -ㄹ 겁니다 / -ㄹ 것으로": the ㄹ is
38
+ // fused into a composed syllable (빨라질) so match the trailing marker itself.
39
+ /(거예요|거에요|거야|겁니다|거고|건데|건가|것으로|것입니다|것이다|것\s*같)/, /(ㄹ|을|를)\s*것\b/,
40
+ ];
41
+ /** Measurable checkpoints: a number, percent, money, date, threshold, comparison. */
42
+ const MEASURABLE = [
43
+ /\d/, /%|percent|퍼센트|프로/i, /\$|원\b|달러|억|만원|USD|KRW/i,
44
+ /\b(faster|slower|lower|higher|cheaper|under|over|below|above|less than|more than|at least|no more than)\b/i,
45
+ /이하|이상|미만|초과|아래|위(로)?|밑(으로)?|이내|보다\s*(빠|느|싸|비|많|적|높|낮)/,
46
+ /\b(mon|tue|wed|thu|fri|sat|sun)\w*/i,
47
+ /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*/i, /\d{4}-\d{2}-\d{2}/,
48
+ ];
49
+ /** Completion verbs: a discrete event reality can confirm happened or not. */
50
+ const COMPLETION = [
51
+ /\b(ship|launch|release|deliver|deploy|close|hire|sign|land|finish|complete|onboard|migrate|cut over)\b/i,
52
+ /출시|배포|출고|런칭|납품|마감|채용|계약|체결|완료|오픈|이전|전환|성사|입사|합류/,
53
+ ];
54
+ /** Past-tense resolution markers: reality has spoken about something. */
55
+ const RESOLVED = [
56
+ /\bturn(s|ed)? out\b/i, /\bended up\b/i, /\bcame in at\b/i, /\bended at\b/i,
57
+ /\bwe (shipped|launched|missed|hit|closed|hired|signed|landed|deployed)\b/i,
58
+ /\bit (went|held|worked|failed|slipped|held up)\b/i,
59
+ /\bdidn'?t (happen|work|ship|hold|land)\b/i, /\b(hit|missed|beat|met) (the|our) (target|number|deadline|goal)\b/i,
60
+ /됐(어|다|고|는데|네|음)|됐다|성사(됐|했)|끝났|출시했|배포했|이전했|전환했/,
61
+ /안\s*(됐|나|됐어|됐다)|못\s*(했|했다|해서|이룬)|실패(했|함)|무산(됐|됨)/,
62
+ /결국|실제로(는)?|막상|나왔(다|어|고)|나온|드러났|밝혀졌|판명/,
63
+ ];
64
+ /** Causal / conditional markers: the reasoning rests on a stated premise. */
65
+ const CONDITIONAL = [
66
+ /\bbecause\b/i, /\bsince\b/i, /\bassuming\b/i, /\bas long as\b/i, /\bdepends on\b/i,
67
+ /\bonly if\b/i, /\bprovided that\b/i, /\bbanking on\b/i, /\bhinges? on\b/i,
68
+ /\bcontingent on\b/i, /\bthe (key|whole thing) (is|hinges|rests|depends)\b/i,
69
+ /니까|때문에|덕분에|탓에/, /(라|다)면\b|(으|)ㄴ다면|는다면/, /는\s*한(에서)?|한(에서만)?/,
70
+ /(에|에게)\s*달렸|달려\s*있|전제로|가정하(면|고)|관건은|핵심은|믿고\s*있/,
71
+ ];
72
+ /* ── Helpers ──────────────────────────────────────────────────────────────── */
73
+ const anyMatch = (groups, s) => groups.some((re) => re.test(s));
74
+ const whichMatch = (name, groups, s) => groups.some((re) => re.test(s)) ? name : null;
75
+ /** Split into sentence-ish spans on terminators + newlines; keep the user's text. */
76
+ function clauses(text) {
77
+ return text
78
+ .split(/(?<=[.!?。!?])\s+|\n+/)
79
+ .map((c) => c.trim())
80
+ .filter((c) => c.length >= 6 && c.length <= 400);
81
+ }
82
+ /** Cheap token overlap for outcome↔open-predicate. Prefix-aware so Korean josa
83
+ * ("이전은" vs "이전", "다운타임은" vs "다운타임") still counts as the same word —
84
+ * naive exact-token overlap silently under-fires on every particle-attached noun. */
85
+ function overlaps(a, b) {
86
+ const tok = (s) => s.toLowerCase()
87
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ')
88
+ .split(/\s+/)
89
+ .filter((t) => t.length >= 2);
90
+ const A = tok(a);
91
+ const B = tok(b);
92
+ if (A.length === 0 || B.length === 0)
93
+ return false;
94
+ let hit = 0;
95
+ for (const x of A) {
96
+ for (const y of B) {
97
+ if (x === y || x.startsWith(y) || y.startsWith(x)) {
98
+ hit++;
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ return hit >= 2; // two salient shared/overlapping tokens — the floor against chatter
104
+ }
105
+ /* ── The detector ─────────────────────────────────────────────────────────── */
106
+ /**
107
+ * Scan a turn's text for the three senses. Pure and deterministic. Returns each
108
+ * detected signal with the user's own span and the cues that fired. High recall
109
+ * by design; the caller owns the firing gate (see module header).
110
+ */
111
+ export function detectSignals(text, opts = {}) {
112
+ if (typeof text !== 'string' || text.trim().length < 6)
113
+ return [];
114
+ const openPredicates = (opts.openPredicates ?? []).filter((p) => typeof p === 'string' && p.trim());
115
+ const max = typeof opts.max === 'number' && opts.max > 0 ? opts.max : 4;
116
+ const out = [];
117
+ const seen = new Set();
118
+ const push = (kind, span, cues) => {
119
+ const key = `${kind}:${span}`;
120
+ if (seen.has(key))
121
+ return;
122
+ seen.add(key);
123
+ out.push({ kind, span, cues });
124
+ };
125
+ for (const c of clauses(text)) {
126
+ // 1) PREDICTION — a claim reality can later check: FUTURE ∧ (MEASURABLE ∨ COMPLETION).
127
+ const future = whichMatch('future', FUTURE, c);
128
+ const measurable = whichMatch('measurable', MEASURABLE, c);
129
+ const completion = whichMatch('completion', COMPLETION, c);
130
+ if (future && (measurable || completion)) {
131
+ push('prediction', c, [future, measurable, completion].filter((x) => !!x));
132
+ }
133
+ // 3) ASSUMPTION — reasoning that rests on a stated premise. Conservative: the
134
+ // clause must ALSO carry a checkable/consequential cue, so a throwaway
135
+ // "because I'm tired" does not register as a load-bearing assumption.
136
+ const conditional = whichMatch('conditional', CONDITIONAL, c);
137
+ if (conditional && (measurable || completion || future)) {
138
+ push('assumption', c, [conditional, measurable, completion, future].filter((x) => !!x));
139
+ }
140
+ // 2) OUTCOME — reality answering a KNOWN open prediction. Needs a resolution
141
+ // cue AND overlap with an open predicate (the floor against past-tense chatter).
142
+ const resolved = whichMatch('resolved', RESOLVED, c);
143
+ if (resolved && openPredicates.some((p) => overlaps(c, p))) {
144
+ push('outcome', c, [resolved, 'matches-open-prediction']);
145
+ }
146
+ }
147
+ return out.slice(0, max);
148
+ }
149
+ /** The named cue groups, exported so the plugin mirror + a drift guard can pin
150
+ * that the two surfaces detect the SAME things (single-source-of-truth rule). */
151
+ export const CUE_GROUPS = { FUTURE, MEASURABLE, COMPLETION, RESOLVED, CONDITIONAL };
@@ -184,7 +184,17 @@ export async function appendLedger(argusDir, events, now, hints) {
184
184
  try {
185
185
  fs.fsyncSync(fd);
186
186
  }
187
- catch { /* fsync unsupported on this fs — the write still landed */ }
187
+ catch (e) {
188
+ // fsync legitimately fails as "unsupported" on some filesystems/handles
189
+ // (EINVAL/ENOTSUP/EOPNOTSUPP — tmpfs, pipes, certain network mounts):
190
+ // there the write still landed, so swallow. But EIO / ENOSPC / EBADF are
191
+ // REAL flush failures — the settlement is NOT durable. Rethrow so the
192
+ // tool returns isError instead of a false "saved!" (fail loud, never
193
+ // report a durability we don't have).
194
+ const code = e?.code;
195
+ if (code !== 'EINVAL' && code !== 'ENOTSUP' && code !== 'EOPNOTSUPP')
196
+ throw e;
197
+ }
188
198
  resolve();
189
199
  }
190
200
  catch (e) {
@@ -24,6 +24,9 @@ export function replayLedger(argusDir, today) {
24
24
  let dropped = 0;
25
25
  let skippedUnknown = 0;
26
26
  let oldestTs;
27
+ // Ids that ever saw a seal EVENT — survives settle/dismiss so total_sealed
28
+ // means "ever sealed", derived per-id (not per-line) below.
29
+ const everSealed = new Set();
27
30
  const watch = { anchors: new Map(), captures: [] };
28
31
  let raw;
29
32
  try {
@@ -86,7 +89,7 @@ export function replayLedger(argusDir, today) {
86
89
  if (typeof ev['basis'] === 'string')
87
90
  cur.basis = ev['basis'];
88
91
  cur.status = 'sealed';
89
- stats.total_sealed++;
92
+ everSealed.add(id);
90
93
  break;
91
94
  }
92
95
  case 'amend':
@@ -123,6 +126,14 @@ export function replayLedger(argusDir, today) {
123
126
  cur.status = 'settled';
124
127
  const outcome = ev['outcome'];
125
128
  cur.outcome = outcome;
129
+ // Retain what-reality-did from the settle line. This binary writes it as
130
+ // `decision`; the plugin CLI writes `what_happened` — read both (same
131
+ // dual-vocab tolerance as ts/at below) so a lost receipt is honestly
132
+ // reconstructable from the fold, whatever surface settled it.
133
+ const wh = typeof ev['decision'] === 'string' ? ev['decision']
134
+ : typeof ev['what_happened'] === 'string' ? ev['what_happened'] : undefined;
135
+ if (wh)
136
+ cur.what_happened = wh;
126
137
  // Timestamp field is two-vocab across surfaces: this binary stamps `ts`,
127
138
  // the plugin CLI stamps `at` — read both so a plugin-settled decision
128
139
  // still gets its settled date on the receipt (O2 방1 finding ⑤).
@@ -131,36 +142,9 @@ export function replayLedger(argusDir, today) {
131
142
  cur.settled_on = settledTs.slice(0, 10);
132
143
  if (typeof ev['broken_premise_id'] === 'string')
133
144
  cur.broken_premise_id = ev['broken_premise_id'];
134
- // `happened` is the plugin CLI's legacy spelling of `held` (pre plain-canon
135
- // unification). Old ledgers keep their bytes the alias lives at read time.
136
- // total_settled is incremented HERE, together with the bucket, so the
137
- // invariant total_settled == sum(buckets) always holds. A settle event
138
- // with an UNKNOWN outcome (a corrupt or externally-synced ledger — the
139
- // zod enum blocks it on the MCP write path) still marks the decision
140
- // settled, but is not counted into a calibration frequency it can't be
141
- // categorized into (it used to inflate total_settled, so the surface read
142
- // "N settled: 0 · 0 · 0 · 0" — a settle that vanished from its own breakdown).
143
- if (outcome === 'held' || outcome === 'happened') {
144
- stats.held++;
145
- stats.total_settled++;
146
- }
147
- else if (outcome === 'avoided') {
148
- stats.avoided++;
149
- stats.total_settled++;
150
- }
151
- else if (outcome === 'partial') {
152
- stats.partial++;
153
- stats.total_settled++;
154
- }
155
- else if (outcome === 'missed') {
156
- stats.missed++;
157
- stats.total_settled++;
158
- }
159
- // 'still_pending' is NOT a terminal outcome — the MCP settle tool routes
160
- // it to a `defer` event, never a `settle`. A settle+still_pending is
161
- // therefore external/legacy only; it is not counted (the displayed
162
- // breakdown shows four buckets, so counting it would make the visible
163
- // line under-sum: "1 settled: 0 · 0 · 0 · 0"). v2 rejects it likewise.
145
+ // Buckets are NOT counted here stats derive from the FOLDED STATE
146
+ // after the loop (see below), so a duplicated or reordered settle line
147
+ // in an externally-edited ledger cannot double-count a calibration.
164
148
  break;
165
149
  }
166
150
  case 'defer': {
@@ -364,16 +348,50 @@ export function replayLedger(argusDir, today) {
364
348
  break;
365
349
  }
366
350
  }
351
+ // Stats are DERIVED from the folded state, never counted per event line
352
+ // (state-derivation, 1.4.6 backlog): a hand-edited/merged ledger with a
353
+ // duplicated seal or settle line folds to the same state, so it must fold to
354
+ // the same calibration. Invariant preserved: total_settled == sum of the four
355
+ // buckets; a settled contract with an unknown/legacy outcome ('still_pending',
356
+ // corrupt) is settled but uncounted, exactly as before. `happened` stays the
357
+ // plugin CLI's legacy alias of `held` (old ledgers keep their bytes).
358
+ stats.total_sealed = everSealed.size;
359
+ for (const c of map.values()) {
360
+ if (c.status !== 'settled')
361
+ continue;
362
+ const o = c.outcome === 'happened' ? 'held' : c.outcome;
363
+ if (o === 'held') {
364
+ stats.held++;
365
+ stats.total_settled++;
366
+ }
367
+ else if (o === 'avoided') {
368
+ stats.avoided++;
369
+ stats.total_settled++;
370
+ }
371
+ else if (o === 'partial') {
372
+ stats.partial++;
373
+ stats.total_settled++;
374
+ }
375
+ else if (o === 'missed') {
376
+ stats.missed++;
377
+ stats.total_settled++;
378
+ }
379
+ }
367
380
  const overdue = [];
381
+ const undatedSeals = [];
368
382
  for (const [id, item] of map.entries()) {
369
383
  if (item.status !== 'sealed')
370
384
  continue;
371
385
  const date = asDate(item.check_by);
372
- if (date && date <= today)
386
+ if (!date) {
387
+ undatedSeals.push(id);
388
+ continue;
389
+ } // sealed but no valid check-by → can never come due; surface it, don't lose it
390
+ if (date <= today)
373
391
  overdue.push({ id, date, text: item.text || '' });
374
392
  }
375
393
  overdue.sort((a, b) => (a.date < b.date ? -1 : 1));
376
- return { today, overdue, ids, sealedPredicates, contracts: map, stats, watch, oldest_ts: oldestTs, integrity: { dropped_lines: dropped, skipped_unknown: skippedUnknown } };
394
+ return { today, overdue, ids, sealedPredicates, contracts: map, stats, watch, oldest_ts: oldestTs, integrity: { dropped_lines: dropped, skipped_unknown: skippedUnknown, ...(undatedSeals.length ? { undated_seals: undatedSeals } : {}) } };
377
395
  }
378
396
  /**
379
397
  * Bearing-file contract seeds that are due but not yet represented in the
@@ -39,8 +39,8 @@ export function appendLocaleMismatchNote(argusDir, args, result) {
39
39
  return result; // never decorate errors
40
40
  fs.writeFileSync(marker, new Date().toISOString());
41
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.)';
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
44
  const next = { ...sc, surface: `${sc['surface']}${note}` };
45
45
  result.structuredContent = next;
46
46
  result.content = [{ type: 'text', text: JSON.stringify(next, null, 2) }];
@@ -29,14 +29,15 @@ const KO_ERRORS = {
29
29
  PREMATURE_SETTLE: { message: '아직 확인일이 되지 않았습니다.', recovery: '확인일까지 기다리세요. 일정이 바뀌었다면 outcome="still_pending"에 defer_to로 새 확인일을 전달하면 됩니다.' },
30
30
  // ko/en 패리티: 아래 코드들은 en에서만 상세했고 ko는 제네릭 폴백이었다 —
31
31
  // 한국어 사용자가 같은 품질의 복구 안내를 받도록 전용 문구를 둔다.
32
- NO_PRIOR_SEAL: { message: '이 id로 저장된 예측이 없습니다.', recovery: 'argus_predict로 반증 가능한 예측과 확인일을 먼저 저장하세요. (id가 argus_settings sync에서 온 "mcp_" 접두사라면 접두사를 뗀 id를 쓰세요.)' },
32
+ NO_PRIOR_SEAL: { message: '이 id로 저장된 예측이 없습니다.', recovery: 'argus_predict로 나중에 확인할 수 있는 예측과 확인일을 먼저 저장하세요. (id가 argus_settings sync에서 온 "mcp_" 접두사라면 접두사를 뗀 id를 쓰세요.)' },
33
33
  BAD_CHECK_BY: { message: '확인일이 오늘 이후의 실제 달력 날짜(YYYY-MM-DD)가 아닙니다 (예: 2026-13-01처럼 없는 달·날짜는 불가).', recovery: '오늘 이후의 올바른 날짜를 YYYY-MM-DD로 다시 전달하세요.' },
34
34
  ILLEGAL_TRANSITION: { message: '이 결정에 지금은 할 수 없는 작업입니다 (id 오타이거나, 이미 저장·정산·종료된 상태일 수 있습니다).', recovery: 'argus_patterns view="all"로 id와 현재 상태를 확인하세요. 없는 id면 argus_capture 또는 argus_predict로 새로 시작하세요.' },
35
35
  PREMISE_LOCKED: { message: '확인일이 지나 전제를 더는 바꿀 수 없습니다.', recovery: '먼저 argus_resolve로 실제 결과를 기록하세요. 확인일이 온 뒤에는 전제/예측을 고칠 수 없습니다.' },
36
36
  ARGUS_DIR_INVALID: { message: 'Argus 기록 경로(argus_dir / ARGUS_DIR)가 올바르지 않습니다.', recovery: '절대 경로여야 하고 ".."을 포함할 수 없습니다. MCP 설정에서 절대 경로(예: C:\\Users\\이름\\.argus, /Users/이름/.argus)로 바꾸거나 ARGUS_DIR을 지워 기본값(~/.argus)을 쓰세요. ${...} 같은 변수는 호스트가 확장하지 못할 수 있습니다.' },
37
+ ARGUS_DIR_UNWRITABLE: { message: 'Argus가 기록 폴더를 만들거나 쓰지 못했습니다.', recovery: 'ARGUS_DIR(또는 argus_dir)을 실제로 있고 쓸 수 있는 폴더로 바꿔 주세요. 실제 드라이브의 절대 경로여야 하고 ".."은 넣을 수 없습니다. 그다음 다시 시도하세요.' },
37
38
  EMPTY_PREDICATE: { message: '확인 가능한 예측 문장이 필요합니다 (공백 제외 최소 8자).', recovery: '현실이 참/거짓으로 확인할 수 있는 문장으로 다시 적으세요. 예: "컷오버 다운타임 5분 미만".' },
38
39
  ALREADY_SETTLED: { message: '이미 실제 결과가 기록된 결정입니다.', recovery: '영수증은 argus_patterns view="receipt"로 볼 수 있습니다. 새 결정이면 새 id로 여세요.' },
39
- DECISION_CLOSED: { message: '접힌(닫힌) 결정이라 더 진행할 수 없습니다.', recovery: '필요하면 새 id로 다시 여세요. 닫힌 기록은 그대로 남습니다.' },
40
+ DECISION_CLOSED: { message: '닫힌 결정이라 더 진행할 수 없습니다.', recovery: '필요하면 새 id로 다시 여세요. 닫힌 기록은 그대로 남습니다.' },
40
41
  GOALPOST_MOVED: { message: '봉인된 예측 문장은 확인일 전에 바꿀 수 없습니다.', recovery: '일정 변경은 outcome="still_pending"과 defer_to로, 예측 자체가 달라졌다면 새 결정으로 여세요.' },
41
42
  NO_SUCH_PREMISE: { message: '해당 번호의 전제를 찾지 못했습니다 (이 결정에 아직 전제가 없을 수 있습니다).', recovery: 'argus_patterns view="decision_context"로 목록과 번호를 확인하고, 전제가 없으면 argus_capture action="add_context"로 먼저 추가하세요.' },
42
43
  WHAT_HAPPENED_REQUIRED: { message: '실제로 일어난 일을 기록해야 합니다.', recovery: '사용자에게 실제 결과를 물어 what_happened에 그대로 전달하세요.' },
@@ -64,9 +65,28 @@ const ENUM_HINTS = {
64
65
  kind: 'premise · open_question',
65
66
  predicate_owner: 'user · ai_surfaced',
66
67
  source: 'user_stated · ai_surfaced (update_fact에서는 url · user_stated · host_reported)',
67
- dismiss_reason: 'became_irrelevant · decided_elsewhere · superseded · user_declined',
68
+ dismiss_reason: 'became_irrelevant · decided_elsewhere · superseded · user_declined · changed_mind · other',
68
69
  };
69
70
  const DATE_FIELDS = new Set(['check_by', 'defer_to', 'today_override', 'snooze_until']);
71
+ const TYPE_KO = { string: '문자열', number: '숫자', boolean: '참/거짓', array: '목록', object: '객체', integer: '정수' };
72
+ /** The REAL allowed values, parsed from Zod's own English message
73
+ * ("… expected one of "a"|"b"|"c""). Lets the Korean surface show the ACTUAL
74
+ * per-tool enum instead of a hardcoded guess — ENUM_HINTS is keyed by field
75
+ * NAME, so `action` in argus_settings (status·update·sync) was being told the
76
+ * argus_capture action set. The message carries the truth per call; use it. */
77
+ function enumValuesFromMessage(msg) {
78
+ const m = msg?.match(/expected one of (.+)$/i);
79
+ if (!m)
80
+ return [];
81
+ return (m[1].match(/"([^"]+)"|'([^']+)'/g) ?? []).map((s) => s.replace(/["']/g, ''));
82
+ }
83
+ /** The offending key name(s) from Zod's "Unrecognized key(s): "x"" message, so
84
+ * the Korean surface can NAME what to remove instead of a blank "요청:". */
85
+ function keysFromMessage(msg) {
86
+ if (!msg || !/unrecognized key/i.test(msg))
87
+ return [];
88
+ return (msg.match(/"([^"]+)"|'([^']+)'/g) ?? []).map((s) => s.replace(/["']/g, ''));
89
+ }
70
90
  /** Translate one Zod issue into a Korean, actionable reason. Keeps the English
71
91
  * argument NAME (models and users see arg names in English), but says in Korean
72
92
  * WHY it failed — the piece the generic message threw away. Field-aware so an id
@@ -88,9 +108,12 @@ function koReason(issue, field) {
88
108
  return `항목이 너무 많습니다 (최대 ${issue.maximum}개)`;
89
109
  return `너무 큽니다 (최대 ${issue.maximum}${unit})`;
90
110
  case 'invalid_type':
91
- return issue.expected === undefined ? '형식이 올바르지 않습니다' : `필수이거나 형식이 올바르지 않습니다 (${issue.expected} 필요)`;
111
+ return issue.expected === undefined ? '형식이 올바르지 않습니다' : `필수이거나 형식이 올바르지 않습니다 (${TYPE_KO[issue.expected] ?? issue.expected} 형식 필요)`;
92
112
  case 'invalid_value':
93
113
  case 'invalid_enum_value': {
114
+ const vals = enumValuesFromMessage(issue.message);
115
+ if (vals.length)
116
+ return `허용되지 않는 값입니다 (가능: ${vals.join(' · ')})`;
94
117
  const hint = ENUM_HINTS[key];
95
118
  return hint ? `허용되지 않는 값입니다 (가능: ${hint})` : '허용되지 않는 값입니다';
96
119
  }
@@ -99,7 +122,7 @@ function koReason(issue, field) {
99
122
  case 'invalid_format':
100
123
  case 'invalid_string':
101
124
  if (key === 'id')
102
- return '영문·숫자와 . _ - 만 쓸 수 있습니다 (한글·공백·특수문자 불가 예: "career-move")';
125
+ return '영문·숫자와 . _ - 만 쓸 수 있습니다 (한글·공백·특수문자 불가, 예: "career-move")';
103
126
  if (DATE_FIELDS.has(key))
104
127
  return 'YYYY-MM-DD 형식의 날짜여야 합니다';
105
128
  return '형식이 올바르지 않습니다';
@@ -107,7 +130,7 @@ function koReason(issue, field) {
107
130
  // Same text as the invalid_format id case so the two issues the id regex +
108
131
  // superRefine both raise dedup to a single line.
109
132
  if (key === 'id')
110
- return '영문·숫자와 . _ - 만 쓸 수 있습니다 (한글·공백·특수문자 불가 예: "career-move")';
133
+ return '영문·숫자와 . _ - 만 쓸 수 있습니다 (한글·공백·특수문자 불가, 예: "career-move")';
111
134
  return '값을 확인해 주세요';
112
135
  }
113
136
  }
@@ -118,8 +141,19 @@ function localizeInvalidInput(fields) {
118
141
  const seen = new Set();
119
142
  const parts = [];
120
143
  for (const f of fields) {
121
- const name = f.field === '(root)' ? '요청' : f.field;
122
- const part = `${name}: ${koReason(f, f.field)}`;
144
+ let part;
145
+ if (f.code === 'unrecognized_keys') {
146
+ // The field path is just '(root)'; the useful info is WHICH key. Name it,
147
+ // and drop the meaningless "요청:" prefix that told the user nothing.
148
+ const keys = keysFromMessage(f.message);
149
+ part = keys.length
150
+ ? `${keys.map((k) => `"${k}"`).join(', ')}: 이 도구가 받지 않는 항목입니다`
151
+ : '이 도구가 받지 않는 항목입니다';
152
+ }
153
+ else {
154
+ const name = f.field === '(root)' ? '요청' : f.field;
155
+ part = `${name}: ${koReason(f, f.field)}`;
156
+ }
123
157
  if (seen.has(part))
124
158
  continue; // dedup "id: …, id: …" (regex + superRefine both fire)
125
159
  seen.add(part);
@@ -128,10 +162,91 @@ function localizeInvalidInput(fields) {
128
162
  break;
129
163
  }
130
164
  return {
131
- message: `입력값이 올바르지 않습니다 ${parts.join(', ')}.`,
165
+ message: `입력값이 올바르지 않습니다: ${parts.join(', ')}.`,
132
166
  recovery: '위에 표시된 인자를 고친 뒤 같은 도구를 다시 호출하세요. 사용자가 정해야 할 값은 추측하지 마세요.',
133
167
  };
134
168
  }
169
+ /** English mirror of koReason: a plain reason for one Zod issue, so an EN user
170
+ * sees "check_by is required" instead of raw "expected string, received undefined". */
171
+ function enReason(issue) {
172
+ const key = issue.field.split('.').pop() || issue.field;
173
+ switch (issue.code) {
174
+ case 'too_small':
175
+ if (issue.origin === 'string')
176
+ return `is too short (min ${issue.minimum} characters)`;
177
+ if (issue.origin === 'array')
178
+ return `needs at least ${issue.minimum} item${issue.minimum === 1 ? '' : 's'}`;
179
+ return `is too small (min ${issue.minimum})`;
180
+ case 'too_big':
181
+ if (issue.origin === 'string')
182
+ return `is too long (max ${issue.maximum} characters)`;
183
+ if (issue.origin === 'array')
184
+ return `has too many items (max ${issue.maximum})`;
185
+ return `is too big (max ${issue.maximum})`;
186
+ case 'invalid_type':
187
+ return issue.expected ? `is required (expected ${issue.expected})` : 'has the wrong format';
188
+ case 'invalid_value':
189
+ case 'invalid_enum_value': {
190
+ const vals = enumValuesFromMessage(issue.message);
191
+ return vals.length ? `must be one of ${vals.join(', ')}` : 'is not an allowed value';
192
+ }
193
+ case 'invalid_format':
194
+ case 'invalid_string':
195
+ if (key === 'id')
196
+ return 'may use only letters, digits and . _ - (no spaces or other characters, e.g. "career-move")';
197
+ if (DATE_FIELDS.has(key))
198
+ return 'must be a YYYY-MM-DD date';
199
+ return 'has the wrong format';
200
+ default:
201
+ if (key === 'id')
202
+ return 'may use only letters, digits and . _ - (no spaces or other characters, e.g. "career-move")';
203
+ return 'needs checking';
204
+ }
205
+ }
206
+ /** English INVALID_INPUT that NAMES the offending argument(s), instead of raw
207
+ * Zod ("(root): Unrecognized key", "expected string, received undefined"). */
208
+ function englishInvalidInput(fields) {
209
+ const recovery = 'Fix the named argument(s) and call the same tool again. Do not infer missing user-owned fields.';
210
+ if (!fields.length)
211
+ return { message: 'Invalid input.', recovery };
212
+ const seen = new Set();
213
+ const parts = [];
214
+ for (const f of fields) {
215
+ let part;
216
+ if (f.code === 'unrecognized_keys') {
217
+ const keys = keysFromMessage(f.message);
218
+ part = keys.length
219
+ ? `${keys.map((k) => `"${k}"`).join(', ')} ${keys.length === 1 ? "isn't a field" : "aren't fields"} this tool accepts`
220
+ : 'an unexpected field was passed';
221
+ }
222
+ else {
223
+ const name = f.field === '(root)' ? 'the request' : f.field;
224
+ part = `${name} ${enReason(f)}`;
225
+ }
226
+ if (seen.has(part))
227
+ continue;
228
+ seen.add(part);
229
+ parts.push(part);
230
+ if (parts.length >= 4)
231
+ break;
232
+ }
233
+ return { message: `Invalid input: ${parts.join('; ')}.`, recovery };
234
+ }
235
+ /** Friendly English for the codes whose handler/guard message otherwise leaks
236
+ * internal machinery (raw Zod, state-machine states). Codes NOT listed keep
237
+ * their handler's already-fine English. */
238
+ const EN_FRIENDLY = {
239
+ ILLEGAL_TRANSITION: {
240
+ message: "This isn't a step you can take on this decision right now (the id may be a typo, or it may already be saved, settled, or closed).",
241
+ recovery: 'Check the id and its state with argus_patterns view="all". If no such id exists, start fresh with argus_capture or argus_predict.',
242
+ },
243
+ };
244
+ function englishHumanize(code, sc) {
245
+ if (code === 'INVALID_INPUT' && Array.isArray(sc['invalid_fields'])) {
246
+ return englishInvalidInput(sc['invalid_fields']);
247
+ }
248
+ return EN_FRIENDLY[code] ?? null;
249
+ }
135
250
  const REPRESENTATIVE_FIELDS = [
136
251
  'decision', 'predicate', 'what_happened', 'finding', 'text', 'question',
137
252
  'human_judgment', 'note', 'title', 'biggest_worry',
@@ -150,12 +265,23 @@ function responseLocale(args) {
150
265
  * user surface even on validation, guard, and rare failure paths.
151
266
  */
152
267
  export function localizeToolResult(args, result) {
153
- if (!result.isError || responseLocale(args) !== 'ko')
268
+ if (!result.isError)
154
269
  return result;
155
270
  const sc = result.structuredContent;
156
271
  if (!sc || sc['ok'] !== false)
157
272
  return result;
158
273
  const code = String(sc['error_code'] ?? 'INTERNAL_ERROR');
274
+ if (responseLocale(args) !== 'ko') {
275
+ // English path: handler prose is already English — humanize ONLY the codes
276
+ // that otherwise leak raw Zod / state-machine internals to the user.
277
+ const en = englishHumanize(code, sc);
278
+ if (!en)
279
+ return result;
280
+ const enLocalized = { ...sc, message: en.message, ...(en.recovery ? { recovery: en.recovery } : {}) };
281
+ result.structuredContent = enLocalized;
282
+ result.content = [{ type: 'text', text: JSON.stringify(enLocalized, null, 2) }];
283
+ return result;
284
+ }
159
285
  // Some handlers already return a hand-written Korean message (e.g.
160
286
  // NOT_FALSIFIABLE: "이건 기분이지 확인 가능한 예측이 아닙니다"). If this code
161
287
  // isn't in KO_ERRORS, the generic fallback used to DESTROY that Korean copy.
@@ -168,6 +294,27 @@ export function localizeToolResult(args, result) {
168
294
  let copy = code === 'INVALID_INPUT' && Array.isArray(sc['invalid_fields'])
169
295
  ? localizeInvalidInput(sc['invalid_fields'])
170
296
  : KO_ERRORS[code] ?? genericFallback;
297
+ // A handler-authored KOREAN message is at least as specific as the generic
298
+ // map — KO_ERRORS exists to replace ENGLISH copy, not better Korean. Without
299
+ // this, errors.ts's "내부 오류가 발생했습니다: EACCES …" lost its detail to
300
+ // the generic '내부 오류가 발생했습니다.' (1.4.6 backlog: ko detail loss).
301
+ // Quoted spans are stripped BEFORE the Hangul test: an English template that
302
+ // merely embeds the user's Korean predicate ('already sealed: "매출 1억…"')
303
+ // is still English-authored and must still be replaced.
304
+ const authoredKo = (s) => /[가-힣]/.test(s.replace(/"[^"]*"|'[^']*'|「[^」]*」/g, ''));
305
+ if (code !== 'INVALID_INPUT' && authoredKo(existingMsg)) {
306
+ copy = {
307
+ message: existingMsg,
308
+ ...(existingRec && authoredKo(existingRec) ? { recovery: existingRec } : copy.recovery ? { recovery: copy.recovery } : {}),
309
+ };
310
+ }
311
+ else if (code === 'INTERNAL_ERROR') {
312
+ // English-authored internal error: carry the diagnostic detail across the
313
+ // language switch instead of discarding it.
314
+ const d = existingMsg.match(/^Internal error: ([\s\S]+)$/);
315
+ if (d)
316
+ copy = { message: `내부 오류가 발생했습니다: ${d[1]}`, ...(copy.recovery ? { recovery: copy.recovery } : {}) };
317
+ }
171
318
  // en에만 있던 날짜 상세를 ko에서도 보존 — "언제가 확인일인데?"에 답이 되도록.
172
319
  if (code === 'PREMATURE_SETTLE') {
173
320
  const m = String(sc['message'] ?? '').match(/check-by (\d{4}-\d{2}-\d{2}), today (\d{4}-\d{2}-\d{2})/);