argus-decision-mcp 1.4.2 → 1.4.6

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.
@@ -88,6 +88,7 @@ export const SURFACES = {
88
88
  },
89
89
  checkin: {
90
90
  nothing_due: 'Nothing is due right now.',
91
+ first_run: 'Argus is ready. Tell me a decision you are weighing. I can clarify what it rests on, save a prediction with a check-by date, and record what actually happened when that date arrives. Nothing is tracked yet; describe a decision to begin.',
91
92
  account_hint: ' This screen reads the local decision record only. Predictions saved in your account show up with argus_settings action=sync.',
92
93
  upcoming: (n, days) => ` ${n} coming due within ${days} day(s). Informational; no result is due yet.`,
93
94
  due_contracts: (n) => `${n} saved prediction(s) past check-by. Time to record what happened (argus_resolve).`,
@@ -131,7 +132,8 @@ export const SURFACES = {
131
132
  days_past: (n) => `${n}d past`,
132
133
  waiting_group: (n) => `waiting for the outcome (${n})`,
133
134
  answer_on: (date) => `due ${date}`,
134
- settled_group: (n, held, avoided, partial) => `results recorded (${n}): held ${held} · avoided ${avoided} · partial ${partial}`,
135
+ settled_group: (n, held, avoided, partial, missed) => `results recorded (${n}): held ${held} · avoided ${avoided} · partial ${partial} · missed ${missed}`,
136
+ outcome_label: (o) => ({ held: 'held', avoided: 'avoided', partial: 'partial', missed: 'missed', still_pending: 'pending' })[o] ?? o,
135
137
  more: (n) => `… (+${n})`,
136
138
  record_since: (date) => `on record since ${date}`,
137
139
  },
@@ -145,8 +147,8 @@ export const SURFACES = {
145
147
  human_only: 'HUMAN-ONLY CALL',
146
148
  made_by_label: '…made by',
147
149
  made_by: 'Me. (not the model)',
148
- called_as: '…called it',
149
- basis_label: (v) => ({ judgment: 'judgment', luck: 'luck', mixed: 'a mix of both', unsure: 'not sure' })[v] ?? v,
150
+ called_as: '…looking back',
151
+ basis_label: (v) => ({ judgment: 'mostly my judgment', luck: 'mostly luck', mixed: 'a mix of both', unsure: 'not sure' })[v] ?? v,
150
152
  // A blank field, stated neutrally — "you skipped naming this" read as a
151
153
  // nag about the user's completeness on a receipt they wanted plain
152
154
  // (experience loop, settler: a zero-judgment surface must not grade even
@@ -176,7 +178,7 @@ export const SURFACES = {
176
178
  watch_exit: ' Leaving it unrecorded is fine.',
177
179
  reconfirm: 'These signals look contradictory (high stakes yet easily reversible). Re-confirm stakes and reversibility before going further.',
178
180
  opened_with_crux: (crux) => `Opened. The one question that decides this: ${crux}`,
179
- opened_bare: 'Opened. Surface exactly ONE neutral crux question (a question, not a fork or a lean), then save a falsifiable prediction.',
181
+ opened_bare: 'Opened. If one neutral question decides this, naming it is the next step. Then save a falsifiable prediction.',
180
182
  },
181
183
  seal: {
182
184
  sealed: (predicate, checkBy) => `Prediction saved. "${predicate}" Check-by is ${checkBy}. Come back then with argus_resolve to record what happened.`,
@@ -230,6 +232,7 @@ export const SURFACES = {
230
232
  },
231
233
  checkin: {
232
234
  nothing_due: '지금 확인할 차례가 된 것은 없습니다.',
235
+ first_run: 'Argus가 준비됐습니다. 지금 고민 중인 결정을 하나 말해 주세요. 그 결정이 어떤 전제 위에 서 있는지 정리하고, 예측과 확인일을 저장한 뒤, 그 날이 오면 실제로 어떻게 됐는지 기록합니다. 아직 기록된 것은 없습니다. 결정을 말하면 시작합니다.',
233
236
  account_hint: ' 이 화면은 로컬 판단 기록만 읽습니다. 계정에 저장한 예측은 argus_settings action=sync로 가져올 수 있습니다.',
234
237
  upcoming: (n, days) => ` ${days}일 안에 확인일이 오는 예측이 ${n}건 있습니다. 아직 결과를 기록할 때는 아닙니다.`,
235
238
  due_contracts: (n) => `저장한 예측 ${n}건이 확인일을 지났습니다. 실제 결과를 기록할 차례입니다 (argus_resolve).`,
@@ -273,9 +276,10 @@ export const SURFACES = {
273
276
  days_past: (n) => `${n}일 경과`,
274
277
  waiting_group: (n) => `결과를 기다리는 중 (${n})`,
275
278
  answer_on: (date) => `답 ${date}`,
276
- settled_group: (n, held, avoided, partial) => `결과 기록됨 (${n}): 그렇게 됨 ${held} · 피함 ${avoided} · 부분 ${partial}`,
279
+ settled_group: (n, held, avoided, partial, missed) => `결과 기록됨 (${n}): 그렇게 됨 ${held} · 피함 ${avoided} · 부분 ${partial} · 빗나감 ${missed}`,
280
+ outcome_label: (o) => ({ held: '그렇게 됨', avoided: '피함', partial: '부분', missed: '빗나감', still_pending: '대기' })[o] ?? o,
277
281
  more: (n) => `… (+${n})`,
278
- record_since: (date) => `기록 시작 ${date} 부터`,
282
+ record_since: (date) => `${date}부터 기록`,
279
283
  },
280
284
  receipt: {
281
285
  header: 'ARGUS · 판단 영수증',
@@ -287,8 +291,8 @@ export const SURFACES = {
287
291
  human_only: '사람만의 판단',
288
292
  made_by_label: '…내린 사람',
289
293
  made_by: '나. (모델이 아니라)',
290
- called_as: '…판단한 내용',
291
- basis_label: (v) => ({ judgment: '판단', luck: '', mixed: '반반', unsure: '모르겠음' })[v] ?? v,
294
+ called_as: '…돌아보니',
295
+ basis_label: (v) => ({ judgment: '판단이 컸다', luck: '운이 컸다', mixed: '판단 반 운 반', unsure: '잘 모르겠다' })[v] ?? v,
292
296
  // 빈 칸을 사실 그대로. "이름 붙이지 않고 넘어갔습니다"는 사용자의 완성도를
293
297
  // 지적하는 잔소리로 읽혔다 (experience loop, settler).
294
298
  skipped: '— (없음)',
@@ -305,23 +309,23 @@ export const SURFACES = {
305
309
  tools: {
306
310
  open_decision: {
307
311
  reason: {
308
- vent: '이건 소리 내어 말할 일이지, 억지로 만들 갈림길이 아닙니다.',
312
+ vent: '이건 소리 내어 말할 일이지, 억지로 결정으로 만들 일이 아닙니다.',
309
313
  factual: '이건 답이 있는 질문이지, 열어둘 결정이 아닙니다.',
310
314
  already_closed: '이미 내린 결정입니다. Argus는 이걸 다시 열지 않습니다.',
311
315
  flat: '선택지가 거의 대등합니다. 억지로 만들 핵심 질문이 없습니다.',
312
316
  reversible_low_stakes: '되돌리기 쉽고 걸린 것도 적습니다. 직접 해보는 것이 곧 검증입니다.',
313
317
  low_stakes: '걸린 것이 별로 없습니다. 그대로 두는 편이 무난합니다.',
314
318
  },
315
- reason_fallback: '여기서 지어낼 갈림길은 없습니다.',
319
+ reason_fallback: '여기서 억지로 지어낼 결정은 없습니다.',
316
320
  leave_coda: '그대로 두는 것도 여전히 진짜 선택지입니다.',
317
321
  watch_exit: ' 기록하지 않고 그대로 두어도 괜찮습니다.',
318
- reconfirm: '신호가 서로 어긋납니다 (걸린 것은 큰데 되돌리기는 쉽습니다). 더 나아가기 전에 stakes와 reversibility를 다시 확인하세요.',
319
- opened_with_crux: (crux) => `열었습니다. 이걸 가르는 단 하나의 질문: ${crux}`,
320
- opened_bare: '열었습니다. 중립적인 핵심 질문 하나만 꺼내세요(갈림길도 기울임도 아닌 질문). 그다음 반증 가능한 예측을 저장하세요.',
322
+ reconfirm: '신호가 서로 어긋납니다 (걸린 것은 큰데 되돌리기는 쉽습니다). 더 나아가기 전에 둘을 다시 짚어 보세요.',
323
+ opened_with_crux: (crux) => `열었습니다. 결정을 좌우하는 단 하나의 질문: ${crux}`,
324
+ opened_bare: '열었습니다. 결정을 좌우하는 핵심 질문 하나가 있다면 그걸 짚어 보는 다음 단계입니다. 그다음 반증 가능한 예측을 저장하면 됩니다.',
321
325
  },
322
326
  seal: {
323
327
  sealed: (predicate, checkBy) => `예측을 저장했습니다. "${predicate}" 확인일은 ${checkBy}입니다. 그날 argus_resolve로 실제로 어땠는지 기록하세요.`,
324
- nudge_assumption: ' 원하면 핵심 전제를 적어 나중에 같이 확인할 수 있어요.',
328
+ nudge_assumption: ' 원하면 핵심 전제를 적어두면 나중에 같이 확인합니다.',
325
329
  synced: ' 계정에 동기화했습니다. 확인일이 오면 이메일로 알려드립니다.',
326
330
  sync_failed: (reason) => ` (계정 동기화가 안 됐습니다. ${reason}. 예측은 로컬에 안전합니다. 동기화되기 전까지는 이메일 알림이 오지 않습니다. 나중에 argus_settings action=sync를 시도하세요.)`,
327
331
  },
@@ -6,7 +6,7 @@ const KO_TOOL_PRESENTATION = {
6
6
  argus_predict: { titleKo: '예측 저장', descriptionKo: '현실이 확인할 수 있는 예측과 확인일을 저장하고 작성 주체를 정직하게 기록합니다.' },
7
7
  argus_resolve: { titleKo: '실제 결과 기록', descriptionKo: '확인일이 된 예측에 실제로 일어난 일을 기록합니다. Argus는 결과를 평가하지 않습니다.' },
8
8
  argus_patterns: { titleKo: '판단 기록 보기', descriptionKo: '진행 중인 결정, 전체 계약, 판단 영수증, 전제, 누적 기록을 읽고, 당신이 쓴 예측·전제와 그 결과를 되읽는 reflection을 봅니다.' },
9
- argus_open_decision: { titleKo: '결정 열기', descriptionKo: '중요하고 되돌리기 어려운 진짜 갈림길인지 확인하고, 맞다면 중립적인 핵심 질문 하나로 결정을 엽니다.' },
9
+ argus_open_decision: { titleKo: '결정 열기', descriptionKo: '중요하고 되돌리기 어려운 진짜 결정인지 확인하고, 맞다면 중립적인 핵심 질문 하나로 결정을 엽니다.' },
10
10
  argus_review: { titleKo: '문서 판단 검수', descriptionKo: '기존 문서의 주장·근거·숨은 전제·사람이 판단할 지점을 원문 위치에 연결해 검수합니다.' },
11
11
  argus_premises: { titleKo: '결정 전제 추적', descriptionKo: '결정이 기대는 사실과 미결 질문을 출처와 함께 추가·수정·정리합니다.' },
12
12
  argus_seal: { titleKo: '예측 봉인', descriptionKo: '현실이 확인할 수 있는 예측과 확인일을 봉인합니다. 예측의 작성 주체를 정직하게 기록합니다.' },
@@ -36,14 +36,14 @@
36
36
  * escape sequences.
37
37
  */
38
38
  // eslint-disable-next-line no-control-regex
39
- const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
39
+ const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
40
40
  /**
41
41
  * Invisible and direction-controlling characters: zero-width space/joiners,
42
42
  * LTR/RTL marks, bidi embeddings and isolates. These render as nothing (or
43
43
  * reverse the surrounding text) for the human while the model reads them intact
44
44
  * - the classic "the human and the model see different strings" gap.
45
45
  */
46
- const INVISIBLE_CHARS = /[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
46
+ const INVISIBLE_CHARS = /[\u200B-\u200F\u202A-\u202E\u2028\u2029\u2066-\u2069\uFEFF]/g;
47
47
  /** Strip the mechanical-injection vectors from one string. Newlines and tabs
48
48
  * survive (a rendered receipt is multi-line ASCII art). */
49
49
  export function stripUnsafeChars(s) {
@@ -52,7 +52,7 @@ export function stripUnsafeChars(s) {
52
52
  /** True when the string carries anything stripUnsafeChars would remove. The
53
53
  * surface lint uses this so a raw path fails loudly in CI. */
54
54
  export function hasUnsafeChars(s) {
55
- return /[\u0000-\u0008\u000B-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/.test(s);
55
+ return /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2028\u2029\u2066-\u2069\uFEFF]/.test(s);
56
56
  }
57
57
  /**
58
58
  * Quote recorded text INLINE into a one-line surface: strip the unsafe
@@ -1,4 +1,4 @@
1
- import { asDate } from './resolve-today.js';
1
+ import { asDate, isRealDate } from './resolve-today.js';
2
2
  // Obvious non-falsifiable vibes. Weak/advisory only.
3
3
  const VIBE = /\b(go well|be fine|be good|be great|work out|feel right|be successful|do better|improve somehow)\b/i;
4
4
  // Korean vibe-predicates (12 P1-4): "잘 될 것 같다 아마도" sealed — and was
@@ -14,10 +14,13 @@ export function validateSeal(predicate, checkBy, today) {
14
14
  };
15
15
  }
16
16
  const date = asDate(checkBy);
17
- if (!date) {
17
+ if (!date || !isRealDate(date)) {
18
+ // A calendar-invalid but digit-shaped date (2026-13-01, 2026-09-31) must be
19
+ // refused here: it would otherwise seal a malformed .ics and a wrong due
20
+ // date, and only be caught by luck if it happened to sort before today.
18
21
  return {
19
22
  code: 'BAD_CHECK_BY',
20
- message: 'check_by must be a real date in YYYY-MM-DD form.',
23
+ message: 'check_by must be a real calendar date in YYYY-MM-DD form.',
21
24
  recovery: 'Pick the date when reality will answer, e.g. "2026-09-01".',
22
25
  };
23
26
  }
package/dist/resources.js CHANGED
@@ -5,6 +5,7 @@ import { readReceipt } from './lib/receipt.js';
5
5
  import { safeSegment } from './lib/safe-path.js';
6
6
  import { logDebug } from './lib/log.js';
7
7
  import { duePremises, groupDuePremises, isMonitored, isDueForRecheck } from './lib/premises.js';
8
+ import { sanitizeOutput } from './lib/untrusted.js';
8
9
  /**
9
10
  * MCP Resources (blueprint §4.3). Read-only context the host can auto-inject —
10
11
  * the ledger, the due contracts, a receipt, the current bearing. They are
@@ -56,7 +57,11 @@ export function readResource(uri) {
56
57
  return { contents: [unbound(uri)] };
57
58
  }
58
59
  const today = resolveToday({});
59
- const payload = computePayload(uri, dir, today);
60
+ // Resources are model-facing (auto-injected context) and bypass the envelope()
61
+ // sanitizer. JSON.stringify escapes C0/ANSI but NOT bidi (U+202E) / zero-width
62
+ // (U+200B) — the "human and model see different strings" gap. Run the same
63
+ // chokepoint sanitizer over the payload first.
64
+ const payload = sanitizeOutput(computePayload(uri, dir, today));
60
65
  return { contents: [{ uri, mimeType: JSON_MIME, text: JSON.stringify(payload, null, 2) }] };
61
66
  }
62
67
  function computePayload(uri, dir, today) {
package/dist/server.js CHANGED
@@ -135,18 +135,23 @@ export async function createServer() {
135
135
  ? { ...parsed.data, today_override: rawArgs['today_override'] }
136
136
  : parsed.data;
137
137
  const raw = await serialize(() => tool.handler(callArgs));
138
+ let result = localizeToolResult(callArgs, raw);
138
139
  // Learn the session's language from the user's OWN words (never env), so
139
140
  // every later surface — including contentless ones (errors, recall) — stays
140
- // in that language start to finish. Runs after the handler (auto-init has
141
- // created config by now) and before localize, so even this call's result
142
- // is localized to the just-learned locale.
143
- const dirForLocale = resolveToolArgusDir(callArgs['argus_dir']);
144
- learnLocaleFromContent(dirForLocale, callArgs);
145
- // §9.7 O1: if an EXPLICIT pin contradicts the language the user is
146
- // actually speaking, say so once (fact + argus_settings handle) — a pin
147
- // is never silently overridden, but it must not be silently obeyed
148
- // against the user's own words forever either.
149
- const result = appendLocaleMismatchNote(dirForLocale, callArgs, localizeToolResult(callArgs, raw));
141
+ // in that language start to finish. This is BEST-EFFORT: a bad argus_dir
142
+ // (relative / unexpanded ${VAR} the #1 setup mistake) already surfaced a
143
+ // proper, localized error from the handler; re-resolving it here to learn
144
+ // the locale must NOT re-throw and clobber that with a raw INTERNAL_ERROR.
145
+ try {
146
+ const dirForLocale = resolveToolArgusDir(callArgs['argus_dir']);
147
+ learnLocaleFromContent(dirForLocale, callArgs);
148
+ // §9.7 O1: if an EXPLICIT pin contradicts the language the user is
149
+ // actually speaking, say so once (fact + argus_settings handle) — a pin
150
+ // is never silently overridden, but it must not be silently obeyed
151
+ // against the user's own words forever either.
152
+ result = appendLocaleMismatchNote(dirForLocale, callArgs, result);
153
+ }
154
+ catch { /* invalid argus_dir — the handler already surfaced it; skip locale learning */ }
150
155
  // Opt-in usage signal: which tool ran + that it didn't crash. Carries no
151
156
  // arguments — never the decision content. Fire-and-forget (see telemetry.ts).
152
157
  recordToolCall(name, true);
@@ -8,6 +8,7 @@ import { validateSeal } from '../lib/validate-seal.js';
8
8
  import { appendLedger, withLedgerLock } from '../lib/ledger-append.js';
9
9
  import { pushToAccount } from '../lib/push-account.js';
10
10
  import { accountPushId } from '../lib/install-id.js';
11
+ import { writeReturnCalendarEvent } from '../lib/calendar.js';
11
12
  import { resolveResponseLocale, SURFACES, humanizeSyncReason } from '../lib/surfaces.js';
12
13
  import { SCHEMA_VERSION } from '../lib/spine.js';
13
14
  import { z } from 'zod';
@@ -35,6 +36,19 @@ export const amend = {
35
36
  const today = resolveToday({ override: a['today_override'] });
36
37
  const current = resolveContract(dir, id, today);
37
38
  guardTransition(current.state, 'amend'); // GOALPOST_MOVED / DECISION_CLOSED / ILLEGAL_TRANSITION
39
+ // Goalpost guard, extended past the `due` state: a still_pending defer
40
+ // re-arms the contract to `sealed`, which the state machine then treats
41
+ // like a never-due decision — so due→defer→sealed→amend let the PREDICATE
42
+ // be rewritten AFTER the original check-by had already arrived (reality had
43
+ // begun answering). Re-scheduling the date via defer stays legitimate;
44
+ // rewriting the claim does not. Refuse only the predicate change post-defer.
45
+ if (a['predicate'] != null && (current.entry?.defer_count ?? 0) > 0) {
46
+ return toolError({
47
+ ok: false, tool: 'argus_amend', error_code: 'GOALPOST_MOVED',
48
+ message: 'Cannot rewrite the prediction after the decision was deferred — its original check-by has passed.',
49
+ recovery: 'Re-schedule the date if the timeline moved, or settle it against reality. The claim itself is locked once its check-by first arrived.',
50
+ });
51
+ }
38
52
  const predicate = a['predicate'] ?? current.predicate;
39
53
  const checkBy = a['check_by'] ?? current.check_by;
40
54
  if (a['check_by'] != null || a['predicate'] != null) {
@@ -54,6 +68,13 @@ export const amend = {
54
68
  const v2Write = asV2WriteField(mirrorAmend);
55
69
  // Response voice follows the (new or existing) predicate (M4).
56
70
  const locale = resolveResponseLocale(dir, predicate);
71
+ // Regenerate the .ics so the return reminder rings on the AMENDED date /
72
+ // text. amend used to update the ledger + bearing + account but leave the
73
+ // on-disk calendar file — the only account-free return channel — pointing
74
+ // at the OLD check-by, so the reminder fired on the stale date.
75
+ if ((a['check_by'] != null || a['predicate'] != null) && predicate && checkBy) {
76
+ await writeReturnCalendarEvent(dir, { id, predicate, check_by: checkBy, created_at: now, locale });
77
+ }
57
78
  const T = SURFACES[locale].tools.amend;
58
79
  // Tell the ACCOUNT the date moved. Without this, argus_amend was silent to
59
80
  // the account forever: the Companion Brief kept emailing on the ORIGINAL
@@ -37,7 +37,11 @@ export const checkIn = {
37
37
  description: 'Return decision contracts whose check-by date has arrived (and optionally upcoming ones). A return nudge — reads and routes to argus_settle. If nothing is due, it says so and stops; it does not manufacture engagement.',
38
38
  inputSchema,
39
39
  outputSchema: ENVELOPE_OUTPUT_SCHEMA,
40
- annotations: { title: 'Check what is due', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
40
+ // readOnlyHint:false honest: the FIRST call on an un-initialized dir routes
41
+ // through ensureInitialized, which creates .argus/ (dirs, config, .gitignore,
42
+ // bound marker, v2 registry). "read-only" must not lie (hardening principle),
43
+ // even though every subsequent call is a pure read.
44
+ annotations: { title: 'Check what is due', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
41
45
  handler: async (a) => {
42
46
  try {
43
47
  const dir = resolveToolArgusDir(a['argus_dir']);
@@ -235,6 +239,25 @@ export const checkIn = {
235
239
  const accountHint = (process.env.ARGUS_TOKEN || '').trim()
236
240
  ? S.account_hint
237
241
  : '';
242
+ // First-run vs caught-up: SERVER_INSTRUCTIONS routes EVERY session start
243
+ // to check_in, so a brand-new user used to land on the same "nothing due"
244
+ // + stop a veteran sees — a dead end at the flagship cold-start. Fire the
245
+ // on-ramp ONLY when the surface would otherwise be a BARE "nothing due":
246
+ // no decisions, no watch mirror, no account, no upcoming/fleet/integrity.
247
+ // Anything else (a caught-up veteran, a watch anchor, an account seal)
248
+ // keeps nothing_due, so the mirror/silence contracts are untouched.
249
+ // `!ledger.oldest_ts` = the ledger has NO events at all (not just no
250
+ // decisions — no watch anchors either), the one true "brand new" signal.
251
+ // Excluded under ARGUS_V2_DEBUG (the v2 observation channel expects its
252
+ // diagnostic payload even on an empty v1 ledger).
253
+ if (!ledger.oldest_ts && !mirrorLine && !accountHint && !upcomingLine && !fleetLine && !integrityLine && process.env['ARGUS_V2_DEBUG'] !== '1') {
254
+ return envelope({
255
+ ok: true, tool: 'argus_check_in',
256
+ surface: S.first_run,
257
+ next_actions: ['argus_capture'],
258
+ data: { due: [], due_count: 0, due_premises: [], due_premise_count: 0, due_open_questions: [], due_open_question_count: 0, first_run: true, today },
259
+ });
260
+ }
238
261
  return envelope({
239
262
  ok: true, tool: 'argus_check_in',
240
263
  surface: mirrorLine + S.nothing_due + accountHint + upcomingLine + fleetLine + integrityLine,
@@ -12,15 +12,15 @@ import { localizedErrorCopy } from '../lib/localized-message.js';
12
12
  export function handleToolException(tool, e) {
13
13
  if (e instanceof ArgusDirError) {
14
14
  const copy = localizedErrorCopy(null, undefined, {
15
- en: { message: e.message, recovery: 'Pass an absolute .argus path with no "..".' },
16
- ko: { message: 'Argus 기록 경로가 올바르지 않습니다.', recovery: '".." 없는 절대 .argus 경로를 전달하세요.' },
15
+ en: { message: e.message, recovery: 'Set ARGUS_DIR (or the per-call argus_dir) to an absolute path with no "..", e.g. C:\\Users\\you\\.argus or /Users/you/.argus — or remove ARGUS_DIR to use the default ~/.argus. A ${VAR} may be passed through unexpanded by your host.' },
16
+ ko: { message: 'Argus 기록 경로(argus_dir / ARGUS_DIR)가 올바르지 않습니다.', recovery: '절대 경로여야 하고 ".." 포함할 수 없습니다. MCP 설정에서 절대 경로(예: C:\\Users\\이름\\.argus, /Users/이름/.argus)로 바꾸거나 ARGUS_DIR을 지워 기본값(~/.argus)을 쓰세요. ${...} 같은 변수는 호스트가 확장하지 못할 수 있습니다.' },
17
17
  });
18
18
  return toolError({ ok: false, tool, error_code: e.code, ...copy });
19
19
  }
20
20
  if (e instanceof PathSafetyError) {
21
21
  const copy = localizedErrorCopy(null, undefined, {
22
- en: { message: e.message, recovery: 'Use ids/labels matching [A-Za-z0-9._-] only.' },
23
- ko: { message: '안전하지 않은 id 또는 label입니다.', recovery: 'id와 label에는 [A-Za-z0-9._-] 문자만 사용하세요.' },
22
+ en: { message: e.message, recovery: 'Use A-Za-z0-9._- only, with no trailing dot or space, and avoid reserved device names (con, nul, com1…). Example: "career-move".' },
23
+ ko: { message: ' id 또는 label은 쓸 수 없습니다.', recovery: '영문·숫자·. _ - 만 쓰되, 끝에 마침표나 공백을 두지 말고 con·nul·com1 같은 예약어는 피하세요. 예: "career-move".' },
24
24
  });
25
25
  return toolError({ ok: false, tool, error_code: e.code, ...copy });
26
26
  }
@@ -131,6 +131,20 @@ export const config = {
131
131
  const writeKeys = ['locale', 'boss', 'team', 'archive', 'ambient_mute', 'premise_sync'].filter((k) => k in a);
132
132
  const existing = readConfig(dir) ?? { schema_version: SCHEMA_VERSION, locale: detectLocale(dir), boss: null, team: null, archive: null };
133
133
  if (writeKeys.length === 0) {
134
+ // action=update with no writable field is a MISTAKE (a read is
135
+ // action=status). Saying "Config read." reports the OPPOSITE operation
136
+ // as a success and hides that nothing changed — name the supported set.
137
+ if (a['action'] === 'update') {
138
+ return envelope({
139
+ ok: true, tool: 'argus_config',
140
+ surface: localizedMessage(dir, undefined, {
141
+ en: 'No setting was changed. Supported fields: locale (ko/en), ambient_mute (true/false), premise_sync (true/false).',
142
+ ko: '변경된 설정이 없습니다. 바꿀 수 있는 항목: locale(ko/en), ambient_mute(true/false), premise_sync(true/false).',
143
+ }),
144
+ next_actions: ['stop'],
145
+ data: { config: existing, existed: !!readConfig(dir), changed: [] },
146
+ });
147
+ }
134
148
  return envelope({
135
149
  ok: true,
136
150
  tool: 'argus_config',
@@ -4,7 +4,7 @@ import { replayLedger } from '../lib/ledger-replay.js';
4
4
  import { resolveToday } from '../lib/resolve-today.js';
5
5
  import { resolveContract } from '../lib/resolve-contract.js';
6
6
  import { guardTransition } from '../lib/state-machine.js';
7
- import { appendLedger } from '../lib/ledger-append.js';
7
+ import { appendLedger, withLedgerLock } from '../lib/ledger-append.js';
8
8
  import { premiseId, resolvePremiseRef, normalizePremiseText, MAX_ACTIVE_PREMISES, MAX_LOAD_BEARING, } from '../lib/premises.js';
9
9
  import { elicit } from '../lib/elicit.js';
10
10
  import { resolveResponseLocale } from '../lib/surfaces.js';
@@ -251,8 +251,21 @@ async function opAdd(dir, id, today, now, state, existing, a) {
251
251
  // premise added under a today_override in real use.
252
252
  anchor_date: today,
253
253
  }));
254
- if (events.length > 0)
255
- await appendLedger(dir, events, now);
254
+ if (events.length > 0) {
255
+ // §9.4 두 기기 안전: the ordinal base was read OUTSIDE any lock, so two
256
+ // concurrent sessions adding premises to the same decision both saw max=4
257
+ // and both assigned ordinal 5 — a DUPLICATE P5 whose second premise is
258
+ // unreferenceable (resolvePremiseRef returns only the first). Re-derive the
259
+ // ordinals under the ledger lock from a fresh fold, then append — the same
260
+ // read-check-append-under-lock discipline seal/settle use.
261
+ await withLedgerLock(dir, async () => {
262
+ const freshPremises = replayLedger(dir, today).contracts.get(id)?.premises ?? [];
263
+ let ord = freshPremises.reduce((m, p) => Math.max(m, p.ordinal), 0) + 1;
264
+ for (const e of events)
265
+ e.ordinal = ord++;
266
+ await appendLedger(dir, events, now);
267
+ });
268
+ }
256
269
  // Full echo — the silent-premise defense: the host always has the material to
257
270
  // show the user exactly what was recorded (plan v5 §2).
258
271
  const echo = events.map((e) => ({
@@ -374,7 +387,12 @@ async function opResolve(dir, id, now, state, existing, a) {
374
387
  if (!decision) {
375
388
  // MCP-native elicitation: ask the USER directly. The question replays their
376
389
  // own open question verbatim and takes free text — no options, no leans.
377
- const got = await elicit(`Your open question on this decision: "${premise.text}". What is your call now, in your own words? (You can also leave it open.)`, { type: 'object', properties: { decision: { type: 'string', maxLength: 400, description: 'Your call, your words.' } }, required: ['decision'] });
390
+ // Localize like the rest of the tool a Korean user closing their own
391
+ // Korean question used to get an English form. Voice follows the question.
392
+ const qLocale = resolveResponseLocale(dir, premise.text);
393
+ const got = await elicit(qLocale === 'ko'
394
+ ? `이 결정에 남겨둔 질문입니다: "${premise.text}". 지금은 어떻게 판단하시나요? 당신의 말로 적어주세요. (그대로 열어둬도 됩니다.)`
395
+ : `Your open question on this decision: "${premise.text}". What is your call now, in your own words? (You can also leave it open.)`, { type: 'object', properties: { decision: { type: 'string', maxLength: 400, description: qLocale === 'ko' ? '당신의 판단, 당신의 표현.' : 'Your call, your words.' } }, required: ['decision'] });
378
396
  decision = typeof got?.['decision'] === 'string' ? got['decision'].trim() : '';
379
397
  }
380
398
  if (!decision) {
@@ -16,6 +16,7 @@ import { checkIn } from './check-in.js';
16
16
  import { settle } from './settle.js';
17
17
  import { detectLocaleFromText } from '../lib/locale.js';
18
18
  import { gitCommonDirOf } from '../v2/git-discovery.js';
19
+ import { handleToolException } from './errors.js';
19
20
  const premiseInput = z.strictObject({
20
21
  text: z.string().min(3).max(400).describe('결정이 기대는 사실 또는 아직 답하지 못한 질문입니다. 사용자의 표현을 그대로 씁니다.').optional(),
21
22
  kind: z.enum(['premise', 'open_question']).default('premise').describe('premise는 확인할 전제, open_question은 사용자가 아직 답하지 않은 질문입니다.'),
@@ -69,7 +70,7 @@ const decideSchema = z.discriminatedUnion('action', [
69
70
  id: zId.describe('대상 결정 id입니다.'),
70
71
  ref: z.string().max(64).describe('재확인할 전제 번호 또는 id입니다.'),
71
72
  finding: z.string().min(1).max(800).describe('현재 확인한 사실을 비교 가능한 한 문장으로 적습니다.'),
72
- numeric_value: z.number().describe('수치 사실의 현재 값을 명시적으로 전달합니다.').optional(),
73
+ numeric_value: z.number().finite().describe('수치 사실의 현재 값을 명시적으로 전달합니다.').optional(),
73
74
  changed: z.boolean().describe('문장형 사실이 기준값에서 실질적으로 달라졌는지 표시합니다.').optional(),
74
75
  // default user_stated: the runtime union validates BEFORE the handler-level
75
76
  // default can apply, so a required source here made every real update_fact
@@ -112,7 +113,7 @@ const decidePublicSchema = z.strictObject({
112
113
  ref: z.string().max(64).describe('답하거나 재확인할 전제 또는 미결 질문 번호입니다.').optional(),
113
114
  reconsider_cadence_days: z.number().int().min(1).max(365).describe('미결 질문을 다시 볼 간격(일)입니다.').optional(),
114
115
  finding: z.string().min(1).max(800).describe('현재 확인한 사실을 비교 가능한 한 문장으로 적습니다.').optional(),
115
- numeric_value: z.number().describe('수치 사실의 현재 값을 명시적으로 전달합니다.').optional(),
116
+ numeric_value: z.number().finite().describe('수치 사실의 현재 값을 명시적으로 전달합니다.').optional(),
116
117
  changed: z.boolean().describe('문장형 사실이 기준값에서 실질적으로 달라졌는지 표시합니다.').optional(),
117
118
  source: z.enum(['url', 'user_stated', 'host_reported']).describe('현재 사실을 확인한 출처입니다.').optional(),
118
119
  source_detail: z.string().max(1000).describe('출처 URL 또는 짧은 인용 정보입니다.').optional(),
@@ -266,10 +267,20 @@ async function ensureInitialized(args) {
266
267
  return null;
267
268
  }
268
269
  async function runPublic(publicName, args, handler) {
269
- const initError = await ensureInitialized(args);
270
- if (initError)
271
- return rewriteResult(initError, publicName);
272
- return rewriteResult(await handler(args), publicName);
270
+ try {
271
+ const initError = await ensureInitialized(args);
272
+ if (initError)
273
+ return rewriteResult(initError, publicName);
274
+ return rewriteResult(await handler(args), publicName);
275
+ }
276
+ catch (e) {
277
+ // ensureInitialized() → resolveToolArgusDir() THROWS ArgusDirError on a
278
+ // relative or unexpanded-${VAR} argus_dir — the #1 setup mistake. Without
279
+ // this catch the throw escaped to the server's String(e) fallback, so the
280
+ // user saw a raw "INTERNAL_ERROR: ArgusDirError: …" with no recovery. Route
281
+ // it through the typed handler to get the localized message + fix.
282
+ return rewriteResult(handleToolException(publicName, e), publicName);
283
+ }
273
284
  }
274
285
  export const decide = {
275
286
  name: 'argus_capture',
@@ -335,7 +346,9 @@ export const history = {
335
346
  description: 'Read decisions already on record: what is open, all contracts, one Judgment Receipt, one decision’s premises, the accumulated timeline, or a reflection that replays your own past predictions and premises next to what reality did. Read-only.',
336
347
  inputSchema: historySchema,
337
348
  outputSchema: ENVELOPE_OUTPUT_SCHEMA,
338
- annotations: { title: 'View judgment history', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
349
+ // readOnlyHint:false like argus_check_in, the first call auto-initializes
350
+ // .argus/ via ensureInitialized, so it can write on first use (honest hint).
351
+ annotations: { title: 'View judgment history', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
339
352
  handler: (a) => {
340
353
  const viewMap = {
341
354
  active: 'bearing',
@@ -18,9 +18,9 @@ const byCheckBy = (a, b) => (a.check_by || '9999-99-99') < (b.check_by || '9999-
18
18
  function frequencyStatement(s, locale) {
19
19
  const n = s.total_settled;
20
20
  if (n === 0)
21
- return locale === 'ko' ? '아직 정산된 결정이 없습니다 — 요약할 것이 없습니다.' : 'No settled decisions yet — nothing to summarize.';
21
+ return locale === 'ko' ? '아직 결과를 기록한 결정이 없습니다 — 요약할 것이 없습니다.' : 'No settled decisions yet — nothing to summarize.';
22
22
  return locale === 'ko'
23
- ? `정산 ${n}건 중: 그렇게 됨 ${s.held} · 피함 ${s.avoided} · 부분 ${s.partial} · 빗나감 ${s.missed}.`
23
+ ? `결과 기록 ${n}건 중: 그렇게 됨 ${s.held} · 피함 ${s.avoided} · 부분 ${s.partial} · 빗나감 ${s.missed}.`
24
24
  : `Of ${n} settled: ${s.held} held, ${s.avoided} avoided, ${s.partial} partial, ${s.missed} missed.`;
25
25
  }
26
26
  /** Voice for a read view: the view's own text, else any ledger user-text, else
@@ -97,8 +97,11 @@ export const recall = {
97
97
  // don't render it. The full formatted card stays in data.receipt_text as
98
98
  // the keepsake. (User-value review, 2026-07-14.)
99
99
  const clip = (s, n) => { const t = String(s ?? '').trim(); return t.length > n ? `${t.slice(0, n)}…` : t; };
100
+ // One word per outcome across the whole flow: surfaces.ts settle uses
101
+ // 부분/대기, so the receipt-recall surface must match (was 부분적/아직 —
102
+ // three words for `partial` in one decision's lifecycle).
100
103
  const label = receiptLocale === 'ko'
101
- ? { held: '그렇게 됨', avoided: '피함', partial: '부분적', missed: '빗나감', still_pending: '아직' }
104
+ ? { held: '그렇게 됨', avoided: '피함', partial: '부분', missed: '빗나감', still_pending: '대기' }
102
105
  : { held: 'held', avoided: 'avoided', partial: 'partial', missed: 'missed', still_pending: 'still pending' };
103
106
  const outcomeKey = (r.outcome ?? 'still_pending');
104
107
  const receiptSurface = receiptLocale === 'ko'
@@ -203,8 +206,13 @@ export const recall = {
203
206
  const shown = all.slice(0, 60);
204
207
  const locale = readVoice(dir, ledger, all[0]?.predicate);
205
208
  const wake = wakeText(ledger, today, dir);
209
+ // Empty ledger → an on-ramp with a capture handle, not "0 decision(s)" +
210
+ // stop (a "show me my decisions" newcomer must get a next move).
211
+ const allSurface = all.length === 0
212
+ ? (locale === 'ko' ? '아직 기록에 남은 결정이 없습니다. 고민 중인 결정을 말하면 argus_capture로 시작합니다.' : 'No decisions on record yet. Describe a decision you are weighing and argus_capture begins it.')
213
+ : (locale === 'ko' ? `기록에 남은 결정 ${all.length}건.` : `${all.length} decision(s) on record.`);
206
214
  return envelope({
207
- ok: true, tool: 'argus_recall', surface: locale === 'ko' ? `기록에 남은 결정 ${all.length}건.` : `${all.length} decision(s) on record.`, next_actions: ['stop'],
215
+ ok: true, tool: 'argus_recall', surface: allSurface, next_actions: all.length === 0 ? ['argus_capture'] : ['stop'],
208
216
  data: { contracts: shown, ...(all.length > shown.length ? { truncated: all.length - shown.length } : {}), today, ...(wake ? { wake_text: wake } : {}) },
209
217
  });
210
218
  }
@@ -244,7 +252,7 @@ export const recall = {
244
252
  return envelope({
245
253
  ok: true, tool: 'argus_recall',
246
254
  surface: rn > 0 ? `${framing} ${rfreq}` : framing,
247
- next_actions: ['stop'],
255
+ next_actions: ledger.ids.size === 0 ? ['argus_capture'] : ['stop'], // empty ledger → a handle, not a dead end
248
256
  data: {
249
257
  judgment_tier: null, judgment_score: null, // spine rule 2 — never a verdict about who you are
250
258
  reflections, reflection_count: reflections.length,
@@ -278,7 +286,7 @@ export const recall = {
278
286
  return envelope({
279
287
  ok: true, tool: 'argus_recall',
280
288
  surface: premiseAttribution ? `${freq} ${premiseAttribution}` : freq,
281
- next_actions: ['stop'],
289
+ next_actions: ledger.ids.size === 0 ? ['argus_capture'] : ['stop'], // empty ledger → a handle, not a dead end
282
290
  data: {
283
291
  judgment_tier: null, judgment_score: null, // drift-guard asserts these stay null
284
292
  frequency_statement: freq,
@@ -10,6 +10,33 @@ import { resolveResponseLocale, SURFACES } from '../lib/surfaces.js';
10
10
  import { envelope, toolError } from '../lib/envelope.js';
11
11
  import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir, zId, zDate } from './tool-types.js';
12
12
  import { handleToolException } from './errors.js';
13
+ /**
14
+ * The materiality engine's `uncertain` reasons are hard-coded Korean and used to
15
+ * splice STRAIGHT into the English recheck surface (`T.uncertain(reason)`) — an
16
+ * English user rechecking an ambiguous numeric premise saw a Korean sentence
17
+ * mid-line. Localize at the surface: map each known reason to an English hint,
18
+ * and fall back to a generic "define a rule" line so no Korean can ever leak.
19
+ * (The raw `reason` stays in `data.reason` for diagnostics.)
20
+ */
21
+ function localizeUncertainReason(reason, locale) {
22
+ if (locale === 'ko')
23
+ return reason;
24
+ if (reason.includes('비율(%)'))
25
+ return 'this reads as a percentage — say whether the change is in percentage-points or on the complement (100 − value) axis';
26
+ if (reason.includes('near-zero'))
27
+ return 'the values sit near zero — set a rule (a delta or a safety floor) so this can be judged mechanically';
28
+ if (reason.includes('임계 경계'))
29
+ return 'the relative change sits right on the threshold — set a rule or a dead-band';
30
+ if (reason.includes('zero_meaningful') || reason.includes('부호 전환'))
31
+ return 'the sign flipped, but whether zero is meaningful is undeclared — set a rule';
32
+ if (reason.includes('boundary') || reason.includes('경계 도달'))
33
+ return 'the value reached the threshold line, but inclusive/exclusive is unspecified — set the boundary';
34
+ if (reason.includes('기준값 0'))
35
+ return 'the baseline is 0, so a relative rule cannot apply — set a delta rule';
36
+ if (reason.includes('canonical scale') || reason.includes('비수치'))
37
+ return 'this label is non-numeric — set a canonical scale so it can be judged mechanically';
38
+ return 'this change is ambiguous under the current rule — define a materiality rule so it can be judged mechanically';
39
+ }
13
40
  /**
14
41
  * argus_recheck — re-check one premise against reality (plan v5 §2, §7.1).
15
42
  *
@@ -30,7 +57,7 @@ const inputSchema = z.strictObject({
30
57
  id: zId.describe('The decision id.'),
31
58
  ref: z.string().max(64).describe('Which premise — ordinal ("P1"), premise_id, or unambiguous prefix.'),
32
59
  finding: z.string().min(3).max(400).describe('The CURRENT state of the fact, one literal comparable sentence. e.g. "base rate 3.75% after a 25bp hike".'),
33
- numeric_value: z.number().optional().describe('The fact\'s current number, named EXPLICITLY (e.g. 3.75). Never extracted from prose by regex. When present, drift is decided mechanically (>=10% move or sign flip).'),
60
+ numeric_value: z.number().finite().optional().describe('The fact\'s current number, named EXPLICITLY (e.g. 3.75). Never extracted from prose by regex. When present, drift is decided mechanically (>=10% move or sign flip).'),
34
61
  changed: z.boolean().optional().describe('Text premises only: has the FACT materially changed vs the recorded baseline? A research finding about external reality (provenance required) — never a judgment of the user.'),
35
62
  source: z.enum(['url', 'user_stated', 'host_reported']).describe('Where the finding comes from. host_reported = the model\'s own research without a citation — recorded honestly as such.'),
36
63
  source_detail: z.string().max(300).optional().describe('URL or short citation when source="url".'),
@@ -63,8 +90,16 @@ export const recheck = {
63
90
  }
64
91
  const finding = String(a['finding']);
65
92
  // Response voice follows the finding sentence (M4): config > text > env.
66
- const T = SURFACES[resolveResponseLocale(dir, String(a['finding']))].tools.recheck;
93
+ const rLocale = resolveResponseLocale(dir, String(a['finding']));
94
+ const T = SURFACES[rLocale].tools.recheck;
67
95
  const source = String(a['source']);
96
+ // Localize the source enum before it reaches the surface — a recheck line
97
+ // printed the raw "(host_reported)" / "(user_stated)" token to the user.
98
+ const SOURCE_LABEL = {
99
+ ko: { url: '출처 링크', user_stated: '당신이 확인함', host_reported: '어시스턴트 조사' },
100
+ en: { url: 'from a cited source', user_stated: 'as you reported it', host_reported: "from the assistant's own research" },
101
+ };
102
+ const srcLabel = SOURCE_LABEL[rLocale]?.[source] ?? source;
68
103
  const sourceDetail = a['source_detail'];
69
104
  const numericValue = a['numeric_value'];
70
105
  const changed = a['changed'];
@@ -143,14 +178,14 @@ export const recheck = {
143
178
  ? T.uncertain_heuristic_note
144
179
  : '';
145
180
  const surface = baselineOnly
146
- ? T.baseline(premise.ordinal, finding, source, recheckCadenceDays(premise))
181
+ ? T.baseline(premise.ordinal, finding, srcLabel, recheckCadenceDays(premise))
147
182
  : status === 'material'
148
- ? T.material(premise.ordinal, prior.finding, finding, source)
183
+ ? T.material(premise.ordinal, prior.finding, finding, srcLabel)
149
184
  : status === 'uncertain'
150
185
  // M2 §4/§7: uncertain surfaces the FACT only — no handle, no fork. The
151
186
  // user decides whether to define a rule or leave it.
152
- ? `${T.uncertain(premise.ordinal, reason)}${heuristicNote}`
153
- : T.unchanged(premise.ordinal, source);
187
+ ? `${T.uncertain(premise.ordinal, localizeUncertainReason(reason, rLocale))}${heuristicNote}`
188
+ : T.unchanged(premise.ordinal, srcLabel);
154
189
  // ── SPINE (M2 §4, mirror clause): the handle auto-attaches ONLY on
155
190
  // `material`. `uncertain` (depends / boundary / rule-uncovered) NEVER
156
191
  // auto-attaches argus_recall — that would manufacture a fork on a flat