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.
- package/dist/lib/ambient-elicit.js +7 -5
- package/dist/lib/calendar.js +0 -0
- package/dist/lib/continuity.js +2 -2
- package/dist/lib/detect-signals.js +151 -0
- package/dist/lib/due-note.js +6 -1
- package/dist/lib/elicit.js +9 -1
- package/dist/lib/ledger-append.js +102 -20
- package/dist/lib/ledger-replay.js +58 -18
- package/dist/lib/locale-mismatch.js +2 -2
- package/dist/lib/localize-result.js +215 -18
- package/dist/lib/numeric-drift.js +13 -13
- package/dist/lib/receipt.js +17 -1
- package/dist/lib/render-receipt.js +111 -57
- package/dist/lib/resolve-today.js +34 -0
- package/dist/lib/review/extract-file-node.js +2 -2
- package/dist/lib/review/render.js +6 -6
- package/dist/lib/review/routing.js +6 -6
- package/dist/lib/review/schema.js +9 -1
- package/dist/lib/safe-path.js +16 -0
- package/dist/lib/spine.js +30 -15
- package/dist/lib/state-machine.js +2 -2
- package/dist/lib/surfaces.js +65 -45
- package/dist/lib/telemetry.js +3 -3
- package/dist/lib/tool-presentation.js +4 -4
- package/dist/lib/untrusted.js +3 -3
- package/dist/lib/validate-crux.js +3 -3
- package/dist/lib/validate-seal.js +26 -7
- package/dist/resources.js +6 -1
- package/dist/server.js +15 -10
- package/dist/tools/amend-dismiss.js +30 -4
- package/dist/tools/candidates.js +1 -1
- package/dist/tools/check-in.js +46 -6
- package/dist/tools/errors.js +18 -4
- package/dist/tools/init-config.js +14 -0
- package/dist/tools/open-decision.js +6 -5
- package/dist/tools/premises.js +29 -9
- package/dist/tools/public-tools.js +37 -21
- package/dist/tools/recall.js +44 -14
- package/dist/tools/recheck.js +65 -11
- package/dist/tools/review.js +6 -6
- package/dist/tools/seal.js +15 -14
- package/dist/tools/settle.js +44 -22
- package/dist/tools/tool-types.js +35 -9
- package/dist/v2/lifecycle-cli.js +23 -8
- package/dist/v2/lifecycle.js +27 -0
- 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
|
|
167
|
-
: `Argus
|
|
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
|
-
? ['
|
|
176
|
-
: ['It held', 'Avoided
|
|
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'],
|
package/dist/lib/calendar.js
CHANGED
|
Binary file
|
package/dist/lib/continuity.js
CHANGED
|
@@ -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
|
|
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
|
|
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 };
|
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
|
}
|
|
@@ -112,7 +184,17 @@ export async function appendLedger(argusDir, events, now, hints) {
|
|
|
112
184
|
try {
|
|
113
185
|
fs.fsyncSync(fd);
|
|
114
186
|
}
|
|
115
|
-
catch {
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
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
|
|
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
|
|
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) }];
|