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
@@ -1,49 +1,98 @@
1
+ import fs from 'fs';
1
2
  import { replayLedger } from './ledger-replay.js';
2
- import { duePremises, groupDuePremises } from './premises.js';
3
3
  import { resolveToolArgusDir } from './argus-dir.js';
4
4
  import { resolveToday } from './resolve-today.js';
5
+ import { configPath } from './layout.js';
6
+ import { ambientDueFromState, ambientLine } from './ambient-due.js';
5
7
  /**
6
- * Dispatch-level due-note piggyback (plan v5 §5-2, restraint rules §3.4).
8
+ * Dispatch-level ambient due-note (plan v5 §5-2 · M1 §1.3, restraint rules §3.4).
7
9
  *
8
10
  * An MCP server is passive — nothing wakes it between seal and settle. The one
9
11
  * moment it reliably has is WHEN THE USER IS ALREADY HERE: any successful tool
10
- * call. So the dispatcher quietly annotates every ok-envelope with what is due.
12
+ * call. So the dispatcher quietly annotates every ok-envelope with what is due,
13
+ * on TWO channels sharing ONE due count (ambient-due.ts — single source, so this
14
+ * can never drift from check_in):
15
+ *
16
+ * 1. data.due_note — machine counts (host-choice; unchanged, back-compat).
17
+ * 2. surface tail — ONE localized FACT line so a due item is not forgotten
18
+ * mid-session (M1 §1.3). Appended LAST so it never obscures the tool's own
19
+ * result; a fact + the argus_check_in handle, never a directive.
11
20
  *
12
21
  * Restraint, by construction:
13
- * - counts only, in `data.due_note` — the surface line stays about the tool
14
- * that was called; whether to raise the note is the HOST's choice
15
22
  * - absent entirely at zero (an empty nag cannot be expressed)
23
+ * - the surface line fires AT MOST ONCE per session/process (not on every tool
24
+ * call) — the count channel still annotates each call for hosts that read it
25
+ * - `ambient_mute: true` in config.yaml silences the surface line (the escape)
16
26
  * - argus_check_in is skipped (it IS the due surface — no duplication)
17
27
  * - never overwrites a due_note a tool set itself; never touches errors
18
28
  * - any internal failure returns the result untouched (a broken note must
19
29
  * never tax the tool call that succeeded)
20
30
  */
21
31
  const SKIP_TOOLS = new Set(['argus_check_in']);
32
+ /** Session-once gate for the SURFACE line: one stdio process = one session, so a
33
+ * process-lifetime Set keyed by argus_dir fires the ambient line only on the
34
+ * first eligible tool call and stays quiet after (the count channel is not
35
+ * gated). Reset for tests via resetAmbientSession(). */
36
+ const ambientShownFor = new Set();
37
+ export function resetAmbientSession() {
38
+ ambientShownFor.clear();
39
+ }
40
+ function ambientMuted(argusDir) {
41
+ try {
42
+ const cfg = fs.readFileSync(configPath(argusDir), 'utf8');
43
+ return /^ambient_mute:\s*true\b/m.test(cfg);
44
+ }
45
+ catch {
46
+ return false; // no config → not muted
47
+ }
48
+ }
22
49
  export function appendDueNote(toolName, args, result) {
23
50
  try {
24
- if (result.isError || SKIP_TOOLS.has(toolName))
51
+ if (result.isError)
52
+ return result;
53
+ if (SKIP_TOOLS.has(toolName)) {
54
+ // check_in IS the due surface — a session that saw it has had its ambient
55
+ // budget spent. Without this mark, the tail re-fired on the NEXT call and
56
+ // read as a debt count at the worst moment (experience loop: it rode the
57
+ // settle right after a check_in triage — "one done, here are your 2 left").
58
+ try {
59
+ ambientShownFor.add(resolveToolArgusDir(args['argus_dir']));
60
+ }
61
+ catch { /* unbound → nothing to mark */ }
25
62
  return result;
63
+ }
26
64
  const sc = result.structuredContent;
27
65
  if (!sc || sc['ok'] !== true)
28
66
  return result;
29
67
  const dir = resolveToolArgusDir(args['argus_dir']); // unbound → throws → untouched
30
68
  const today = resolveToday({ override: args['today_override'] });
31
69
  const state = replayLedger(dir, today);
32
- const contractsDue = state.overdue.length;
33
- const premiseFactsDue = groupDuePremises(duePremises(state)).length;
34
- if (contractsDue === 0 && premiseFactsDue === 0)
70
+ const due = ambientDueFromState(state); // SINGLE SOURCE (shared with check_in via ambient-due)
71
+ if (due.contractsDue === 0 && due.premiseFactsDue === 0 && due.openQuestionsDue === 0)
35
72
  return result;
73
+ // ── channel 1: machine counts (host-choice; unchanged) ──
36
74
  const parts = [];
37
- if (premiseFactsDue > 0)
38
- parts.push(`${premiseFactsDue} premise fact(s) to re-check (argus_recheck)`);
39
- if (contractsDue > 0)
40
- parts.push(`${contractsDue} contract(s) to settle (argus_settle)`);
75
+ if (due.premiseFactsDue > 0)
76
+ parts.push(`${due.premiseFactsDue} premise fact(s) to re-check (argus_recheck)`);
77
+ if (due.openQuestionsDue > 0)
78
+ parts.push(`${due.openQuestionsDue} open question(s) to reconsider (argus_premises)`);
79
+ if (due.contractsDue > 0)
80
+ parts.push(`${due.contractsDue} contract(s) to settle (argus_settle)`);
41
81
  const data = (sc['data'] ??= {});
42
82
  if (!('due_note' in data))
43
83
  data['due_note'] = parts.join(' · ');
44
84
  const na = sc['next_actions'];
45
85
  if (Array.isArray(na) && !na.includes('argus_check_in'))
46
86
  na.push('argus_check_in');
87
+ // ── channel 2: the surface tail — session-once, mute-respecting, localized ──
88
+ if (!ambientShownFor.has(dir) && !ambientMuted(dir)) {
89
+ const line = ambientLine(dir, due, state); // state lends the ledger's own voice
90
+ if (line && typeof sc['surface'] === 'string') {
91
+ sc['surface'] = String(sc['surface']) + line;
92
+ data['ambient_shown'] = true;
93
+ }
94
+ ambientShownFor.add(dir); // once per session regardless (mute or empty line included)
95
+ }
47
96
  // Refresh the text mirror — envelope() serialized before this annotation.
48
97
  result.content = [{ type: 'text', text: JSON.stringify(sc, null, 2) }];
49
98
  return result;
@@ -1,13 +1,28 @@
1
+ import { sanitizeOutput } from './untrusted.js';
2
+ /**
3
+ * Every tool's output funnels through these two functions, which makes them the
4
+ * one place a trust boundary can be enforced without relying on a dozen
5
+ * interpolation sites remembering to. Recorded text (a user's anchor, a premise
6
+ * lifted from a document, a `source_title` off the account API) is echoed into
7
+ * `surface` and `data`, and the host model reads all of it as trusted output.
8
+ *
9
+ * `sanitizeOutput` removes the MECHANICAL vectors — ANSI/terminal escapes,
10
+ * carriage-return overwrites, bidi overrides, zero-width smuggling — so the
11
+ * bytes the human sees and the bytes the model reads are the same bytes.
12
+ * Semantic injection is not solvable here; see lib/untrusted.ts.
13
+ */
1
14
  export function envelope(e) {
15
+ const safe = sanitizeOutput(e);
2
16
  return {
3
- content: [{ type: 'text', text: JSON.stringify(e, null, 2) }],
4
- structuredContent: e,
17
+ content: [{ type: 'text', text: JSON.stringify(safe, null, 2) }],
18
+ structuredContent: safe,
5
19
  };
6
20
  }
7
21
  export function toolError(e) {
22
+ const safe = sanitizeOutput(e);
8
23
  return {
9
- content: [{ type: 'text', text: JSON.stringify(e, null, 2) }],
10
- structuredContent: e,
24
+ content: [{ type: 'text', text: JSON.stringify(safe, null, 2) }],
25
+ structuredContent: safe,
11
26
  isError: true,
12
27
  };
13
28
  }
@@ -0,0 +1,53 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { randomBytes } from 'crypto';
4
+ /**
5
+ * Ledger install id (BS-1, BLUEPRINT §9.4 두 기기 안전).
6
+ *
7
+ * Account rows used to be keyed `mcp_<slug>` — so two ledgers under one
8
+ * account (laptop + desktop, or two projects) that both sealed a naturally
9
+ * named decision ("migrate-db") silently collided on the same account row.
10
+ * Every ledger now carries a stable random id (`.argus/.install`, 8 hex),
11
+ * and the account key becomes `mcp_<install8>_<slug>`.
12
+ *
13
+ * Local decision ids are untouched — this namespaces only the account bridge.
14
+ * The id is not a device fingerprint: it is random, local, and never reused
15
+ * across ledgers.
16
+ */
17
+ export function ledgerInstallId(argusDir) {
18
+ const p = path.join(argusDir, '.install');
19
+ try {
20
+ const raw = fs.readFileSync(p, 'utf8').trim();
21
+ if (/^[0-9a-f]{8}$/.test(raw))
22
+ return raw;
23
+ }
24
+ catch { /* first run */ }
25
+ const fresh = randomBytes(4).toString('hex');
26
+ try {
27
+ fs.mkdirSync(argusDir, { recursive: true });
28
+ fs.writeFileSync(p, fresh, 'utf8');
29
+ }
30
+ catch { /* read-only dir — fall through with the in-memory id */ }
31
+ return fresh;
32
+ }
33
+ /** The account-row id for a local decision: mcp_<install8>_<slug> (web adds the mcp_). */
34
+ export function accountPushId(argusDir, localId) {
35
+ return `${ledgerInstallId(argusDir)}_${localId}`;
36
+ }
37
+ /**
38
+ * Reverse-map an account row id to OUR local id.
39
+ * `mcp_<our-install>_<slug>` → slug; legacy `mcp_<slug>` (pre-namespace rows)
40
+ * → slug; a row namespaced by ANOTHER ledger → null (it is not ours to settle).
41
+ */
42
+ export function localIdFromAccountId(argusDir, accountId) {
43
+ if (!accountId.startsWith('mcp_'))
44
+ return null;
45
+ const rest = accountId.slice(4);
46
+ const own = `${ledgerInstallId(argusDir)}_`;
47
+ if (rest.startsWith(own))
48
+ return rest.slice(own.length);
49
+ // another ledger's namespaced row: 8-hex + '_' prefix that is not ours
50
+ if (/^[0-9a-f]{8}_/.test(rest))
51
+ return null;
52
+ return rest; // legacy un-namespaced row — historically ours
53
+ }
@@ -13,6 +13,7 @@ import { safeSegment, assertInside } from './safe-path.js';
13
13
  export const sessionsRoot = (argusDir) => path.join(argusDir, 'sessions');
14
14
  export const ledgerDir = (argusDir) => path.join(argusDir, 'ledger');
15
15
  export const ledgerPath = (argusDir) => path.join(ledgerDir(argusDir), 'ledger.jsonl');
16
+ export const calendarDir = (argusDir) => path.join(argusDir, 'calendar');
16
17
  export const configPath = (argusDir) => path.join(argusDir, 'config.yaml');
17
18
  export const boundMarkerPath = (argusDir) => path.join(argusDir, '.bound');
18
19
  export function sessionDir(argusDir, id) {
@@ -27,6 +28,12 @@ export function bearingPath(argusDir, id) {
27
28
  export function receiptPath(argusDir, id) {
28
29
  return path.join(sessionDir(argusDir, id), 'receipt.json');
29
30
  }
31
+ export function calendarPath(argusDir, id) {
32
+ const root = calendarDir(argusDir);
33
+ const file = path.join(root, `${safeSegment(id, 'id')}.ics`);
34
+ assertInside(root, file);
35
+ return file;
36
+ }
30
37
  export function sessionFilePath(argusDir, id) {
31
38
  return path.join(sessionDir(argusDir, id), 'session.json');
32
39
  }
@@ -2,17 +2,117 @@ import fs from 'fs';
2
2
  import fsP from 'fs/promises';
3
3
  import { ledgerPath, ledgerDir } from './layout.js';
4
4
  import { SCHEMA_VERSION } from './spine.js';
5
- export async function appendLedger(argusDir, events, now) {
5
+ import { mirrorV1Events } from '../v2/mirror.js';
6
+ /**
7
+ * Cross-process critical section for read-check-append sequences (§9.4 두 기기
8
+ * 안전). The in-process dispatcher already serializes calls WITHIN one stdio
9
+ * server; this lockfile extends that to two concurrent sessions on one dir —
10
+ * without it, two settles could both replay 'sealed' and both append, double-
11
+ * counting the calibration record. O_EXCL create is the atomic primitive;
12
+ * a lock older than STALE_MS is treated as a crash leftover and stolen.
13
+ */
14
+ const LOCK_STALE_MS = 5000;
15
+ const LOCK_WAIT_MS = 25;
16
+ const LOCK_TRIES = 120; // ~3s worst case
17
+ export async function withLedgerLock(argusDir, fn) {
6
18
  await fsP.mkdir(ledgerDir(argusDir), { recursive: true });
19
+ const lockPath = ledgerPath(argusDir) + '.lock';
20
+ let acquired = false;
21
+ for (let i = 0; i < LOCK_TRIES && !acquired; i++) {
22
+ try {
23
+ const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
24
+ fs.writeSync(fd, String(process.pid), null, 'utf8');
25
+ fs.closeSync(fd);
26
+ acquired = true;
27
+ }
28
+ catch {
29
+ try {
30
+ const st = fs.statSync(lockPath);
31
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
32
+ fs.unlinkSync(lockPath);
33
+ continue;
34
+ } // crash leftover
35
+ }
36
+ catch {
37
+ continue;
38
+ } // lock vanished between attempts — retry immediately
39
+ await new Promise((r) => setTimeout(r, LOCK_WAIT_MS));
40
+ }
41
+ }
42
+ // Lock or no lock, the work proceeds (availability over strictness — a stuck
43
+ // lock must never brick the ledger; the steal above bounds the wait).
44
+ try {
45
+ return await fn();
46
+ }
47
+ finally {
48
+ if (acquired) {
49
+ try {
50
+ fs.unlinkSync(lockPath);
51
+ }
52
+ catch { /* already gone */ }
53
+ }
54
+ }
55
+ }
56
+ /**
57
+ * Does the ledger currently end in a newline? A crash or ENOSPC mid-write leaves
58
+ * a final line with no terminator; the next O_APPEND then writes its bytes
59
+ * directly onto those, fusing the torn remnant with the new first event into one
60
+ * invalid JSON line. Replay counts that as ONE dropped line — so the torn record
61
+ * silently EATS the next event too. If that event is a `settle`, the receipt file
62
+ * is written but the ledger settle is gone: the decision stays due forever and the
63
+ * outcome vanishes from the calibration record, with nothing turning red.
64
+ * Cheap to detect (read the last byte), so we heal instead of corrupting.
65
+ */
66
+ function needsLeadingNewline(lPath) {
67
+ let fd;
68
+ try {
69
+ const size = fs.statSync(lPath).size;
70
+ if (size === 0)
71
+ return false;
72
+ fd = fs.openSync(lPath, fs.constants.O_RDONLY);
73
+ const buf = Buffer.alloc(1);
74
+ fs.readSync(fd, buf, 0, 1, size - 1);
75
+ return buf[0] !== 0x0a; // '\n'
76
+ }
77
+ catch {
78
+ return false; // no file yet (or unreadable) — the append creates it
79
+ }
80
+ finally {
81
+ if (fd !== undefined) {
82
+ try {
83
+ fs.closeSync(fd);
84
+ }
85
+ catch { /* already closed */ }
86
+ }
87
+ }
88
+ }
89
+ export async function appendLedger(argusDir, events, now, hints) {
90
+ const dir = ledgerDir(argusDir);
91
+ await fsP.mkdir(dir, { recursive: true });
7
92
  const lPath = ledgerPath(argusDir);
8
- const lines = events
93
+ const body = events
9
94
  .map((ev) => JSON.stringify({ v: SCHEMA_VERSION, ts: ev.ts || now, ...ev }))
10
95
  .join('\n') + '\n';
96
+ // Heal a torn tail so it can only ever cost the ONE line it tore, never the
97
+ // next event. The torn remnant still counts as dropped_lines (disclosed), but
98
+ // the events we are writing now survive.
99
+ const lines = (needsLeadingNewline(lPath) ? '\n' : '') + body;
100
+ // fsync of the file makes its CONTENTS durable, but the very first append also
101
+ // creates the directory ENTRY — and that entry can be lost on a crash unless
102
+ // the parent directory is itself synced. Only matters on first create.
103
+ const isFirstCreate = !fs.existsSync(lPath);
11
104
  await new Promise((resolve, reject) => {
12
105
  let fd;
13
106
  try {
14
107
  fd = fs.openSync(lPath, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY);
15
108
  fs.writeSync(fd, lines, null, 'utf8');
109
+ // The ledger is the product's only durable asset. A rename-based atomic
110
+ // write can't be used on an append, so fsync is the one thing standing
111
+ // between a power loss and a lost settlement.
112
+ try {
113
+ fs.fsyncSync(fd);
114
+ }
115
+ catch { /* fsync unsupported on this fs — the write still landed */ }
16
116
  resolve();
17
117
  }
18
118
  catch (e) {
@@ -23,5 +123,36 @@ export async function appendLedger(argusDir, events, now) {
23
123
  fs.closeSync(fd);
24
124
  }
25
125
  });
26
- return { written: events.length };
126
+ if (isFirstCreate) {
127
+ // Persist the new file's directory entry, consistent with the file fsync above.
128
+ let dfd;
129
+ try {
130
+ dfd = fs.openSync(dir, fs.constants.O_RDONLY);
131
+ fs.fsyncSync(dfd);
132
+ }
133
+ catch { /* directory fsync unsupported (e.g. Windows) — best effort */ }
134
+ finally {
135
+ if (dfd !== undefined) {
136
+ try {
137
+ fs.closeSync(dfd);
138
+ }
139
+ catch { /* already closed */ }
140
+ }
141
+ }
142
+ }
143
+ // ── v2 미러 (단일 관문) — v1 쓰기가 성공한 뒤에만, 그리고 반드시 여기서.
144
+ // 툴별 dual-write 호출을 없애 배선 누락을 구조적으로 불가능하게 만든 지점이다
145
+ // (src/v2/mirror.ts 헤더 참조). mirrorV1Events는 절대 던지지 않지만, 만에
146
+ // 하나를 위해 한 번 더 감싼다 — 미러가 v1 쓰기 성공을 오염하면 안 된다.
147
+ let v2_mirror;
148
+ try {
149
+ v2_mirror = mirrorV1Events(argusDir, events, now, hints);
150
+ }
151
+ catch (e) {
152
+ v2_mirror = {
153
+ bound: false, mirrored: 0, skipped_unmapped: [],
154
+ errors: [], reason: `mirror crashed: ${e instanceof Error ? e.message : String(e)}`,
155
+ };
156
+ }
157
+ return { written: events.length, v2_mirror };
27
158
  }
@@ -3,6 +3,9 @@ import { deBom } from './deBom.js';
3
3
  import { ledgerPath, sessionsRoot, bearingPath } from './layout.js';
4
4
  import { asDate } from './resolve-today.js';
5
5
  import { safeSegment } from './safe-path.js';
6
+ function normalizePremiseSource(source) {
7
+ return source === 'user' || source === 'user_stated' ? 'user_stated' : 'ai_surfaced';
8
+ }
6
9
  function freshEntry(id) {
7
10
  return { id, status: 'candidate', text: '', amend_history: [], premises: [] };
8
11
  }
@@ -16,17 +19,18 @@ export function replayLedger(argusDir, today) {
16
19
  const map = new Map();
17
20
  const stats = {
18
21
  total_sealed: 0, total_settled: 0,
19
- held: 0, avoided: 0, partial: 0, still_pending: 0,
22
+ held: 0, avoided: 0, partial: 0, still_pending: 0, missed: 0,
20
23
  };
21
24
  let dropped = 0;
22
25
  let skippedUnknown = 0;
23
26
  let oldestTs;
27
+ const watch = { anchors: new Map(), captures: [] };
24
28
  let raw;
25
29
  try {
26
30
  raw = deBom(fs.readFileSync(ledgerPath(argusDir), 'utf8'));
27
31
  }
28
32
  catch {
29
- return { today, overdue: [], ids, sealedPredicates, contracts: map, stats, integrity: { dropped_lines: 0, skipped_unknown: 0 } };
33
+ return { today, overdue: [], ids, sealedPredicates, contracts: map, stats, watch, integrity: { dropped_lines: 0, skipped_unknown: 0 } };
30
34
  }
31
35
  for (const line of raw.split('\n')) {
32
36
  if (!line.trim())
@@ -46,8 +50,9 @@ export function replayLedger(argusDir, today) {
46
50
  const id = ev['id'];
47
51
  // gate_input is an over-fire-gate audit record, not a decision the user
48
52
  // 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')
53
+ // restrained argus_open_decision call (11 S7). Watch events (당직, §9) are
54
+ // likewise NOT decisions — they must never count as one.
55
+ if (ev['event'] !== 'gate_input' && ev['event'] !== 'watch_anchor' && ev['event'] !== 'watch_capture')
51
56
  ids.add(id);
52
57
  // Record inception (P1-E7): ISO timestamps compare lexicographically.
53
58
  if (typeof ev['ts'] === 'string' && ev['ts'] && (!oldestTs || ev['ts'] < oldestTs))
@@ -125,6 +130,30 @@ export function replayLedger(argusDir, today) {
125
130
  stats.partial++;
126
131
  else if (outcome === 'still_pending')
127
132
  stats.still_pending++;
133
+ else if (outcome === 'missed')
134
+ stats.missed++;
135
+ break;
136
+ }
137
+ case 'defer': {
138
+ // still_pending at the check-by → re-arm, do NOT settle. The contract
139
+ // moves its check_by forward and stays `sealed` (alive, will come due
140
+ // again). The original due date and the deferral reason are preserved so
141
+ // the eventual receipt can state the fact honestly.
142
+ if (!cur) {
143
+ cur = freshEntry(id);
144
+ map.set(id, cur);
145
+ } // defensive; the write-time guard requires `due`
146
+ const to = typeof ev['check_by'] === 'string' ? ev['check_by'] : undefined;
147
+ const from = typeof ev['from'] === 'string' ? ev['from'] : cur.check_by;
148
+ if (to)
149
+ cur.check_by = to;
150
+ cur.status = 'sealed';
151
+ cur.defer_count = (cur.defer_count ?? 0) + 1;
152
+ (cur.defer_history ??= []).push({
153
+ from, to,
154
+ ...(typeof ev['note'] === 'string' ? { note: ev['note'] } : {}),
155
+ ...(typeof ev['ts'] === 'string' ? { ts: ev['ts'] } : {}),
156
+ });
128
157
  break;
129
158
  }
130
159
  // ── living premises (plan v5 §6.1). The fold is not a validator — the
@@ -149,8 +178,17 @@ export function replayLedger(argusDir, today) {
149
178
  text: ev['text'],
150
179
  external: ev['external'] === true,
151
180
  load_bearing: ev['load_bearing'] === true,
152
- source: (ev['source'] === 'user' ? 'user' : 'ai'),
181
+ source: normalizePremiseSource(ev['source']),
153
182
  ...(typeof ev['ai_original'] === 'string' ? { ai_original: ev['ai_original'] } : {}),
183
+ ...(isMaterialityRule(ev['materiality_rule']) ? { materiality_rule: ev['materiality_rule'] } : {}),
184
+ ...(typeof ev['recheck_cadence_days'] === 'number' && Number.isFinite(ev['recheck_cadence_days']) ? { recheck_cadence_days: ev['recheck_cadence_days'] } : {}),
185
+ ...(typeof ev['reponder_cadence_days'] === 'number' && Number.isFinite(ev['reponder_cadence_days']) ? { reponder_cadence_days: ev['reponder_cadence_days'] } : {}),
186
+ // M3 — anchor the reconsider clock at add time (an open_question has no
187
+ // last_recheck; this is the date the first reconsider-due is measured
188
+ // from). Prefer the logical anchor_date (deterministic, honors
189
+ // today_override) over the wall-clock ts.
190
+ ...(typeof ev['anchor_date'] === 'string' ? { added_ts: ev['anchor_date'] }
191
+ : typeof ev['ts'] === 'string' ? { added_ts: ev['ts'] } : {}),
154
192
  status: 'active',
155
193
  amend_history: [],
156
194
  recheck_count: 0,
@@ -179,6 +217,29 @@ export function replayLedger(argusDir, today) {
179
217
  p.external = ev['external'];
180
218
  if (typeof ev['load_bearing'] === 'boolean')
181
219
  p.load_bearing = ev['load_bearing'];
220
+ // M1 §1.2: the user may re-set the cadence (how often to nudge). A
221
+ // number widens/narrows the interval; nothing else touches it.
222
+ if (typeof ev['recheck_cadence_days'] === 'number' && Number.isFinite(ev['recheck_cadence_days']))
223
+ p.recheck_cadence_days = ev['recheck_cadence_days'];
224
+ // M3 §3: the user may re-set the reconsider cadence for an open_question.
225
+ if (typeof ev['reponder_cadence_days'] === 'number' && Number.isFinite(ev['reponder_cadence_days']))
226
+ p.reponder_cadence_days = ev['reponder_cadence_days'];
227
+ break;
228
+ }
229
+ // M3 §3 — the user chose `still_open`: the question stays unresolved, but
230
+ // the reconsider clock resets so it is not nagged again until the next
231
+ // cadence. "Leaving it open" is a valid answer — this is a defer, not a
232
+ // resolve; no verdict, no closing decision.
233
+ case 'premise_reconsider': {
234
+ const p = cur?.premises?.find((x) => x.premise_id === ev['premise_id']);
235
+ if (!p)
236
+ break;
237
+ // Prefer the logical anchor_date over wall-clock ts (deterministic reset).
238
+ p.last_reconsidered = (typeof ev['anchor_date'] === 'string' ? ev['anchor_date']
239
+ : typeof ev['ts'] === 'string' ? ev['ts'] : undefined);
240
+ // optionally re-set cadence at the same time (host may pass it)
241
+ if (typeof ev['reponder_cadence_days'] === 'number' && Number.isFinite(ev['reponder_cadence_days']))
242
+ p.reponder_cadence_days = ev['reponder_cadence_days'];
182
243
  break;
183
244
  }
184
245
  case 'premise_recheck': {
@@ -194,7 +255,11 @@ export function replayLedger(argusDir, today) {
194
255
  baseline_only: ev['baseline_only'] === true,
195
256
  source: ev['source'],
196
257
  ...(typeof ev['source_detail'] === 'string' ? { source_detail: ev['source_detail'] } : {}),
197
- ts: ev['ts'],
258
+ // Prefer the logical anchor_date over the wall-clock ts — the same
259
+ // deterministic clock premise_add (added_ts) and premise_reconsider use.
260
+ // The cadence math reads dateOnly(last_recheck.ts), so a UTC ts made the
261
+ // next nudge fire a day early for a UTC+9 user and broke sim timelines.
262
+ ts: (typeof ev['anchor_date'] === 'string' ? ev['anchor_date'] : ev['ts']),
198
263
  };
199
264
  p.recheck_count++;
200
265
  break;
@@ -210,9 +275,47 @@ export function replayLedger(argusDir, today) {
210
275
  }
211
276
  case 'gate_input':
212
277
  break; // known meta event (over-fire gate audit) — not a state change, not corrupt
278
+ // ── 당직 루프 (§9) — outside the decision state machine. No contract
279
+ // entry is created; nothing touches stats. Anchor is a note, not a bet.
280
+ case 'watch_anchor': {
281
+ const date = typeof ev['anchor_date'] === 'string' ? ev['anchor_date']
282
+ : typeof ev['ts'] === 'string' ? ev['ts'].slice(0, 10) : undefined;
283
+ if (!date || typeof ev['text'] !== 'string') {
284
+ dropped++;
285
+ break;
286
+ }
287
+ watch.anchors.set(date, { date, text: ev['text'], ts: ev['ts'] });
288
+ break;
289
+ }
290
+ case 'watch_capture': {
291
+ const date = typeof ev['anchor_date'] === 'string' ? ev['anchor_date']
292
+ : typeof ev['ts'] === 'string' ? ev['ts'].slice(0, 10) : undefined;
293
+ if (!date || typeof ev['text'] !== 'string') {
294
+ dropped++;
295
+ break;
296
+ }
297
+ // capture_id is sha256(date|text), and argus_watch documents re-capturing
298
+ // the same sentence on the same day as idempotent — but the fold used to
299
+ // push blindly, so a double note left TWO identical captures. The user
300
+ // could then never promote it: argus_premises from_capture matched both
301
+ // and hard-errored AMBIGUOUS_REF. Dedup here, like premise_add does.
302
+ const capId = typeof ev['capture_id'] === 'string' ? ev['capture_id'] : undefined;
303
+ if (capId && watch.captures.some((c) => c.id === capId))
304
+ break;
305
+ watch.captures.push({
306
+ ...(typeof ev['capture_id'] === 'string' ? { id: ev['capture_id'] } : {}),
307
+ date,
308
+ kind: (ev['kind'] === 'claim' || ev['kind'] === 'question' ? ev['kind'] : 'premise'),
309
+ text: ev['text'],
310
+ source: normalizePremiseSource(ev['source']),
311
+ ...(typeof ev['ai_original'] === 'string' ? { ai_original: ev['ai_original'] } : {}),
312
+ ts: ev['ts'],
313
+ });
314
+ break;
315
+ }
213
316
  default:
214
317
  // 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 —
318
+ // whose type this binary doesn't know was written by a newer argus-decision-mcp —
216
319
  // skip it silently (like gate_input) instead of counting it as corruption,
217
320
  // so an old install never raises a false integrity alarm on a new ledger.
218
321
  // Only unversioned/structurally-broken events still count as dropped.
@@ -232,7 +335,7 @@ export function replayLedger(argusDir, today) {
232
335
  overdue.push({ id, date, text: item.text || '' });
233
336
  }
234
337
  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 } };
338
+ return { today, overdue, ids, sealedPredicates, contracts: map, stats, watch, oldest_ts: oldestTs, integrity: { dropped_lines: dropped, skipped_unknown: skippedUnknown } };
236
339
  }
237
340
  /**
238
341
  * Bearing-file contract seeds that are due but not yet represented in the
@@ -283,6 +386,15 @@ function readJson(file) {
283
386
  return null;
284
387
  }
285
388
  }
389
+ const KNOWN_RULE_TYPES = new Set(['threshold', 'step', 'delta', 'relative', 'band', 'map', 'stateful']);
390
+ /** Defensive shape check for a persisted materiality_rule (jsonb from the ledger).
391
+ * Replay never throws — a malformed rule is simply ignored (heuristic fallback). */
392
+ function isMaterialityRule(v) {
393
+ if (!v || typeof v !== 'object')
394
+ return false;
395
+ const r = v;
396
+ return typeof r['type'] === 'string' && KNOWN_RULE_TYPES.has(r['type']) && typeof r['params'] === 'object' && r['params'] !== null;
397
+ }
286
398
  // Re-export for callers that imported these from here historically.
287
399
  export { asDate };
288
400
  export const _ledgerFileExists = (argusDir) => {
@@ -1,5 +1,14 @@
1
1
  import fs from 'fs';
2
+ import { detect } from 'tinyld/light';
2
3
  import { configPath } from './layout.js';
4
+ /**
5
+ * detectLocale — CONFIG/ENV detection seeded at argus_init write time.
6
+ *
7
+ * Chain (write-time): explicit config > env (LANG/LC_ALL) > Intl > 'en'.
8
+ * The runtime input-text step lives in resolveResponseLocale (surfaces.ts),
9
+ * which layers text detection on TOP of a persisted config. This function
10
+ * stays env-only so a fresh dir on a KST machine seeds locale:ko once.
11
+ */
3
12
  export function detectLocale(argusDir) {
4
13
  try {
5
14
  const cfg = fs.readFileSync(configPath(argusDir), 'utf8');
@@ -18,3 +27,39 @@ export function detectLocale(argusDir) {
18
27
  catch { /* Intl unavailable */ }
19
28
  return 'en';
20
29
  }
30
+ /**
31
+ * detectLocaleFromText — the runtime language sniff (M4, spec §4).
32
+ *
33
+ * Argus targets exactly two locales, ko and en. Korean is written in Hangul,
34
+ * a distinct, unambiguous Unicode block — so a Hangul PRESENCE check is 100%
35
+ * precise for the ko side and needs no statistics. `tinyld/light` (the ~70KB
36
+ * pure-JS n-gram profile; NO native binding, ships in the published tarball)
37
+ * is the mature-library secondary signal the spec calls for — but on SHORT
38
+ * strings its statistical guess misfires ("base rate stays at 3.5%" → 'no',
39
+ * "downtime < 5 min" → 'pl'), so for our binary ko/en decision the reliable
40
+ * rule is: any Hangul ⇒ ko, else ⇒ en. tinyld is consulted only to promote a
41
+ * Hangul-free string to 'ko' if it somehow reads Korean (it never does for
42
+ * Latin text) — a belt-and-suspenders that never overrides the script check.
43
+ *
44
+ * Returns null on empty/too-short/ambiguous input so the caller can fall
45
+ * through to config/env — a low-confidence guess must never win.
46
+ */
47
+ const HANGUL = /[가-힣ᄀ-ᇿ㄰-㆏]/;
48
+ export function detectLocaleFromText(text) {
49
+ if (!text)
50
+ return null;
51
+ const t = text.trim();
52
+ if (t.length < 2)
53
+ return null;
54
+ // Script check dominates: Hangul is unambiguous and short-text-safe.
55
+ if (HANGUL.test(t))
56
+ return 'ko';
57
+ // No Hangul. Consult tinyld only to catch a would-be Korean romanization;
58
+ // in practice Latin text returns a non-ko guess, so this stays 'en'.
59
+ try {
60
+ if (detect(t) === 'ko')
61
+ return 'ko';
62
+ }
63
+ catch { /* detector unavailable → script check already ruled */ }
64
+ return 'en';
65
+ }
package/dist/lib/log.js CHANGED
@@ -5,10 +5,10 @@
5
5
  */
6
6
  export function logError(msg, err) {
7
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`);
8
+ process.stderr.write(`argus-decision-mcp ERROR ${msg}${detail ? ' :: ' + detail : ''}\n`);
9
9
  }
10
10
  export function logDebug(msg) {
11
11
  if (process.env['ARGUS_DEBUG']) {
12
- process.stderr.write(`argus-mcp DEBUG ${msg}\n`);
12
+ process.stderr.write(`argus-decision-mcp DEBUG ${msg}\n`);
13
13
  }
14
14
  }