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.
Files changed (113) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -41
  3. package/SECURITY.md +22 -8
  4. package/dist/a0/account-connect.js +180 -0
  5. package/dist/a0/account-credentials.js +86 -0
  6. package/dist/index.js +35 -1
  7. package/dist/lib/ambient-due.js +63 -0
  8. package/dist/lib/ambient-elicit.js +225 -0
  9. package/dist/lib/argus-dir.js +76 -9
  10. package/dist/lib/calendar.js +75 -0
  11. package/dist/lib/canonical-scales.js +105 -0
  12. package/dist/lib/due-note.js +62 -13
  13. package/dist/lib/elicit.js +13 -1
  14. package/dist/lib/envelope.js +19 -4
  15. package/dist/lib/install-id.js +53 -0
  16. package/dist/lib/layout.js +7 -0
  17. package/dist/lib/ledger-append.js +134 -3
  18. package/dist/lib/ledger-replay.js +139 -11
  19. package/dist/lib/locale-mismatch.js +52 -0
  20. package/dist/lib/locale.js +120 -4
  21. package/dist/lib/localize-result.js +120 -0
  22. package/dist/lib/localized-message.js +13 -0
  23. package/dist/lib/log.js +2 -2
  24. package/dist/lib/numeric-drift.js +343 -22
  25. package/dist/lib/package-meta.js +14 -0
  26. package/dist/lib/premise-sync.js +23 -0
  27. package/dist/lib/premises-core.js +174 -0
  28. package/dist/lib/premises.js +51 -50
  29. package/dist/lib/privacy.js +1 -1
  30. package/dist/lib/push-account.js +28 -4
  31. package/dist/lib/receipt.js +5 -1
  32. package/dist/lib/render-receipt.js +32 -22
  33. package/dist/lib/resolve-today.js +11 -3
  34. package/dist/lib/review/extract-core.js +345 -0
  35. package/dist/lib/review/extract-file-node.js +288 -0
  36. package/dist/lib/review/index.js +1 -1
  37. package/dist/lib/review/ingest.js +84 -20
  38. package/dist/lib/review/lenses.js +18 -0
  39. package/dist/lib/review/prompts.js +254 -15
  40. package/dist/lib/review/render.js +86 -38
  41. package/dist/lib/review/reviewability.js +8 -7
  42. package/dist/lib/review/routing.js +38 -17
  43. package/dist/lib/review/schema.js +4 -1
  44. package/dist/lib/review-path.js +108 -0
  45. package/dist/lib/spine.js +16 -12
  46. package/dist/lib/state-machine.js +59 -13
  47. package/dist/lib/surface-lint.js +97 -0
  48. package/dist/lib/surfaces.js +313 -48
  49. package/dist/lib/telemetry.js +169 -0
  50. package/dist/lib/tool-presentation.js +35 -0
  51. package/dist/lib/untrusted.js +89 -0
  52. package/dist/lib/validate-crux.js +32 -7
  53. package/dist/resources.js +52 -14
  54. package/dist/server.js +105 -42
  55. package/dist/tools/amend-dismiss.js +46 -9
  56. package/dist/tools/candidates.js +129 -0
  57. package/dist/tools/check-in.js +178 -20
  58. package/dist/tools/errors.js +17 -3
  59. package/dist/tools/index.js +36 -2
  60. package/dist/tools/init-config.js +83 -11
  61. package/dist/tools/open-decision.js +71 -36
  62. package/dist/tools/premises.js +246 -29
  63. package/dist/tools/public-tools.js +382 -0
  64. package/dist/tools/recall.js +158 -19
  65. package/dist/tools/recheck.js +51 -15
  66. package/dist/tools/review.js +234 -33
  67. package/dist/tools/seal.js +167 -31
  68. package/dist/tools/semantic-record.js +225 -0
  69. package/dist/tools/settle.js +204 -20
  70. package/dist/tools/sync.js +245 -13
  71. package/dist/tools/tool-types.js +174 -0
  72. package/dist/tools/watch.js +117 -0
  73. package/dist/v2/bridge.js +240 -0
  74. package/dist/v2/brief.js +122 -0
  75. package/dist/v2/candidate-capture.js +150 -0
  76. package/dist/v2/capture-cli.js +72 -0
  77. package/dist/v2/capture-runtime.js +73 -0
  78. package/dist/v2/connection-io.js +47 -0
  79. package/dist/v2/connection.js +93 -0
  80. package/dist/v2/events.js +187 -0
  81. package/dist/v2/evidence.js +55 -0
  82. package/dist/v2/gate-keywords.js +48 -0
  83. package/dist/v2/gate.js +98 -0
  84. package/dist/v2/git-discovery.js +80 -0
  85. package/dist/v2/harvest.js +122 -0
  86. package/dist/v2/init.js +42 -0
  87. package/dist/v2/ledger.js +290 -0
  88. package/dist/v2/lifecycle-cli.js +50 -0
  89. package/dist/v2/lifecycle.js +418 -0
  90. package/dist/v2/logbook.js +142 -0
  91. package/dist/v2/mirror.js +305 -0
  92. package/dist/v2/outbox.js +52 -0
  93. package/dist/v2/queue.js +183 -0
  94. package/dist/v2/reducer.js +462 -0
  95. package/dist/v2/sanitize.js +44 -0
  96. package/dist/v2/v1-reader.js +300 -0
  97. package/dist/v3/fixtures/dkk-corpus.js +55 -0
  98. package/dist/v3/fixtures/p5-measurement-plan.js +42 -0
  99. package/dist/v3/index.js +5 -0
  100. package/dist/v3/legacy-v2.js +169 -0
  101. package/dist/v3/p5-gate.js +123 -0
  102. package/dist/v3/reducer.js +290 -0
  103. package/dist/v3/store.js +133 -0
  104. package/dist/v3/types.js +193 -0
  105. package/dist/v4/index.js +5 -0
  106. package/dist/v4/reducer.js +361 -0
  107. package/dist/v4/relation-validation.js +40 -0
  108. package/dist/v4/shadow.js +22 -0
  109. package/dist/v4/types.js +326 -0
  110. package/dist/v4/watch.js +18 -0
  111. package/package.json +20 -6
  112. package/dist/lib/discipline.js +0 -42
  113. 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
+ }
@@ -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` argument (works on every host)
14
- * 2. last dir written to `.bound` by argus_init (read-side for Resources)
15
- * 3. ARGUS_DIR env (host-dependent interpolation)
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
- throw new ArgusDirError('No argus_dir given and ARGUS_DIR is not set. Pass an absolute argus_dir, or set "ARGUS_DIR" in your MCP config env (then you can omit it every call).');
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
- * Returns null (unbound) rather than throwing — the Resource degrades cleanly.
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 && path.isAbsolute(env)) {
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 null;
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
+ }