argus-decision-mcp 1.0.0 → 1.2.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 (73) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +130 -20
  3. package/SECURITY.md +22 -8
  4. package/dist/lib/ambient-due.js +63 -0
  5. package/dist/lib/argus-dir.js +50 -1
  6. package/dist/lib/calendar.js +75 -0
  7. package/dist/lib/canonical-scales.js +105 -0
  8. package/dist/lib/due-note.js +62 -13
  9. package/dist/lib/envelope.js +19 -4
  10. package/dist/lib/install-id.js +53 -0
  11. package/dist/lib/layout.js +7 -0
  12. package/dist/lib/ledger-append.js +134 -3
  13. package/dist/lib/ledger-replay.js +120 -8
  14. package/dist/lib/locale.js +45 -0
  15. package/dist/lib/log.js +2 -2
  16. package/dist/lib/numeric-drift.js +343 -22
  17. package/dist/lib/package-meta.js +14 -0
  18. package/dist/lib/premise-sync.js +23 -0
  19. package/dist/lib/premises-core.js +174 -0
  20. package/dist/lib/premises.js +51 -48
  21. package/dist/lib/privacy.js +1 -1
  22. package/dist/lib/push-account.js +25 -2
  23. package/dist/lib/receipt.js +5 -1
  24. package/dist/lib/render-receipt.js +32 -22
  25. package/dist/lib/resolve-today.js +11 -3
  26. package/dist/lib/review/extract-core.js +229 -0
  27. package/dist/lib/review/extract-file-node.js +172 -0
  28. package/dist/lib/review/prompts.js +65 -3
  29. package/dist/lib/review/render.js +86 -38
  30. package/dist/lib/review/schema.js +4 -1
  31. package/dist/lib/review-path.js +108 -0
  32. package/dist/lib/spine.js +15 -3
  33. package/dist/lib/state-machine.js +54 -8
  34. package/dist/lib/surface-lint.js +97 -0
  35. package/dist/lib/surfaces.js +307 -38
  36. package/dist/lib/telemetry.js +169 -0
  37. package/dist/lib/untrusted.js +89 -0
  38. package/dist/lib/validate-crux.js +32 -7
  39. package/dist/server.js +23 -14
  40. package/dist/tools/amend-dismiss.js +46 -9
  41. package/dist/tools/candidates.js +129 -0
  42. package/dist/tools/check-in.js +165 -19
  43. package/dist/tools/index.js +3 -1
  44. package/dist/tools/init-config.js +49 -5
  45. package/dist/tools/open-decision.js +20 -18
  46. package/dist/tools/premises.js +239 -22
  47. package/dist/tools/recall.js +79 -15
  48. package/dist/tools/recheck.js +49 -13
  49. package/dist/tools/review.js +116 -19
  50. package/dist/tools/seal.js +128 -28
  51. package/dist/tools/settle.js +161 -18
  52. package/dist/tools/sync.js +245 -13
  53. package/dist/tools/watch.js +117 -0
  54. package/dist/v2/bridge.js +240 -0
  55. package/dist/v2/brief.js +122 -0
  56. package/dist/v2/events.js +187 -0
  57. package/dist/v2/evidence.js +51 -0
  58. package/dist/v2/gate-keywords.js +48 -0
  59. package/dist/v2/gate.js +98 -0
  60. package/dist/v2/git-discovery.js +80 -0
  61. package/dist/v2/harvest.js +147 -0
  62. package/dist/v2/init.js +42 -0
  63. package/dist/v2/ledger.js +290 -0
  64. package/dist/v2/lifecycle.js +122 -0
  65. package/dist/v2/logbook.js +142 -0
  66. package/dist/v2/mirror.js +305 -0
  67. package/dist/v2/outbox.js +52 -0
  68. package/dist/v2/queue.js +124 -0
  69. package/dist/v2/reducer.js +462 -0
  70. package/dist/v2/sanitize.js +44 -0
  71. package/dist/v2/v1-reader.js +297 -0
  72. package/package.json +17 -6
  73. package/snippets/claude-code-watch.md +47 -0
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Argus
3
+ Copyright (c) 2026 commet
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # argus-decision-mcp
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/argus-decision-mcp.svg)](https://www.npmjs.com/package/argus-decision-mcp)
4
+ [![npm downloads](https://img.shields.io/npm/dm/argus-decision-mcp.svg)](https://www.npmjs.com/package/argus-decision-mcp)
5
+ [![license](https://img.shields.io/npm/l/argus-decision-mcp.svg)](./LICENSE)
6
+
3
7
  > **Your AI gives you an answer. Argus gives you a receipt — and checks it against reality on the date you set.**
4
8
 
5
9
  Argus is an MCP server for **decision accountability**. Instead of grading your
@@ -9,7 +13,9 @@ what actually happened. The artifact it produces — a **Judgment Receipt** —
9
13
  carries one line no other AI tool will: `AI VERDICT … NONE`. The model never
10
14
  graded you. Reality did.
11
15
 
12
- Works with any MCP host Claude, ChatGPT, Gemini, or any MCP-compatible client.
16
+ Runs on any MCP host that supports local **stdio** servers Claude Desktop,
17
+ Claude Code, and other clients that launch a local process. (Remote-only
18
+ connectors that require an HTTP transport aren't supported yet — see the roadmap.)
13
19
 
14
20
  ```
15
21
  ┌─ ARGUS · JUDGMENT RECEIPT ────────────────────────────────┐
@@ -32,6 +38,14 @@ Works with any MCP host — Claude, ChatGPT, Gemini, or any MCP-compatible clien
32
38
 
33
39
  ## Why it's different
34
40
 
41
+ **It is not a receipt of what your *agent* did.** A growing set of tools now
42
+ log an AI coding run — prompt captured, files touched, checkpoint saved, replay
43
+ path written down. That receipt is about the *machine's* actions over one run.
44
+ Argus receipts something no run-logger can: *your* judgment call, in your own
45
+ words, opened again on a date you set and settled against **reality** — not
46
+ against a model's opinion, and not against a diff. Machine-action receipts and
47
+ judgment receipts are different primitives; you can keep both.
48
+
35
49
  Most decision tools compete on a *better answer*, a *score*, a *confidence*.
36
50
  Argus does the opposite, and the opposite is enforced **structurally**, not
37
51
  promised in prose:
@@ -39,7 +53,7 @@ promised in prose:
39
53
  - **There is no verdict tool.** The model cannot grade your decision because no
40
54
  `argus_verdict` / `argus_score` tool exists to call. `grep dist/` and see.
41
55
  - **You can't settle what you never sealed.** `argus_settle` hard-errors without
42
- a prior `argus_seal` — "no judgment without a falsifiable bet" is a
56
+ a prior `argus_seal` — "no judgment without a falsifiable prediction" is a
43
57
  precondition, not a suggestion.
44
58
  - **State is the ledger, not a flag.** A decision's status is the fold of an
45
59
  append-only event log, so it can't be faked by calling tools out of order.
@@ -52,7 +66,10 @@ Claude Code:
52
66
  claude mcp add argus -- npx -y argus-decision-mcp
53
67
  ```
54
68
 
55
- Or add to your host's MCP config:
69
+ Or add to your host's MCP config. **Zero config works**: with no `env` at all,
70
+ your ledger lives in `~/.argus`.
71
+
72
+ **Claude Code** (expands `${CLAUDE_PROJECT_DIR}`, so a per-project ledger works):
56
73
 
57
74
  ```jsonc
58
75
  {
@@ -61,19 +78,21 @@ Or add to your host's MCP config:
61
78
  "command": "npx",
62
79
  "args": ["-y", "argus-decision-mcp"],
63
80
  "env": {
64
- // Set this ONCE and you never pass argus_dir again — every tool falls
65
- // back to it. Claude Code expands ${CLAUDE_PROJECT_DIR}; on other hosts
66
- // put an absolute path here (or pass argus_dir per call).
81
+ // OPTIONAL per-project ledger. Omit entirely to use ~/.argus.
67
82
  "ARGUS_DIR": "${CLAUDE_PROJECT_DIR}/.argus",
68
83
  // OPTIONAL — connect to your Argus account so sealed predictions get an
69
84
  // email at their check-by date (the Companion Brief) and show up in the
70
- // web dashboard. Issue the token in the web app. Leave it unset to stay
71
- // fully local (the privacy-preserving default).
85
+ // web dashboard. Issue the token in the web app (Settings sync token).
86
+ // Leave it unset to stay fully local (the privacy-preserving default).
72
87
  "ARGUS_TOKEN": "argus_pat_…",
73
88
  // OPTIONAL — the timezone that decides when a check-by date becomes
74
- // "today". Unset = UTC, which for Korean users means the day flips at
75
- // 9am KST (a due decision won't show until then). Set your zone:
76
- "ARGUS_TZ": "Asia/Seoul"
89
+ // "today". Unset = your machine's local timezone (usually right).
90
+ // Set it only if your machine's clock zone isn't where you live:
91
+ "ARGUS_TZ": "Asia/Seoul",
92
+ // OPTIONAL — opt in to anonymous usage telemetry (OFF by default). Sends
93
+ // a random install id + which tool ran + version/platform; never your
94
+ // decisions or token. Honors DO_NOT_TRACK. See SECURITY.md → Telemetry.
95
+ "ARGUS_TELEMETRY": "1"
77
96
  // "ARGUS_API_URL": "https://argus.voyage" // override only for self-host
78
97
  }
79
98
  }
@@ -81,14 +100,98 @@ Or add to your host's MCP config:
81
100
  }
82
101
  ```
83
102
 
103
+ **Claude Desktop** does **not** expand `${...}` variables — a literal
104
+ `${CLAUDE_PROJECT_DIR}` would fail on every call (Argus names this error when
105
+ it happens). Either omit `ARGUS_DIR` (→ `~/.argus`) or use an absolute path:
106
+
107
+ ```jsonc
108
+ // macOS
109
+ "env": { "ARGUS_DIR": "/Users/you/.argus" }
110
+ // Windows — also note the command form below
111
+ "env": { "ARGUS_DIR": "C:\\Users\\you\\.argus" }
112
+ ```
113
+
114
+ **Windows** hosts often can't launch bare `npx` (it's `npx.cmd`). If the server
115
+ fails to start, use:
116
+
117
+ ```jsonc
118
+ {
119
+ "command": "cmd",
120
+ "args": ["/c", "npx", "-y", "argus-decision-mcp"]
121
+ }
122
+ ```
123
+
84
124
  > `argus_dir` is **optional** on every tool: omit it and it resolves from
85
- > `ARGUS_DIR`. A per-call `argus_dir` still wins — so Argus works on any host
86
- > even when env-variable interpolation doesn't.
125
+ > `ARGUS_DIR`, then falls back to `~/.argus`. A per-call `argus_dir` still
126
+ > wins — so Argus works on any host even when env-variable interpolation
127
+ > doesn't.
128
+
129
+ ## Your first receipt (2 minutes)
130
+
131
+ You never call these tools by name. Once the server is connected, you just
132
+ talk to your AI in plain language and it calls the right tool for you. One
133
+ full loop — from sealing a prediction to letting reality grade it — looks like
134
+ this:
135
+
136
+ **1 · Seal a prediction** — before you commit to a decision, tell your AI:
137
+
138
+ > **You:** "I'm shipping the new onboarding flow next week. Seal a prediction:
139
+ > signups go up at least 10% by the end of the month."
140
+
141
+ Argus records your predicate and a check-by date. Nothing is scored — it's a
142
+ prediction against reality, not a grade from the model.
143
+
144
+ **2 · Re-check a premise** *(optional, any time before the date)* — if a fact
145
+ your decision leaned on might have moved:
146
+
147
+ > **You:** "Re-check my onboarding decision — did the weekly signup number
148
+ > change since I sealed it?"
149
+
150
+ Argus compares the new number to the baseline and tells you if it drifted. It
151
+ returns the handle — whether to revisit the decision stays your call.
152
+
153
+ **3 · Settle on the check-by date** — when the date arrives:
154
+
155
+ > **You:** "Settle my onboarding prediction. Signups went up 14%."
156
+
157
+ Argus prints the **Judgment Receipt** at the top of this page — with
158
+ `AI VERDICT … NONE`. You made the call; reality graded it, not the model.
159
+
160
+ That's the whole spine. Everything below is detail on top of these three steps.
161
+
162
+ ## Two loops
163
+
164
+ Argus runs two loops side by side — the same three habits its author
165
+ uses daily: **봉인하기** (seal it before you ask the AI) · **드러내기**
166
+ (surface claims you accepted without checking) · **돌아보기** (return when
167
+ reality has answered):
168
+
169
+ - **The daily watch** (`argus_watch`) — turns over once a *day*. In the morning
170
+ (or whenever a session starts) you anchor one line: today's aim, your working
171
+ hypothesis, where you lean. While you work, you capture the things that
172
+ usually evaporate: a claim you accepted without checking, a premise you
173
+ haven't verified, a question you're deferring. Tomorrow's `check_in` mirrors
174
+ yesterday's line back — *"so, how did it go?"* — as a question, never a grade.
175
+ An anchor is a **note, not a prediction**: nothing about it is scored, counted, or
176
+ streaked, ever.
177
+ - **The decision loop** (everything below) — plays out over days to months.
178
+ When a capture turns out to be load-bearing, *you* promote it: open the
179
+ decision, seal a falsifiable prediction, track its premises, and let reality
180
+ settle it into a Judgment Receipt.
181
+
182
+ The watch builds the habit; the decision loop builds the record.
87
183
 
88
184
  ## The loop
89
185
 
186
+ **The spine is three tools:** **`argus_seal`** (write the falsifiable prediction) →
187
+ optional **`argus_recheck`** (has a load-bearing premise moved?) →
188
+ **`argus_settle`** (reality answers, on the date). If you only ever learn
189
+ these three, Argus works. Everything else in the table supports that spine —
190
+ reach for it when you need it, not before.
191
+
90
192
  | Tool | What it does |
91
193
  |------|--------------|
194
+ | `argus_watch` | The daily watch. `anchor` keeps today's one-line aim (your words, verbatim); `capture` notes an unchecked claim / unverified premise / deferred question mid-work without opening a decision; `list` reads the recent log. Anchors are mirrored back by the next `check_in` as a question — never evaluated, never counted in any record. |
92
195
  | `argus_open_decision` | Opens a consequential decision. Runs a restraint gate first — on a flat / low-stakes / reversible / already-closed call it tells you to leave it as is. If it fires, it surfaces **one** neutral question, never a fork or a lean. |
93
196
  | `argus_review` | Reviews an existing document (strategy memo / PRD / deck text / AI answer) for judgment risk: reviewability score, routed lenses, source units with anchors, and the extraction prompt — then hands the analysis to you. Degrades honestly on unextractable input; never a verdict. End by sealing one follow-up. |
94
197
  | `argus_seal` | Seals a falsifiable prediction (`predicate` + `check_by`) and captures the receipt's real-question / unverified-assumption / human-only / your-call fields. Refuses an empty predicate or a non-future date. If you seal without naming the assumption, it's recorded as an explicit **skip** — never a forced gate (which would just eject the tiredest user), never a silent blank. With `ARGUS_TOKEN` set, the prediction also syncs to your account so the Companion Brief can email you at its check-by date. |
@@ -97,7 +200,7 @@ Or add to your host's MCP config:
97
200
  | `argus_settle` | On the check-by date, records what reality did and issues the Judgment Receipt. Hard-errors without a prior seal. Optionally records **which premise broke** (your attribution, never inferred) — over time your track record can say "3 of your 4 missed bets traced to a broken external premise": a frequency, never a diagnosis. |
98
201
  | `argus_amend` | Changes the predicate or check-by date **before** reality answers — a course change, not an erasure (the original stays on the append-only ledger). Hard-errors once the decision is settled. |
99
202
  | `argus_dismiss` | Closes a decision **without settling** — it became irrelevant, was decided elsewhere, or you changed your mind. No verdict is recorded; terminal, not reopened. |
100
- | `argus_check_in` | Returns contracts past their check-by date **and premise facts due for a re-check** (the same fact under several decisions is one re-check). If nothing is due, it says so and stops — it doesn't manufacture engagement. |
203
+ | `argus_check_in` | Returns contracts past their check-by date **and premise facts due for a re-check** (the same fact under several decisions is one re-check), and mirrors yesterday's watch anchor back as a question. If nothing is due, it says so and stops — it doesn't manufacture engagement. |
101
204
  | `argus_sync` | Pulls your account receipts into the terminal (live judgments + what's due) so you can settle here. Seals push automatically; this is the read side. Requires `ARGUS_TOKEN`. |
102
205
  | `argus_recall` | Reads your own history: a receipt, the open contracts, or a sample-size-caveated track record (never a tier or score). |
103
206
  | `argus_init` / `argus_config` | Initialize the `.argus` directory; read/write non-spine settings. |
@@ -124,12 +227,19 @@ Argus does not pretend liveness.
124
227
 
125
228
  Everything is local, under `.argus/` in your project (gitignored by default) —
126
229
  an append-only `ledger.jsonl` **you own**: plain JSON lines, no lock-in,
127
- receipts render to shareable text. No telemetry. The **only** network call
128
- Argus ever makes is the opt-in account sync: if — and only if — you set
129
- `ARGUS_TOKEN`, a sealed/settled prediction is POSTed to your own Argus account
130
- so it can email you at its check-by date. **Premise data never leaves your
131
- machine** it is not part of the sync payload. Unset the token and Argus never
132
- touches the network. See [SECURITY.md](SECURITY.md).
230
+ receipts render to shareable text. **No telemetry by default.** The only network
231
+ call Argus makes out of the box is the opt-in account sync: if — and only if —
232
+ you set `ARGUS_TOKEN`, a sealed/settled prediction is POSTed to your own Argus
233
+ account so it can email you at its check-by date. Separately, you can opt in to
234
+ **anonymous** usage telemetry with `ARGUS_TELEMETRY=1` (a random install id +
235
+ which tool ran + version/platform, never your decisions or token; honors
236
+ `DO_NOT_TRACK`) — see SECURITY.md. **Premise data stays on your machine
237
+ by default** — it is not part of the sync payload. There is exactly one switch
238
+ that changes this: `argus_config premise_sync:true` (off unless you set it)
239
+ sends a sealed decision's *monitored* premises along, so your account's
240
+ autonomous premise-watch can re-check them against reality and email you when
241
+ one materially moves. Unset the token and Argus never touches the network.
242
+ See [SECURITY.md](SECURITY.md).
133
243
 
134
244
  ## Measured
135
245
 
package/SECURITY.md CHANGED
@@ -2,14 +2,28 @@
2
2
 
3
3
  ## Data & privacy
4
4
 
5
- - **All data is local.** Argus writes only under the `.argus/` directory you
6
- point it at: an append-only `ledger/ledger.jsonl`, per-decision
7
- `sessions/{id}/` files, and a `config.yaml`. Nothing leaves the machine.
8
- - **No telemetry, no network.** The runtime dependency surface is two packages
9
- (`@modelcontextprotocol/sdk`, `js-yaml`). `npm ls --prod` should show no
10
- analytics or network client. CI fails if the production dependency tree grows.
11
- - **Private by default.** `argus_init` adds `sessions/`, `ledger/`, and `.bound`
12
- to `.argus/.gitignore` so your decisions are never committed.
5
+ - **All data is local by default.** Argus writes only under the `.argus/`
6
+ directory you point it at: an append-only `ledger/ledger.jsonl`,
7
+ per-decision `sessions/{id}/` files, and a `config.yaml`. Nothing leaves the
8
+ machine unless you explicitly set `ARGUS_TOKEN` for account sync (or opt in to
9
+ anonymous telemetry with `ARGUS_TELEMETRY=1`; see below).
10
+ - **No telemetry by default.** With no `ARGUS_TOKEN` and no `ARGUS_TELEMETRY=1`,
11
+ the server makes no network calls. Telemetry is strictly **opt-in**: set
12
+ `ARGUS_TELEMETRY=1` and it sends an anonymous, content-free ping — a random,
13
+ machine-local install id (`~/.argus/.telemetry-id`, not tied to your account
14
+ or token, regenerable by deleting the file), which of the built-in tools ran
15
+ and whether it succeeded, plus the package version, coarse OS platform, and
16
+ Node major. It **never** sends decision content, titles, predicates, file
17
+ paths, `argus_dir`, or the account token. `DO_NOT_TRACK=1` disables it even if
18
+ the flag is set, and the sink stores no `user_id` (nothing to identify).
19
+ Telemetry rows are **deleted automatically after 90 days** (a daily database
20
+ job — retention is enforced by the database itself, not by policy prose). The
21
+ runtime dependency surface is intentionally small and visible in
22
+ `package.json`: MCP SDK, schema/config helpers, and document parsers for
23
+ `argus_review`. `npm audit --omit=dev` should stay clean before publishing.
24
+ - **Private by default.** `argus_init` adds `sessions/`, `ledger/`,
25
+ `config.yaml`, and `.bound` to `.argus/.gitignore` so your decisions and
26
+ local settings are never committed accidentally.
13
27
 
14
28
  ## Threat model
15
29
 
@@ -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
+ }
@@ -1,4 +1,5 @@
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
  /**
@@ -26,6 +27,14 @@ export function requireArgusDir(callArg) {
26
27
  if (typeof callArg !== 'string' || callArg.length === 0) {
27
28
  throw new ArgusDirError('argus_dir is required (absolute path to the .argus directory).');
28
29
  }
30
+ // An unexpanded config variable is the #1 Claude Desktop first-run failure:
31
+ // only Claude Code expands ${CLAUDE_PROJECT_DIR}, so Desktop passes the
32
+ // literal string through. Name the actual problem instead of "not absolute".
33
+ if (/\$\{[^}]*\}|%[A-Za-z_]+%/.test(callArg)) {
34
+ throw new ArgusDirError(`Your MCP host did not expand the variable in "${callArg}" (only some hosts interpolate env vars). ` +
35
+ 'Replace it with an absolute path in your MCP config (e.g. "C:\\Users\\you\\.argus" or "/Users/you/.argus"), ' +
36
+ 'or remove ARGUS_DIR entirely to use the default ~/.argus.');
37
+ }
29
38
  const resolved = path.resolve(callArg);
30
39
  if (!path.isAbsolute(callArg)) {
31
40
  throw new ArgusDirError('argus_dir must be an absolute path.');
@@ -48,7 +57,10 @@ export function resolveToolArgusDir(callArg) {
48
57
  const env = process.env['ARGUS_DIR'];
49
58
  if (typeof env === 'string' && env.length > 0)
50
59
  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).');
60
+ // Zero-config default (blueprint §9.4 "첫 설치의 "): a brand-new user on a
61
+ // host without env interpolation still gets a working home for their ledger.
62
+ // Per-call argus_dir and ARGUS_DIR both keep winning over this.
63
+ return path.join(os.homedir(), '.argus');
52
64
  }
53
65
  /** Record the bound dir so Resources (which get no args) can find it later. */
54
66
  export function writeBoundMarker(argusDir) {
@@ -61,6 +73,43 @@ export function writeBoundMarker(argusDir) {
61
73
  catch {
62
74
  /* non-critical */
63
75
  }
76
+ // Global registry (M2 fleet, §9.4): every init'd project dir also lands in
77
+ // ~/.argus/.bound so ONE check_in can report due items across all of a
78
+ // user's projects. Local paths only, stays on this machine, best-effort.
79
+ try {
80
+ const home = path.join(os.homedir(), '.argus');
81
+ fs.mkdirSync(home, { recursive: true });
82
+ const reg = path.join(home, '.bound');
83
+ let dirs = [];
84
+ try {
85
+ const parsed = JSON.parse(fs.readFileSync(reg, 'utf8'));
86
+ dirs = Array.isArray(parsed.bound) ? parsed.bound.filter((x) => typeof x === 'string') : [];
87
+ }
88
+ catch { /* fresh registry */ }
89
+ if (!dirs.includes(argusDir))
90
+ dirs.unshift(argusDir);
91
+ fs.writeFileSync(reg, JSON.stringify({ bound: dirs.slice(0, 16) }), 'utf8');
92
+ }
93
+ catch {
94
+ /* non-critical */
95
+ }
96
+ }
97
+ /** The cross-project registry (~/.argus/.bound) — existing dirs only. */
98
+ export function readGlobalBoundList() {
99
+ try {
100
+ const reg = path.join(os.homedir(), '.argus', '.bound');
101
+ const parsed = JSON.parse(fs.readFileSync(reg, 'utf8'));
102
+ const dirs = Array.isArray(parsed.bound) ? parsed.bound.filter((x) => typeof x === 'string') : [];
103
+ return dirs.filter((d) => { try {
104
+ return fs.existsSync(d);
105
+ }
106
+ catch {
107
+ return false;
108
+ } });
109
+ }
110
+ catch {
111
+ return [];
112
+ }
64
113
  }
65
114
  function readBoundList(argusDir) {
66
115
  try {
@@ -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
+ 'Your sealed Argus decision is due today.',
39
+ `Predicate: ${args.predicate}`,
40
+ 'Open your terminal and run argus_check_in, then argus_settle 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
+ }