argus-decision-mcp 1.0.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.
- package/LICENSE +1 -1
- package/README.md +135 -41
- package/SECURITY.md +22 -8
- 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-due.js +63 -0
- package/dist/lib/ambient-elicit.js +225 -0
- package/dist/lib/argus-dir.js +76 -9
- package/dist/lib/calendar.js +75 -0
- package/dist/lib/canonical-scales.js +105 -0
- package/dist/lib/due-note.js +62 -13
- package/dist/lib/elicit.js +13 -1
- package/dist/lib/envelope.js +19 -4
- package/dist/lib/install-id.js +53 -0
- package/dist/lib/layout.js +7 -0
- package/dist/lib/ledger-append.js +134 -3
- package/dist/lib/ledger-replay.js +139 -11
- package/dist/lib/locale-mismatch.js +52 -0
- package/dist/lib/locale.js +120 -4
- package/dist/lib/localize-result.js +120 -0
- package/dist/lib/localized-message.js +13 -0
- package/dist/lib/log.js +2 -2
- package/dist/lib/numeric-drift.js +343 -22
- package/dist/lib/package-meta.js +14 -0
- package/dist/lib/premise-sync.js +23 -0
- package/dist/lib/premises-core.js +174 -0
- package/dist/lib/premises.js +51 -50
- package/dist/lib/privacy.js +1 -1
- package/dist/lib/push-account.js +28 -4
- package/dist/lib/receipt.js +5 -1
- package/dist/lib/render-receipt.js +32 -22
- package/dist/lib/resolve-today.js +11 -3
- package/dist/lib/review/extract-core.js +345 -0
- package/dist/lib/review/extract-file-node.js +288 -0
- 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 +254 -15
- package/dist/lib/review/render.js +86 -38
- package/dist/lib/review/reviewability.js +8 -7
- package/dist/lib/review/routing.js +38 -17
- package/dist/lib/review/schema.js +4 -1
- package/dist/lib/review-path.js +108 -0
- package/dist/lib/spine.js +16 -12
- package/dist/lib/state-machine.js +59 -13
- package/dist/lib/surface-lint.js +97 -0
- package/dist/lib/surfaces.js +313 -48
- package/dist/lib/telemetry.js +169 -0
- package/dist/lib/tool-presentation.js +35 -0
- package/dist/lib/untrusted.js +89 -0
- package/dist/lib/validate-crux.js +32 -7
- package/dist/resources.js +52 -14
- package/dist/server.js +105 -42
- package/dist/tools/amend-dismiss.js +46 -9
- package/dist/tools/candidates.js +129 -0
- package/dist/tools/check-in.js +178 -20
- package/dist/tools/errors.js +17 -3
- package/dist/tools/index.js +36 -2
- package/dist/tools/init-config.js +83 -11
- package/dist/tools/open-decision.js +71 -36
- package/dist/tools/premises.js +246 -29
- package/dist/tools/public-tools.js +382 -0
- package/dist/tools/recall.js +158 -19
- package/dist/tools/recheck.js +51 -15
- package/dist/tools/review.js +234 -33
- package/dist/tools/seal.js +167 -31
- package/dist/tools/semantic-record.js +225 -0
- package/dist/tools/settle.js +204 -20
- package/dist/tools/sync.js +245 -13
- package/dist/tools/tool-types.js +174 -0
- package/dist/tools/watch.js +117 -0
- package/dist/v2/bridge.js +240 -0
- package/dist/v2/brief.js +122 -0
- 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/events.js +187 -0
- package/dist/v2/evidence.js +55 -0
- package/dist/v2/gate-keywords.js +48 -0
- package/dist/v2/gate.js +98 -0
- package/dist/v2/git-discovery.js +80 -0
- package/dist/v2/harvest.js +122 -0
- package/dist/v2/init.js +42 -0
- package/dist/v2/ledger.js +290 -0
- package/dist/v2/lifecycle-cli.js +50 -0
- package/dist/v2/lifecycle.js +418 -0
- package/dist/v2/logbook.js +142 -0
- package/dist/v2/mirror.js +305 -0
- package/dist/v2/outbox.js +52 -0
- package/dist/v2/queue.js +183 -0
- package/dist/v2/reducer.js +462 -0
- package/dist/v2/sanitize.js +44 -0
- package/dist/v2/v1-reader.js +300 -0
- 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 +20 -6
- package/dist/lib/discipline.js +0 -42
- package/dist/prompts.js +0 -72
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { replayLedger } from './ledger-replay.js';
|
|
2
|
+
import { duePremises, groupDuePremises, dueOpenQuestions } from './premises.js';
|
|
3
|
+
import { surfacesFor, SURFACES, resolveResponseLocale } from './surfaces.js';
|
|
4
|
+
/** Count from an already-replayed ledger (when the caller has one in hand). */
|
|
5
|
+
export function ambientDueFromState(state) {
|
|
6
|
+
return {
|
|
7
|
+
contractsDue: state.overdue.length,
|
|
8
|
+
premiseFactsDue: groupDuePremises(duePremises(state)).length,
|
|
9
|
+
openQuestionsDue: dueOpenQuestions(state).length,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Count by replaying the ledger for `dir` as of `today`. */
|
|
13
|
+
export function ambientDue(dir, today) {
|
|
14
|
+
return ambientDueFromState(replayLedger(dir, today));
|
|
15
|
+
}
|
|
16
|
+
/** True when nothing at all is due — the silence gate (restraint: zero renders
|
|
17
|
+
* nothing, never an empty nag). */
|
|
18
|
+
export function isSilent(due) {
|
|
19
|
+
return due.contractsDue === 0 && due.premiseFactsDue === 0 && due.openQuestionsDue === 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The user's own language, read from the LEDGER's user-authored text — the
|
|
23
|
+
* one deterministic voice source for surfaces that have no fresh input (the
|
|
24
|
+
* ambient tail, recall's textless views). Chain: the latest watch anchor, an
|
|
25
|
+
* overdue predicate, a due premise's decision, a due question, else ANY
|
|
26
|
+
* contract predicate (replay order = deterministic). Never env/Intl — an
|
|
27
|
+
* English user on a Korean machine must not get a Korean frame (experience
|
|
28
|
+
* loop caught exactly that).
|
|
29
|
+
*/
|
|
30
|
+
export function ledgerVoiceText(state) {
|
|
31
|
+
const anchors = [...state.watch.anchors.values()].sort((a, b) => (a.date < b.date ? 1 : -1));
|
|
32
|
+
return anchors[0]?.text
|
|
33
|
+
|| state.overdue[0]?.text
|
|
34
|
+
|| duePremises(state)[0]?.decision_text
|
|
35
|
+
|| dueOpenQuestions(state)[0]?.text
|
|
36
|
+
|| [...state.contracts.values()].find((c) => typeof c.predicate === 'string' && c.predicate)?.predicate;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The localized one-line ambient fact for a due count, or '' when silent.
|
|
40
|
+
* Leads with a space so it appends cleanly to the END of a tool's surface
|
|
41
|
+
* (M1 §1.3, §6: never obscure the result — the line is last, and it is a fact
|
|
42
|
+
* + the argus_check_in handle, never a directive).
|
|
43
|
+
* `state` (when the caller has one) lends the ledger's own voice — without it
|
|
44
|
+
* the tail fell back to config-or-EN and read "By the way — 2 to settle" to a
|
|
45
|
+
* user whose every decision is Korean (experience-loop worst-moment).
|
|
46
|
+
*/
|
|
47
|
+
export function ambientLine(dir, due, state) {
|
|
48
|
+
if (isSilent(due))
|
|
49
|
+
return '';
|
|
50
|
+
const voice = state ? ledgerVoiceText(state) : undefined;
|
|
51
|
+
const A = (voice ? SURFACES[resolveResponseLocale(dir, voice)] : surfacesFor(dir)).ambient;
|
|
52
|
+
const { contractsDue: c, premiseFactsDue: p, openQuestionsDue: q } = due;
|
|
53
|
+
// Fragment-composed so any subset of the three due kinds reads naturally in
|
|
54
|
+
// both locales (M3 adds a third kind — a fixed combo table would be 7 cases).
|
|
55
|
+
const frags = [];
|
|
56
|
+
if (c > 0)
|
|
57
|
+
frags.push(A.frag_contracts(c));
|
|
58
|
+
if (p > 0)
|
|
59
|
+
frags.push(A.frag_premises(p));
|
|
60
|
+
if (q > 0)
|
|
61
|
+
frags.push(A.frag_open_questions(q));
|
|
62
|
+
return A.wrap(frags);
|
|
63
|
+
}
|
|
@@ -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
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
import { boundMarkerPath } from './layout.js';
|
|
4
5
|
/**
|
|
5
|
-
* argus_dir resolution + precedence (blueprint §4.0 + addendum A/B).
|
|
6
|
+
* argus_dir resolution + precedence (blueprint §4.0 + addendum A/B; §9.7 O1).
|
|
6
7
|
*
|
|
7
8
|
* The `${workspaceFolder}` config variable only expands in VS Code — Claude
|
|
8
9
|
* Code expands `${CLAUDE_PROJECT_DIR}`, others expand nothing. So env
|
|
9
10
|
* interpolation is the LEAST reliable channel and must come last. Tools always
|
|
10
11
|
* receive a per-call `argus_dir`, which works on every host, so that wins.
|
|
11
12
|
*
|
|
12
|
-
* Precedence:
|
|
13
|
-
* 1. per-call `argus_dir`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
16
21
|
*/
|
|
17
22
|
export class ArgusDirError extends Error {
|
|
18
23
|
code = 'ARGUS_DIR_INVALID';
|
|
@@ -26,6 +31,14 @@ export function requireArgusDir(callArg) {
|
|
|
26
31
|
if (typeof callArg !== 'string' || callArg.length === 0) {
|
|
27
32
|
throw new ArgusDirError('argus_dir is required (absolute path to the .argus directory).');
|
|
28
33
|
}
|
|
34
|
+
// An unexpanded config variable is the #1 Claude Desktop first-run failure:
|
|
35
|
+
// only Claude Code expands ${CLAUDE_PROJECT_DIR}, so Desktop passes the
|
|
36
|
+
// literal string through. Name the actual problem instead of "not absolute".
|
|
37
|
+
if (/\$\{[^}]*\}|%[A-Za-z_]+%/.test(callArg)) {
|
|
38
|
+
throw new ArgusDirError(`Your MCP host did not expand the variable in "${callArg}" (only some hosts interpolate env vars). ` +
|
|
39
|
+
'Replace it with an absolute path in your MCP config (e.g. "C:\\Users\\you\\.argus" or "/Users/you/.argus"), ' +
|
|
40
|
+
'or remove ARGUS_DIR entirely to use the default ~/.argus.');
|
|
41
|
+
}
|
|
29
42
|
const resolved = path.resolve(callArg);
|
|
30
43
|
if (!path.isAbsolute(callArg)) {
|
|
31
44
|
throw new ArgusDirError('argus_dir must be an absolute path.');
|
|
@@ -48,7 +61,10 @@ export function resolveToolArgusDir(callArg) {
|
|
|
48
61
|
const env = process.env['ARGUS_DIR'];
|
|
49
62
|
if (typeof env === 'string' && env.length > 0)
|
|
50
63
|
return requireArgusDir(env);
|
|
51
|
-
|
|
64
|
+
// Zero-config default (blueprint §9.4 "첫 설치의 문"): a brand-new user on a
|
|
65
|
+
// host without env interpolation still gets a working home for their ledger.
|
|
66
|
+
// Per-call argus_dir and ARGUS_DIR both keep winning over this.
|
|
67
|
+
return path.join(os.homedir(), '.argus');
|
|
52
68
|
}
|
|
53
69
|
/** Record the bound dir so Resources (which get no args) can find it later. */
|
|
54
70
|
export function writeBoundMarker(argusDir) {
|
|
@@ -61,6 +77,43 @@ export function writeBoundMarker(argusDir) {
|
|
|
61
77
|
catch {
|
|
62
78
|
/* non-critical */
|
|
63
79
|
}
|
|
80
|
+
// Global registry (M2 fleet, §9.4): every init'd project dir also lands in
|
|
81
|
+
// ~/.argus/.bound so ONE check_in can report due items across all of a
|
|
82
|
+
// user's projects. Local paths only, stays on this machine, best-effort.
|
|
83
|
+
try {
|
|
84
|
+
const home = path.join(os.homedir(), '.argus');
|
|
85
|
+
fs.mkdirSync(home, { recursive: true });
|
|
86
|
+
const reg = path.join(home, '.bound');
|
|
87
|
+
let dirs = [];
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(fs.readFileSync(reg, 'utf8'));
|
|
90
|
+
dirs = Array.isArray(parsed.bound) ? parsed.bound.filter((x) => typeof x === 'string') : [];
|
|
91
|
+
}
|
|
92
|
+
catch { /* fresh registry */ }
|
|
93
|
+
if (!dirs.includes(argusDir))
|
|
94
|
+
dirs.unshift(argusDir);
|
|
95
|
+
fs.writeFileSync(reg, JSON.stringify({ bound: dirs.slice(0, 16) }), 'utf8');
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* non-critical */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** The cross-project registry (~/.argus/.bound) — existing dirs only. */
|
|
102
|
+
export function readGlobalBoundList() {
|
|
103
|
+
try {
|
|
104
|
+
const reg = path.join(os.homedir(), '.argus', '.bound');
|
|
105
|
+
const parsed = JSON.parse(fs.readFileSync(reg, 'utf8'));
|
|
106
|
+
const dirs = Array.isArray(parsed.bound) ? parsed.bound.filter((x) => typeof x === 'string') : [];
|
|
107
|
+
return dirs.filter((d) => { try {
|
|
108
|
+
return fs.existsSync(d);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
} });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
64
117
|
}
|
|
65
118
|
function readBoundList(argusDir) {
|
|
66
119
|
try {
|
|
@@ -74,12 +127,26 @@ function readBoundList(argusDir) {
|
|
|
74
127
|
}
|
|
75
128
|
/**
|
|
76
129
|
* Resolve argus_dir for a Resource read (no per-call arg available).
|
|
77
|
-
*
|
|
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.
|
|
78
143
|
*/
|
|
79
144
|
export function resolveArgusDirForResource() {
|
|
80
145
|
const env = process.env['ARGUS_DIR'];
|
|
81
|
-
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;
|
|
82
149
|
return path.resolve(env);
|
|
83
150
|
}
|
|
84
|
-
return
|
|
151
|
+
return path.join(os.homedir(), '.argus');
|
|
85
152
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { calendarPath } from './layout.js';
|
|
4
|
+
function ymdCompact(ymd) {
|
|
5
|
+
return ymd.replace(/-/g, '');
|
|
6
|
+
}
|
|
7
|
+
function addOneDay(ymd) {
|
|
8
|
+
const t = Date.parse(`${ymd}T00:00:00Z`);
|
|
9
|
+
if (Number.isNaN(t))
|
|
10
|
+
return ymd;
|
|
11
|
+
return new Date(t + 86_400_000).toISOString().slice(0, 10);
|
|
12
|
+
}
|
|
13
|
+
function escapeIcs(value) {
|
|
14
|
+
return value
|
|
15
|
+
.replace(/\\/g, '\\\\')
|
|
16
|
+
.replace(/\r?\n/g, '\\n')
|
|
17
|
+
.replace(/,/g, '\\,')
|
|
18
|
+
.replace(/;/g, '\\;');
|
|
19
|
+
}
|
|
20
|
+
function foldLine(line) {
|
|
21
|
+
if (line.length <= 74)
|
|
22
|
+
return line;
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let rest = line;
|
|
25
|
+
while (rest.length > 74) {
|
|
26
|
+
chunks.push(rest.slice(0, 74));
|
|
27
|
+
rest = rest.slice(74);
|
|
28
|
+
}
|
|
29
|
+
chunks.push(rest);
|
|
30
|
+
return chunks.map((chunk, i) => (i === 0 ? chunk : ` ${chunk}`)).join('\r\n');
|
|
31
|
+
}
|
|
32
|
+
export function renderReturnCalendarEvent(args) {
|
|
33
|
+
const start = ymdCompact(args.check_by);
|
|
34
|
+
const end = ymdCompact(addOneDay(args.check_by));
|
|
35
|
+
const stamp = args.created_at.replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
36
|
+
const summary = `Argus return: ${args.predicate.slice(0, 80)}`;
|
|
37
|
+
const description = [
|
|
38
|
+
'An Argus prediction has reached its check date.',
|
|
39
|
+
`Predicate: ${args.predicate}`,
|
|
40
|
+
'Open your terminal and run argus_check_in, then argus_resolve if reality is clear.',
|
|
41
|
+
].join('\n');
|
|
42
|
+
const lines = [
|
|
43
|
+
'BEGIN:VCALENDAR',
|
|
44
|
+
'VERSION:2.0',
|
|
45
|
+
'PRODID:-//Argus//Return Calendar//EN',
|
|
46
|
+
'CALSCALE:GREGORIAN',
|
|
47
|
+
'METHOD:PUBLISH',
|
|
48
|
+
'BEGIN:VEVENT',
|
|
49
|
+
`UID:${escapeIcs(args.id)}@argus.local`,
|
|
50
|
+
`DTSTAMP:${stamp}`,
|
|
51
|
+
`DTSTART;VALUE=DATE:${start}`,
|
|
52
|
+
`DTEND;VALUE=DATE:${end}`,
|
|
53
|
+
`SUMMARY:${escapeIcs(summary)}`,
|
|
54
|
+
`DESCRIPTION:${escapeIcs(description)}`,
|
|
55
|
+
'BEGIN:VALARM',
|
|
56
|
+
// DTSTART is local midnight of the check-by (an all-day event), so a POSITIVE
|
|
57
|
+
// offset lands on the morning OF the due date. `-PT9H` fired nine hours
|
|
58
|
+
// BEFORE midnight — 15:00 the previous afternoon — while the alarm text said
|
|
59
|
+
// "due today". This .ics is the only thing that brings a sealed bet back
|
|
60
|
+
// without an account, so it has to ring on the right day.
|
|
61
|
+
'TRIGGER;RELATED=START:PT9H',
|
|
62
|
+
'ACTION:DISPLAY',
|
|
63
|
+
`DESCRIPTION:${escapeIcs(summary)}`,
|
|
64
|
+
'END:VALARM',
|
|
65
|
+
'END:VEVENT',
|
|
66
|
+
'END:VCALENDAR',
|
|
67
|
+
];
|
|
68
|
+
return `${lines.map(foldLine).join('\r\n')}\r\n`;
|
|
69
|
+
}
|
|
70
|
+
export async function writeReturnCalendarEvent(argusDir, args) {
|
|
71
|
+
const file = calendarPath(argusDir, args.id);
|
|
72
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
73
|
+
await fs.writeFile(file, renderReturnCalendarEvent(args), 'utf8');
|
|
74
|
+
return file;
|
|
75
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical scales & units — the pre-rule normalization layer for M2 drift
|
|
3
|
+
* materiality (internal design notes §3, §10.4).
|
|
4
|
+
*
|
|
5
|
+
* Rules compare CANONICAL values, never raw labels. This module turns an ordinal
|
|
6
|
+
* label ("BBB", "Baa2", "Enterprise", "Maintenance") into a comparable number via
|
|
7
|
+
* a built-in ordinal map, and converts a few units into a common axis before the
|
|
8
|
+
* rule ever runs. When a label can't be placed on a known scale, this returns
|
|
9
|
+
* `null` — the caller routes that to `uncertain` (host-judgment lane), never a
|
|
10
|
+
* silent guess (§3.1, "규칙이 아니라 매번 모델 판정 = 스파인 위반").
|
|
11
|
+
*
|
|
12
|
+
* Alias normalization is the load-bearing win: Baa2 ≡ BBB, so a Moody's→S&P
|
|
13
|
+
* relabel is `unchanged`, not a manufactured downgrade (CAT-06).
|
|
14
|
+
*/
|
|
15
|
+
/** Normalize a label for lookup: trim, collapse spaces, lowercase. Ordinal maps
|
|
16
|
+
* are keyed by this normalized form so "BBB ", "bbb", "BBB" all hit. */
|
|
17
|
+
function normLabel(label) {
|
|
18
|
+
return label.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
19
|
+
}
|
|
20
|
+
// ── S&P / Moody's credit ratings (AAA=1 … D=22), with cross-agency aliases ──
|
|
21
|
+
// The canonical axis is S&P notation; Moody's labels are aliases onto the same
|
|
22
|
+
// rank so Baa2 ≡ BBB (CAT-06), A2 ≡ A, Ba1 ≡ BB+, etc.
|
|
23
|
+
const SP_ORDER = [
|
|
24
|
+
'AAA', 'AA+', 'AA', 'AA-', 'A+', 'A', 'A-', 'BBB+', 'BBB', 'BBB-',
|
|
25
|
+
'BB+', 'BB', 'BB-', 'B+', 'B', 'B-', 'CCC+', 'CCC', 'CCC-', 'CC', 'C', 'D',
|
|
26
|
+
];
|
|
27
|
+
// Moody's, index-aligned to SP_ORDER above.
|
|
28
|
+
const MOODYS_ORDER = [
|
|
29
|
+
'Aaa', 'Aa1', 'Aa2', 'Aa3', 'A1', 'A2', 'A3', 'Baa1', 'Baa2', 'Baa3',
|
|
30
|
+
'Ba1', 'Ba2', 'Ba3', 'B1', 'B2', 'B3', 'Caa1', 'Caa2', 'Caa3', 'Ca', 'C', 'DDD',
|
|
31
|
+
];
|
|
32
|
+
function buildCreditRanks() {
|
|
33
|
+
const r = {};
|
|
34
|
+
SP_ORDER.forEach((lbl, i) => {
|
|
35
|
+
r[normLabel(lbl)] = i + 1;
|
|
36
|
+
const m = MOODYS_ORDER[i];
|
|
37
|
+
if (m)
|
|
38
|
+
r[normLabel(m)] = i + 1; // alias onto the same rank
|
|
39
|
+
});
|
|
40
|
+
return r;
|
|
41
|
+
}
|
|
42
|
+
// ── LTS / lifecycle stages (Active=1 … EOL highest) ──
|
|
43
|
+
const LTS_ORDER = ['Active', 'Maintenance', 'Security', 'Deprecated', 'EOL'];
|
|
44
|
+
// ── Product tiers (Free=1 … Enterprise highest) ──
|
|
45
|
+
const TIER_ORDER = ['Free', 'Trial', 'Starter', 'Pro', 'Business', 'Team', 'Enterprise'];
|
|
46
|
+
// ── Generic quality letter grades (A=1 … F) ──
|
|
47
|
+
const QUALITY_ORDER = ['A', 'B', 'C', 'D', 'E', 'F'];
|
|
48
|
+
function buildOrdinal(order) {
|
|
49
|
+
const r = {};
|
|
50
|
+
order.forEach((lbl, i) => { r[normLabel(lbl)] = i + 1; });
|
|
51
|
+
return r;
|
|
52
|
+
}
|
|
53
|
+
/** Built-in scales, addressable by name from a premise's `scale` modifier. */
|
|
54
|
+
export const BUILTIN_SCALES = {
|
|
55
|
+
sp_credit: { name: 'S&P/Moody\'s credit rating', kind: 'credit', ranks: buildCreditRanks() },
|
|
56
|
+
lts: { name: 'lifecycle stage', kind: 'lts', ranks: buildOrdinal(LTS_ORDER) },
|
|
57
|
+
tier: { name: 'product tier', kind: 'tier', ranks: buildOrdinal(TIER_ORDER) },
|
|
58
|
+
quality: { name: 'quality grade', kind: 'quality', ranks: buildOrdinal(QUALITY_ORDER) },
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Map a label onto a named scale's ordinal rank. Returns null when the scale is
|
|
62
|
+
* unknown OR the label isn't on it — the caller routes null to `uncertain`
|
|
63
|
+
* (host-judgment lane), never a silent numeric guess (§3.1).
|
|
64
|
+
*
|
|
65
|
+
* A custom scale (user-provided ordered labels) can be passed inline via
|
|
66
|
+
* `customScales` so a premise isn't limited to the built-ins.
|
|
67
|
+
*/
|
|
68
|
+
export function normalizeLabel(scale, label, customScales) {
|
|
69
|
+
if (!scale)
|
|
70
|
+
return null;
|
|
71
|
+
const custom = customScales?.[scale];
|
|
72
|
+
if (custom) {
|
|
73
|
+
const ranks = Array.isArray(custom) ? buildOrdinal(custom) : custom.ranks;
|
|
74
|
+
const hit = ranks[normLabel(label)];
|
|
75
|
+
return typeof hit === 'number' ? hit : null;
|
|
76
|
+
}
|
|
77
|
+
const builtin = BUILTIN_SCALES[scale];
|
|
78
|
+
if (!builtin)
|
|
79
|
+
return null;
|
|
80
|
+
const hit = builtin.ranks[normLabel(label)];
|
|
81
|
+
return typeof hit === 'number' ? hit : null;
|
|
82
|
+
}
|
|
83
|
+
const UNIT_TABLE = {
|
|
84
|
+
// temperature: Kelvin ↔ Celsius (offset). Canonical = Celsius for delta axes.
|
|
85
|
+
k: { to: 'c', convert: (v) => v - 273.15 },
|
|
86
|
+
kelvin: { to: 'c', convert: (v) => v - 273.15 },
|
|
87
|
+
// fuel economy: mpg (US) → L/100km (reciprocal). Canonical = L/100km.
|
|
88
|
+
mpg: { to: 'l/100km', convert: (v) => (v === 0 ? Infinity : 235.215 / v) },
|
|
89
|
+
// percentage points and percent share a numeric magnitude; the AXIS (not the
|
|
90
|
+
// unit) disambiguates — handled by unit_axis, not here.
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Convert a value from a source unit to its canonical unit. Returns null when the
|
|
94
|
+
* unit is unknown (caller keeps host_reported + low_confidence). When from===to
|
|
95
|
+
* or from is already canonical, returns the value unchanged.
|
|
96
|
+
*/
|
|
97
|
+
export function normalizeUnit(value, from) {
|
|
98
|
+
if (!from)
|
|
99
|
+
return null;
|
|
100
|
+
const key = from.trim().toLowerCase();
|
|
101
|
+
const conv = UNIT_TABLE[key];
|
|
102
|
+
if (!conv)
|
|
103
|
+
return { value, unit: key }; // unknown but not an error — pass through as-is unit-tagged
|
|
104
|
+
return { value: conv.convert(value), unit: conv.to };
|
|
105
|
+
}
|