argus-decision-mcp 1.4.2 → 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 (46) 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/due-note.js +6 -1
  6. package/dist/lib/elicit.js +9 -1
  7. package/dist/lib/ledger-append.js +102 -20
  8. package/dist/lib/ledger-replay.js +58 -18
  9. package/dist/lib/locale-mismatch.js +2 -2
  10. package/dist/lib/localize-result.js +215 -18
  11. package/dist/lib/numeric-drift.js +13 -13
  12. package/dist/lib/receipt.js +17 -1
  13. package/dist/lib/render-receipt.js +111 -57
  14. package/dist/lib/resolve-today.js +34 -0
  15. package/dist/lib/review/extract-file-node.js +2 -2
  16. package/dist/lib/review/render.js +6 -6
  17. package/dist/lib/review/routing.js +6 -6
  18. package/dist/lib/review/schema.js +9 -1
  19. package/dist/lib/safe-path.js +16 -0
  20. package/dist/lib/spine.js +30 -15
  21. package/dist/lib/state-machine.js +2 -2
  22. package/dist/lib/surfaces.js +65 -45
  23. package/dist/lib/telemetry.js +3 -3
  24. package/dist/lib/tool-presentation.js +4 -4
  25. package/dist/lib/untrusted.js +3 -3
  26. package/dist/lib/validate-crux.js +3 -3
  27. package/dist/lib/validate-seal.js +26 -7
  28. package/dist/resources.js +6 -1
  29. package/dist/server.js +15 -10
  30. package/dist/tools/amend-dismiss.js +30 -4
  31. package/dist/tools/candidates.js +1 -1
  32. package/dist/tools/check-in.js +46 -6
  33. package/dist/tools/errors.js +18 -4
  34. package/dist/tools/init-config.js +14 -0
  35. package/dist/tools/open-decision.js +6 -5
  36. package/dist/tools/premises.js +29 -9
  37. package/dist/tools/public-tools.js +37 -21
  38. package/dist/tools/recall.js +44 -14
  39. package/dist/tools/recheck.js +65 -11
  40. package/dist/tools/review.js +6 -6
  41. package/dist/tools/seal.js +15 -14
  42. package/dist/tools/settle.js +44 -22
  43. package/dist/tools/tool-types.js +35 -9
  44. package/dist/v2/lifecycle-cli.js +23 -8
  45. package/dist/v2/lifecycle.js +27 -0
  46. package/package.json +1 -1
@@ -4,7 +4,7 @@ const KO_ERRORS = {
4
4
  INVALID_LOCALE: { message: '지원하지 않는 언어입니다.', recovery: 'locale에는 "ko" 또는 "en"을 사용하세요.' },
5
5
  ALREADY_CLOSED: { message: '이미 진행 중이거나 닫힌 결정입니다.', recovery: '실제 결과를 기록하려면 argus_resolve를 사용하세요. 닫힌 결정은 다시 열지 않습니다.' },
6
6
  CAPTURE_NOT_FOUND: { message: '일치하는 내부 메모를 찾지 못했습니다.', recovery: '전제 문장을 text에 직접 전달하세요.' },
7
- AMBIGUOUS_REF: { message: '여러 내부 메모와 일치해 대상을 정할 수 없습니다.', recovery: '전제 문장을 text에 직접 전달하세요.' },
7
+ AMBIGUOUS_REF: { message: '참조가 여러 항목과 일치합니다.', recovery: 'P1 같은 서수(번호)로 정확히 지정하세요. 번호는 argus_patterns view="decision_context"에서 볼 수 있습니다.' },
8
8
  PROVENANCE_REQUIRED: { message: '문장의 출처를 확인해야 합니다.', recovery: '사용자가 쓴 문장이면 user_stated, AI가 제기한 문장이면 ai_surfaced와 원문을 전달하세요.' },
9
9
  PREMISES_REQUIRED: { message: '추가할 전제가 없습니다.', recovery: 'text, kind, external, load_bearing, source를 포함한 전제 1~5개를 전달하세요.' },
10
10
  PREMISE_ID_COLLISION: { message: '다른 전제가 같은 식별자를 사용하고 있습니다.', recovery: '전제 문장을 조금 다르게 표현한 뒤 다시 추가하세요.' },
@@ -29,13 +29,17 @@ 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를 쓰세요.)' },
33
- BAD_CHECK_BY: { message: '확인일이 오늘 이후의 날짜(YYYY-MM-DD)가 아닙니다.', recovery: 'check_by를 오늘 이후 날짜로 다시 전달하세요.' },
34
- ILLEGAL_TRANSITION: { message: '지금 상태에서는 할 수 없는 작업입니다.', recovery: 'argus_patterns view="all"로 결정의 현재 상태를 확인한 맞는 도구를 사용하세요.' },
32
+ NO_PRIOR_SEAL: { message: '이 id로 저장된 예측이 없습니다.', recovery: 'argus_predict로 나중에 확인할 수 있는 예측과 확인일을 먼저 저장하세요. (id가 argus_settings sync에서 온 "mcp_" 접두사라면 접두사를 뗀 id를 쓰세요.)' },
33
+ BAD_CHECK_BY: { message: '확인일이 오늘 이후의 실제 달력 날짜(YYYY-MM-DD)가 아닙니다 (예: 2026-13-01처럼 없는 달·날짜는 불가).', recovery: '오늘 이후의 올바른 날짜를 YYYY-MM-DD로 다시 전달하세요.' },
34
+ ILLEGAL_TRANSITION: { message: ' 결정에 지금은 할 수 없는 작업입니다 (id 오타이거나, 이미 저장·정산·종료된 상태일 수 있습니다).', recovery: 'argus_patterns view="all"로 id와 현재 상태를 확인하세요. 없는 id면 argus_capture 또는 argus_predict로 새로 시작하세요.' },
35
+ PREMISE_LOCKED: { message: '확인일이 지나 전제를 더는 바꿀 수 없습니다.', recovery: '먼저 argus_resolve로 실제 결과를 기록하세요. 확인일이 온 뒤에는 전제/예측을 고칠 수 없습니다.' },
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)을 실제로 있고 쓸 수 있는 폴더로 바꿔 주세요. 실제 드라이브의 절대 경로여야 하고 ".."은 넣을 수 없습니다. 그다음 다시 시도하세요.' },
38
+ EMPTY_PREDICATE: { message: '확인 가능한 예측 문장이 필요합니다 (공백 제외 최소 8자).', recovery: '현실이 참/거짓으로 확인할 수 있는 문장으로 다시 적으세요. 예: "컷오버 다운타임 5분 미만".' },
35
39
  ALREADY_SETTLED: { message: '이미 실제 결과가 기록된 결정입니다.', recovery: '영수증은 argus_patterns view="receipt"로 볼 수 있습니다. 새 결정이면 새 id로 여세요.' },
36
- DECISION_CLOSED: { message: '접힌(닫힌) 결정이라 더 진행할 수 없습니다.', recovery: '필요하면 새 id로 다시 여세요. 닫힌 기록은 그대로 남습니다.' },
40
+ DECISION_CLOSED: { message: '닫힌 결정이라 더 진행할 수 없습니다.', recovery: '필요하면 새 id로 다시 여세요. 닫힌 기록은 그대로 남습니다.' },
37
41
  GOALPOST_MOVED: { message: '봉인된 예측 문장은 확인일 전에 바꿀 수 없습니다.', recovery: '일정 변경은 outcome="still_pending"과 defer_to로, 예측 자체가 달라졌다면 새 결정으로 여세요.' },
38
- NO_SUCH_PREMISE: { message: '해당 번호의 전제를 찾지 못했습니다.', recovery: 'argus_patterns view="decision_context"로 전제 목록과 번호를 확인하세요.' },
42
+ NO_SUCH_PREMISE: { message: '해당 번호의 전제를 찾지 못했습니다 (이 결정에 아직 전제가 없을 수 있습니다).', recovery: 'argus_patterns view="decision_context"로 목록과 번호를 확인하고, 전제가 없으면 argus_capture action="add_context"로 먼저 추가하세요.' },
39
43
  WHAT_HAPPENED_REQUIRED: { message: '실제로 일어난 일을 기록해야 합니다.', recovery: '사용자에게 실제 결과를 물어 what_happened에 그대로 전달하세요.' },
40
44
  DEFER_DATE_REQUIRED: { message: '다시 확인할 날짜가 필요합니다.', recovery: '사용자에게 날짜를 물어 defer_to에 YYYY-MM-DD로 전달하세요. 더는 중요하지 않다면 argus_capture action="close"를 사용하세요.' },
41
45
  NOT_CONNECTED: { message: '이 터미널은 Argus 계정과 연결돼 있지 않습니다.', recovery: '웹 설정에서 동기화 토큰을 발급하고 MCP 설정의 ARGUS_TOKEN에 넣으세요.' },
@@ -46,10 +50,49 @@ const KO_ERRORS = {
46
50
  UNKNOWN_TOOL: { message: '알 수 없는 도구입니다.', recovery: 'tools/list에 나온 정확한 도구 이름을 사용하세요.' },
47
51
  };
48
52
  export const LOCALIZED_ERROR_CODES = new Set(Object.keys(KO_ERRORS));
53
+ // The allowed values for each enum field, so a wrong guess (stakes="medium",
54
+ // outcome="success", …) TEACHES the model the valid set instead of a bare "not
55
+ // allowed". Keyed by the field's LAST path segment (so premises.0.kind → kind).
56
+ const ENUM_HINTS = {
57
+ stakes: 'trivial · low · moderate · high',
58
+ reversibility: 'one_way_door · costly_to_reverse · easily_reversible',
59
+ outcome: 'held · avoided · partial · still_pending · missed',
60
+ outcome_source: 'user_stated',
61
+ view: 'active · all · receipt · decision_context · timeline · reflection',
62
+ action: 'open · add_context · answer_question · keep_question_open · update_fact · change_prediction · close',
63
+ locale: 'ko · en',
64
+ basis: 'judgment · luck · mixed · unsure',
65
+ kind: 'premise · open_question',
66
+ predicate_owner: 'user · ai_surfaced',
67
+ source: 'user_stated · ai_surfaced (update_fact에서는 url · user_stated · host_reported)',
68
+ dismiss_reason: 'became_irrelevant · decided_elsewhere · superseded · user_declined · changed_mind · other',
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
+ }
49
90
  /** Translate one Zod issue into a Korean, actionable reason. Keeps the English
50
91
  * argument NAME (models and users see arg names in English), but says in Korean
51
- * WHY it failed — the piece the generic message threw away. */
52
- function koReason(issue) {
92
+ * WHY it failed — the piece the generic message threw away. Field-aware so an id
93
+ * regex failure isn't told to "use YYYY-MM-DD" and an enum lists its values. */
94
+ function koReason(issue, field) {
95
+ const key = field.split('.').pop() || field;
53
96
  const unit = issue.origin === 'string' ? '자' : issue.origin === 'array' ? '개' : '';
54
97
  switch (issue.code) {
55
98
  case 'too_small':
@@ -65,16 +108,29 @@ function koReason(issue) {
65
108
  return `항목이 너무 많습니다 (최대 ${issue.maximum}개)`;
66
109
  return `너무 큽니다 (최대 ${issue.maximum}${unit})`;
67
110
  case 'invalid_type':
68
- return issue.expected === undefined ? '형식이 올바르지 않습니다' : `필수이거나 형식이 올바르지 않습니다 (${issue.expected} 필요)`;
111
+ return issue.expected === undefined ? '형식이 올바르지 않습니다' : `필수이거나 형식이 올바르지 않습니다 (${TYPE_KO[issue.expected] ?? issue.expected} 형식 필요)`;
69
112
  case 'invalid_value':
70
- case 'invalid_enum_value':
71
- return '허용되지 않는 값입니다';
113
+ case 'invalid_enum_value': {
114
+ const vals = enumValuesFromMessage(issue.message);
115
+ if (vals.length)
116
+ return `허용되지 않는 값입니다 (가능: ${vals.join(' · ')})`;
117
+ const hint = ENUM_HINTS[key];
118
+ return hint ? `허용되지 않는 값입니다 (가능: ${hint})` : '허용되지 않는 값입니다';
119
+ }
72
120
  case 'unrecognized_keys':
73
121
  return '알 수 없는 항목입니다';
74
122
  case 'invalid_format':
75
123
  case 'invalid_string':
76
- return '형식이 올바르지 않습니다 (예: 날짜는 YYYY-MM-DD)';
124
+ if (key === 'id')
125
+ return '영문·숫자와 . _ - 만 쓸 수 있습니다 (한글·공백·특수문자 불가, 예: "career-move")';
126
+ if (DATE_FIELDS.has(key))
127
+ return 'YYYY-MM-DD 형식의 날짜여야 합니다';
128
+ return '형식이 올바르지 않습니다';
77
129
  default:
130
+ // Same text as the invalid_format id case so the two issues the id regex +
131
+ // superRefine both raise dedup to a single line.
132
+ if (key === 'id')
133
+ return '영문·숫자와 . _ - 만 쓸 수 있습니다 (한글·공백·특수문자 불가, 예: "career-move")';
78
134
  return '값을 확인해 주세요';
79
135
  }
80
136
  }
@@ -82,12 +138,115 @@ function koReason(issue) {
82
138
  function localizeInvalidInput(fields) {
83
139
  if (!fields.length)
84
140
  return KO_ERRORS.INVALID_INPUT;
85
- const parts = fields.slice(0, 4).map((f) => `${f.field === '(root)' ? '요청' : f.field}: ${koReason(f)}`);
141
+ const seen = new Set();
142
+ const parts = [];
143
+ for (const f of fields) {
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
+ }
157
+ if (seen.has(part))
158
+ continue; // dedup "id: …, id: …" (regex + superRefine both fire)
159
+ seen.add(part);
160
+ parts.push(part);
161
+ if (parts.length >= 4)
162
+ break;
163
+ }
86
164
  return {
87
- message: `입력값이 올바르지 않습니다 ${parts.join(', ')}.`,
165
+ message: `입력값이 올바르지 않습니다: ${parts.join(', ')}.`,
88
166
  recovery: '위에 표시된 인자를 고친 뒤 같은 도구를 다시 호출하세요. 사용자가 정해야 할 값은 추측하지 마세요.',
89
167
  };
90
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
+ }
91
250
  const REPRESENTATIVE_FIELDS = [
92
251
  'decision', 'predicate', 'what_happened', 'finding', 'text', 'question',
93
252
  'human_judgment', 'note', 'title', 'biggest_worry',
@@ -106,18 +265,56 @@ function responseLocale(args) {
106
265
  * user surface even on validation, guard, and rare failure paths.
107
266
  */
108
267
  export function localizeToolResult(args, result) {
109
- if (!result.isError || responseLocale(args) !== 'ko')
268
+ if (!result.isError)
110
269
  return result;
111
270
  const sc = result.structuredContent;
112
271
  if (!sc || sc['ok'] !== false)
113
272
  return result;
114
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
+ }
285
+ // Some handlers already return a hand-written Korean message (e.g.
286
+ // NOT_FALSIFIABLE: "이건 기분이지 확인 가능한 예측이 아닙니다"). If this code
287
+ // isn't in KO_ERRORS, the generic fallback used to DESTROY that Korean copy.
288
+ // Preserve any message that already contains Hangul instead of overwriting it.
289
+ const existingMsg = typeof sc['message'] === 'string' ? sc['message'] : '';
290
+ const existingRec = typeof sc['recovery'] === 'string' ? sc['recovery'] : '';
291
+ const genericFallback = /[가-힣]/.test(existingMsg)
292
+ ? { message: existingMsg, ...(existingRec ? { recovery: existingRec } : {}) }
293
+ : { message: '요청을 처리하지 못했습니다.', recovery: '입력값과 현재 결정 상태를 확인한 뒤 다시 시도하세요.' };
115
294
  let copy = code === 'INVALID_INPUT' && Array.isArray(sc['invalid_fields'])
116
295
  ? localizeInvalidInput(sc['invalid_fields'])
117
- : KO_ERRORS[code] ?? {
118
- message: '요청을 처리하지 못했습니다.',
119
- recovery: '입력값과 현재 결정 상태를 확인한 다시 시도하세요.',
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 } : {}),
120
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
+ }
121
318
  // en에만 있던 날짜 상세를 ko에서도 보존 — "언제가 확인일인데?"에 답이 되도록.
122
319
  if (code === 'PREMATURE_SETTLE') {
123
320
  const m = String(sc['message'] ?? '').match(/check-by (\d{4}-\d{2}-\d{2}), today (\d{4}-\d{2}-\d{2})/);
@@ -111,7 +111,7 @@ export function evaluateMateriality(prev, next, rule, ctx) {
111
111
  return { status: 'unchanged', reason: '라벨 동일 (정규화 후)' };
112
112
  return {
113
113
  status: 'uncertain',
114
- reason: '비수치/미정규 라벨 canonical scale를 지정하면 기계 판정 가능 (host 판정 레인)',
114
+ reason: '비수치/미정규 라벨. canonical scale를 지정하면 기계 판정 가능 (host 판정 레인)',
115
115
  low_confidence: true,
116
116
  };
117
117
  }
@@ -120,7 +120,7 @@ export function evaluateMateriality(prev, next, rule, ctx) {
120
120
  pv = asNumber(prev);
121
121
  nv = asNumber(next);
122
122
  if (pv === null || nv === null) {
123
- return { status: 'unchanged', reason: 'non-finite input not comparable' };
123
+ return { status: 'unchanged', reason: 'non-finite input; not comparable' };
124
124
  }
125
125
  // unit canonicalization before comparing (§3.2)
126
126
  if (ctx?.unit_from) {
@@ -159,7 +159,7 @@ export function evaluateMateriality(prev, next, rule, ctx) {
159
159
  }
160
160
  return {
161
161
  status: 'uncertain',
162
- reason: '부호 전환이나 0의 의미(zero_meaningful) 미선언 규칙을 정해주세요',
162
+ reason: '부호 전환이나 0의 의미(zero_meaningful) 미선언. 규칙을 정해주세요',
163
163
  low_confidence: true,
164
164
  };
165
165
  }
@@ -169,7 +169,7 @@ export function evaluateMateriality(prev, next, rule, ctx) {
169
169
  if (axis === 'ratio') {
170
170
  return {
171
171
  status: 'uncertain',
172
- reason: '값이 비율/%% %p(delta)로 볼지 complement 축으로 볼지 정해주세요',
172
+ reason: '값이 비율(%)입니다. %p(퍼센트포인트)로 볼지 여집합(100−값) 축으로 볼지 정해주세요',
173
173
  low_confidence: true,
174
174
  };
175
175
  }
@@ -190,14 +190,14 @@ export function evaluateMateriality(prev, next, rule, ctx) {
190
190
  }
191
191
  return rel >= BIG_MULT
192
192
  ? { status: 'material', reason: `near-zero ${rel.toFixed(1)}배 이동: ${pv} → ${nv}` }
193
- : { status: 'uncertain', reason: 'near-zero 애매 규칙(delta/safety_floor)을 정해주세요', low_confidence: true };
193
+ : { status: 'uncertain', reason: 'near-zero 애매. 규칙(delta/safety_floor)을 정해주세요', low_confidence: true };
194
194
  }
195
195
  // 4b/4c. general scale-free: knife-edge FIRST (exactly-at-threshold is the
196
196
  // >= vs > hostage case → uncertain, §8 PCT-08/DATE-09), then relative.
197
197
  const rel = Math.abs(nv - pv) / Math.abs(pv);
198
198
  const P = REL_DEFAULT;
199
199
  if (Math.abs(rel - P) <= EPS) {
200
- return { status: 'uncertain', reason: `상대변화가 임계 경계에 걸침 (${Math.round(rel * 100)}%) 규칙/밴드를 정해주세요`, low_confidence: true };
200
+ return { status: 'uncertain', reason: `상대변화가 임계 경계에 걸침 (${Math.round(rel * 100)}%). 규칙/밴드를 정해주세요`, low_confidence: true };
201
201
  }
202
202
  if (rel >= P) {
203
203
  return { status: 'material', reason: `${Math.round(rel * 100)}% 이동: ${pv} → ${nv}` };
@@ -213,7 +213,7 @@ function evaluateDeclaredRule(pv, nv, rule, ctx) {
213
213
  // then it's uncertain, never a manufactured alert. (threshold/band/step/map
214
214
  // are axis-agnostic — a crossed line is a crossed line.)
215
215
  if (mod?.unit_axis === 'ratio' && (rule.type === 'relative' || rule.type === 'delta')) {
216
- return { status: 'uncertain', reason: '값이 비율/%% %p(delta)로 볼지 complement 축으로 볼지 정해주세요', low_confidence: true };
216
+ return { status: 'uncertain', reason: '값이 비율(%)입니다. %p(퍼센트포인트)로 볼지 여집합(100−값) 축으로 볼지 정해주세요', low_confidence: true };
217
217
  }
218
218
  // sign-flip modifier (§2.3 / §6.5): dead-band AND, never "always material".
219
219
  // A declared sign_flip is decided here (not by the raw delta/relative magnitude).
@@ -225,7 +225,7 @@ function evaluateDeclaredRule(pv, nv, rule, ctx) {
225
225
  if (Math.abs(pv) >= floor && Math.abs(nv) >= floor) {
226
226
  return mod.zero_meaningful === true
227
227
  ? { status: 'material', reason: `부호 전환: ${pv} → ${nv}` }
228
- : { status: 'uncertain', reason: '부호 전환이나 0의 의미(zero_meaningful) 미선언 규칙을 정해주세요', low_confidence: true };
228
+ : { status: 'uncertain', reason: '부호 전환이나 0의 의미(zero_meaningful) 미선언. 규칙을 정해주세요', low_confidence: true };
229
229
  }
230
230
  return { status: 'unchanged', reason: 'dead-band 안 왕복 = 노이즈' };
231
231
  }
@@ -251,7 +251,7 @@ function evaluateDeclaredRule(pv, nv, rule, ctx) {
251
251
  // boundary undefined and it matters near the line → uncertain (§1.4)
252
252
  const atLine = pv === line || nv === line;
253
253
  if (atLine)
254
- return { status: 'uncertain', reason: 'threshold 경계 도달인데 boundary(inclusive/exclusive) 미지정 정해주세요', low_confidence: true };
254
+ return { status: 'uncertain', reason: 'threshold 경계 도달인데 boundary(inclusive/exclusive) 미지정. 정해주세요', low_confidence: true };
255
255
  }
256
256
  const incl = boundary === 'inclusive';
257
257
  const crossed = (() => {
@@ -291,10 +291,10 @@ function evaluateDeclaredRule(pv, nv, rule, ctx) {
291
291
  case 'relative': {
292
292
  const P = numParam(rule.params['P'], REL_DEFAULT);
293
293
  if (pv === 0)
294
- return { status: 'uncertain', reason: 'relative 기준값 0 delta 규칙이 필요합니다', low_confidence: true };
294
+ return { status: 'uncertain', reason: 'relative 기준값 0. delta 규칙이 필요합니다', low_confidence: true };
295
295
  const rel = Math.abs(nv - pv) / Math.abs(pv);
296
296
  if (Math.abs(rel - P) <= EPS) {
297
- return { status: 'uncertain', reason: `상대변화가 임계 경계에 걸침 (${Math.round(rel * 100)}%) 규칙/밴드를 정해주세요`, low_confidence: true };
297
+ return { status: 'uncertain', reason: `상대변화가 임계 경계에 걸침 (${Math.round(rel * 100)}%). 규칙/밴드를 정해주세요`, low_confidence: true };
298
298
  }
299
299
  return rel >= P
300
300
  ? dirGate({ status: 'material', reason: `${Math.round(rel * 100)}% 이동: ${pv} → ${nv}` })
@@ -316,7 +316,7 @@ function evaluateDeclaredRule(pv, nv, rule, ctx) {
316
316
  // v1: opt-in only, and this numeric path sees just two snapshots — it cannot
317
317
  // observe a window. Honest: surface as uncertain (path/volatility not decidable
318
318
  // from prev→next alone), never fabricate a peak/crossing verdict (§1.3, §10.5).
319
- return { status: 'uncertain', reason: 'stateful(경로/변동성)은 두 스냅샷으로 판정 불가 관측 이력이 필요합니다', low_confidence: true };
319
+ return { status: 'uncertain', reason: 'stateful(경로/변동성)은 두 스냅샷으로 판정 불가. 관측 이력이 필요합니다', low_confidence: true };
320
320
  case 'map':
321
321
  // handled in the label path; a numeric map here means mis-typed rule.
322
322
  return { status: 'uncertain', reason: 'map 규칙은 라벨 값에만 적용됩니다', low_confidence: true };
@@ -337,7 +337,7 @@ function evaluateMap(prevLabel, nextLabel, rule) {
337
337
  }
338
338
  return {
339
339
  status: 'uncertain',
340
- reason: `상태 전이이나 등록된 material 상태집합 밖: ${prevLabel} → ${nextLabel} 규칙 정할지 당신 몫`,
340
+ reason: `상태 전이이나 등록된 material 상태집합 밖: ${prevLabel} → ${nextLabel}. 규칙 정할지 당신 몫`,
341
341
  low_confidence: true,
342
342
  };
343
343
  }
@@ -64,6 +64,15 @@ fallback) {
64
64
  null;
65
65
  const merged = {
66
66
  ...base,
67
+ // The ledger is the source of truth for the sealed prediction and its date.
68
+ // A change_prediction (amend) updates the CONTRACT but not the seal-time
69
+ // receipt file on disk, so `base.predicate`/`check_by` can be STALE — the
70
+ // receipt then contradicts every list view (dogfood: receipt printed the
71
+ // pre-amend "8월" predicate while the record showed the amended "9월" one).
72
+ // Prefer the current contract values the settle handler resolved from the
73
+ // fold, so the keepsake can never disagree with the ledger.
74
+ ...(fallback?.predicate ? { predicate: fallback.predicate } : {}),
75
+ ...(fallback?.check_by ? { check_by: fallback.check_by } : {}),
67
76
  settled_at: patch.settled_at,
68
77
  what_happened: patch.what_happened,
69
78
  outcome: patch.outcome,
@@ -79,7 +88,14 @@ fallback) {
79
88
  }
80
89
  export function readReceipt(argusDir, id) {
81
90
  try {
82
- return JSON.parse(deBom(fs.readFileSync(receiptPath(argusDir, id), 'utf8')));
91
+ const parsed = JSON.parse(deBom(fs.readFileSync(receiptPath(argusDir, id), 'utf8')));
92
+ // A hand-edited / corrupt receipt could be a primitive, null, or an array;
93
+ // renderReceipt does r.predicate.split(...) unguarded and writeSettleReceipt
94
+ // spreads `base`, so a non-object would crash the render / drop every field.
95
+ // Reject anything but a plain object — a corrupt keepsake degrades, not dies.
96
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
97
+ return null;
98
+ return parsed;
83
99
  }
84
100
  catch {
85
101
  return null;