argus-decision-mcp 1.4.1 → 1.4.6
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/dist/lib/calendar.js +0 -0
- package/dist/lib/due-note.js +6 -1
- package/dist/lib/elicit.js +9 -1
- package/dist/lib/ledger-append.js +91 -19
- package/dist/lib/ledger-replay.js +32 -10
- package/dist/lib/localize-result.js +64 -14
- package/dist/lib/numeric-drift.js +2 -2
- package/dist/lib/receipt.js +17 -1
- package/dist/lib/render-receipt.js +36 -11
- package/dist/lib/resolve-today.js +20 -0
- package/dist/lib/safe-path.js +16 -0
- package/dist/lib/surfaces.js +18 -14
- package/dist/lib/tool-presentation.js +1 -1
- package/dist/lib/untrusted.js +3 -3
- package/dist/lib/validate-seal.js +6 -3
- package/dist/resources.js +6 -1
- package/dist/server.js +15 -10
- package/dist/tools/amend-dismiss.js +21 -0
- package/dist/tools/check-in.js +24 -1
- package/dist/tools/errors.js +4 -4
- package/dist/tools/index.js +11 -0
- package/dist/tools/init-config.js +14 -0
- package/dist/tools/premises.js +22 -4
- package/dist/tools/public-tools.js +20 -7
- package/dist/tools/recall.js +14 -6
- package/dist/tools/recheck.js +41 -6
- package/dist/tools/seal.js +11 -8
- package/dist/tools/settle.js +18 -10
- package/dist/tools/tool-types.js +31 -5
- package/package.json +1 -1
package/dist/lib/calendar.js
CHANGED
|
Binary file
|
package/dist/lib/due-note.js
CHANGED
|
@@ -4,6 +4,7 @@ import { resolveToolArgusDir } from './argus-dir.js';
|
|
|
4
4
|
import { resolveToday } from './resolve-today.js';
|
|
5
5
|
import { configPath } from './layout.js';
|
|
6
6
|
import { ambientDueFromState, ambientLine } from './ambient-due.js';
|
|
7
|
+
import { stripUnsafeChars } from './untrusted.js';
|
|
7
8
|
/**
|
|
8
9
|
* Dispatch-level ambient due-note (plan v5 §5-2 · M1 §1.3, restraint rules §3.4).
|
|
9
10
|
*
|
|
@@ -86,7 +87,11 @@ export function appendDueNote(toolName, args, result) {
|
|
|
86
87
|
na.push('argus_check_in');
|
|
87
88
|
// ── channel 2: the surface tail — session-once, mute-respecting, localized ──
|
|
88
89
|
if (!ambientShownFor.has(dir) && !ambientMuted(dir)) {
|
|
89
|
-
|
|
90
|
+
// This tail is appended AFTER envelope() already ran sanitizeOutput, so it
|
|
91
|
+
// must be sanitized itself — ambientLine reads ledger text (via `state`),
|
|
92
|
+
// and one future change that quotes the ledger's words would otherwise
|
|
93
|
+
// inject past the sanitizer.
|
|
94
|
+
const line = stripUnsafeChars(ambientLine(dir, due, state));
|
|
90
95
|
if (line && typeof sc['surface'] === 'string') {
|
|
91
96
|
sc['surface'] = String(sc['surface']) + line;
|
|
92
97
|
data['ambient_shown'] = true;
|
package/dist/lib/elicit.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* elicitation (many don't yet), `elicit` returns null and callers fall back to
|
|
10
10
|
* the text flow — no crash, no dead end.
|
|
11
11
|
*/
|
|
12
|
+
import { stripUnsafeChars } from './untrusted.js';
|
|
12
13
|
let _elicit = null;
|
|
13
14
|
let _capable = null;
|
|
14
15
|
/** Wire the elicitor and, optionally, a live capability probe. The probe must
|
|
@@ -33,7 +34,14 @@ export async function elicit(message, requestedSchema) {
|
|
|
33
34
|
if (!_elicit)
|
|
34
35
|
return null;
|
|
35
36
|
try {
|
|
36
|
-
|
|
37
|
+
// The elicitation `message` is a SEPARATE server→client request — it does NOT
|
|
38
|
+
// pass through envelope()/sanitizeOutput, so a raw predicate/premise
|
|
39
|
+
// interpolated into it (seal.ts, premises.ts) could carry ANSI escapes or a
|
|
40
|
+
// forged "AI VERDICT" line that a terminal host renders (screen-clear +
|
|
41
|
+
// spine spoof). Sanitize here, at the one seam every picker passes through,
|
|
42
|
+
// so no call site can forget. stripUnsafeChars keeps \n and \t (a message
|
|
43
|
+
// like `Record this?\n"…"` needs the newline).
|
|
44
|
+
const res = await _elicit(stripUnsafeChars(message), requestedSchema);
|
|
37
45
|
return res.action === 'accept' && res.content ? res.content : null;
|
|
38
46
|
}
|
|
39
47
|
catch {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import fsP from 'fs/promises';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
3
4
|
import { ledgerPath, ledgerDir } from './layout.js';
|
|
4
5
|
import { SCHEMA_VERSION } from './spine.js';
|
|
5
6
|
import { mirrorV1Events } from '../v2/mirror.js';
|
|
@@ -11,45 +12,116 @@ import { mirrorV1Events } from '../v2/mirror.js';
|
|
|
11
12
|
* counting the calibration record. O_EXCL create is the atomic primitive;
|
|
12
13
|
* a lock older than STALE_MS is treated as a crash leftover and stolen.
|
|
13
14
|
*/
|
|
14
|
-
const LOCK_STALE_MS = 5000;
|
|
15
15
|
const LOCK_WAIT_MS = 25;
|
|
16
|
-
const LOCK_TRIES = 120; // ~3s worst case
|
|
16
|
+
const LOCK_TRIES = 120; // ~3s worst case before failing OPEN (availability > strictness)
|
|
17
|
+
// A normal critical section is milliseconds. 10 min = a zombie / reused pid, or a
|
|
18
|
+
// lock synced in from a now-offline machine — the only cases old enough to steal
|
|
19
|
+
// when we can't prove the holder is dead by pid.
|
|
20
|
+
const LOCK_HELD_TOO_LONG_MS = 10 * 60_000;
|
|
21
|
+
function pidAlive(pid) {
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
return e.code === 'EPERM';
|
|
28
|
+
} // EPERM = alive but not ours; ESRCH = dead
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A held lock is stealable ONLY if its holder is provably gone: the pid is dead
|
|
32
|
+
* (same machine) or it has been held absurdly long. The old code stole any lock
|
|
33
|
+
* older than 5s by mtime, which let a LIVE holder inside a slow critical section
|
|
34
|
+
* (large ledger fsync, network FS) be robbed — two writers then both believed
|
|
35
|
+
* they held the lock and both appended, double-counting the calibration record.
|
|
36
|
+
*/
|
|
37
|
+
function lockStealable(lockPath) {
|
|
38
|
+
try {
|
|
39
|
+
let body = null;
|
|
40
|
+
try {
|
|
41
|
+
body = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
catch { /* legacy/torn — fall to mtime */ }
|
|
44
|
+
if (body && typeof body === 'object') {
|
|
45
|
+
const b = body;
|
|
46
|
+
const started = typeof b.started_at === 'string' ? Date.parse(b.started_at) : NaN;
|
|
47
|
+
if (Number.isFinite(started) && Date.now() - started > LOCK_HELD_TOO_LONG_MS)
|
|
48
|
+
return true;
|
|
49
|
+
if (typeof b.pid === 'number')
|
|
50
|
+
return !pidAlive(b.pid);
|
|
51
|
+
}
|
|
52
|
+
// legacy bare-pid or malformed body: fall back to a GENEROUS mtime age.
|
|
53
|
+
return Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_HELD_TOO_LONG_MS;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return true; // vanished between attempts — retry create
|
|
57
|
+
}
|
|
58
|
+
}
|
|
17
59
|
export async function withLedgerLock(argusDir, fn) {
|
|
18
60
|
await fsP.mkdir(ledgerDir(argusDir), { recursive: true });
|
|
19
61
|
const lockPath = ledgerPath(argusDir) + '.lock';
|
|
62
|
+
const nonce = randomUUID();
|
|
63
|
+
const bodyStr = JSON.stringify({ nonce, pid: process.pid, started_at: new Date().toISOString() });
|
|
64
|
+
const tmp = `${lockPath}.${nonce}.tmp`;
|
|
20
65
|
let acquired = false;
|
|
21
66
|
for (let i = 0; i < LOCK_TRIES && !acquired; i++) {
|
|
22
67
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
68
|
+
// Complete the body first, then create the lock via an ATOMIC hardlink —
|
|
69
|
+
// the lock file exists only ever with a full body (no empty window a racer
|
|
70
|
+
// could misread as "crashed mid-write"), and EEXIST means someone holds it.
|
|
71
|
+
fs.writeFileSync(tmp, bodyStr, 'utf8');
|
|
72
|
+
fs.linkSync(tmp, lockPath);
|
|
26
73
|
acquired = true;
|
|
27
74
|
}
|
|
28
|
-
catch {
|
|
75
|
+
catch (e) {
|
|
76
|
+
const code = e.code;
|
|
77
|
+
if (code !== 'EEXIST') {
|
|
78
|
+
// linkSync unsupported on this FS (rare) — degrade to O_EXCL create.
|
|
79
|
+
try {
|
|
80
|
+
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
|
81
|
+
fs.writeSync(fd, bodyStr, null, 'utf8');
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
acquired = true;
|
|
84
|
+
}
|
|
85
|
+
catch { /* held — fall through to steal/wait */ }
|
|
86
|
+
}
|
|
87
|
+
if (!acquired) {
|
|
88
|
+
if (lockStealable(lockPath)) {
|
|
89
|
+
// Steal ATOMICALLY via rename — exactly one racer's rename wins; the
|
|
90
|
+
// loser throws (already moved) and retries. (unlink+recreate let two
|
|
91
|
+
// stealers both delete then both create — the double-steal race.)
|
|
92
|
+
try {
|
|
93
|
+
const grave = `${lockPath}.stale-${nonce}`;
|
|
94
|
+
fs.renameSync(lockPath, grave);
|
|
95
|
+
fs.unlinkSync(grave);
|
|
96
|
+
}
|
|
97
|
+
catch { /* lost the steal to another racer — just retry create */ }
|
|
98
|
+
continue; // retry immediately
|
|
99
|
+
}
|
|
100
|
+
await new Promise((r) => setTimeout(r, LOCK_WAIT_MS));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
29
104
|
try {
|
|
30
|
-
|
|
31
|
-
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
32
|
-
fs.unlinkSync(lockPath);
|
|
33
|
-
continue;
|
|
34
|
-
} // crash leftover
|
|
105
|
+
fs.unlinkSync(tmp);
|
|
35
106
|
}
|
|
36
|
-
catch {
|
|
37
|
-
continue;
|
|
38
|
-
} // lock vanished between attempts — retry immediately
|
|
39
|
-
await new Promise((r) => setTimeout(r, LOCK_WAIT_MS));
|
|
107
|
+
catch { /* linked away on success, or never created */ }
|
|
40
108
|
}
|
|
41
109
|
}
|
|
42
|
-
//
|
|
43
|
-
//
|
|
110
|
+
// Fail OPEN if never acquired (availability > strictness — a stuck lock must
|
|
111
|
+
// never brick the ledger; the steal logic bounds the wait to ~3s).
|
|
44
112
|
try {
|
|
45
113
|
return await fn();
|
|
46
114
|
}
|
|
47
115
|
finally {
|
|
48
116
|
if (acquired) {
|
|
117
|
+
// Release only our OWN lock (nonce match) — never delete a lock a later
|
|
118
|
+
// holder created after ours was legitimately stolen (ABA guard).
|
|
49
119
|
try {
|
|
50
|
-
fs.
|
|
120
|
+
const cur = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
121
|
+
if (cur.nonce === nonce)
|
|
122
|
+
fs.unlinkSync(lockPath);
|
|
51
123
|
}
|
|
52
|
-
catch { /*
|
|
124
|
+
catch { /* gone, or not ours */ }
|
|
53
125
|
}
|
|
54
126
|
}
|
|
55
127
|
}
|
|
@@ -32,7 +32,13 @@ export function replayLedger(argusDir, today) {
|
|
|
32
32
|
catch {
|
|
33
33
|
return { today, overdue: [], ids, sealedPredicates, contracts: map, stats, watch, integrity: { dropped_lines: 0, skipped_unknown: 0 } };
|
|
34
34
|
}
|
|
35
|
-
for (const
|
|
35
|
+
for (const rawLine of raw.split('\n')) {
|
|
36
|
+
// Strip a per-LINE BOM: deBom only removes one at byte 0, but a U+FEFF can
|
|
37
|
+
// ride the first line of a concatenated second file, or be prepended per
|
|
38
|
+
// append by a Windows PowerShell co-writer (>>/Out-File) sharing the ledger.
|
|
39
|
+
// JSON.parse('{…}') throws, so without this a real settle line would
|
|
40
|
+
// be dropped and its outcome vanish from calibration.
|
|
41
|
+
const line = rawLine.charCodeAt(0) === 0xfeff ? rawLine.slice(1) : rawLine;
|
|
36
42
|
if (!line.trim())
|
|
37
43
|
continue;
|
|
38
44
|
let ev;
|
|
@@ -115,7 +121,6 @@ export function replayLedger(argusDir, today) {
|
|
|
115
121
|
map.set(id, cur);
|
|
116
122
|
} // B1: self-create
|
|
117
123
|
cur.status = 'settled';
|
|
118
|
-
stats.total_settled++;
|
|
119
124
|
const outcome = ev['outcome'];
|
|
120
125
|
cur.outcome = outcome;
|
|
121
126
|
// Timestamp field is two-vocab across surfaces: this binary stamps `ts`,
|
|
@@ -127,18 +132,35 @@ export function replayLedger(argusDir, today) {
|
|
|
127
132
|
if (typeof ev['broken_premise_id'] === 'string')
|
|
128
133
|
cur.broken_premise_id = ev['broken_premise_id'];
|
|
129
134
|
// `happened` is the plugin CLI's legacy spelling of `held` (pre plain-canon
|
|
130
|
-
// unification). Old ledgers keep their bytes — the alias lives at read time
|
|
131
|
-
//
|
|
132
|
-
|
|
135
|
+
// unification). Old ledgers keep their bytes — the alias lives at read time.
|
|
136
|
+
// total_settled is incremented HERE, together with the bucket, so the
|
|
137
|
+
// invariant total_settled == sum(buckets) always holds. A settle event
|
|
138
|
+
// with an UNKNOWN outcome (a corrupt or externally-synced ledger — the
|
|
139
|
+
// zod enum blocks it on the MCP write path) still marks the decision
|
|
140
|
+
// settled, but is not counted into a calibration frequency it can't be
|
|
141
|
+
// categorized into (it used to inflate total_settled, so the surface read
|
|
142
|
+
// "N settled: 0 · 0 · 0 · 0" — a settle that vanished from its own breakdown).
|
|
143
|
+
if (outcome === 'held' || outcome === 'happened') {
|
|
133
144
|
stats.held++;
|
|
134
|
-
|
|
145
|
+
stats.total_settled++;
|
|
146
|
+
}
|
|
147
|
+
else if (outcome === 'avoided') {
|
|
135
148
|
stats.avoided++;
|
|
136
|
-
|
|
149
|
+
stats.total_settled++;
|
|
150
|
+
}
|
|
151
|
+
else if (outcome === 'partial') {
|
|
137
152
|
stats.partial++;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
else if (outcome === 'missed')
|
|
153
|
+
stats.total_settled++;
|
|
154
|
+
}
|
|
155
|
+
else if (outcome === 'missed') {
|
|
141
156
|
stats.missed++;
|
|
157
|
+
stats.total_settled++;
|
|
158
|
+
}
|
|
159
|
+
// 'still_pending' is NOT a terminal outcome — the MCP settle tool routes
|
|
160
|
+
// it to a `defer` event, never a `settle`. A settle+still_pending is
|
|
161
|
+
// therefore external/legacy only; it is not counted (the displayed
|
|
162
|
+
// breakdown shows four buckets, so counting it would make the visible
|
|
163
|
+
// line under-sum: "1 settled: 0 · 0 · 0 · 0"). v2 rejects it likewise.
|
|
142
164
|
break;
|
|
143
165
|
}
|
|
144
166
|
case 'defer': {
|
|
@@ -4,7 +4,7 @@ const KO_ERRORS = {
|
|
|
4
4
|
INVALID_LOCALE: { message: '지원하지 않는 언어입니다.', recovery: 'locale에는 "ko" 또는 "en"을 사용하세요.' },
|
|
5
5
|
ALREADY_CLOSED: { message: '이미 진행 중이거나 닫힌 결정입니다.', recovery: '실제 결과를 기록하려면 argus_resolve를 사용하세요. 닫힌 결정은 다시 열지 않습니다.' },
|
|
6
6
|
CAPTURE_NOT_FOUND: { message: '일치하는 내부 메모를 찾지 못했습니다.', recovery: '전제 문장을 text에 직접 전달하세요.' },
|
|
7
|
-
AMBIGUOUS_REF: { message: '여러
|
|
7
|
+
AMBIGUOUS_REF: { message: '참조가 여러 항목과 일치합니다.', recovery: 'P1 같은 서수(번호)로 정확히 지정하세요. 번호는 argus_patterns view="decision_context"에서 볼 수 있습니다.' },
|
|
8
8
|
PROVENANCE_REQUIRED: { message: '문장의 출처를 확인해야 합니다.', recovery: '사용자가 쓴 문장이면 user_stated, AI가 제기한 문장이면 ai_surfaced와 원문을 전달하세요.' },
|
|
9
9
|
PREMISES_REQUIRED: { message: '추가할 전제가 없습니다.', recovery: 'text, kind, external, load_bearing, source를 포함한 전제 1~5개를 전달하세요.' },
|
|
10
10
|
PREMISE_ID_COLLISION: { message: '다른 전제가 같은 식별자를 사용하고 있습니다.', recovery: '전제 문장을 조금 다르게 표현한 뒤 다시 추가하세요.' },
|
|
@@ -30,12 +30,15 @@ const KO_ERRORS = {
|
|
|
30
30
|
// ko/en 패리티: 아래 코드들은 en에서만 상세했고 ko는 제네릭 폴백이었다 —
|
|
31
31
|
// 한국어 사용자가 같은 품질의 복구 안내를 받도록 전용 문구를 둔다.
|
|
32
32
|
NO_PRIOR_SEAL: { message: '이 id로 저장된 예측이 없습니다.', recovery: 'argus_predict로 반증 가능한 예측과 확인일을 먼저 저장하세요. (id가 argus_settings sync에서 온 "mcp_" 접두사라면 접두사를 뗀 id를 쓰세요.)' },
|
|
33
|
-
BAD_CHECK_BY: { message: '확인일이 오늘 이후의 날짜(YYYY-MM-DD)가
|
|
34
|
-
ILLEGAL_TRANSITION: { message: '
|
|
33
|
+
BAD_CHECK_BY: { message: '확인일이 오늘 이후의 실제 달력 날짜(YYYY-MM-DD)가 아닙니다 (예: 2026-13-01처럼 없는 달·날짜는 불가).', recovery: '오늘 이후의 올바른 날짜를 YYYY-MM-DD로 다시 전달하세요.' },
|
|
34
|
+
ILLEGAL_TRANSITION: { message: '이 결정에 지금은 할 수 없는 작업입니다 (id 오타이거나, 이미 저장·정산·종료된 상태일 수 있습니다).', recovery: 'argus_patterns view="all"로 id와 현재 상태를 확인하세요. 없는 id면 argus_capture 또는 argus_predict로 새로 시작하세요.' },
|
|
35
|
+
PREMISE_LOCKED: { message: '확인일이 지나 전제를 더는 바꿀 수 없습니다.', recovery: '먼저 argus_resolve로 실제 결과를 기록하세요. 확인일이 온 뒤에는 전제/예측을 고칠 수 없습니다.' },
|
|
36
|
+
ARGUS_DIR_INVALID: { message: 'Argus 기록 경로(argus_dir / ARGUS_DIR)가 올바르지 않습니다.', recovery: '절대 경로여야 하고 ".."을 포함할 수 없습니다. MCP 설정에서 절대 경로(예: C:\\Users\\이름\\.argus, /Users/이름/.argus)로 바꾸거나 ARGUS_DIR을 지워 기본값(~/.argus)을 쓰세요. ${...} 같은 변수는 호스트가 확장하지 못할 수 있습니다.' },
|
|
37
|
+
EMPTY_PREDICATE: { message: '확인 가능한 예측 문장이 필요합니다 (공백 제외 최소 8자).', recovery: '현실이 참/거짓으로 확인할 수 있는 문장으로 다시 적으세요. 예: "컷오버 다운타임 5분 미만".' },
|
|
35
38
|
ALREADY_SETTLED: { message: '이미 실제 결과가 기록된 결정입니다.', recovery: '영수증은 argus_patterns view="receipt"로 볼 수 있습니다. 새 결정이면 새 id로 여세요.' },
|
|
36
39
|
DECISION_CLOSED: { message: '접힌(닫힌) 결정이라 더 진행할 수 없습니다.', recovery: '필요하면 새 id로 다시 여세요. 닫힌 기록은 그대로 남습니다.' },
|
|
37
40
|
GOALPOST_MOVED: { message: '봉인된 예측 문장은 확인일 전에 바꿀 수 없습니다.', recovery: '일정 변경은 outcome="still_pending"과 defer_to로, 예측 자체가 달라졌다면 새 결정으로 여세요.' },
|
|
38
|
-
NO_SUCH_PREMISE: { message: '해당 번호의 전제를 찾지
|
|
41
|
+
NO_SUCH_PREMISE: { message: '해당 번호의 전제를 찾지 못했습니다 (이 결정에 아직 전제가 없을 수 있습니다).', recovery: 'argus_patterns view="decision_context"로 목록과 번호를 확인하고, 전제가 없으면 argus_capture action="add_context"로 먼저 추가하세요.' },
|
|
39
42
|
WHAT_HAPPENED_REQUIRED: { message: '실제로 일어난 일을 기록해야 합니다.', recovery: '사용자에게 실제 결과를 물어 what_happened에 그대로 전달하세요.' },
|
|
40
43
|
DEFER_DATE_REQUIRED: { message: '다시 확인할 날짜가 필요합니다.', recovery: '사용자에게 날짜를 물어 defer_to에 YYYY-MM-DD로 전달하세요. 더는 중요하지 않다면 argus_capture action="close"를 사용하세요.' },
|
|
41
44
|
NOT_CONNECTED: { message: '이 터미널은 Argus 계정과 연결돼 있지 않습니다.', recovery: '웹 설정에서 동기화 토큰을 발급하고 MCP 설정의 ARGUS_TOKEN에 넣으세요.' },
|
|
@@ -46,10 +49,30 @@ const KO_ERRORS = {
|
|
|
46
49
|
UNKNOWN_TOOL: { message: '알 수 없는 도구입니다.', recovery: 'tools/list에 나온 정확한 도구 이름을 사용하세요.' },
|
|
47
50
|
};
|
|
48
51
|
export const LOCALIZED_ERROR_CODES = new Set(Object.keys(KO_ERRORS));
|
|
52
|
+
// The allowed values for each enum field, so a wrong guess (stakes="medium",
|
|
53
|
+
// outcome="success", …) TEACHES the model the valid set instead of a bare "not
|
|
54
|
+
// allowed". Keyed by the field's LAST path segment (so premises.0.kind → kind).
|
|
55
|
+
const ENUM_HINTS = {
|
|
56
|
+
stakes: 'trivial · low · moderate · high',
|
|
57
|
+
reversibility: 'one_way_door · costly_to_reverse · easily_reversible',
|
|
58
|
+
outcome: 'held · avoided · partial · still_pending · missed',
|
|
59
|
+
outcome_source: 'user_stated',
|
|
60
|
+
view: 'active · all · receipt · decision_context · timeline · reflection',
|
|
61
|
+
action: 'open · add_context · answer_question · keep_question_open · update_fact · change_prediction · close',
|
|
62
|
+
locale: 'ko · en',
|
|
63
|
+
basis: 'judgment · luck · mixed · unsure',
|
|
64
|
+
kind: 'premise · open_question',
|
|
65
|
+
predicate_owner: 'user · ai_surfaced',
|
|
66
|
+
source: 'user_stated · ai_surfaced (update_fact에서는 url · user_stated · host_reported)',
|
|
67
|
+
dismiss_reason: 'became_irrelevant · decided_elsewhere · superseded · user_declined',
|
|
68
|
+
};
|
|
69
|
+
const DATE_FIELDS = new Set(['check_by', 'defer_to', 'today_override', 'snooze_until']);
|
|
49
70
|
/** Translate one Zod issue into a Korean, actionable reason. Keeps the English
|
|
50
71
|
* argument NAME (models and users see arg names in English), but says in Korean
|
|
51
|
-
* WHY it failed — the piece the generic message threw away.
|
|
52
|
-
|
|
72
|
+
* WHY it failed — the piece the generic message threw away. Field-aware so an id
|
|
73
|
+
* regex failure isn't told to "use YYYY-MM-DD" and an enum lists its values. */
|
|
74
|
+
function koReason(issue, field) {
|
|
75
|
+
const key = field.split('.').pop() || field;
|
|
53
76
|
const unit = issue.origin === 'string' ? '자' : issue.origin === 'array' ? '개' : '';
|
|
54
77
|
switch (issue.code) {
|
|
55
78
|
case 'too_small':
|
|
@@ -67,14 +90,24 @@ function koReason(issue) {
|
|
|
67
90
|
case 'invalid_type':
|
|
68
91
|
return issue.expected === undefined ? '형식이 올바르지 않습니다' : `필수이거나 형식이 올바르지 않습니다 (${issue.expected} 필요)`;
|
|
69
92
|
case 'invalid_value':
|
|
70
|
-
case 'invalid_enum_value':
|
|
71
|
-
|
|
93
|
+
case 'invalid_enum_value': {
|
|
94
|
+
const hint = ENUM_HINTS[key];
|
|
95
|
+
return hint ? `허용되지 않는 값입니다 (가능: ${hint})` : '허용되지 않는 값입니다';
|
|
96
|
+
}
|
|
72
97
|
case 'unrecognized_keys':
|
|
73
98
|
return '알 수 없는 항목입니다';
|
|
74
99
|
case 'invalid_format':
|
|
75
100
|
case 'invalid_string':
|
|
76
|
-
|
|
101
|
+
if (key === 'id')
|
|
102
|
+
return '영문·숫자와 . _ - 만 쓸 수 있습니다 (한글·공백·특수문자 불가 — 예: "career-move")';
|
|
103
|
+
if (DATE_FIELDS.has(key))
|
|
104
|
+
return 'YYYY-MM-DD 형식의 날짜여야 합니다';
|
|
105
|
+
return '형식이 올바르지 않습니다';
|
|
77
106
|
default:
|
|
107
|
+
// Same text as the invalid_format id case so the two issues the id regex +
|
|
108
|
+
// superRefine both raise dedup to a single line.
|
|
109
|
+
if (key === 'id')
|
|
110
|
+
return '영문·숫자와 . _ - 만 쓸 수 있습니다 (한글·공백·특수문자 불가 — 예: "career-move")';
|
|
78
111
|
return '값을 확인해 주세요';
|
|
79
112
|
}
|
|
80
113
|
}
|
|
@@ -82,7 +115,18 @@ function koReason(issue) {
|
|
|
82
115
|
function localizeInvalidInput(fields) {
|
|
83
116
|
if (!fields.length)
|
|
84
117
|
return KO_ERRORS.INVALID_INPUT;
|
|
85
|
-
const
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const parts = [];
|
|
120
|
+
for (const f of fields) {
|
|
121
|
+
const name = f.field === '(root)' ? '요청' : f.field;
|
|
122
|
+
const part = `${name}: ${koReason(f, f.field)}`;
|
|
123
|
+
if (seen.has(part))
|
|
124
|
+
continue; // dedup "id: …, id: …" (regex + superRefine both fire)
|
|
125
|
+
seen.add(part);
|
|
126
|
+
parts.push(part);
|
|
127
|
+
if (parts.length >= 4)
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
86
130
|
return {
|
|
87
131
|
message: `입력값이 올바르지 않습니다 — ${parts.join(', ')}.`,
|
|
88
132
|
recovery: '위에 표시된 인자를 고친 뒤 같은 도구를 다시 호출하세요. 사용자가 정해야 할 값은 추측하지 마세요.',
|
|
@@ -112,12 +156,18 @@ export function localizeToolResult(args, result) {
|
|
|
112
156
|
if (!sc || sc['ok'] !== false)
|
|
113
157
|
return result;
|
|
114
158
|
const code = String(sc['error_code'] ?? 'INTERNAL_ERROR');
|
|
159
|
+
// Some handlers already return a hand-written Korean message (e.g.
|
|
160
|
+
// NOT_FALSIFIABLE: "이건 기분이지 확인 가능한 예측이 아닙니다"). If this code
|
|
161
|
+
// isn't in KO_ERRORS, the generic fallback used to DESTROY that Korean copy.
|
|
162
|
+
// Preserve any message that already contains Hangul instead of overwriting it.
|
|
163
|
+
const existingMsg = typeof sc['message'] === 'string' ? sc['message'] : '';
|
|
164
|
+
const existingRec = typeof sc['recovery'] === 'string' ? sc['recovery'] : '';
|
|
165
|
+
const genericFallback = /[가-힣]/.test(existingMsg)
|
|
166
|
+
? { message: existingMsg, ...(existingRec ? { recovery: existingRec } : {}) }
|
|
167
|
+
: { message: '요청을 처리하지 못했습니다.', recovery: '입력값과 현재 결정 상태를 확인한 뒤 다시 시도하세요.' };
|
|
115
168
|
let copy = code === 'INVALID_INPUT' && Array.isArray(sc['invalid_fields'])
|
|
116
169
|
? localizeInvalidInput(sc['invalid_fields'])
|
|
117
|
-
: KO_ERRORS[code] ??
|
|
118
|
-
message: '요청을 처리하지 못했습니다.',
|
|
119
|
-
recovery: '입력값과 현재 결정 상태를 확인한 뒤 다시 시도하세요.',
|
|
120
|
-
};
|
|
170
|
+
: KO_ERRORS[code] ?? genericFallback;
|
|
121
171
|
// en에만 있던 날짜 상세를 ko에서도 보존 — "언제가 확인일인데?"에 답이 되도록.
|
|
122
172
|
if (code === 'PREMATURE_SETTLE') {
|
|
123
173
|
const m = String(sc['message'] ?? '').match(/check-by (\d{4}-\d{2}-\d{2}), today (\d{4}-\d{2}-\d{2})/);
|
|
@@ -169,7 +169,7 @@ export function evaluateMateriality(prev, next, rule, ctx) {
|
|
|
169
169
|
if (axis === 'ratio') {
|
|
170
170
|
return {
|
|
171
171
|
status: 'uncertain',
|
|
172
|
-
reason: '값이
|
|
172
|
+
reason: '값이 비율(%)입니다 — %p(퍼센트포인트)로 볼지 여집합(100−값) 축으로 볼지 정해주세요',
|
|
173
173
|
low_confidence: true,
|
|
174
174
|
};
|
|
175
175
|
}
|
|
@@ -213,7 +213,7 @@ function evaluateDeclaredRule(pv, nv, rule, ctx) {
|
|
|
213
213
|
// then it's uncertain, never a manufactured alert. (threshold/band/step/map
|
|
214
214
|
// are axis-agnostic — a crossed line is a crossed line.)
|
|
215
215
|
if (mod?.unit_axis === 'ratio' && (rule.type === 'relative' || rule.type === 'delta')) {
|
|
216
|
-
return { status: 'uncertain', reason: '값이
|
|
216
|
+
return { status: 'uncertain', reason: '값이 비율(%)입니다 — %p(퍼센트포인트)로 볼지 여집합(100−값) 축으로 볼지 정해주세요', low_confidence: true };
|
|
217
217
|
}
|
|
218
218
|
// sign-flip modifier (§2.3 / §6.5): dead-band AND, never "always material".
|
|
219
219
|
// A declared sign_flip is decided here (not by the raw delta/relative magnitude).
|
package/dist/lib/receipt.js
CHANGED
|
@@ -64,6 +64,15 @@ fallback) {
|
|
|
64
64
|
null;
|
|
65
65
|
const merged = {
|
|
66
66
|
...base,
|
|
67
|
+
// The ledger is the source of truth for the sealed prediction and its date.
|
|
68
|
+
// A change_prediction (amend) updates the CONTRACT but not the seal-time
|
|
69
|
+
// receipt file on disk, so `base.predicate`/`check_by` can be STALE — the
|
|
70
|
+
// receipt then contradicts every list view (dogfood: receipt printed the
|
|
71
|
+
// pre-amend "8월" predicate while the record showed the amended "9월" one).
|
|
72
|
+
// Prefer the current contract values the settle handler resolved from the
|
|
73
|
+
// fold, so the keepsake can never disagree with the ledger.
|
|
74
|
+
...(fallback?.predicate ? { predicate: fallback.predicate } : {}),
|
|
75
|
+
...(fallback?.check_by ? { check_by: fallback.check_by } : {}),
|
|
67
76
|
settled_at: patch.settled_at,
|
|
68
77
|
what_happened: patch.what_happened,
|
|
69
78
|
outcome: patch.outcome,
|
|
@@ -79,7 +88,14 @@ fallback) {
|
|
|
79
88
|
}
|
|
80
89
|
export function readReceipt(argusDir, id) {
|
|
81
90
|
try {
|
|
82
|
-
|
|
91
|
+
const parsed = JSON.parse(deBom(fs.readFileSync(receiptPath(argusDir, id), 'utf8')));
|
|
92
|
+
// A hand-edited / corrupt receipt could be a primitive, null, or an array;
|
|
93
|
+
// renderReceipt does r.predicate.split(...) unguarded and writeSettleReceipt
|
|
94
|
+
// spreads `base`, so a non-object would crash the render / drop every field.
|
|
95
|
+
// Reject anything but a plain object — a corrupt keepsake degrades, not dies.
|
|
96
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
97
|
+
return null;
|
|
98
|
+
return parsed;
|
|
83
99
|
}
|
|
84
100
|
catch {
|
|
85
101
|
return null;
|
|
@@ -4,8 +4,9 @@ export function renderReceipt(r, premises, locale = 'en') {
|
|
|
4
4
|
const L = [];
|
|
5
5
|
const sealed = r.created_at ? r.created_at.slice(0, 10) : '—';
|
|
6
6
|
const settled = r.settled_at ? r.settled_at.slice(0, 10) : R.not_settled;
|
|
7
|
-
const
|
|
8
|
-
const
|
|
7
|
+
const RW = 64; // target display columns — top and bottom derive from one width
|
|
8
|
+
const top = '┌─ ' + R.header + ' ' + '─'.repeat(Math.max(2, RW - 5 - dw(R.header))) + '┐';
|
|
9
|
+
const bottom = '└' + '─'.repeat(Math.max(2, RW - 6 - dw(R.footer))) + ' ' + R.footer + ' ─┘';
|
|
9
10
|
L.push(top);
|
|
10
11
|
L.push(` ${R.sealed_label} ${sealed} ${R.settled_label} ${settled}`);
|
|
11
12
|
// Deferral fact (still_pending re-arms): "originally due X · deferred N×".
|
|
@@ -68,17 +69,20 @@ export function renderReceipt(r, premises, locale = 'en') {
|
|
|
68
69
|
export function renderSeal(opts) {
|
|
69
70
|
const S = SURFACES[opts.locale].seal;
|
|
70
71
|
const L = [];
|
|
71
|
-
const
|
|
72
|
-
const
|
|
72
|
+
const SW = 64; // target display columns — top and bottom derive from one width
|
|
73
|
+
const top = '┌─ ' + S.header + ' ' + '─'.repeat(Math.max(2, SW - 5 - dw(S.header))) + '┐';
|
|
74
|
+
const bottom = '└' + '─'.repeat(Math.max(2, SW - 6 - dw(S.footer))) + ' ' + S.footer + ' ─┘';
|
|
73
75
|
L.push(top);
|
|
74
76
|
L.push('');
|
|
75
77
|
// the quote block — the user's own falsifiable sentence (continuation lines
|
|
76
78
|
// sit inside the opening quote)
|
|
77
79
|
L.push(` "${wrap(opts.predicate, 50).split('\n ').join('\n ')}"`);
|
|
78
80
|
L.push('');
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
// The prose already states provenance honestly ("these words are yours" /
|
|
82
|
+
// "Argus drafted these"). The raw `(predicate_owner: user)` machine tag beside
|
|
83
|
+
// it was plumbing on the keepsake certificate — the honest-provenance sentence
|
|
84
|
+
// carries the meaning; the token stays in `data`, off the rendered card.
|
|
85
|
+
L.push(` ${opts.predicate_owner === 'user' ? S.owner_user : S.owner_ai}`);
|
|
82
86
|
L.push('');
|
|
83
87
|
const labelWidth = Math.max(S.sealed_label.length, S.answers_label.length) + 4;
|
|
84
88
|
const days = Math.round((Date.parse(opts.check_by) - Date.parse(opts.today)) / 86400000);
|
|
@@ -112,7 +116,13 @@ recordSince) {
|
|
|
112
116
|
const L = [];
|
|
113
117
|
const headText = W.header + ' ';
|
|
114
118
|
const countText = ' ' + W.counts(contracts.length, sealed.length, settled.length) + ' ';
|
|
115
|
-
|
|
119
|
+
// The header+counts row can exceed WIDTH (Korean counts are wide); when it
|
|
120
|
+
// does, the top must GROW rather than clamp its dashes to a floor while the
|
|
121
|
+
// short footer sits at WIDTH — that was the 1-column top/bottom mismatch. Both
|
|
122
|
+
// edges derive from one barWidth: WIDTH, or wider if the top needs it.
|
|
123
|
+
const topFixed = 3 + dw(headText) + dw(countText) + 2; // everything but the dashes
|
|
124
|
+
const barWidth = Math.max(WIDTH, topFixed + 2);
|
|
125
|
+
L.push('┌─ ' + headText + '─'.repeat(barWidth - topFixed) + countText + '─┐');
|
|
116
126
|
const pushGroup = (rows, head, line, hint) => {
|
|
117
127
|
if (rows.length === 0)
|
|
118
128
|
return;
|
|
@@ -129,14 +139,29 @@ recordSince) {
|
|
|
129
139
|
// reference the host can settle by; "past check-by" alone, no day-count.
|
|
130
140
|
pushGroup(overdue, W.overdue_group(overdue.length), (c) => `"${label(c)}" ${mmdd(c.check_by)} · ${c.id}`, W.overdue_hint);
|
|
131
141
|
pushGroup(waiting, W.waiting_group(waiting.length), (c) => `"${label(c)}" ${W.answer_on(mmdd(c.check_by))} · ${c.id}`);
|
|
132
|
-
pushGroup(settled, W.settled_group(settled.length, stats.held, stats.avoided, stats.partial), (c) => `"${label(c)}" ${(c.outcome
|
|
142
|
+
pushGroup(settled, W.settled_group(settled.length, stats.held, stats.avoided, stats.partial, stats.missed), (c) => `"${label(c)}" ${c.outcome ? W.outcome_label(c.outcome) : '—'} ${mmdd(c.settled_on || c.check_by)} · ${c.id}`);
|
|
133
143
|
L.push('');
|
|
134
144
|
const foot = recordSince ? ' ' + W.record_since(recordSince) + ' ' : '';
|
|
135
|
-
L.push('└' + '─'.repeat(Math.max(2,
|
|
145
|
+
L.push('└' + '─'.repeat(Math.max(2, barWidth - 3 - dw(foot))) + foot + '─┘');
|
|
136
146
|
return L.join('\n');
|
|
137
147
|
}
|
|
148
|
+
// Display width: Hangul/CJK/fullwidth and the ⚓ anchor render as TWO terminal
|
|
149
|
+
// columns while String.length counts them as one. Every box border was built
|
|
150
|
+
// from `.length`, so a Korean header/footer made the top and bottom edges
|
|
151
|
+
// disagree by several columns — the keepsake's frame did not close. Both edges
|
|
152
|
+
// are now derived from one target WIDTH using this measure.
|
|
153
|
+
const WIDE = /[ᄀ-ᇿ⺀-鿿ꥠ-가-힣豈-︰-﹏-⦆¢-₩⚓]/;
|
|
154
|
+
function dw(s) {
|
|
155
|
+
let w = 0;
|
|
156
|
+
for (const ch of s)
|
|
157
|
+
w += WIDE.test(ch) ? 2 : 1;
|
|
158
|
+
return w;
|
|
159
|
+
}
|
|
138
160
|
function wrap(s, width = 54) {
|
|
139
|
-
|
|
161
|
+
// Defense in depth: a corrupt receipt field could be non-string (readReceipt
|
|
162
|
+
// rejects a non-object file, but a wrong-typed field inside a valid object
|
|
163
|
+
// would still reach here) — coerce so .split never throws on the keepsake.
|
|
164
|
+
const words = String(s ?? '').split(/\s+/);
|
|
140
165
|
const lines = [];
|
|
141
166
|
let cur = '';
|
|
142
167
|
for (const w of words) {
|
|
@@ -49,6 +49,26 @@ export function asDate(value) {
|
|
|
49
49
|
const m = value.match(/(\d{4})-(\d{2})-(\d{2})/);
|
|
50
50
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* asDate matches the digit SHAPE but never the calendar: "2026-13-01" (month 13)
|
|
54
|
+
* and "2026-09-31" (Sept has 30 days) both pass its regex. A calendar-invalid
|
|
55
|
+
* check_by used to seal — comparisons are lexical, so a future-looking bad date
|
|
56
|
+
* slid past the "must be in the future" gate — producing a malformed .ics
|
|
57
|
+
* (DTSTART:20261301) and a wrong due date. isRealDate rejects impossible
|
|
58
|
+
* month/day so the seal gate can fail loud instead.
|
|
59
|
+
*/
|
|
60
|
+
export function isRealDate(value) {
|
|
61
|
+
if (typeof value !== 'string')
|
|
62
|
+
return false;
|
|
63
|
+
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
64
|
+
if (!m)
|
|
65
|
+
return false;
|
|
66
|
+
const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
|
|
67
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31)
|
|
68
|
+
return false;
|
|
69
|
+
const dt = new Date(Date.UTC(y, mo - 1, d));
|
|
70
|
+
return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
|
|
71
|
+
}
|
|
52
72
|
export function isFutureDate(check, today) {
|
|
53
73
|
const c = asDate(check);
|
|
54
74
|
return !!c && c > today;
|
package/dist/lib/safe-path.js
CHANGED
|
@@ -12,6 +12,12 @@ import fs from 'fs';
|
|
|
12
12
|
// A single path segment: letters, digits, dot, underscore, hyphen.
|
|
13
13
|
// Rejects separators (/ \), '..', '.', percent-encoding, NUL, etc.
|
|
14
14
|
const SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
15
|
+
// Windows reserved device basenames (CON, NUL, COM1…). Case-insensitive and
|
|
16
|
+
// matched on the name up to the first dot, so `nul` and `nul.ics` both hit.
|
|
17
|
+
// On Windows `calendar/NUL.ics` resolves to the null device → the write
|
|
18
|
+
// "succeeds" but the bytes vanish (silent reminder loss); `sessions/con`
|
|
19
|
+
// throws a cryptic EINVAL. Reject the whole family loudly instead.
|
|
20
|
+
const WIN_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
|
|
15
21
|
export function safeSegment(raw, kind = 'segment') {
|
|
16
22
|
if (typeof raw !== 'string' || raw.length === 0 || raw.length > 128) {
|
|
17
23
|
throw new PathSafetyError(`invalid_${kind}`, `${kind} must be a 1-128 char string`);
|
|
@@ -19,6 +25,16 @@ export function safeSegment(raw, kind = 'segment') {
|
|
|
19
25
|
if (raw === '.' || raw === '..' || !SEGMENT.test(raw)) {
|
|
20
26
|
throw new PathSafetyError(`invalid_${kind}`, `${kind} must match [A-Za-z0-9._-] and not be '.'/'..'`);
|
|
21
27
|
}
|
|
28
|
+
// A trailing dot or space is stripped by Windows at path-resolution time, so
|
|
29
|
+
// "build." and "build" alias to ONE directory → the second decision silently
|
|
30
|
+
// overwrites the first. assertInside can't catch it (the aliasing happens in
|
|
31
|
+
// the OS at write time, invisible to a lexical check). Reject it here.
|
|
32
|
+
if (/[. ]$/.test(raw)) {
|
|
33
|
+
throw new PathSafetyError(`invalid_${kind}`, `${kind} must not end with a '.' or space`);
|
|
34
|
+
}
|
|
35
|
+
if (WIN_RESERVED.test(raw)) {
|
|
36
|
+
throw new PathSafetyError(`invalid_${kind}`, `${kind} must not be a reserved device name (CON, NUL, COM1…)`);
|
|
37
|
+
}
|
|
22
38
|
// Defense in depth: reject any percent-encoded or NUL byte that slipped the regex.
|
|
23
39
|
if (raw.includes('\0') || /%2e/i.test(raw) || /%2f/i.test(raw) || /%5c/i.test(raw)) {
|
|
24
40
|
throw new PathSafetyError(`invalid_${kind}`, `${kind} contains an encoded separator`);
|