argus-decision-mcp 1.0.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +168 -0
  3. package/SECURITY.md +35 -0
  4. package/dist/index.js +13 -0
  5. package/dist/lib/argus-dir.js +85 -0
  6. package/dist/lib/atomic-write.js +19 -0
  7. package/dist/lib/continuity.js +31 -0
  8. package/dist/lib/deBom.js +3 -0
  9. package/dist/lib/discipline.js +42 -0
  10. package/dist/lib/due-note.js +54 -0
  11. package/dist/lib/elicit.js +30 -0
  12. package/dist/lib/envelope.js +13 -0
  13. package/dist/lib/layout.js +32 -0
  14. package/dist/lib/ledger-append.js +27 -0
  15. package/dist/lib/ledger-replay.js +295 -0
  16. package/dist/lib/locale.js +20 -0
  17. package/dist/lib/log.js +14 -0
  18. package/dist/lib/numeric-drift.js +32 -0
  19. package/dist/lib/overfire-gate.js +39 -0
  20. package/dist/lib/premises.js +153 -0
  21. package/dist/lib/privacy.js +22 -0
  22. package/dist/lib/push-account.js +70 -0
  23. package/dist/lib/receipt.js +83 -0
  24. package/dist/lib/render-receipt.js +144 -0
  25. package/dist/lib/resolve-contract.js +17 -0
  26. package/dist/lib/resolve-today.js +47 -0
  27. package/dist/lib/review/ids.js +29 -0
  28. package/dist/lib/review/index.js +18 -0
  29. package/dist/lib/review/ingest.js +294 -0
  30. package/dist/lib/review/lenses.js +132 -0
  31. package/dist/lib/review/prompts.js +155 -0
  32. package/dist/lib/review/render.js +77 -0
  33. package/dist/lib/review/reviewability.js +83 -0
  34. package/dist/lib/review/routing.js +147 -0
  35. package/dist/lib/review/schema.js +36 -0
  36. package/dist/lib/safe-path.js +70 -0
  37. package/dist/lib/spine.js +79 -0
  38. package/dist/lib/state-machine.js +80 -0
  39. package/dist/lib/surfaces.js +106 -0
  40. package/dist/lib/validate-crux.js +35 -0
  41. package/dist/lib/validate-seal.js +48 -0
  42. package/dist/prompts.js +72 -0
  43. package/dist/resources.js +122 -0
  44. package/dist/server.js +107 -0
  45. package/dist/tools/amend-dismiss.js +90 -0
  46. package/dist/tools/check-in.js +158 -0
  47. package/dist/tools/errors.js +23 -0
  48. package/dist/tools/index.js +14 -0
  49. package/dist/tools/init-config.js +99 -0
  50. package/dist/tools/open-decision.js +128 -0
  51. package/dist/tools/premises.js +209 -0
  52. package/dist/tools/recall.js +157 -0
  53. package/dist/tools/recheck.js +147 -0
  54. package/dist/tools/review.js +182 -0
  55. package/dist/tools/seal.js +152 -0
  56. package/dist/tools/settle.js +126 -0
  57. package/dist/tools/sync.js +129 -0
  58. package/dist/tools/tool-types.js +32 -0
  59. package/package.json +64 -0
@@ -0,0 +1,295 @@
1
+ import fs from 'fs';
2
+ import { deBom } from './deBom.js';
3
+ import { ledgerPath, sessionsRoot, bearingPath } from './layout.js';
4
+ import { asDate } from './resolve-today.js';
5
+ import { safeSegment } from './safe-path.js';
6
+ function freshEntry(id) {
7
+ return { id, status: 'candidate', text: '', amend_history: [], premises: [] };
8
+ }
9
+ /**
10
+ * Fold the ledger into contract states as of `today` (YYYY-MM-DD).
11
+ * `today` is passed in (never read here) so replay is fully deterministic.
12
+ */
13
+ export function replayLedger(argusDir, today) {
14
+ const ids = new Set();
15
+ const sealedPredicates = new Set();
16
+ const map = new Map();
17
+ const stats = {
18
+ total_sealed: 0, total_settled: 0,
19
+ held: 0, avoided: 0, partial: 0, still_pending: 0,
20
+ };
21
+ let dropped = 0;
22
+ let skippedUnknown = 0;
23
+ let oldestTs;
24
+ let raw;
25
+ try {
26
+ raw = deBom(fs.readFileSync(ledgerPath(argusDir), 'utf8'));
27
+ }
28
+ catch {
29
+ return { today, overdue: [], ids, sealedPredicates, contracts: map, stats, integrity: { dropped_lines: 0, skipped_unknown: 0 } };
30
+ }
31
+ for (const line of raw.split('\n')) {
32
+ if (!line.trim())
33
+ continue;
34
+ let ev;
35
+ try {
36
+ ev = JSON.parse(line);
37
+ }
38
+ catch {
39
+ dropped++; // torn/corrupt line (e.g. crash mid-append) — count, don't silently swallow (N3)
40
+ continue;
41
+ }
42
+ if (!ev['id'] || typeof ev['id'] !== 'string') {
43
+ dropped++;
44
+ continue;
45
+ }
46
+ const id = ev['id'];
47
+ // gate_input is an over-fire-gate audit record, not a decision the user
48
+ // opened — counting it made the first-use greeting vanish after a single
49
+ // restrained argus_open_decision call (11 S7).
50
+ if (ev['event'] !== 'gate_input')
51
+ ids.add(id);
52
+ // Record inception (P1-E7): ISO timestamps compare lexicographically.
53
+ if (typeof ev['ts'] === 'string' && ev['ts'] && (!oldestTs || ev['ts'] < oldestTs))
54
+ oldestTs = ev['ts'];
55
+ let cur = map.get(id);
56
+ switch (ev['event']) {
57
+ case 'harvest':
58
+ if (!cur) {
59
+ cur = freshEntry(id);
60
+ cur.text = ev['decision'] || ev['quote'] || '';
61
+ map.set(id, cur);
62
+ }
63
+ break;
64
+ case 'seal': {
65
+ if (!cur) {
66
+ cur = freshEntry(id);
67
+ map.set(id, cur);
68
+ } // B1: self-create instead of drop
69
+ if (typeof ev['predicate'] === 'string') {
70
+ sealedPredicates.add(ev['predicate']);
71
+ cur.predicate = ev['predicate'];
72
+ cur.text = ev['predicate'];
73
+ }
74
+ cur.check_by = ev['check_by'];
75
+ if (typeof ev['basis'] === 'string')
76
+ cur.basis = ev['basis'];
77
+ cur.status = 'sealed';
78
+ stats.total_sealed++;
79
+ break;
80
+ }
81
+ case 'amend':
82
+ if (!cur) {
83
+ cur = freshEntry(id);
84
+ map.set(id, cur);
85
+ }
86
+ if (ev['predicate'] != null) {
87
+ cur.predicate = ev['predicate'];
88
+ cur.text = ev['predicate'];
89
+ }
90
+ if (ev['check_by'] != null)
91
+ cur.check_by = ev['check_by'];
92
+ cur.amend_history.push({
93
+ predicate: ev['predicate'],
94
+ check_by: ev['check_by'],
95
+ ts: ev['ts'],
96
+ });
97
+ break;
98
+ case 'dismiss':
99
+ if (!cur) {
100
+ cur = freshEntry(id);
101
+ map.set(id, cur);
102
+ }
103
+ cur.status = 'dismissed';
104
+ if (typeof ev['dismiss_reason'] === 'string')
105
+ cur.dismiss_reason = ev['dismiss_reason'];
106
+ break;
107
+ case 'settle': {
108
+ if (!cur) {
109
+ cur = freshEntry(id);
110
+ map.set(id, cur);
111
+ } // B1: self-create
112
+ cur.status = 'settled';
113
+ stats.total_settled++;
114
+ const outcome = ev['outcome'];
115
+ cur.outcome = outcome;
116
+ if (typeof ev['ts'] === 'string' && ev['ts'].length >= 10)
117
+ cur.settled_on = ev['ts'].slice(0, 10);
118
+ if (typeof ev['broken_premise_id'] === 'string')
119
+ cur.broken_premise_id = ev['broken_premise_id'];
120
+ if (outcome === 'held')
121
+ stats.held++;
122
+ else if (outcome === 'avoided')
123
+ stats.avoided++;
124
+ else if (outcome === 'partial')
125
+ stats.partial++;
126
+ else if (outcome === 'still_pending')
127
+ stats.still_pending++;
128
+ break;
129
+ }
130
+ // ── living premises (plan v5 §6.1). The fold is not a validator — the
131
+ // write-time guard is; replay stays defensive and never throws. ──
132
+ case 'premise_add': {
133
+ if (!cur) {
134
+ cur = freshEntry(id);
135
+ map.set(id, cur);
136
+ } // defensive only; the guard refuses absent at write time
137
+ const pid = ev['premise_id'];
138
+ if (typeof pid !== 'string' || typeof ev['text'] !== 'string') {
139
+ dropped++;
140
+ break;
141
+ }
142
+ const list = (cur.premises ??= []);
143
+ if (list.some((p) => p.premise_id === pid))
144
+ break; // idempotent re-add
145
+ list.push({
146
+ premise_id: pid,
147
+ ordinal: typeof ev['ordinal'] === 'number' ? ev['ordinal'] : list.length + 1,
148
+ kind: (ev['kind'] === 'open_question' ? 'open_question' : 'premise'),
149
+ text: ev['text'],
150
+ external: ev['external'] === true,
151
+ load_bearing: ev['load_bearing'] === true,
152
+ source: (ev['source'] === 'user' ? 'user' : 'ai'),
153
+ ...(typeof ev['ai_original'] === 'string' ? { ai_original: ev['ai_original'] } : {}),
154
+ status: 'active',
155
+ amend_history: [],
156
+ recheck_count: 0,
157
+ });
158
+ break;
159
+ }
160
+ case 'premise_amend': {
161
+ const p = cur?.premises?.find((x) => x.premise_id === ev['premise_id']);
162
+ if (!p)
163
+ break; // amend of an unknown premise: write-time guard prevents; replay tolerates
164
+ const action = ev['action'];
165
+ p.amend_history.push({
166
+ action,
167
+ from: ev['from'],
168
+ to: ev['to'],
169
+ note: ev['note'],
170
+ ts: ev['ts'],
171
+ });
172
+ if ((action === 'refine' || action === 'replace') && typeof ev['to'] === 'string')
173
+ p.text = ev['to'];
174
+ if (action === 'retire')
175
+ p.status = 'retired';
176
+ // Flags may be corrected post-add (e.g. marking a promoted premise external
177
+ // so monitoring can arm) — monitoring stays DERIVED from these flags.
178
+ if (typeof ev['external'] === 'boolean')
179
+ p.external = ev['external'];
180
+ if (typeof ev['load_bearing'] === 'boolean')
181
+ p.load_bearing = ev['load_bearing'];
182
+ break;
183
+ }
184
+ case 'premise_recheck': {
185
+ const p = cur?.premises?.find((x) => x.premise_id === ev['premise_id']);
186
+ if (!p)
187
+ break;
188
+ if (typeof ev['finding'] !== 'string' || typeof ev['source'] !== 'string')
189
+ break;
190
+ p.last_recheck = {
191
+ finding: ev['finding'],
192
+ ...(typeof ev['numeric_value'] === 'number' ? { numeric_value: ev['numeric_value'] } : {}),
193
+ drifted: ev['drifted'] === true,
194
+ baseline_only: ev['baseline_only'] === true,
195
+ source: ev['source'],
196
+ ...(typeof ev['source_detail'] === 'string' ? { source_detail: ev['source_detail'] } : {}),
197
+ ts: ev['ts'],
198
+ };
199
+ p.recheck_count++;
200
+ break;
201
+ }
202
+ case 'premise_resolve': {
203
+ const p = cur?.premises?.find((x) => x.premise_id === ev['premise_id']);
204
+ if (!p)
205
+ break;
206
+ p.status = 'resolved';
207
+ if (typeof ev['decision'] === 'string')
208
+ p.resolved_decision = ev['decision'];
209
+ break;
210
+ }
211
+ case 'gate_input':
212
+ break; // known meta event (over-fire gate audit) — not a state change, not corrupt
213
+ default:
214
+ // Forward-compat tolerance (plan v5 §6.3): a well-formed, VERSIONED event
215
+ // whose type this binary doesn't know was written by a newer argus-mcp —
216
+ // skip it silently (like gate_input) instead of counting it as corruption,
217
+ // so an old install never raises a false integrity alarm on a new ledger.
218
+ // Only unversioned/structurally-broken events still count as dropped.
219
+ if (typeof ev['event'] === 'string' && typeof ev['v'] === 'number')
220
+ skippedUnknown++;
221
+ else
222
+ dropped++;
223
+ break;
224
+ }
225
+ }
226
+ const overdue = [];
227
+ for (const [id, item] of map.entries()) {
228
+ if (item.status !== 'sealed')
229
+ continue;
230
+ const date = asDate(item.check_by);
231
+ if (date && date <= today)
232
+ overdue.push({ id, date, text: item.text || '' });
233
+ }
234
+ overdue.sort((a, b) => (a.date < b.date ? -1 : 1));
235
+ return { today, overdue, ids, sealedPredicates, contracts: map, stats, oldest_ts: oldestTs, integrity: { dropped_lines: dropped, skipped_unknown: skippedUnknown } };
236
+ }
237
+ /**
238
+ * Bearing-file contract seeds that are due but not yet represented in the
239
+ * ledger (the seal may have been written as a bearing before the ledger event).
240
+ * Directory names read off disk are validated with `safeSegment` before use.
241
+ */
242
+ export function bearingContracts(argusDir, today, ledger) {
243
+ const out = [];
244
+ const root = sessionsRoot(argusDir);
245
+ let ids = [];
246
+ try {
247
+ ids = fs.readdirSync(root);
248
+ }
249
+ catch {
250
+ return out;
251
+ }
252
+ for (const rawId of ids) {
253
+ let id;
254
+ try {
255
+ id = safeSegment(rawId, 'id');
256
+ }
257
+ catch {
258
+ continue;
259
+ } // skip stray/unsafe dir names
260
+ if (ledger.sealedPredicates.size && ledger.ids.has(id)) {
261
+ const entry = ledger.contracts.get(id);
262
+ if (entry && entry.status !== 'candidate')
263
+ continue; // already represented
264
+ }
265
+ const bearing = readJson(bearingPath(argusDir, id));
266
+ const seed = bearing && bearing['contract_seed'];
267
+ if (!seed || typeof seed['predicate'] !== 'string')
268
+ continue;
269
+ if (ledger.sealedPredicates.has(seed['predicate']))
270
+ continue;
271
+ const date = asDate(seed['check_by']);
272
+ if (date && date <= today) {
273
+ out.push({ id, date, predicate: seed['predicate'], check_by: seed['check_by'] });
274
+ }
275
+ }
276
+ return out;
277
+ }
278
+ function readJson(file) {
279
+ try {
280
+ return JSON.parse(deBom(fs.readFileSync(file, 'utf8')));
281
+ }
282
+ catch {
283
+ return null;
284
+ }
285
+ }
286
+ // Re-export for callers that imported these from here historically.
287
+ export { asDate };
288
+ export const _ledgerFileExists = (argusDir) => {
289
+ try {
290
+ return fs.existsSync(ledgerPath(argusDir));
291
+ }
292
+ catch {
293
+ return false;
294
+ }
295
+ };
@@ -0,0 +1,20 @@
1
+ import fs from 'fs';
2
+ import { configPath } from './layout.js';
3
+ export function detectLocale(argusDir) {
4
+ try {
5
+ const cfg = fs.readFileSync(configPath(argusDir), 'utf8');
6
+ const m = cfg.match(/^locale:\s*(ko|en)\b/m);
7
+ if (m)
8
+ return m[1];
9
+ }
10
+ catch { /* no config */ }
11
+ const env = process.env['LANG'] || process.env['LC_ALL'] || '';
12
+ if (/^ko/i.test(env))
13
+ return 'ko';
14
+ try {
15
+ if (/^ko/i.test(Intl.DateTimeFormat().resolvedOptions().locale))
16
+ return 'ko';
17
+ }
18
+ catch { /* Intl unavailable */ }
19
+ return 'en';
20
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Diagnostics go to stderr ONLY (addendum N4). A stdio MCP server must never
3
+ * write to stdout — that channel carries JSON-RPC frames and any stray byte
4
+ * corrupts the protocol. Verbose output is gated behind ARGUS_DEBUG.
5
+ */
6
+ export function logError(msg, err) {
7
+ const detail = err instanceof Error ? err.stack || err.message : err !== undefined ? String(err) : '';
8
+ process.stderr.write(`argus-mcp ERROR ${msg}${detail ? ' :: ' + detail : ''}\n`);
9
+ }
10
+ export function logDebug(msg) {
11
+ if (process.env['ARGUS_DEBUG']) {
12
+ process.stderr.write(`argus-mcp DEBUG ${msg}\n`);
13
+ }
14
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Numeric premise drift — the only MECHANICAL drift decision (plan v5 §7.1).
3
+ *
4
+ * Deliberately takes explicit numbers, never parses them out of prose: the
5
+ * webapp's regex-first-number approach reads "2026년 기준금리 3.5%" as 2026 and
6
+ * manufactures fake drift. The HOST names the number; this function only compares.
7
+ *
8
+ * Text premises are NOT decided here — a paraphrase is not a changed fact, so
9
+ * string comparison over-fires. For text, the host asserts `changed` as a
10
+ * research finding (provenance-armed, recorded verbatim; see argus_recheck).
11
+ */
12
+ /** Relative move (fraction) below which a numeric change is noise, not drift. */
13
+ export const NUMERIC_DRIFT_THRESHOLD = 0.1;
14
+ export function numericDrift(prev, next) {
15
+ if (!Number.isFinite(prev) || !Number.isFinite(next)) {
16
+ return { drifted: false, reason: 'non-finite input — not comparable' };
17
+ }
18
+ if (prev === next)
19
+ return { drifted: false, reason: 'unchanged' };
20
+ const signFlip = Math.sign(prev) !== Math.sign(next) && prev !== 0 && next !== 0;
21
+ if (prev === 0) {
22
+ // No relative base — any move off zero is a real change.
23
+ return { drifted: true, reason: `moved off zero: 0 → ${next}` };
24
+ }
25
+ const rel = Math.abs(next - prev) / Math.abs(prev);
26
+ if (signFlip)
27
+ return { drifted: true, reason: `sign flip: ${prev} → ${next}` };
28
+ if (rel >= NUMERIC_DRIFT_THRESHOLD) {
29
+ return { drifted: true, reason: `moved ${Math.round(rel * 100)}%: ${prev} → ${next}` };
30
+ }
31
+ return { drifted: false, reason: `moved ${Math.round(rel * 100)}% (<${Math.round(NUMERIC_DRIFT_THRESHOLD * 100)}% threshold)` };
32
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Over-fire gate (blueprint §3.3 — the mirror clause as code).
3
+ *
4
+ * `zero judgment` is wider than "don't judge the user" — it also means don't
5
+ * judge WHETHER to intervene. This gate runs inside argus_open_decision BEFORE
6
+ * any crux question is formed. On a flat / low-stakes / reversible / already-
7
+ * closed decision it returns restraint ("leave as is"), emitting no question
8
+ * and no fork.
9
+ *
10
+ * HONEST LIMIT (M2): the inputs are CLAIMED by the model, not measured. The gate
11
+ * cannot be more right than its inputs — it is a restraint bias, not a
12
+ * correcting oracle. Contradictory inputs are flagged for one re-confirmation,
13
+ * and the inputs are logged so post-hoc evals can measure input accuracy.
14
+ */
15
+ export function overfireGate(s) {
16
+ // Contradictory signals → ask once before trusting them (M2).
17
+ if (s.reversibility === 'easily_reversible' && s.stakes === 'high') {
18
+ return {
19
+ fire: false,
20
+ reason: 'contradictory_signals',
21
+ response: 'reconfirm',
22
+ };
23
+ }
24
+ if (s.is_vent)
25
+ return { fire: false, reason: 'vent', response: 'leave_as_is' };
26
+ if (s.is_factual)
27
+ return { fire: false, reason: 'factual', response: 'leave_as_is' };
28
+ if (s.already_decided)
29
+ return { fire: false, reason: 'already_closed', response: 'leave_as_is' };
30
+ // Reversible + not high stakes → restraint (the cost of being wrong is an undo).
31
+ if (s.reversibility === 'easily_reversible' && s.stakes !== 'high') {
32
+ return { fire: false, reason: 'reversible_low_stakes', response: 'leave_as_is' };
33
+ }
34
+ // Genuinely low/trivial stakes → restraint.
35
+ if (s.stakes === 'trivial' || s.stakes === 'low') {
36
+ return { fire: false, reason: 'low_stakes', response: 'leave_as_is' };
37
+ }
38
+ return { fire: true, reason: 'consequential_open_fork', response: 'fire' };
39
+ }
@@ -0,0 +1,153 @@
1
+ import { deriveState } from './state-machine.js';
2
+ import { GuardError } from './state-machine.js';
3
+ /** Hard caps (plan v5 §2): a decision is 5 premises, not a wiki. */
4
+ export const MAX_ACTIVE_PREMISES = 5;
5
+ export const MAX_LOAD_BEARING = 2;
6
+ /** Re-check cadence: gates DUE-COMPUTATION ONLY — an explicit recheck is always
7
+ * writable (the mistake-correction path, plan v5 P3). */
8
+ export const RECHECK_MIN_INTERVAL_DAYS = 7;
9
+ // ── identity ──────────────────────────────────────────────────────────────
10
+ export function normalizePremiseText(text) {
11
+ return text.trim().toLowerCase().replace(/\s+/g, ' ');
12
+ }
13
+ /** Stable, decision-scoped premise id (djb2). Stable across re-adds so nothing
14
+ * is orphaned; scoped by decision so identical facts in two decisions don't
15
+ * collide on the row while still being groupable by normalized text. */
16
+ export function premiseId(decisionId, kind, text) {
17
+ const key = `${decisionId}:${kind}:${normalizePremiseText(text)}`;
18
+ let h = 5381;
19
+ for (let i = 0; i < key.length; i++)
20
+ h = ((h << 5) + h + key.charCodeAt(i)) >>> 0;
21
+ return `p_${h.toString(36)}`;
22
+ }
23
+ /** Monitoring is DERIVED, never stored (state-is-the-fold): only an active,
24
+ * load-bearing, external premise is watched — the opt-out default that arms
25
+ * the return loop without ceremony (plan v5 §12). */
26
+ export function isMonitored(p) {
27
+ return p.kind === 'premise' && p.status === 'active' && p.external && p.load_bearing;
28
+ }
29
+ // ── ref resolution (ordinals beat opaque ids across turns) ────────────────
30
+ /**
31
+ * Resolve a user/host premise reference: an ordinal ("P1"/"p1"/"1"), a full
32
+ * premise_id, or an unambiguous id prefix (≥4 chars). Throws GuardError
33
+ * NO_SUCH_PREMISE / AMBIGUOUS_REF with the current list in the recovery hint.
34
+ */
35
+ export function resolvePremiseRef(premises, ref) {
36
+ const list = premises ?? [];
37
+ const listing = list.map((p) => `P${p.ordinal}=${p.premise_id} (${p.status}) "${p.text.slice(0, 40)}"`).join('; ') || '(none)';
38
+ const r = ref.trim();
39
+ const ord = /^[Pp]?(\d+)$/.exec(r);
40
+ if (ord) {
41
+ const n = parseInt(ord[1], 10);
42
+ const hit = list.find((p) => p.ordinal === n);
43
+ if (hit)
44
+ return hit;
45
+ throw new GuardError('NO_SUCH_PREMISE', `No premise P${n} on this decision.`, `Current premises: ${listing}`);
46
+ }
47
+ const exact = list.find((p) => p.premise_id === r);
48
+ if (exact)
49
+ return exact;
50
+ if (r.length >= 4) {
51
+ const matches = list.filter((p) => p.premise_id.startsWith(r));
52
+ if (matches.length === 1)
53
+ return matches[0];
54
+ if (matches.length > 1) {
55
+ throw new GuardError('AMBIGUOUS_REF', `"${r}" matches ${matches.length} premises.`, `Use the ordinal instead: ${listing}`);
56
+ }
57
+ }
58
+ throw new GuardError('NO_SUCH_PREMISE', `No premise matches "${r}".`, `Use an ordinal like "P1". Current premises: ${listing}`);
59
+ }
60
+ function dateOnly(ts) {
61
+ return ts && ts.length >= 10 ? ts.slice(0, 10) : undefined;
62
+ }
63
+ function daysBetween(from, to) {
64
+ const a = Date.parse(from + 'T00:00:00Z');
65
+ const b = Date.parse(to + 'T00:00:00Z');
66
+ if (Number.isNaN(a) || Number.isNaN(b))
67
+ return 0;
68
+ return Math.round((b - a) / 86400000);
69
+ }
70
+ /** Is this premise due for a re-check as of `today`? Monitored + never checked,
71
+ * or last checked ≥ RECHECK_MIN_INTERVAL_DAYS ago. (Writing a recheck is never
72
+ * blocked by this — it gates the nudge, not the pen.) */
73
+ export function isDueForRecheck(p, today) {
74
+ if (!isMonitored(p))
75
+ return false;
76
+ const last = dateOnly(p.last_recheck?.ts);
77
+ if (!last)
78
+ return true;
79
+ return daysBetween(last, today) >= RECHECK_MIN_INTERVAL_DAYS;
80
+ }
81
+ /**
82
+ * All due premises across the ledger. Sealing arms monitoring (plan v5 P4): only
83
+ * decisions in sealed|due state count — an opened-never-sealed decision's
84
+ * premises are tracked but never nagged, and settled/dismissed are closed.
85
+ */
86
+ export function duePremises(state) {
87
+ const out = [];
88
+ for (const entry of state.contracts.values()) {
89
+ const dState = deriveState(entry, state.today);
90
+ if (dState !== 'sealed' && dState !== 'due')
91
+ continue;
92
+ for (const p of entry.premises ?? []) {
93
+ if (!isDueForRecheck(p, state.today))
94
+ continue;
95
+ const last = dateOnly(p.last_recheck?.ts);
96
+ out.push({
97
+ decision_id: entry.id,
98
+ decision_text: (entry.text || '').slice(0, 48),
99
+ ordinal: p.ordinal,
100
+ premise_id: p.premise_id,
101
+ text: p.text,
102
+ last_checked: last,
103
+ days_stale: last ? daysBetween(last, state.today) : null,
104
+ });
105
+ }
106
+ }
107
+ // Never-checked first (most stale), then by staleness.
108
+ out.sort((a, b) => (b.days_stale ?? Infinity) - (a.days_stale ?? Infinity));
109
+ return out;
110
+ }
111
+ export function groupDuePremises(due) {
112
+ const byText = new Map();
113
+ for (const d of due) {
114
+ const key = normalizePremiseText(d.text);
115
+ const g = byText.get(key);
116
+ if (g)
117
+ g.premises.push(d);
118
+ else
119
+ byText.set(key, { text: d.text, premises: [d] });
120
+ }
121
+ return [...byText.values()];
122
+ }
123
+ /** The living-premises summary a receipt renders from (plan v5 §3.3): the
124
+ * premise set is canonical — headline = first active load-bearing premise. */
125
+ export function receiptPremisesInfo(entry) {
126
+ const list = entry?.premises ?? [];
127
+ if (list.length === 0)
128
+ return undefined;
129
+ const headline = list.find((p) => p.status === 'active' && p.load_bearing && p.kind === 'premise')?.text;
130
+ return {
131
+ ...(headline ? { headline } : {}),
132
+ tracked: list.length,
133
+ changed_at_recheck: list.filter((p) => p.last_recheck?.drifted === true).length,
134
+ };
135
+ }
136
+ /** Active monitored premises across OTHER decisions whose normalized text matches
137
+ * — the explicit apply_to_matching fan-out targets (plan v5 P1). */
138
+ export function matchingMonitoredPremises(state, sourceDecisionId, text) {
139
+ const key = normalizePremiseText(text);
140
+ const out = [];
141
+ for (const entry of state.contracts.values()) {
142
+ if (entry.id === sourceDecisionId)
143
+ continue;
144
+ const dState = deriveState(entry, state.today);
145
+ if (dState !== 'sealed' && dState !== 'due' && dState !== 'opened')
146
+ continue;
147
+ for (const p of entry.premises ?? []) {
148
+ if (isMonitored(p) && normalizePremiseText(p.text) === key)
149
+ out.push({ entry, premise: p });
150
+ }
151
+ }
152
+ return out;
153
+ }
@@ -0,0 +1,22 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ /**
4
+ * Ensure the `.argus/.gitignore` keeps private decision data out of git
5
+ * (SECURITY.md: ".argus holds private decisions, gitignored"). Idempotent.
6
+ */
7
+ export async function ensurePrivacyGitignore(argusDir) {
8
+ const gitignorePath = path.join(argusDir, '.gitignore');
9
+ try {
10
+ const existing = await fs.readFile(gitignorePath, 'utf8').catch(() => '');
11
+ const lines = new Set(existing.split('\n').map((l) => l.trim()));
12
+ const needed = ['sessions/', 'ledger/', '.bound'];
13
+ const toAdd = needed.filter((n) => !lines.has(n));
14
+ if (toAdd.length) {
15
+ await fs.mkdir(argusDir, { recursive: true });
16
+ await fs.appendFile(gitignorePath, (existing && !existing.endsWith('\n') ? '\n' : '') + toAdd.join('\n') + '\n');
17
+ }
18
+ }
19
+ catch {
20
+ /* non-critical */
21
+ }
22
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Opt-in account sync (design: "MCP도 이메일로 귀환").
3
+ *
4
+ * The MCP server is a short-lived stdio process — it is NOT alive at a
5
+ * prediction's check-by date, so it cannot email you itself. To close the loop
6
+ * it pushes the sealed prediction to the user's Argus account, where an
7
+ * always-on server cron (the Companion Brief) emails it when it comes due and
8
+ * the webapp dashboard shows it.
9
+ *
10
+ * Strictly opt-in: with no ARGUS_TOKEN the push is a silent no-op and the seal
11
+ * stays purely local (the privacy-preserving default). Fire-safe: any failure
12
+ * degrades to local-only and never breaks the seal that already succeeded.
13
+ *
14
+ * MCP config env:
15
+ * ARGUS_TOKEN argus_pat_… (issued in the webapp; same token as `argus push`)
16
+ * ARGUS_API_URL optional, defaults to https://argus.voyage
17
+ */
18
+ const TIMEOUT_MS = 5000;
19
+ /** Pull the account's receipts (the sync's read side). No token ⇒ empty. */
20
+ export async function fetchAccountReceipts() {
21
+ const token = (process.env.ARGUS_TOKEN || '').trim();
22
+ if (!token || !token.startsWith('argus_pat_'))
23
+ return { ok: false, reason: 'no_token', receipts: [] };
24
+ const base = (process.env.ARGUS_API_URL || 'https://argus.voyage').replace(/\/+$/, '');
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
27
+ try {
28
+ const res = await fetch(`${base}/api/mcp/receipts`, {
29
+ headers: { authorization: `Bearer ${token}` },
30
+ signal: controller.signal,
31
+ });
32
+ if (!res.ok)
33
+ return { ok: false, reason: `http_${res.status}`, receipts: [] };
34
+ const body = (await res.json());
35
+ return { ok: true, receipts: Array.isArray(body.receipts) ? body.receipts : [] };
36
+ }
37
+ catch {
38
+ return { ok: false, reason: 'network', receipts: [] };
39
+ }
40
+ finally {
41
+ clearTimeout(timer);
42
+ }
43
+ }
44
+ export async function pushToAccount(payload) {
45
+ const token = (process.env.ARGUS_TOKEN || '').trim();
46
+ if (!token)
47
+ return { synced: false, reason: 'no_token' }; // local-only (default)
48
+ if (!token.startsWith('argus_pat_'))
49
+ return { synced: false, reason: 'bad_token_format' };
50
+ const base = (process.env.ARGUS_API_URL || 'https://argus.voyage').replace(/\/+$/, '');
51
+ const controller = new AbortController();
52
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
53
+ try {
54
+ const res = await fetch(`${base}/api/mcp/seal`, {
55
+ method: 'POST',
56
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
57
+ body: JSON.stringify(payload),
58
+ signal: controller.signal,
59
+ });
60
+ if (!res.ok)
61
+ return { synced: false, reason: `http_${res.status}` };
62
+ return { synced: true };
63
+ }
64
+ catch {
65
+ return { synced: false, reason: 'network' }; // never throws — local seal already stands
66
+ }
67
+ finally {
68
+ clearTimeout(timer);
69
+ }
70
+ }