argus-decision-mcp 1.4.2 → 1.5.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 (46) hide show
  1. package/dist/lib/ambient-elicit.js +7 -5
  2. package/dist/lib/calendar.js +0 -0
  3. package/dist/lib/continuity.js +2 -2
  4. package/dist/lib/detect-signals.js +151 -0
  5. package/dist/lib/due-note.js +6 -1
  6. package/dist/lib/elicit.js +9 -1
  7. package/dist/lib/ledger-append.js +102 -20
  8. package/dist/lib/ledger-replay.js +58 -18
  9. package/dist/lib/locale-mismatch.js +2 -2
  10. package/dist/lib/localize-result.js +215 -18
  11. package/dist/lib/numeric-drift.js +13 -13
  12. package/dist/lib/receipt.js +17 -1
  13. package/dist/lib/render-receipt.js +111 -57
  14. package/dist/lib/resolve-today.js +34 -0
  15. package/dist/lib/review/extract-file-node.js +2 -2
  16. package/dist/lib/review/render.js +6 -6
  17. package/dist/lib/review/routing.js +6 -6
  18. package/dist/lib/review/schema.js +9 -1
  19. package/dist/lib/safe-path.js +16 -0
  20. package/dist/lib/spine.js +30 -15
  21. package/dist/lib/state-machine.js +2 -2
  22. package/dist/lib/surfaces.js +65 -45
  23. package/dist/lib/telemetry.js +3 -3
  24. package/dist/lib/tool-presentation.js +4 -4
  25. package/dist/lib/untrusted.js +3 -3
  26. package/dist/lib/validate-crux.js +3 -3
  27. package/dist/lib/validate-seal.js +26 -7
  28. package/dist/resources.js +6 -1
  29. package/dist/server.js +15 -10
  30. package/dist/tools/amend-dismiss.js +30 -4
  31. package/dist/tools/candidates.js +1 -1
  32. package/dist/tools/check-in.js +46 -6
  33. package/dist/tools/errors.js +18 -4
  34. package/dist/tools/init-config.js +14 -0
  35. package/dist/tools/open-decision.js +6 -5
  36. package/dist/tools/premises.js +29 -9
  37. package/dist/tools/public-tools.js +37 -21
  38. package/dist/tools/recall.js +44 -14
  39. package/dist/tools/recheck.js +65 -11
  40. package/dist/tools/review.js +6 -6
  41. package/dist/tools/seal.js +15 -14
  42. package/dist/tools/settle.js +44 -22
  43. package/dist/tools/tool-types.js +35 -9
  44. package/dist/v2/lifecycle-cli.js +23 -8
  45. package/dist/v2/lifecycle.js +27 -0
  46. package/package.json +1 -1
@@ -163,17 +163,19 @@ async function fire(dir, todayOverride) {
163
163
  // 물음 1 — 정산 outcome (spine-SAFE 구조 픽: 현실이지 평결이 아니다).
164
164
  // 대화 맥락이 없는 사용자를 위해 그의 예측을 그대로 되비춘다 (우정 1조).
165
165
  const picked = await withTimeout(elicit(ko
166
- ? `Argus 확인일이 지난 예측이 있어요: "${text}" (확인일 ${first.date}). 현실이 어떻게 답했나요? 지금 어려우면 닫아도 됩니다 다시 조르지 않아요.`
167
- : `Argus a prediction passed its check-by: "${text}" (due ${first.date}). What did reality do? Dismiss if now is a bad time no re-asking.`, {
166
+ ? `Argus: 확인일이 지난 예측이 있어요. "${text}" (확인일 ${first.date}). 현실이 어떻게 답했나요? 지금 어려우면 닫아도 됩니다. 다시 조르지 않아요.`
167
+ : `Argus: a prediction passed its check-by. "${text}" (due ${first.date}). What did reality do? Dismiss if now is a bad time; no re-asking.`, {
168
168
  type: 'object',
169
169
  required: ['outcome'],
170
170
  properties: {
171
171
  outcome: {
172
172
  type: 'string',
173
173
  enum: ['held', 'avoided', 'partial', 'still_pending', 'missed'],
174
+ // Same labels as the in-band settle picker (settle.ts) — 풀어쓰기.
175
+ // (Duplicated across the two picker sites; keep them in lockstep.)
174
176
  enumNames: ko
175
- ? ['그렇게 됐다 (held)', '피했다 (avoided)', '부분적으로 (partial)', '아직 불분명 (still pending)', '빗나갔다 (missed)']
176
- : ['It held', 'Avoided it', 'Partially', 'Still pending', 'Missed my read was wrong'],
177
+ ? ['예측대로 됐다', '걱정한 일은 안 일어났다', '일부만 맞았다', '아직 불분명', '예측이 빗나갔다']
178
+ : ['It held', 'Avoided', 'Partially', 'Still unclear', 'Missed: my read was wrong'],
177
179
  description: ko ? '봉인한 예측에 현실이 어떻게 답했는지.' : 'What reality did to your sealed prediction.',
178
180
  },
179
181
  },
@@ -188,7 +190,7 @@ async function fire(dir, todayOverride) {
188
190
  let whatHappened;
189
191
  if (outcome !== 'still_pending') {
190
192
  const wh = await withTimeout(elicit(ko
191
- ? '실제로 무슨 일이 있었나요? 한 줄이면 됩니다 당신의 말 그대로 기록됩니다.'
193
+ ? '실제로 무슨 일이 있었나요? 한 줄이면 됩니다. 당신의 말 그대로 기록됩니다.'
192
194
  : 'What actually happened, in one line? Recorded verbatim, in your words.', {
193
195
  type: 'object',
194
196
  required: ['what_happened'],
Binary file
@@ -21,11 +21,11 @@ export function computeContinuity(argusDir, relatedIds) {
21
21
  }
22
22
  const frequency_statement = settled === 0
23
23
  ? 'No settled history among the decisions you tagged as similar.'
24
- : `Of ${settled} similar decision(s) you settled: ${held} held, ${settled - held} did not.`;
24
+ : `Of ${settled} similar ${settled === 1 ? 'decision' : 'decisions'} you settled: ${held} held, ${settled - held} did not.`;
25
25
  return {
26
26
  related,
27
27
  frequency_statement,
28
28
  sample_size: settled,
29
- sample_size_caveat: settled > 0 && settled < 10 ? 'Sample is small read this as history, not a pattern about you.' : undefined,
29
+ sample_size_caveat: settled > 0 && settled < 10 ? 'Sample is small. Read this as history, not a pattern about you.' : undefined,
30
30
  };
31
31
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Layer-2 deterministic signal detection (blueprint continuation of the plugin's
3
+ * `decision-signals.js`, whose comment reads "Layer 2 widens precision — later").
4
+ *
5
+ * WHY THIS EXISTS (the trust answer, 2026-07-20). In an MCP the *noticing* of a
6
+ * passing prediction / a surfacing outcome / a load-bearing assumption is, by the
7
+ * protocol, the host model's act — the server never sees the conversation, and
8
+ * Claude does not yet support server-driven `sampling` (feature req #1785). So
9
+ * "the model will notice" is a goodwill dependency, not a guarantee. This module
10
+ * turns the part that CAN be code into code: a pure, dependency-free, unit-tested
11
+ * RULE scan of a turn's text. It does not replace the model — it RAISES RECALL so
12
+ * the model is handed a specific candidate in the user's own words instead of
13
+ * being trusted to spot it from scratch.
14
+ *
15
+ * LLM-glue invariant: because this is rules (not a model), a test can pin it —
16
+ * "plausible" cannot masquerade as "correct". Every detected span is the user's
17
+ * OWN words (a verbatim slice), never a fabricated stand-in.
18
+ *
19
+ * SPINE (mirror clause — max detect, min fire): this detector leans HIGH-RECALL.
20
+ * Restraint lives in the FIRING gate around it (the hook's once-per-session +
21
+ * cooldown, and the model's freedom to stay silent on a flat case), NOT here.
22
+ * Detection ≠ interruption: a caller may detect and still choose to say nothing.
23
+ */
24
+ /* ── Cue groups (named so tests + the plugin mirror can pin them) ──────────── */
25
+ /** Future / modal markers: the claim points at a not-yet-settled state. */
26
+ const FUTURE = [
27
+ /\bwill\b/i, /\bwon'?t\b/i, /\bgoing to\b/i, /\bgonna\b/i, /\bshall\b/i,
28
+ /\bexpect(s|ed|ing)?\b/i, /\bshould\b/i, /\blikely\b/i, /\bplan(ning)? to\b/i,
29
+ /\bby (mon|tue|wed|thu|fri|sat|sun)\w*/i,
30
+ /\bby (jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*/i,
31
+ /\bby \d/i, /\bwithin \d+\s*(day|week|month|quarter)/i,
32
+ /\bnext (week|month|quarter|year|sprint)\b/i,
33
+ // Korean futures / commitments / thresholds-as-future
34
+ /(할|될|갈|낼|올|줄)\s*(것|거|게|걸)/, /하겠|되겠|시키겠/, /(ㄹ|를|을)\s*거(다|예요|야|임)/,
35
+ /예상|전망|목표|계획|할 예정|될 예정/, /까지(는|\b|\s)/, /안에|이내(에)?/,
36
+ /다음\s*(주|달|분기|해|스프린트)/, /(유지|달성|돌파|출시|완료)(할|될|하겠|되겠|한다|된다)/,
37
+ // Korean prospective endings — "-ㄹ 거예요 / -ㄹ 겁니다 / -ㄹ 것으로": the ㄹ is
38
+ // fused into a composed syllable (빨라질) so match the trailing marker itself.
39
+ /(거예요|거에요|거야|겁니다|거고|건데|건가|것으로|것입니다|것이다|것\s*같)/, /(ㄹ|을|를)\s*것\b/,
40
+ ];
41
+ /** Measurable checkpoints: a number, percent, money, date, threshold, comparison. */
42
+ const MEASURABLE = [
43
+ /\d/, /%|percent|퍼센트|프로/i, /\$|원\b|달러|억|만원|USD|KRW/i,
44
+ /\b(faster|slower|lower|higher|cheaper|under|over|below|above|less than|more than|at least|no more than)\b/i,
45
+ /이하|이상|미만|초과|아래|위(로)?|밑(으로)?|이내|보다\s*(빠|느|싸|비|많|적|높|낮)/,
46
+ /\b(mon|tue|wed|thu|fri|sat|sun)\w*/i,
47
+ /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*/i, /\d{4}-\d{2}-\d{2}/,
48
+ ];
49
+ /** Completion verbs: a discrete event reality can confirm happened or not. */
50
+ const COMPLETION = [
51
+ /\b(ship|launch|release|deliver|deploy|close|hire|sign|land|finish|complete|onboard|migrate|cut over)\b/i,
52
+ /출시|배포|출고|런칭|납품|마감|채용|계약|체결|완료|오픈|이전|전환|성사|입사|합류/,
53
+ ];
54
+ /** Past-tense resolution markers: reality has spoken about something. */
55
+ const RESOLVED = [
56
+ /\bturn(s|ed)? out\b/i, /\bended up\b/i, /\bcame in at\b/i, /\bended at\b/i,
57
+ /\bwe (shipped|launched|missed|hit|closed|hired|signed|landed|deployed)\b/i,
58
+ /\bit (went|held|worked|failed|slipped|held up)\b/i,
59
+ /\bdidn'?t (happen|work|ship|hold|land)\b/i, /\b(hit|missed|beat|met) (the|our) (target|number|deadline|goal)\b/i,
60
+ /됐(어|다|고|는데|네|음)|됐다|성사(됐|했)|끝났|출시했|배포했|이전했|전환했/,
61
+ /안\s*(됐|나|됐어|됐다)|못\s*(했|했다|해서|이룬)|실패(했|함)|무산(됐|됨)/,
62
+ /결국|실제로(는)?|막상|나왔(다|어|고)|나온|드러났|밝혀졌|판명/,
63
+ ];
64
+ /** Causal / conditional markers: the reasoning rests on a stated premise. */
65
+ const CONDITIONAL = [
66
+ /\bbecause\b/i, /\bsince\b/i, /\bassuming\b/i, /\bas long as\b/i, /\bdepends on\b/i,
67
+ /\bonly if\b/i, /\bprovided that\b/i, /\bbanking on\b/i, /\bhinges? on\b/i,
68
+ /\bcontingent on\b/i, /\bthe (key|whole thing) (is|hinges|rests|depends)\b/i,
69
+ /니까|때문에|덕분에|탓에/, /(라|다)면\b|(으|)ㄴ다면|는다면/, /는\s*한(에서)?|한(에서만)?/,
70
+ /(에|에게)\s*달렸|달려\s*있|전제로|가정하(면|고)|관건은|핵심은|믿고\s*있/,
71
+ ];
72
+ /* ── Helpers ──────────────────────────────────────────────────────────────── */
73
+ const anyMatch = (groups, s) => groups.some((re) => re.test(s));
74
+ const whichMatch = (name, groups, s) => groups.some((re) => re.test(s)) ? name : null;
75
+ /** Split into sentence-ish spans on terminators + newlines; keep the user's text. */
76
+ function clauses(text) {
77
+ return text
78
+ .split(/(?<=[.!?。!?])\s+|\n+/)
79
+ .map((c) => c.trim())
80
+ .filter((c) => c.length >= 6 && c.length <= 400);
81
+ }
82
+ /** Cheap token overlap for outcome↔open-predicate. Prefix-aware so Korean josa
83
+ * ("이전은" vs "이전", "다운타임은" vs "다운타임") still counts as the same word —
84
+ * naive exact-token overlap silently under-fires on every particle-attached noun. */
85
+ function overlaps(a, b) {
86
+ const tok = (s) => s.toLowerCase()
87
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ')
88
+ .split(/\s+/)
89
+ .filter((t) => t.length >= 2);
90
+ const A = tok(a);
91
+ const B = tok(b);
92
+ if (A.length === 0 || B.length === 0)
93
+ return false;
94
+ let hit = 0;
95
+ for (const x of A) {
96
+ for (const y of B) {
97
+ if (x === y || x.startsWith(y) || y.startsWith(x)) {
98
+ hit++;
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ return hit >= 2; // two salient shared/overlapping tokens — the floor against chatter
104
+ }
105
+ /* ── The detector ─────────────────────────────────────────────────────────── */
106
+ /**
107
+ * Scan a turn's text for the three senses. Pure and deterministic. Returns each
108
+ * detected signal with the user's own span and the cues that fired. High recall
109
+ * by design; the caller owns the firing gate (see module header).
110
+ */
111
+ export function detectSignals(text, opts = {}) {
112
+ if (typeof text !== 'string' || text.trim().length < 6)
113
+ return [];
114
+ const openPredicates = (opts.openPredicates ?? []).filter((p) => typeof p === 'string' && p.trim());
115
+ const max = typeof opts.max === 'number' && opts.max > 0 ? opts.max : 4;
116
+ const out = [];
117
+ const seen = new Set();
118
+ const push = (kind, span, cues) => {
119
+ const key = `${kind}:${span}`;
120
+ if (seen.has(key))
121
+ return;
122
+ seen.add(key);
123
+ out.push({ kind, span, cues });
124
+ };
125
+ for (const c of clauses(text)) {
126
+ // 1) PREDICTION — a claim reality can later check: FUTURE ∧ (MEASURABLE ∨ COMPLETION).
127
+ const future = whichMatch('future', FUTURE, c);
128
+ const measurable = whichMatch('measurable', MEASURABLE, c);
129
+ const completion = whichMatch('completion', COMPLETION, c);
130
+ if (future && (measurable || completion)) {
131
+ push('prediction', c, [future, measurable, completion].filter((x) => !!x));
132
+ }
133
+ // 3) ASSUMPTION — reasoning that rests on a stated premise. Conservative: the
134
+ // clause must ALSO carry a checkable/consequential cue, so a throwaway
135
+ // "because I'm tired" does not register as a load-bearing assumption.
136
+ const conditional = whichMatch('conditional', CONDITIONAL, c);
137
+ if (conditional && (measurable || completion || future)) {
138
+ push('assumption', c, [conditional, measurable, completion, future].filter((x) => !!x));
139
+ }
140
+ // 2) OUTCOME — reality answering a KNOWN open prediction. Needs a resolution
141
+ // cue AND overlap with an open predicate (the floor against past-tense chatter).
142
+ const resolved = whichMatch('resolved', RESOLVED, c);
143
+ if (resolved && openPredicates.some((p) => overlaps(c, p))) {
144
+ push('outcome', c, [resolved, 'matches-open-prediction']);
145
+ }
146
+ }
147
+ return out.slice(0, max);
148
+ }
149
+ /** The named cue groups, exported so the plugin mirror + a drift guard can pin
150
+ * that the two surfaces detect the SAME things (single-source-of-truth rule). */
151
+ export const CUE_GROUPS = { FUTURE, MEASURABLE, COMPLETION, RESOLVED, CONDITIONAL };
@@ -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
- const line = ambientLine(dir, due, state); // state lends the ledger's own voice
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;
@@ -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
- const res = await _elicit(message, requestedSchema);
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
- 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);
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
- const st = fs.statSync(lockPath);
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
- // 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).
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.unlinkSync(lockPath);
120
+ const cur = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
121
+ if (cur.nonce === nonce)
122
+ fs.unlinkSync(lockPath);
51
123
  }
52
- catch { /* already gone */ }
124
+ catch { /* gone, or not ours */ }
53
125
  }
54
126
  }
55
127
  }
@@ -112,7 +184,17 @@ export async function appendLedger(argusDir, events, now, hints) {
112
184
  try {
113
185
  fs.fsyncSync(fd);
114
186
  }
115
- catch { /* fsync unsupported on this fs — the write still landed */ }
187
+ catch (e) {
188
+ // fsync legitimately fails as "unsupported" on some filesystems/handles
189
+ // (EINVAL/ENOTSUP/EOPNOTSUPP — tmpfs, pipes, certain network mounts):
190
+ // there the write still landed, so swallow. But EIO / ENOSPC / EBADF are
191
+ // REAL flush failures — the settlement is NOT durable. Rethrow so the
192
+ // tool returns isError instead of a false "saved!" (fail loud, never
193
+ // report a durability we don't have).
194
+ const code = e?.code;
195
+ if (code !== 'EINVAL' && code !== 'ENOTSUP' && code !== 'EOPNOTSUPP')
196
+ throw e;
197
+ }
116
198
  resolve();
117
199
  }
118
200
  catch (e) {
@@ -24,6 +24,9 @@ export function replayLedger(argusDir, today) {
24
24
  let dropped = 0;
25
25
  let skippedUnknown = 0;
26
26
  let oldestTs;
27
+ // Ids that ever saw a seal EVENT — survives settle/dismiss so total_sealed
28
+ // means "ever sealed", derived per-id (not per-line) below.
29
+ const everSealed = new Set();
27
30
  const watch = { anchors: new Map(), captures: [] };
28
31
  let raw;
29
32
  try {
@@ -32,7 +35,13 @@ export function replayLedger(argusDir, today) {
32
35
  catch {
33
36
  return { today, overdue: [], ids, sealedPredicates, contracts: map, stats, watch, integrity: { dropped_lines: 0, skipped_unknown: 0 } };
34
37
  }
35
- for (const line of raw.split('\n')) {
38
+ for (const rawLine of raw.split('\n')) {
39
+ // Strip a per-LINE BOM: deBom only removes one at byte 0, but a U+FEFF can
40
+ // ride the first line of a concatenated second file, or be prepended per
41
+ // append by a Windows PowerShell co-writer (>>/Out-File) sharing the ledger.
42
+ // JSON.parse('{…}') throws, so without this a real settle line would
43
+ // be dropped and its outcome vanish from calibration.
44
+ const line = rawLine.charCodeAt(0) === 0xfeff ? rawLine.slice(1) : rawLine;
36
45
  if (!line.trim())
37
46
  continue;
38
47
  let ev;
@@ -80,7 +89,7 @@ export function replayLedger(argusDir, today) {
80
89
  if (typeof ev['basis'] === 'string')
81
90
  cur.basis = ev['basis'];
82
91
  cur.status = 'sealed';
83
- stats.total_sealed++;
92
+ everSealed.add(id);
84
93
  break;
85
94
  }
86
95
  case 'amend':
@@ -115,9 +124,16 @@ export function replayLedger(argusDir, today) {
115
124
  map.set(id, cur);
116
125
  } // B1: self-create
117
126
  cur.status = 'settled';
118
- stats.total_settled++;
119
127
  const outcome = ev['outcome'];
120
128
  cur.outcome = outcome;
129
+ // Retain what-reality-did from the settle line. This binary writes it as
130
+ // `decision`; the plugin CLI writes `what_happened` — read both (same
131
+ // dual-vocab tolerance as ts/at below) so a lost receipt is honestly
132
+ // reconstructable from the fold, whatever surface settled it.
133
+ const wh = typeof ev['decision'] === 'string' ? ev['decision']
134
+ : typeof ev['what_happened'] === 'string' ? ev['what_happened'] : undefined;
135
+ if (wh)
136
+ cur.what_happened = wh;
121
137
  // Timestamp field is two-vocab across surfaces: this binary stamps `ts`,
122
138
  // the plugin CLI stamps `at` — read both so a plugin-settled decision
123
139
  // still gets its settled date on the receipt (O2 방1 finding ⑤).
@@ -126,19 +142,9 @@ export function replayLedger(argusDir, today) {
126
142
  cur.settled_on = settledTs.slice(0, 10);
127
143
  if (typeof ev['broken_premise_id'] === 'string')
128
144
  cur.broken_premise_id = ev['broken_premise_id'];
129
- // `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
- // so the frequency buckets stay complete (total_settled == sum of buckets).
132
- if (outcome === 'held' || outcome === 'happened')
133
- stats.held++;
134
- else if (outcome === 'avoided')
135
- stats.avoided++;
136
- else if (outcome === 'partial')
137
- stats.partial++;
138
- else if (outcome === 'still_pending')
139
- stats.still_pending++;
140
- else if (outcome === 'missed')
141
- stats.missed++;
145
+ // Buckets are NOT counted here stats derive from the FOLDED STATE
146
+ // after the loop (see below), so a duplicated or reordered settle line
147
+ // in an externally-edited ledger cannot double-count a calibration.
142
148
  break;
143
149
  }
144
150
  case 'defer': {
@@ -342,16 +348,50 @@ export function replayLedger(argusDir, today) {
342
348
  break;
343
349
  }
344
350
  }
351
+ // Stats are DERIVED from the folded state, never counted per event line
352
+ // (state-derivation, 1.4.6 backlog): a hand-edited/merged ledger with a
353
+ // duplicated seal or settle line folds to the same state, so it must fold to
354
+ // the same calibration. Invariant preserved: total_settled == sum of the four
355
+ // buckets; a settled contract with an unknown/legacy outcome ('still_pending',
356
+ // corrupt) is settled but uncounted, exactly as before. `happened` stays the
357
+ // plugin CLI's legacy alias of `held` (old ledgers keep their bytes).
358
+ stats.total_sealed = everSealed.size;
359
+ for (const c of map.values()) {
360
+ if (c.status !== 'settled')
361
+ continue;
362
+ const o = c.outcome === 'happened' ? 'held' : c.outcome;
363
+ if (o === 'held') {
364
+ stats.held++;
365
+ stats.total_settled++;
366
+ }
367
+ else if (o === 'avoided') {
368
+ stats.avoided++;
369
+ stats.total_settled++;
370
+ }
371
+ else if (o === 'partial') {
372
+ stats.partial++;
373
+ stats.total_settled++;
374
+ }
375
+ else if (o === 'missed') {
376
+ stats.missed++;
377
+ stats.total_settled++;
378
+ }
379
+ }
345
380
  const overdue = [];
381
+ const undatedSeals = [];
346
382
  for (const [id, item] of map.entries()) {
347
383
  if (item.status !== 'sealed')
348
384
  continue;
349
385
  const date = asDate(item.check_by);
350
- if (date && date <= today)
386
+ if (!date) {
387
+ undatedSeals.push(id);
388
+ continue;
389
+ } // sealed but no valid check-by → can never come due; surface it, don't lose it
390
+ if (date <= today)
351
391
  overdue.push({ id, date, text: item.text || '' });
352
392
  }
353
393
  overdue.sort((a, b) => (a.date < b.date ? -1 : 1));
354
- return { today, overdue, ids, sealedPredicates, contracts: map, stats, watch, oldest_ts: oldestTs, integrity: { dropped_lines: dropped, skipped_unknown: skippedUnknown } };
394
+ return { today, overdue, ids, sealedPredicates, contracts: map, stats, watch, oldest_ts: oldestTs, integrity: { dropped_lines: dropped, skipped_unknown: skippedUnknown, ...(undatedSeals.length ? { undated_seals: undatedSeals } : {}) } };
355
395
  }
356
396
  /**
357
397
  * Bearing-file contract seeds that are due but not yet represented in the
@@ -39,8 +39,8 @@ export function appendLocaleMismatchNote(argusDir, args, result) {
39
39
  return result; // never decorate errors
40
40
  fs.writeFileSync(marker, new Date().toISOString());
41
41
  const note = spoken === 'ko'
42
- ? '\n(설정 언어가 English로 고정되어 있어요. 한국어로 바꾸려면 argus_settings에 locale="ko" 한 번이면 돼요 이 안내는 다시 나오지 않아요.)'
43
- : '\n(Your saved language is Korean. To switch, call argus_settings with locale="en" once this note will not appear again.)';
42
+ ? '\n(설정 언어가 English로 고정되어 있어요. 한국어로 바꾸려면 argus_settings에 locale="ko" 한 번이면 돼요. 이 안내는 다시 나오지 않아요.)'
43
+ : '\n(Your saved language is Korean. To switch, call argus_settings with locale="en" once; this note will not appear again.)';
44
44
  const next = { ...sc, surface: `${sc['surface']}${note}` };
45
45
  result.structuredContent = next;
46
46
  result.content = [{ type: 'text', text: JSON.stringify(next, null, 2) }];