argus-decision-mcp 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +44 -60
  2. package/SECURITY.md +3 -3
  3. package/dist/a0/account-connect.js +180 -0
  4. package/dist/a0/account-credentials.js +86 -0
  5. package/dist/index.js +35 -1
  6. package/dist/lib/ambient-elicit.js +225 -0
  7. package/dist/lib/argus-dir.js +26 -8
  8. package/dist/lib/calendar.js +2 -2
  9. package/dist/lib/due-note.js +3 -3
  10. package/dist/lib/elicit.js +13 -1
  11. package/dist/lib/ledger-replay.js +19 -3
  12. package/dist/lib/locale-mismatch.js +52 -0
  13. package/dist/lib/locale.js +81 -10
  14. package/dist/lib/localize-result.js +120 -0
  15. package/dist/lib/localized-message.js +13 -0
  16. package/dist/lib/premises.js +0 -2
  17. package/dist/lib/push-account.js +4 -3
  18. package/dist/lib/review/extract-core.js +116 -0
  19. package/dist/lib/review/extract-file-node.js +145 -29
  20. package/dist/lib/review/index.js +1 -1
  21. package/dist/lib/review/ingest.js +84 -20
  22. package/dist/lib/review/lenses.js +18 -0
  23. package/dist/lib/review/prompts.js +199 -22
  24. package/dist/lib/review/reviewability.js +8 -7
  25. package/dist/lib/review/routing.js +38 -17
  26. package/dist/lib/review-path.js +2 -2
  27. package/dist/lib/spine.js +13 -21
  28. package/dist/lib/state-machine.js +12 -12
  29. package/dist/lib/surfaces.js +90 -94
  30. package/dist/lib/tool-presentation.js +35 -0
  31. package/dist/resources.js +52 -14
  32. package/dist/server.js +85 -31
  33. package/dist/tools/candidates.js +3 -3
  34. package/dist/tools/check-in.js +24 -12
  35. package/dist/tools/errors.js +17 -3
  36. package/dist/tools/index.js +34 -2
  37. package/dist/tools/init-config.js +39 -11
  38. package/dist/tools/open-decision.js +58 -25
  39. package/dist/tools/premises.js +18 -18
  40. package/dist/tools/public-tools.js +382 -0
  41. package/dist/tools/recall.js +99 -24
  42. package/dist/tools/recheck.js +3 -3
  43. package/dist/tools/review.js +133 -29
  44. package/dist/tools/seal.js +62 -26
  45. package/dist/tools/semantic-record.js +225 -0
  46. package/dist/tools/settle.js +49 -8
  47. package/dist/tools/sync.js +2 -2
  48. package/dist/tools/tool-types.js +174 -0
  49. package/dist/tools/watch.js +1 -1
  50. package/dist/v2/bridge.js +2 -2
  51. package/dist/v2/brief.js +1 -1
  52. package/dist/v2/candidate-capture.js +150 -0
  53. package/dist/v2/capture-cli.js +72 -0
  54. package/dist/v2/capture-runtime.js +73 -0
  55. package/dist/v2/connection-io.js +47 -0
  56. package/dist/v2/connection.js +93 -0
  57. package/dist/v2/evidence.js +5 -1
  58. package/dist/v2/harvest.js +26 -51
  59. package/dist/v2/lifecycle-cli.js +50 -0
  60. package/dist/v2/lifecycle.js +298 -2
  61. package/dist/v2/logbook.js +14 -14
  62. package/dist/v2/mirror.js +2 -2
  63. package/dist/v2/queue.js +71 -12
  64. package/dist/v2/v1-reader.js +6 -3
  65. package/dist/v3/fixtures/dkk-corpus.js +55 -0
  66. package/dist/v3/fixtures/p5-measurement-plan.js +42 -0
  67. package/dist/v3/index.js +5 -0
  68. package/dist/v3/legacy-v2.js +169 -0
  69. package/dist/v3/p5-gate.js +123 -0
  70. package/dist/v3/reducer.js +290 -0
  71. package/dist/v3/store.js +133 -0
  72. package/dist/v3/types.js +193 -0
  73. package/dist/v4/index.js +5 -0
  74. package/dist/v4/reducer.js +361 -0
  75. package/dist/v4/relation-validation.js +40 -0
  76. package/dist/v4/shadow.js +22 -0
  77. package/dist/v4/types.js +326 -0
  78. package/dist/v4/watch.js +18 -0
  79. package/package.json +6 -3
  80. package/dist/lib/discipline.js +0 -42
  81. package/dist/prompts.js +0 -72
  82. package/snippets/claude-code-watch.md +0 -47
package/dist/server.js CHANGED
@@ -1,22 +1,26 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
- import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
3
- import { TOOLS, TOOL_MAP } from './tools/index.js';
4
- import { toolJsonSchema } from './tools/tool-types.js';
2
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
3
+ import { PUBLIC_TOOLS, TOOL_MAP, servedPublicTools } from './tools/index.js';
5
4
  import { listResources, listResourceTemplates, readResource } from './resources.js';
6
- import { listPrompts, getPrompt } from './prompts.js';
7
5
  import { SERVER_INSTRUCTIONS } from './lib/spine.js';
8
6
  import { setElicitor } from './lib/elicit.js';
7
+ import { initAmbientElicit, armAmbientElicit } from './lib/ambient-elicit.js';
8
+ import { settle } from './tools/settle.js';
9
9
  import { appendDueNote } from './lib/due-note.js';
10
10
  import { logError } from './lib/log.js';
11
11
  import { packageMeta } from './lib/package-meta.js';
12
+ import { localizeToolResult } from './lib/localize-result.js';
13
+ import { learnLocaleFromContent } from './lib/locale.js';
14
+ import { appendLocaleMismatchNote } from './lib/locale-mismatch.js';
15
+ import { resolveToolArgusDir } from './lib/argus-dir.js';
12
16
  import { recordServerStart, recordToolCall } from './lib/telemetry.js';
13
17
  /**
14
18
  * Argus MCP server (blueprint §4). v1 surface = Tools only — the universal
15
19
  * floor that works on every host. The spine bias is carried once by the
16
20
  * `instructions` field (the one spec-sanctioned home for the killed
17
- * paste-prompt), rendered from the single spine source. Resources and Prompts
18
- * are Phase 2; their capabilities are NOT declared until their handlers exist,
19
- * so a host never probes a no-op.
21
+ * paste-prompt), rendered from the single spine source. Resources provide one
22
+ * passive attention view. Separate MCP prompts were removed: they duplicated
23
+ * the tool surface and made users learn a second invocation system.
20
24
  */
21
25
  export async function createServer() {
22
26
  const meta = packageMeta();
@@ -25,33 +29,25 @@ export async function createServer() {
25
29
  // a host never probes a no-op (addendum J). `elicitation` is a client
26
30
  // capability we USE, not a server one we serve — advertised so the SDK
27
31
  // permits elicitInput; tools degrade to text when the host lacks it.
28
- capabilities: { tools: {}, resources: {}, prompts: {} },
32
+ capabilities: { tools: {}, resources: {} },
29
33
  instructions: SERVER_INSTRUCTIONS,
30
34
  });
31
- setElicitor((message, requestedSchema) => server.elicitInput({ message, requestedSchema }));
35
+ const ec = server;
36
+ // The capability probe reads the client's DECLARED elicitation support (set at
37
+ // initialize). Gating canElicit() on it means a host that never declared the
38
+ // capability takes the text path instead of calling elicitInput (which the SDK
39
+ // throws on) and silently dropping a confirm_draft seal.
40
+ setElicitor((message, requestedSchema) => ec.elicitInput({ message, requestedSchema }), () => Boolean(ec.getClientCapabilities?.()?.elicitation));
32
41
  // Resources — read-only context (blueprint §4.3).
33
42
  server.setRequestHandler(ListResourcesRequestSchema, async () => listResources());
34
43
  server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => listResourceTemplates());
35
44
  server.setRequestHandler(ReadResourceRequestSchema, async (req) => readResource(req.params.uri));
36
- // Prompts — user-triggered discipline rituals (blueprint §4.2).
37
- server.setRequestHandler(ListPromptsRequestSchema, async () => listPrompts());
38
- server.setRequestHandler(GetPromptRequestSchema, async (req) => getPrompt(req.params.name, req.params.arguments));
39
45
  // Anonymous, opt-in activation signal (no-op unless ARGUS_TELEMETRY=1). Fire-
40
46
  // and-forget: never blocks server startup, never throws. See lib/telemetry.ts.
41
47
  recordServerStart();
42
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
43
- tools: TOOLS.map((t) => ({
44
- name: t.name,
45
- // Top-level human-readable title (2025-06-18 spec; display priority
46
- // title > annotations.title > name). Reuse the annotation we already set.
47
- ...(t.annotations?.title ? { title: t.annotations.title } : {}),
48
- description: t.description,
49
- // JSON Schema generated from the Zod source of truth (no hand-kept copy).
50
- inputSchema: toolJsonSchema(t.inputSchema),
51
- ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
52
- ...(t.annotations ? { annotations: t.annotations } : {}),
53
- })),
54
- }));
48
+ // Single source (tools/index.ts): builds the descriptors AND runs schemas
49
+ // through publicCopy so a legacy tool name in a field description can't leak.
50
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: servedPublicTools() }));
55
51
  // Serialize tool calls so concurrent invocations can't interleave a
56
52
  // read-replay-then-append against the same ledger (real hosts already wait
57
53
  // for each response; this removes the foot-gun for batched/parallel clients).
@@ -61,6 +57,12 @@ export async function createServer() {
61
57
  chain = run.then(() => undefined, () => undefined);
62
58
  return run;
63
59
  };
60
+ // Out-of-band ambient ask (lib/ambient-elicit.ts) — after a tool call ends
61
+ // and the session goes quiet, the server may ask the ONE due settlement
62
+ // question directly (spike-proven server→client elicitation). Recording rides
63
+ // the real settle handler through the SAME serialize chain, so an ambient
64
+ // write can never interleave with an in-band tool call.
65
+ initAmbientElicit({ settleHandler: (a) => settle.handler(a), serialize });
64
66
  // The low-level SDK handler's expected return is a broad ServerResult union
65
67
  // (incl. a task-augmented variant) our envelope type isn't nominally part of —
66
68
  // `any` is the sanctioned boundary here; every return below is a real
@@ -69,7 +71,11 @@ export async function createServer() {
69
71
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
70
72
  const { name, arguments: args } = request.params;
71
73
  const tool = TOOL_MAP.get(name);
72
- if (!tool) {
74
+ // The v6 semantic recorder is a P4 pilot. Keeping it out of discovery is
75
+ // not enough: a cached or hand-written client must not be able to invoke it
76
+ // until the operator explicitly opts in.
77
+ const pilotDisabled = name === 'argus_record' && process.env['ARGUS_DKK_V6_PILOT'] !== '1';
78
+ if (!tool || pilotDisabled) {
73
79
  return {
74
80
  content: [{ type: 'text', text: JSON.stringify({ ok: false, error_code: 'UNKNOWN_TOOL', message: `Unknown tool: ${name}` }) }],
75
81
  isError: true,
@@ -79,28 +85,76 @@ export async function createServer() {
79
85
  // A schema failure is a client bug → a clean, actionable tool-result error
80
86
  // (not a protocol crash); the handler only ever sees validated, default-
81
87
  // applied args.
82
- const parsed = tool.inputSchema.safeParse(args ?? {});
88
+ const rawArgs = (args ?? {});
89
+ // Deterministic protocol tests need a logical clock, but that test-only
90
+ // control must not become part of the public MCP schema users and models
91
+ // have to understand.
92
+ const hiddenTestClock = process.env['NODE_ENV'] === 'test'
93
+ && PUBLIC_TOOLS.some((candidate) => candidate.name === name)
94
+ && typeof rawArgs['today_override'] === 'string';
95
+ const validationArgs = hiddenTestClock
96
+ ? Object.fromEntries(Object.entries(rawArgs).filter(([key]) => key !== 'today_override'))
97
+ : rawArgs;
98
+ const parsed = tool.inputSchema.safeParse(validationArgs);
83
99
  if (!parsed.success) {
84
100
  const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ');
101
+ // Carry the offending field(s) + machine-readable reason STRUCTURALLY, so
102
+ // the Korean localizer can name what to fix (not collapse every failure to
103
+ // a generic "invalid input"). Without this, a Korean user is told to "fix
104
+ // the flagged argument" but nothing is flagged — an unactionable dead end.
105
+ const invalidFields = parsed.error.issues.map((i) => {
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
+ const raw = i;
108
+ return {
109
+ field: i.path.join('.') || '(root)',
110
+ code: i.code,
111
+ message: i.message,
112
+ ...(typeof raw.minimum === 'number' ? { minimum: raw.minimum } : {}),
113
+ ...(typeof raw.maximum === 'number' ? { maximum: raw.maximum } : {}),
114
+ ...(raw.expected !== undefined ? { expected: String(raw.expected) } : {}),
115
+ // Zod v4 tags size issues with `origin` ('string' | 'number' | 'array'…).
116
+ ...(raw.origin !== undefined ? { origin: String(raw.origin) } : raw.type !== undefined ? { origin: String(raw.type) } : {}),
117
+ };
118
+ });
85
119
  const error = {
86
120
  ok: false,
87
121
  tool: name,
88
122
  error_code: 'INVALID_INPUT',
89
123
  message: `Invalid arguments. ${issues}`,
124
+ invalid_fields: invalidFields,
90
125
  recovery: 'Fix the named argument(s) and call the same tool again. Do not infer missing user-owned fields.',
91
126
  };
92
- return {
127
+ return localizeToolResult((args ?? {}), {
93
128
  content: [{ type: 'text', text: JSON.stringify(error) }],
94
129
  structuredContent: error,
95
130
  isError: true,
96
- };
131
+ });
97
132
  }
98
133
  try {
99
- const result = await serialize(() => tool.handler(parsed.data));
134
+ const callArgs = hiddenTestClock
135
+ ? { ...parsed.data, today_override: rawArgs['today_override'] }
136
+ : parsed.data;
137
+ const raw = await serialize(() => tool.handler(callArgs));
138
+ // Learn the session's language from the user's OWN words (never env), so
139
+ // 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));
100
150
  // Opt-in usage signal: which tool ran + that it didn't crash. Carries no
101
151
  // arguments — never the decision content. Fire-and-forget (see telemetry.ts).
102
152
  recordToolCall(name, true);
103
- return appendDueNote(name, parsed.data, result);
153
+ // Debounced out-of-band ask arms on every call and fires only after the
154
+ // session goes quiet; a check_in call spends the budget instead (the user
155
+ // just saw their dues). Never throws, never taxes this call.
156
+ armAmbientElicit(name, callArgs);
157
+ return appendDueNote(name, callArgs, result);
104
158
  }
105
159
  catch (e) {
106
160
  recordToolCall(name, false);
@@ -48,7 +48,7 @@ export const candidates = {
48
48
  today_override: zDate.optional(),
49
49
  })
50
50
  .refine((a) => a.action === 'list' || a.candidate_id !== undefined, { message: 'candidate_id is required for promote/drop/snooze' })
51
- .refine((a) => a.action !== 'promote' || a.decision_id !== undefined, { message: 'promote links a candidate to an existing sealed decision — pass decision_id (seal first with argus_seal)' })
51
+ .refine((a) => a.action !== 'promote' || a.decision_id !== undefined, { message: 'promote links a candidate to an existing saved prediction — pass decision_id (save it first with argus_predict)' })
52
52
  .refine((a) => a.action !== 'snooze' || a.snooze_until !== undefined, { message: 'snooze requires snooze_until (YYYY-MM-DD)' }),
53
53
  outputSchema: ENVELOPE_OUTPUT_SCHEMA,
54
54
  annotations: { title: 'Captured decision candidates', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -74,7 +74,7 @@ export const candidates = {
74
74
  : [T.header(brief.candidates_active.length, brief.candidates_expired), ...rows, T.quote_note].join('\n');
75
75
  return envelope({
76
76
  ok: true, tool: 'argus_candidates', surface,
77
- next_actions: rows.length === 0 ? ['stop'] : ['argus_seal', 'argus_candidates'],
77
+ next_actions: rows.length === 0 ? ['stop'] : ['argus_predict', 'stop'],
78
78
  data: {
79
79
  candidates: brief.candidates_active.map((c) => {
80
80
  const rec = state.candidates.get(c.candidate_id);
@@ -99,7 +99,7 @@ export const candidates = {
99
99
  return envelope({
100
100
  ok: true, tool: 'argus_candidates',
101
101
  surface: T.promoted(candidateId, String(a['decision_id'])),
102
- next_actions: ['argus_seal'],
102
+ next_actions: ['argus_predict'],
103
103
  data: { candidate_id: candidateId, action, decision_id: a['decision_id'], today },
104
104
  });
105
105
  }
@@ -9,6 +9,7 @@ import { envelope } from '../lib/envelope.js';
9
9
  import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir, zDate } from './tool-types.js';
10
10
  import { handleToolException } from './errors.js';
11
11
  import { briefDivergence, readV2Brief } from '../v2/mirror.js';
12
+ import { drainCaptureOnCheckIn } from '../v2/capture-runtime.js';
12
13
  /**
13
14
  * The watch anchor is the one surface a user cannot close: a contract has
14
15
  * settle/dismiss and an open_question has still_open, but an anchor has no ack
@@ -41,6 +42,7 @@ export const checkIn = {
41
42
  try {
42
43
  const dir = resolveToolArgusDir(a['argus_dir']);
43
44
  const today = resolveToday({ override: a['today_override'] });
45
+ const captureStatus = await drainCaptureOnCheckIn(dir, today);
44
46
  const ledger = replayLedger(dir, today);
45
47
  const seeds = bearingContracts(dir, today, ledger);
46
48
  const dueMap = new Map();
@@ -98,13 +100,23 @@ export const checkIn = {
98
100
  // a due question came back framed in English around the user's own words.
99
101
  const premiseGroups = groupDuePremises(duePremises(ledger));
100
102
  const openQs = dueOpenQuestions(ledger);
101
- // Last resort: ANY contract predicate (replay order = deterministic)
102
- // without it a quiet day on an all-Korean ledger greeted in English
103
- // ("Nothing is due.") because nothing due meant nothing to sniff
104
- // (75-day life loop find). Still ledger text only, never env/Intl.
105
- const anyLedgerText = [...ledger.contracts.values()].find((c) => typeof c.predicate === 'string' && c.predicate)?.predicate;
106
- const ledgerVoiceSample = lastAnchor?.text || ledger.overdue[0]?.text || due[0]?.predicate
107
- || premiseGroups[0]?.premises[0]?.decision_text || openQs[0]?.text || anyLedgerText;
103
+ // Sniff the user's LOCALE from the ledger's own words (never env/Intl for
104
+ // Korean users on an English OS). The old priority-OR chain silently fell
105
+ // through to `undefined` English whenever every earlier slot was empty
106
+ // AND the last resort read a field that doesn't exist on ContractEntry
107
+ // (`.predicate`; the entry stores `.text`). A Korean session whose only due
108
+ // item was an open question came back framed in English around its own
109
+ // Korean quote. Fix: pool ALL available ledger user-text defensively so any
110
+ // single Korean line is enough to detect. (Found via the check_in
111
+ // localization yellow, 2026-07-14.)
112
+ const ledgerVoiceSample = [
113
+ lastAnchor?.text,
114
+ ...ledger.overdue.map((o) => o.text),
115
+ ...due.map((d) => d.predicate),
116
+ ...premiseGroups.flatMap((g) => g.premises.map((p) => p.decision_text)),
117
+ ...openQs.map((q) => q.text),
118
+ ...[...ledger.contracts.values()].map((c) => c.text),
119
+ ].filter((t) => typeof t === 'string' && t.trim().length > 0).join(' ') || undefined;
108
120
  const S = ledgerVoiceSample
109
121
  ? SURFACES[resolveResponseLocale(dir, ledgerVoiceSample)].checkin
110
122
  : SURFACES[surfaceLocale(dir)].checkin;
@@ -227,7 +239,7 @@ export const checkIn = {
227
239
  ok: true, tool: 'argus_check_in',
228
240
  surface: mirrorLine + S.nothing_due + accountHint + upcomingLine + fleetLine + integrityLine,
229
241
  next_actions: ['stop'],
230
- data: { due: [], due_count: 0, due_premises: [], due_premise_count: 0, due_open_questions: [], due_open_question_count: 0, ...(upDays > 0 ? { upcoming } : {}), ...(a['fleet'] === true ? { fleet: fleetRows } : {}), ...watchData, today, v2_brief: readV2Brief(dir, today), v2_divergence: briefDivergence([], readV2Brief(dir, today)) },
242
+ data: { due: [], due_count: 0, due_premises: [], due_premise_count: 0, due_open_questions: [], due_open_question_count: 0, ...(upDays > 0 ? { upcoming } : {}), ...(a['fleet'] === true ? { fleet: fleetRows } : {}), ...watchData, today, capture_status: captureStatus, v2_brief: readV2Brief(dir, today), v2_divergence: briefDivergence([], readV2Brief(dir, today)) },
231
243
  });
232
244
  }
233
245
  const parts = [];
@@ -252,10 +264,10 @@ export const checkIn = {
252
264
  // Route to the tool that acts on whatever is due: settle a contract first,
253
265
  // else reconsider/recall. argus_premises closes or defers an open question.
254
266
  const next = due.length > 0
255
- ? ['argus_settle']
267
+ ? ['argus_resolve']
256
268
  : openQs.length > 0
257
- ? ['argus_premises', 'argus_recall']
258
- : ['argus_recall'];
269
+ ? ['argus_capture', 'argus_patterns']
270
+ : ['argus_patterns'];
259
271
  return envelope({
260
272
  ok: true, tool: 'argus_check_in',
261
273
  surface: mirrorLine + parts.join(' ') + upcomingLine + fleetLine + integrityLine,
@@ -270,7 +282,7 @@ export const checkIn = {
270
282
  ...(upDays > 0 ? { upcoming } : {}),
271
283
  ...(a['fleet'] === true ? { fleet: fleetRows } : {}),
272
284
  ...watchData,
273
- today, integrity: ledger.integrity,
285
+ today, integrity: ledger.integrity, capture_status: captureStatus,
274
286
  // v2 병기 (P2-3): v1이 여전히 정본이고 surface는 무접촉 — 관찰용.
275
287
  // P2 읽기 전환 전에 두 원장의 답이 갈리는지 실사용에서 드러낸다.
276
288
  v2_brief: readV2Brief(dir, today),
@@ -3,6 +3,7 @@ import { ArgusDirError } from '../lib/argus-dir.js';
3
3
  import { PathSafetyError } from '../lib/safe-path.js';
4
4
  import { GuardError } from '../lib/state-machine.js';
5
5
  import { logError } from '../lib/log.js';
6
+ import { localizedErrorCopy } from '../lib/localized-message.js';
6
7
  /**
7
8
  * Map a thrown exception to a spine-safe tool error envelope. Known typed
8
9
  * errors carry their own code + recovery hint; anything else is an internal
@@ -10,14 +11,27 @@ import { logError } from '../lib/log.js';
10
11
  */
11
12
  export function handleToolException(tool, e) {
12
13
  if (e instanceof ArgusDirError) {
13
- return toolError({ ok: false, tool, error_code: e.code, message: e.message, recovery: 'Pass an absolute .argus path with no "..".' });
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 경로를 전달하세요.' },
17
+ });
18
+ return toolError({ ok: false, tool, error_code: e.code, ...copy });
14
19
  }
15
20
  if (e instanceof PathSafetyError) {
16
- return toolError({ ok: false, tool, error_code: e.code, message: e.message, recovery: 'Use ids/labels matching [A-Za-z0-9._-] only.' });
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._-] 문자만 사용하세요.' },
24
+ });
25
+ return toolError({ ok: false, tool, error_code: e.code, ...copy });
17
26
  }
18
27
  if (e instanceof GuardError) {
19
28
  return toolError({ ok: false, tool, error_code: e.code, message: e.message, recovery: e.recovery });
20
29
  }
21
30
  logError(`[${tool}] unhandled`, e);
22
- return toolError({ ok: false, tool, error_code: 'INTERNAL_ERROR', message: String(e instanceof Error ? e.message : e) });
31
+ const detail = String(e instanceof Error ? e.message : e);
32
+ const copy = localizedErrorCopy(null, undefined, {
33
+ en: { message: `Internal error: ${detail}`, recovery: 'Try the same operation again. If it repeats, inspect the server log.' },
34
+ ko: { message: `내부 오류가 발생했습니다: ${detail}`, recovery: '같은 작업을 다시 시도하세요. 반복되면 서버 로그를 확인하세요.' },
35
+ });
36
+ return toolError({ ok: false, tool, error_code: 'INTERNAL_ERROR', ...copy });
23
37
  }
@@ -11,6 +11,38 @@ import { premises } from './premises.js';
11
11
  import { recheck } from './recheck.js';
12
12
  import { watch } from './watch.js';
13
13
  import { candidates } from './candidates.js';
14
+ import { decide, history, settings, publicSeal, publicCheckIn, publicSettle, publicCopy } from './public-tools.js';
15
+ import { toolJsonSchema } from './tool-types.js';
16
+ import { bilingualToolPresentation } from '../lib/tool-presentation.js';
17
+ import { semanticRecord } from './semantic-record.js';
14
18
  /** The full registered tool set. There is deliberately no verdict/grade/score tool. */
15
- export const TOOLS = [openDecision, review, premises, seal, recheck, settle, checkIn, recall, sync, amend, dismiss, candidates, watch, init, config];
16
- export const TOOL_MAP = new Map(TOOLS.map((t) => [t.name, t]));
19
+ export const TOOLS = [openDecision, review, premises, seal, recheck, settle, checkIn, recall, sync, amend, dismiss, candidates, watch, init, config, semanticRecord];
20
+ /** The small, purpose-led surface returned by tools/list. Legacy tools stay in
21
+ * TOOL_MAP for cached clients and one-version compatibility, but new users and
22
+ * models no longer have to choose among internal state-machine parts. */
23
+ export const PUBLIC_TOOLS = [decide, publicSeal, publicCheckIn, publicSettle, history, settings];
24
+ /** The semantic vertical slice is deliberately opt-in until the P5 value gate. */
25
+ export const V3_PILOT_TOOLS = [semanticRecord];
26
+ export const TOOL_MAP = new Map([...TOOLS, ...PUBLIC_TOOLS].map((t) => [t.name, t]));
27
+ /**
28
+ * The exact tool descriptors returned by tools/list — single source shared by
29
+ * the server and the host-surface guard test. inputSchema runs through
30
+ * publicCopy so that a legacy tool name embedded in a Zod field description
31
+ * (e.g. seal's "no prior argus_open_decision is needed") is rewritten to the
32
+ * public name before it reaches a host. Without this, the tool-call RESULTS are
33
+ * translated but the tool SCHEMAS still leaked the old vocabulary.
34
+ */
35
+ export function servedPublicTools() {
36
+ const served = process.env['ARGUS_DKK_V6_PILOT'] === '1' ? [...PUBLIC_TOOLS, ...V3_PILOT_TOOLS] : PUBLIC_TOOLS;
37
+ return served.map((t) => {
38
+ const presentation = bilingualToolPresentation(t.name, t.annotations?.title, t.description);
39
+ return {
40
+ name: t.name,
41
+ title: presentation.title,
42
+ description: publicCopy(presentation.description),
43
+ inputSchema: publicCopy(toolJsonSchema(t.inputSchema)),
44
+ ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
45
+ ...(t.annotations ? { annotations: t.annotations } : {}),
46
+ };
47
+ });
48
+ }
@@ -14,6 +14,7 @@ import path from 'path';
14
14
  import { envelope, toolError } from '../lib/envelope.js';
15
15
  import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir } from './tool-types.js';
16
16
  import { handleToolException } from './errors.js';
17
+ import { localizedMessage } from '../lib/localized-message.js';
17
18
  import { gitCommonDirOf } from '../v2/git-discovery.js';
18
19
  import { initV2 } from '../v2/init.js';
19
20
  import { argusHome, ledgerPath } from '../v2/ledger.js';
@@ -39,7 +40,19 @@ export const init = {
39
40
  await ensurePrivacyGitignore(dir);
40
41
  writeBoundMarker(dir);
41
42
  if (!fsSync.existsSync(configPath(dir))) {
42
- const cfg = { schema_version: SCHEMA_VERSION, locale: detectLocale(dir), boss: null, team: null, archive: null };
43
+ // Seed a locale ONLY on a positive Korean env signal (KST machine ko
44
+ // once, so the very first contentless surface is Korean). On the default
45
+ // English/Intl fallback, OMIT locale so runtime content-detection stays
46
+ // live — otherwise a Korean user on an English-locale OS gets locale:en
47
+ // pinned on first use, and config-wins means their Korean text can NEVER
48
+ // reclaim a Korean surface. (Found via the check_in localization yellow,
49
+ // 2026-07-14: an all-Korean session came back in English forever.)
50
+ const seededLocale = detectLocale(dir);
51
+ const cfg = {
52
+ schema_version: SCHEMA_VERSION,
53
+ ...(seededLocale === 'ko' ? { locale: 'ko' } : {}),
54
+ boss: null, team: null, archive: null,
55
+ };
43
56
  await atomicWriteText(configPath(dir), yaml.dump(cfg));
44
57
  }
45
58
  // ── v2 바인딩 (P1 수술 1단계 — 파괴 없는 추가) ──
@@ -78,18 +91,18 @@ export const init = {
78
91
  // "today is yesterday" immediately instead of at the first missed check-in.
79
92
  // Default is system-local; ARGUS_TZ is the explicit override.
80
93
  const tz = process.env['ARGUS_TZ'] || `${resolveDefaultTimeZone()} (system local; set ARGUS_TZ to override)`;
94
+ const locale = readConfig(dir)?.locale ?? detectLocale(dir);
81
95
  return envelope({
82
96
  ok: true, tool: 'argus_init',
83
97
  surface: empty
84
- ? 'Argus is ready. It does not give answers — it records a prediction + a check-by date and meets reality on that date. Open your first decision with argus_open_decision.'
85
- : 'Argus is ready.',
86
- next_actions: empty ? ['argus_open_decision'] : ['argus_check_in'],
98
+ ? localizedMessage(dir, undefined, {
99
+ en: 'Argus is ready. Describe a decision you are weighing. It can clarify the decision, save a prediction with a check date, and later record what actually happened. No grades, just your own track record over time.',
100
+ ko: 'Argus가 준비됐습니다. 고민 중인 결정을 말해 주세요. 결정을 명료하게 정리하고 확인일이 있는 예측을 저장한 뒤, 그날 실제로 일어난 일을 기록할 수 있습니다. 점수나 평결 없이 당신의 판단 기록만 남습니다.',
101
+ })
102
+ : localizedMessage(dir, undefined, { en: 'Argus is ready.', ko: 'Argus가 준비됐습니다.' }),
103
+ next_actions: empty ? ['argus_capture'] : ['argus_check_in'],
87
104
  data: {
88
- initialized: true, argus_dir: dir, today, tz, v2,
89
- // §9.3 — one quiet pointer, data-only, never a push: the daily-watch
90
- // host snippets (CLAUDE.md block + SessionStart hook) ship in the
91
- // package for users who want the watch rhythm carried by their host.
92
- watch_snippets: 'optional — see snippets/claude-code-watch.md in the argus-decision-mcp package',
105
+ initialized: true, argus_dir: dir, today, tz, locale, v2,
93
106
  },
94
107
  });
95
108
  }
@@ -118,7 +131,13 @@ export const config = {
118
131
  const writeKeys = ['locale', 'boss', 'team', 'archive', 'ambient_mute', 'premise_sync'].filter((k) => k in a);
119
132
  const existing = readConfig(dir) ?? { schema_version: SCHEMA_VERSION, locale: detectLocale(dir), boss: null, team: null, archive: null };
120
133
  if (writeKeys.length === 0) {
121
- return envelope({ ok: true, tool: 'argus_config', surface: 'Config read.', next_actions: ['stop'], data: { config: existing, existed: !!readConfig(dir) } });
134
+ return envelope({
135
+ ok: true,
136
+ tool: 'argus_config',
137
+ surface: localizedMessage(dir, undefined, { en: 'Config read.', ko: '설정을 읽었습니다.' }),
138
+ next_actions: ['stop'],
139
+ data: { config: existing, existed: !!readConfig(dir) },
140
+ });
122
141
  }
123
142
  if ('locale' in a && a['locale'] !== 'ko' && a['locale'] !== 'en') {
124
143
  return toolError({ ok: false, tool: 'argus_config', error_code: 'INVALID_LOCALE', message: 'locale must be "ko" or "en".' });
@@ -134,7 +153,16 @@ export const config = {
134
153
  ...(('premise_sync' in a) ? { premise_sync: a['premise_sync'] } : {}),
135
154
  };
136
155
  await atomicWriteText(configPath(dir), yaml.dump(merged));
137
- return envelope({ ok: true, tool: 'argus_config', surface: 'Config updated.', next_actions: ['stop'], data: { config: merged } });
156
+ return envelope({
157
+ ok: true,
158
+ tool: 'argus_config',
159
+ surface: localizedMessage(dir, undefined, {
160
+ en: `Config updated: ${writeKeys.join(', ')}.`,
161
+ ko: `설정을 수정했습니다: ${writeKeys.join(', ')}.`,
162
+ }),
163
+ next_actions: ['stop'],
164
+ data: { config: merged },
165
+ });
138
166
  }
139
167
  catch (e) {
140
168
  return handleToolException('argus_config', e);
@@ -6,6 +6,7 @@ import { resolveContract } from '../lib/resolve-contract.js';
6
6
  import { overfireGate } from '../lib/overfire-gate.js';
7
7
  import { validateCrux } from '../lib/validate-crux.js';
8
8
  import { computeContinuity } from '../lib/continuity.js';
9
+ import { relatedOpenForPremises } from '../v2/connection-io.js';
9
10
  import { resolveResponseLocale, SURFACES } from '../lib/surfaces.js';
10
11
  import { appendLedger } from '../lib/ledger-append.js';
11
12
  import { ensurePrivacyGitignore } from '../lib/privacy.js';
@@ -50,13 +51,14 @@ export const openDecision = {
50
51
  const id = String(a['id'] ?? '');
51
52
  const today = resolveToday({ override: a['today_override'] });
52
53
  // Response voice follows the decision sentence (M4): config > text > env.
53
- const T = SURFACES[resolveResponseLocale(dir, a['decision'])].tools.open_decision;
54
+ const locale = resolveResponseLocale(dir, a['decision']);
55
+ const T = SURFACES[locale].tools.open_decision;
54
56
  const current = resolveContract(dir, id, today);
55
57
  if (a['already_decided'] === true && (current.state === 'sealed' || current.state === 'due' || current.state === 'settled')) {
56
58
  return toolError({
57
59
  ok: false, tool: 'argus_open_decision', error_code: 'ALREADY_CLOSED',
58
60
  message: 'This decision is already underway or closed.',
59
- recovery: 'To check reality, call argus_settle. Closed decisions are not reopened.',
61
+ recovery: 'To record what reality did, call argus_resolve. Closed decisions are not reopened.',
60
62
  });
61
63
  }
62
64
  const signals = {
@@ -65,16 +67,36 @@ export const openDecision = {
65
67
  already_decided: a['already_decided'] === true,
66
68
  };
67
69
  const gate = overfireGate(signals);
70
+ // Validate any model-supplied crux BEFORE any side-effect — invalid input
71
+ // must error without persisting. (The public capture surface no longer
72
+ // sends a crux; this guards internal callers that still can.)
73
+ const cruxErr = validateCrux(a['crux_question']);
74
+ if (cruxErr) {
75
+ return toolError({ ok: false, tool: 'argus_open_decision', error_code: cruxErr.code, message: cruxErr.message, recovery: cruxErr.recovery });
76
+ }
68
77
  const now = new Date().toISOString();
69
78
  // Always log the gate inputs for post-hoc accuracy measurement (M2).
70
79
  await appendLedger(dir, [{ id, event: 'gate_input', gate: { ...signals, verdict: gate.reason } }], now);
80
+ // 기록과 의식을 분리한다: the user's own decision and premise are recorded
81
+ // REGARDLESS of the gate — deciding it "isn't worth keeping" would itself
82
+ // be a judgment about the user's decision (zero-judgment violation). The
83
+ // over-fire gate now governs only the surface CEREMONY (whether a crux is
84
+ // offered and a seal is nudged), never whether the record is written.
85
+ await ensurePrivacyGitignore(dir);
86
+ await atomicWriteJson(sessionFilePath(dir, id), {
87
+ v: SCHEMA_VERSION, id, problem_text: a['decision'], status_quo: a['status_quo'],
88
+ load_bearing_assumption: a['load_bearing_assumption'] ?? null, created_at: now,
89
+ });
90
+ await appendLedger(dir, [{ id, event: 'harvest', decision: a['decision'] }], now);
91
+ const relatedIds = Array.isArray(a['related_to']) ? a['related_to'] : [];
92
+ const continuity = relatedIds.length ? computeContinuity(dir, relatedIds) : undefined;
71
93
  if (gate.response === 'reconfirm') {
72
94
  return envelope({
73
95
  ok: true, tool: 'argus_open_decision',
74
96
  surface: T.reconfirm,
75
- next_actions: ['argus_open_decision', 'leave_as_is'],
97
+ next_actions: ['leave_as_is'],
76
98
  over_fire_gate: { fired: false, reason: gate.reason },
77
- data: { id, crux_question: null, restraint_option: a['status_quo'], fork_emitted: false, harvest_written: false },
99
+ data: { id, crux_question: null, restraint_option: a['status_quo'], fork_emitted: false, harvest_written: true, continuity },
78
100
  });
79
101
  }
80
102
  if (!gate.fire) {
@@ -82,33 +104,40 @@ export const openDecision = {
82
104
  ok: true, tool: 'argus_open_decision',
83
105
  // Human sentence, not a snake_case enum (11 P2-1). Contract (§4): the
84
106
  // line ENDS by naming the option and returning the handle — never a
85
- // directive ("leave it") issued in the user's stead.
86
- // §9.4 절벽 제거: the restraint verdict stands, but a user who still
87
- // wants the thought KEPT gets an exit a watch note, not a decision.
88
- surface: `${T.reason[gate.reason] ?? T.reason_fallback} ${T.leave_coda}${T.watch_exit}`,
89
- next_actions: ['leave_as_is', 'argus_watch', 'skip'],
107
+ // directive ("leave it") issued in the user's stead. The decision is
108
+ // now recorded quietly, so the old "jot a note if you want it kept"
109
+ // exit is gone (it IS kept); the gate only withholds the ceremony.
110
+ surface: `${T.reason[gate.reason] ?? T.reason_fallback} ${T.leave_coda}`,
111
+ next_actions: ['leave_as_is', 'skip'],
90
112
  over_fire_gate: { fired: false, reason: gate.reason },
91
- data: { id, crux_question: null, restraint_option: a['status_quo'], fork_emitted: false, harvest_written: false },
113
+ data: { id, crux_question: null, restraint_option: a['status_quo'], fork_emitted: false, harvest_written: true, continuity },
92
114
  });
93
115
  }
94
- // FIRE: validate any model-supplied crux, persist the harvest.
95
- const cruxErr = validateCrux(a['crux_question']);
96
- if (cruxErr) {
97
- return toolError({ ok: false, tool: 'argus_open_decision', error_code: cruxErr.code, message: cruxErr.message, recovery: cruxErr.recovery });
98
- }
99
- await ensurePrivacyGitignore(dir);
100
- await atomicWriteJson(sessionFilePath(dir, id), {
101
- v: SCHEMA_VERSION, id, problem_text: a['decision'], status_quo: a['status_quo'],
102
- load_bearing_assumption: a['load_bearing_assumption'] ?? null, created_at: now,
103
- });
104
- await appendLedger(dir, [{ id, event: 'harvest', decision: a['decision'] }], now);
116
+ // FIRE: the ceremony surface the one neutral crux (if supplied) and the
117
+ // seal path. Persistence already happened above.
105
118
  const crux = a['crux_question'] ?? null;
106
- const relatedIds = Array.isArray(a['related_to']) ? a['related_to'] : [];
107
- const continuity = relatedIds.length ? computeContinuity(dir, relatedIds) : undefined;
119
+ // Capture-time connection (정본 §8-§11, §8-C): the same mechanical read the
120
+ // settle surface uses, moved to the front door. If the premise this
121
+ // decision rests on is one the user already tracks under another OPEN
122
+ // decision, name that fact + the handle — never a verdict, never "revisit
123
+ // it". best-effort: a non-git/uninit/failed v2 read just yields no line.
124
+ const premiseTexts = [
125
+ typeof a['load_bearing_assumption'] === 'string' ? a['load_bearing_assumption'] : '',
126
+ ...(Array.isArray(a['premises']) ? a['premises'].map((p) => p?.text ?? '') : []),
127
+ ];
128
+ const connections = relatedOpenForPremises(dir, today, premiseTexts, id);
129
+ let connectionLine = '';
130
+ if (connections.length > 0) {
131
+ const shown = connections.slice(0, 3).map((c) => c.decision_id);
132
+ const extra = connections.length - shown.length;
133
+ connectionLine = locale === 'ko'
134
+ ? `\n이 결정이 기댄 전제와 같은 가정이나 근거에 선 다른 열린 결정: ${shown.join(', ')}${extra > 0 ? ` 외 ${extra}개` : ''}. argus_check_in으로 함께 볼 수 있어요.`
135
+ : `\nOther open decisions rest on the same assumption or fact this one leans on: ${shown.join(', ')}${extra > 0 ? ` (+${extra} more)` : ''}. Review them together with argus_check_in.`;
136
+ }
108
137
  return envelope({
109
138
  ok: true, tool: 'argus_open_decision',
110
- surface: crux ? T.opened_with_crux(crux) : T.opened_bare,
111
- next_actions: ['argus_seal', 'leave_as_is', 'skip'],
139
+ surface: (crux ? T.opened_with_crux(crux) : T.opened_bare) + connectionLine,
140
+ next_actions: ['argus_predict', 'leave_as_is', 'skip'],
112
141
  over_fire_gate: { fired: true, reason: gate.reason },
113
142
  data: {
114
143
  id,
@@ -119,6 +148,10 @@ export const openDecision = {
119
148
  fork_emitted: false,
120
149
  harvest_written: true,
121
150
  continuity,
151
+ ...(connections.length > 0 ? {
152
+ connections: connections.map((c) => c.decision_id),
153
+ connection_reasons: connections.map((c) => ({ id: c.decision_id, reason: c.reason, ...(c.via ? { via: c.via } : {}) })),
154
+ } : {}),
122
155
  lean_disclosure: 'Naming the load-bearing question points faintly at the flip; that residual lean is a known limit, not a verdict.',
123
156
  },
124
157
  });