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.
- package/LICENSE +1 -1
- package/README.md +130 -20
- package/SECURITY.md +22 -8
- package/dist/lib/ambient-due.js +63 -0
- package/dist/lib/argus-dir.js +50 -1
- package/dist/lib/calendar.js +75 -0
- package/dist/lib/canonical-scales.js +105 -0
- package/dist/lib/due-note.js +62 -13
- package/dist/lib/envelope.js +19 -4
- package/dist/lib/install-id.js +53 -0
- package/dist/lib/layout.js +7 -0
- package/dist/lib/ledger-append.js +134 -3
- package/dist/lib/ledger-replay.js +120 -8
- package/dist/lib/locale.js +45 -0
- package/dist/lib/log.js +2 -2
- package/dist/lib/numeric-drift.js +343 -22
- package/dist/lib/package-meta.js +14 -0
- package/dist/lib/premise-sync.js +23 -0
- package/dist/lib/premises-core.js +174 -0
- package/dist/lib/premises.js +51 -48
- package/dist/lib/privacy.js +1 -1
- package/dist/lib/push-account.js +25 -2
- package/dist/lib/receipt.js +5 -1
- package/dist/lib/render-receipt.js +32 -22
- package/dist/lib/resolve-today.js +11 -3
- package/dist/lib/review/extract-core.js +229 -0
- package/dist/lib/review/extract-file-node.js +172 -0
- package/dist/lib/review/prompts.js +65 -3
- package/dist/lib/review/render.js +86 -38
- package/dist/lib/review/schema.js +4 -1
- package/dist/lib/review-path.js +108 -0
- package/dist/lib/spine.js +15 -3
- package/dist/lib/state-machine.js +54 -8
- package/dist/lib/surface-lint.js +97 -0
- package/dist/lib/surfaces.js +307 -38
- package/dist/lib/telemetry.js +169 -0
- package/dist/lib/untrusted.js +89 -0
- package/dist/lib/validate-crux.js +32 -7
- package/dist/server.js +23 -14
- package/dist/tools/amend-dismiss.js +46 -9
- package/dist/tools/candidates.js +129 -0
- package/dist/tools/check-in.js +165 -19
- package/dist/tools/index.js +3 -1
- package/dist/tools/init-config.js +49 -5
- package/dist/tools/open-decision.js +20 -18
- package/dist/tools/premises.js +239 -22
- package/dist/tools/recall.js +79 -15
- package/dist/tools/recheck.js +49 -13
- package/dist/tools/review.js +116 -19
- package/dist/tools/seal.js +128 -28
- package/dist/tools/settle.js +161 -18
- package/dist/tools/sync.js +245 -13
- package/dist/tools/watch.js +117 -0
- package/dist/v2/bridge.js +240 -0
- package/dist/v2/brief.js +122 -0
- package/dist/v2/events.js +187 -0
- package/dist/v2/evidence.js +51 -0
- package/dist/v2/gate-keywords.js +48 -0
- package/dist/v2/gate.js +98 -0
- package/dist/v2/git-discovery.js +80 -0
- package/dist/v2/harvest.js +147 -0
- package/dist/v2/init.js +42 -0
- package/dist/v2/ledger.js +290 -0
- package/dist/v2/lifecycle.js +122 -0
- package/dist/v2/logbook.js +142 -0
- package/dist/v2/mirror.js +305 -0
- package/dist/v2/outbox.js +52 -0
- package/dist/v2/queue.js +124 -0
- package/dist/v2/reducer.js +462 -0
- package/dist/v2/sanitize.js +44 -0
- package/dist/v2/v1-reader.js +297 -0
- package/package.json +17 -6
- package/snippets/claude-code-watch.md +47 -0
package/dist/lib/due-note.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
33
|
-
|
|
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 (
|
|
40
|
-
parts.push(`${
|
|
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;
|
package/dist/lib/envelope.js
CHANGED
|
@@ -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(
|
|
4
|
-
structuredContent:
|
|
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(
|
|
10
|
-
structuredContent:
|
|
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
|
+
}
|
package/dist/lib/layout.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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']
|
|
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
|
|
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) => {
|
package/dist/lib/locale.js
CHANGED
|
@@ -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
|
}
|