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