argus-decision-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/SECURITY.md +35 -0
- package/dist/index.js +13 -0
- package/dist/lib/argus-dir.js +85 -0
- package/dist/lib/atomic-write.js +19 -0
- package/dist/lib/continuity.js +31 -0
- package/dist/lib/deBom.js +3 -0
- package/dist/lib/discipline.js +42 -0
- package/dist/lib/due-note.js +54 -0
- package/dist/lib/elicit.js +30 -0
- package/dist/lib/envelope.js +13 -0
- package/dist/lib/layout.js +32 -0
- package/dist/lib/ledger-append.js +27 -0
- package/dist/lib/ledger-replay.js +295 -0
- package/dist/lib/locale.js +20 -0
- package/dist/lib/log.js +14 -0
- package/dist/lib/numeric-drift.js +32 -0
- package/dist/lib/overfire-gate.js +39 -0
- package/dist/lib/premises.js +153 -0
- package/dist/lib/privacy.js +22 -0
- package/dist/lib/push-account.js +70 -0
- package/dist/lib/receipt.js +83 -0
- package/dist/lib/render-receipt.js +144 -0
- package/dist/lib/resolve-contract.js +17 -0
- package/dist/lib/resolve-today.js +47 -0
- package/dist/lib/review/ids.js +29 -0
- package/dist/lib/review/index.js +18 -0
- package/dist/lib/review/ingest.js +294 -0
- package/dist/lib/review/lenses.js +132 -0
- package/dist/lib/review/prompts.js +155 -0
- package/dist/lib/review/render.js +77 -0
- package/dist/lib/review/reviewability.js +83 -0
- package/dist/lib/review/routing.js +147 -0
- package/dist/lib/review/schema.js +36 -0
- package/dist/lib/safe-path.js +70 -0
- package/dist/lib/spine.js +79 -0
- package/dist/lib/state-machine.js +80 -0
- package/dist/lib/surfaces.js +106 -0
- package/dist/lib/validate-crux.js +35 -0
- package/dist/lib/validate-seal.js +48 -0
- package/dist/prompts.js +72 -0
- package/dist/resources.js +122 -0
- package/dist/server.js +107 -0
- package/dist/tools/amend-dismiss.js +90 -0
- package/dist/tools/check-in.js +158 -0
- package/dist/tools/errors.js +23 -0
- package/dist/tools/index.js +14 -0
- package/dist/tools/init-config.js +99 -0
- package/dist/tools/open-decision.js +128 -0
- package/dist/tools/premises.js +209 -0
- package/dist/tools/recall.js +157 -0
- package/dist/tools/recheck.js +147 -0
- package/dist/tools/review.js +182 -0
- package/dist/tools/seal.js +152 -0
- package/dist/tools/settle.js +126 -0
- package/dist/tools/sync.js +129 -0
- package/dist/tools/tool-types.js +32 -0
- package/package.json +64 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { resolveToolArgusDir } from '../lib/argus-dir.js';
|
|
3
|
+
import { resolveToday } from '../lib/resolve-today.js';
|
|
4
|
+
import { resolveContract } from '../lib/resolve-contract.js';
|
|
5
|
+
import { guardTransition } from '../lib/state-machine.js';
|
|
6
|
+
import { appendLedger } from '../lib/ledger-append.js';
|
|
7
|
+
import { premiseId, resolvePremiseRef, MAX_ACTIVE_PREMISES, MAX_LOAD_BEARING, } from '../lib/premises.js';
|
|
8
|
+
import { elicit } from '../lib/elicit.js';
|
|
9
|
+
import { envelope, toolError } from '../lib/envelope.js';
|
|
10
|
+
import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir, zId, zDate } from './tool-types.js';
|
|
11
|
+
import { handleToolException } from './errors.js';
|
|
12
|
+
/**
|
|
13
|
+
* argus_premises — the write surface for a decision's living premises
|
|
14
|
+
* (plan v5 §2). One tool, three ops, so hosts thread ONE name:
|
|
15
|
+
*
|
|
16
|
+
* add — record the facts / open questions the decision rests on
|
|
17
|
+
* amend — the user corrects a premise (the edit IS the signal; authorship
|
|
18
|
+
* transfers honestly, ai_original preserved)
|
|
19
|
+
* resolve — the user closes an open question IN THEIR OWN WORDS
|
|
20
|
+
* (elicitation-only: this tool never generates options, examples,
|
|
21
|
+
* or leans — a two-pole fork cannot be expressed here)
|
|
22
|
+
*
|
|
23
|
+
* Premise text is DATA, never instructions; it is echoed back in full
|
|
24
|
+
* (data.premises) so the host can always show the user what was recorded.
|
|
25
|
+
*/
|
|
26
|
+
const zPremiseInput = z.strictObject({
|
|
27
|
+
text: z.string().min(3).max(400).describe('One literal, factual sentence. No metaphor. Good: "base rate stays at 3.5% through 2026". Bad: "the ground this rests on".'),
|
|
28
|
+
kind: z.enum(['premise', 'open_question']).default('premise').describe('premise = a fact/belief the decision rests on. open_question = something the user explicitly left undecided.'),
|
|
29
|
+
external: z.boolean().default(false).describe('Can reality verify this later (a rate, a date, supply, a third party)? external + load_bearing arms re-checking.'),
|
|
30
|
+
load_bearing: z.boolean().default(false).describe(`Would the decision flip if this is wrong? Mark sparingly — max ${MAX_LOAD_BEARING} per decision.`),
|
|
31
|
+
source: z.enum(['ai', 'user']).describe('Provenance. Never forge: "user" = the user\'s own words; "ai" = model-drafted (requires ai_original).'),
|
|
32
|
+
ai_original: z.string().max(400).optional().describe('REQUIRED when source="ai": the model\'s original wording, preserved verbatim across later edits.'),
|
|
33
|
+
});
|
|
34
|
+
const inputSchema = z.strictObject({
|
|
35
|
+
argus_dir: zArgusDir,
|
|
36
|
+
id: zId.describe('The decision id from argus_open_decision.'),
|
|
37
|
+
op: z.enum(['add', 'amend', 'resolve']).describe('add = record premises; amend = correct one (user edit = signal); resolve = close an open question in the user\'s words.'),
|
|
38
|
+
premises: z.array(zPremiseInput).min(1).max(MAX_ACTIVE_PREMISES).optional().describe('op=add only.'),
|
|
39
|
+
ref: z.string().max(64).optional().describe('op=amend/resolve: which premise — an ordinal ("P1"), the premise_id, or an unambiguous id prefix. Ordinals are permanent (a retired P2 stays P2).'),
|
|
40
|
+
action: z.enum(['accept', 'refine', 'replace', 'retire']).optional().describe('op=amend only. accept = confirm as-is; refine/replace = correct the text; retire = remove from active tracking (stays on the record).'),
|
|
41
|
+
text: z.string().min(3).max(400).optional().describe('op=amend refine/replace: the corrected text — the USER\'s wording, verbatim, never re-summarized.'),
|
|
42
|
+
note: z.string().max(300).optional().describe('op=amend: optional why (never required).'),
|
|
43
|
+
external: z.boolean().optional().describe('op=amend: correct the external flag (true lets re-checking arm for a load-bearing premise).'),
|
|
44
|
+
load_bearing: z.boolean().optional().describe('op=amend: correct the load-bearing flag.'),
|
|
45
|
+
decision: z.string().min(1).max(400).optional().describe('op=resolve: the user\'s own closing call. MUST be the user\'s words — never an Argus-drafted line.'),
|
|
46
|
+
today_override: zDate.optional(),
|
|
47
|
+
});
|
|
48
|
+
export const premises = {
|
|
49
|
+
name: 'argus_premises',
|
|
50
|
+
description: 'Record and maintain the premises a decision rests on — the facts and open questions behind it. ' +
|
|
51
|
+
'op=add records them (echoed back in full so the user sees what was written); op=amend lets the user correct one (edits are the signal; provenance is preserved); ' +
|
|
52
|
+
'op=resolve closes an open question in the user\'s own words. Premises lock once the check-by date arrives. ' +
|
|
53
|
+
'A load-bearing external premise is re-checked against reality via argus_recheck once the decision is sealed.',
|
|
54
|
+
inputSchema,
|
|
55
|
+
outputSchema: ENVELOPE_OUTPUT_SCHEMA,
|
|
56
|
+
annotations: { title: 'Track decision premises', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
57
|
+
handler: async (a) => {
|
|
58
|
+
try {
|
|
59
|
+
const dir = resolveToolArgusDir(a['argus_dir']);
|
|
60
|
+
const id = String(a['id'] ?? '');
|
|
61
|
+
const today = resolveToday({ override: a['today_override'] });
|
|
62
|
+
const op = String(a['op']);
|
|
63
|
+
const now = new Date().toISOString();
|
|
64
|
+
const current = resolveContract(dir, id, today);
|
|
65
|
+
const existing = current.entry?.premises ?? [];
|
|
66
|
+
if (op === 'add')
|
|
67
|
+
return await opAdd(dir, id, today, now, current.state, existing, a);
|
|
68
|
+
if (op === 'amend')
|
|
69
|
+
return await opAmend(dir, id, now, current.state, existing, a);
|
|
70
|
+
return await opResolve(dir, id, now, current.state, existing, a);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
return handleToolException('argus_premises', e);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
// ── op=add ─────────────────────────────────────────────────────────────────
|
|
78
|
+
async function opAdd(dir, id, _today, now, state, existing, a) {
|
|
79
|
+
guardTransition(state, 'premise_add');
|
|
80
|
+
const inputs = a['premises'];
|
|
81
|
+
if (!inputs || inputs.length === 0) {
|
|
82
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'PREMISES_REQUIRED', message: 'op=add needs a non-empty `premises` array.', recovery: 'Pass 1-5 premises: {text, kind, external, load_bearing, source}.' });
|
|
83
|
+
}
|
|
84
|
+
for (const p of inputs) {
|
|
85
|
+
if (p.source === 'ai' && !(p.ai_original && p.ai_original.trim())) {
|
|
86
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'PROVENANCE_REQUIRED', message: `source="ai" requires ai_original (the model's original wording): "${p.text.slice(0, 60)}"`, recovery: 'Set ai_original to the exact model-drafted sentence, or source="user" if the user wrote it.' });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Dedup against the ledger by stable id — re-adding is idempotent, not an error.
|
|
90
|
+
const known = new Set(existing.map((p) => p.premise_id));
|
|
91
|
+
const fresh = inputs.filter((p) => !known.has(premiseId(id, p.kind, p.text)));
|
|
92
|
+
const skippedDup = inputs.length - fresh.length;
|
|
93
|
+
const activeCount = existing.filter((p) => p.status === 'active').length;
|
|
94
|
+
if (activeCount + fresh.length > MAX_ACTIVE_PREMISES) {
|
|
95
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'PREMISE_CAP', message: `A decision holds at most ${MAX_ACTIVE_PREMISES} active premises (${activeCount} already active).`, recovery: 'Retire one first (op=amend action=retire), or fold minor premises into the load-bearing ones — a decision is 5 premises, not a wiki.' });
|
|
96
|
+
}
|
|
97
|
+
const lbExisting = existing.filter((p) => p.status === 'active' && p.load_bearing).length;
|
|
98
|
+
const lbNew = fresh.filter((p) => p.load_bearing).length;
|
|
99
|
+
if (lbExisting + lbNew > MAX_LOAD_BEARING) {
|
|
100
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'PREMISE_CAP', message: `At most ${MAX_LOAD_BEARING} load-bearing premises (${lbExisting} already marked).`, recovery: 'Load-bearing means the decision flips if it is wrong — if three qualify, the sharpest one is hiding among them.' });
|
|
101
|
+
}
|
|
102
|
+
let nextOrdinal = existing.reduce((m, p) => Math.max(m, p.ordinal), 0) + 1;
|
|
103
|
+
const events = fresh.map((p) => ({
|
|
104
|
+
id, event: 'premise_add',
|
|
105
|
+
premise_id: premiseId(id, p.kind, p.text),
|
|
106
|
+
ordinal: nextOrdinal++,
|
|
107
|
+
kind: p.kind, text: p.text,
|
|
108
|
+
external: p.external, load_bearing: p.load_bearing,
|
|
109
|
+
source: p.source,
|
|
110
|
+
...(p.ai_original ? { ai_original: p.ai_original } : {}),
|
|
111
|
+
}));
|
|
112
|
+
if (events.length > 0)
|
|
113
|
+
await appendLedger(dir, events, now);
|
|
114
|
+
// Full echo — the silent-premise defense: the host always has the material to
|
|
115
|
+
// show the user exactly what was recorded (plan v5 §2).
|
|
116
|
+
const echo = events.map((e) => ({
|
|
117
|
+
ref: `P${e.ordinal}`, premise_id: e.premise_id, kind: e.kind, text: e.text,
|
|
118
|
+
external: e.external, load_bearing: e.load_bearing, source: e.source,
|
|
119
|
+
monitored: e.kind === 'premise' && e.external === true && e.load_bearing === true,
|
|
120
|
+
}));
|
|
121
|
+
const monitoredCount = echo.filter((p) => p.monitored).length;
|
|
122
|
+
return envelope({
|
|
123
|
+
ok: true, tool: 'argus_premises',
|
|
124
|
+
surface: events.length === 0
|
|
125
|
+
? 'All of those premises are already recorded (nothing new written).'
|
|
126
|
+
: `${events.length} premise(s) recorded (${echo[0].ref}${echo.length > 1 ? `–${echo[echo.length - 1].ref}` : ''}). Fix anything wrong with op=amend — your correction is part of the record.${monitoredCount > 0 ? ` ${monitoredCount} will be re-checked against reality once the decision is sealed.` : ''}`,
|
|
127
|
+
next_actions: ['argus_seal', 'argus_recall', 'leave_as_is'],
|
|
128
|
+
data: { id, premises: echo, skipped_duplicates: skippedDup, ledger_events_written: events.map(() => 'premise_add') },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// ── op=amend ───────────────────────────────────────────────────────────────
|
|
132
|
+
async function opAmend(dir, id, now, state, existing, a) {
|
|
133
|
+
guardTransition(state, 'premise_amend');
|
|
134
|
+
const ref = a['ref'];
|
|
135
|
+
const action = a['action'];
|
|
136
|
+
if (typeof ref !== 'string' || !action) {
|
|
137
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'AMEND_NEEDS_REF', message: 'op=amend needs `ref` (e.g. "P1") and `action`.', recovery: 'List premises via argus_recall view="premises", then amend by ordinal.' });
|
|
138
|
+
}
|
|
139
|
+
const premise = resolvePremiseRef(existing, ref);
|
|
140
|
+
if (premise.status !== 'active' && action !== 'accept') {
|
|
141
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'PREMISE_RETIRED', message: `P${premise.ordinal} is ${premise.status} — it stays on the record but is no longer edited.`, recovery: 'Add a fresh premise instead (op=add); the old one keeps its history.' });
|
|
142
|
+
}
|
|
143
|
+
const text = a['text'];
|
|
144
|
+
if ((action === 'refine' || action === 'replace') && !(text && text.trim())) {
|
|
145
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'AMEND_NEEDS_TEXT', message: `action=${action} needs the corrected text.`, recovery: "Pass `text` with the user's wording, verbatim — never re-summarize it." });
|
|
146
|
+
}
|
|
147
|
+
const loadBearing = a['load_bearing'];
|
|
148
|
+
if (loadBearing === true && !premise.load_bearing) {
|
|
149
|
+
const lbActive = existing.filter((p) => p.status === 'active' && p.load_bearing).length;
|
|
150
|
+
if (lbActive >= MAX_LOAD_BEARING) {
|
|
151
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'PREMISE_CAP', message: `At most ${MAX_LOAD_BEARING} load-bearing premises.`, recovery: 'Unmark another one first (op=amend load_bearing=false).' });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const ev = {
|
|
155
|
+
id, event: 'premise_amend', premise_id: premise.premise_id, action,
|
|
156
|
+
...(action === 'refine' || action === 'replace' ? { from: premise.text, to: text } : {}),
|
|
157
|
+
...(typeof a['note'] === 'string' ? { note: a['note'] } : {}),
|
|
158
|
+
...(typeof a['external'] === 'boolean' ? { external: a['external'] } : {}),
|
|
159
|
+
...(typeof loadBearing === 'boolean' ? { load_bearing: loadBearing } : {}),
|
|
160
|
+
};
|
|
161
|
+
await appendLedger(dir, [ev], now);
|
|
162
|
+
const nowExternal = typeof ev.external === 'boolean' ? ev.external : premise.external;
|
|
163
|
+
const nowLb = typeof ev.load_bearing === 'boolean' ? ev.load_bearing : premise.load_bearing;
|
|
164
|
+
const armed = action !== 'retire' && premise.kind === 'premise' && nowExternal && nowLb;
|
|
165
|
+
const surface = action === 'retire' ? `P${premise.ordinal} retired — it stays on the record with its history.` :
|
|
166
|
+
action === 'accept' ? `P${premise.ordinal} confirmed as-is.` :
|
|
167
|
+
`P${premise.ordinal} updated in your words.${premise.source === 'ai' ? ' The AI\'s original stays on the record for provenance.' : ''}${armed ? '' : ''}`;
|
|
168
|
+
return envelope({
|
|
169
|
+
ok: true, tool: 'argus_premises',
|
|
170
|
+
surface,
|
|
171
|
+
next_actions: ['argus_recall', 'leave_as_is'],
|
|
172
|
+
data: {
|
|
173
|
+
id, ref: `P${premise.ordinal}`, premise_id: premise.premise_id, action,
|
|
174
|
+
...(text ? { text } : {}), monitored_after: armed,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// ── op=resolve (elicitation-only — no options, no examples, no leans) ──────
|
|
179
|
+
async function opResolve(dir, id, now, state, existing, a) {
|
|
180
|
+
guardTransition(state, 'premise_resolve');
|
|
181
|
+
const ref = a['ref'];
|
|
182
|
+
if (typeof ref !== 'string') {
|
|
183
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'RESOLVE_NEEDS_REF', message: 'op=resolve needs `ref`.', recovery: 'List open questions via argus_recall view="premises", then resolve by ordinal.' });
|
|
184
|
+
}
|
|
185
|
+
const premise = resolvePremiseRef(existing, ref);
|
|
186
|
+
if (premise.kind !== 'open_question') {
|
|
187
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'NOT_AN_OPEN_QUESTION', message: `P${premise.ordinal} is a premise, not an open question.`, recovery: 'Premises are re-checked against reality (argus_recheck), not resolved. Only an open_question takes the user\'s closing call.' });
|
|
188
|
+
}
|
|
189
|
+
if (premise.status !== 'active') {
|
|
190
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'PREMISE_RETIRED', message: `P${premise.ordinal} is already ${premise.status}.`, recovery: 'Read it via argus_recall view="premises".' });
|
|
191
|
+
}
|
|
192
|
+
let decision = typeof a['decision'] === 'string' ? a['decision'].trim() : '';
|
|
193
|
+
if (!decision) {
|
|
194
|
+
// MCP-native elicitation: ask the USER directly. The question replays their
|
|
195
|
+
// own open question verbatim and takes free text — no options, no leans.
|
|
196
|
+
const got = await elicit(`Your open question on this decision: "${premise.text}". What is your call now, in your own words? (You can also leave it open.)`, { type: 'object', properties: { decision: { type: 'string', maxLength: 400, description: 'Your call, your words.' } }, required: ['decision'] });
|
|
197
|
+
decision = typeof got?.['decision'] === 'string' ? got['decision'].trim() : '';
|
|
198
|
+
}
|
|
199
|
+
if (!decision) {
|
|
200
|
+
return toolError({ ok: false, tool: 'argus_premises', error_code: 'RESOLVE_NEEDS_DECISION', message: 'An open question closes only in the user\'s own words.', recovery: 'Ask the user for their call and pass it as `decision` — never draft it for them. "Still undecided" is a valid answer: then leave it open (no call needed).' });
|
|
201
|
+
}
|
|
202
|
+
await appendLedger(dir, [{ id, event: 'premise_resolve', premise_id: premise.premise_id, decision }], now);
|
|
203
|
+
return envelope({
|
|
204
|
+
ok: true, tool: 'argus_premises',
|
|
205
|
+
surface: `Open question P${premise.ordinal} closed in your words: "${decision}".`,
|
|
206
|
+
next_actions: ['argus_recall', 'leave_as_is'],
|
|
207
|
+
data: { id, ref: `P${premise.ordinal}`, premise_id: premise.premise_id, decision, decision_owner: 'user' },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { resolveToolArgusDir } from '../lib/argus-dir.js';
|
|
2
|
+
import { resolveToday } from '../lib/resolve-today.js';
|
|
3
|
+
import { replayLedger } from '../lib/ledger-replay.js';
|
|
4
|
+
import { readReceipt } from '../lib/receipt.js';
|
|
5
|
+
import { renderReceipt, renderWake } from '../lib/render-receipt.js';
|
|
6
|
+
import { surfaceLocale } from '../lib/surfaces.js';
|
|
7
|
+
import { isMonitored, isDueForRecheck, receiptPremisesInfo } from '../lib/premises.js';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { envelope, toolError } from '../lib/envelope.js';
|
|
10
|
+
import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir, zId, zDate } from './tool-types.js';
|
|
11
|
+
import { handleToolException } from './errors.js';
|
|
12
|
+
/** check_by ascending; rows without a date sink to the end. */
|
|
13
|
+
const byCheckBy = (a, b) => (a.check_by || '9999-99-99') < (b.check_by || '9999-99-99') ? -1 : 1;
|
|
14
|
+
/** wake_text (P1-E7 = 12 §3.5) — rendered only when a wake exists (at least
|
|
15
|
+
* one sealed or settled contract); candidates/dismissed never fill the frame. */
|
|
16
|
+
function wakeText(ledger, today, dir) {
|
|
17
|
+
const rows = [...ledger.contracts.values()];
|
|
18
|
+
if (!rows.some((c) => c.status === 'sealed' || c.status === 'settled'))
|
|
19
|
+
return undefined;
|
|
20
|
+
return renderWake(rows, ledger.stats, today, surfaceLocale(dir), ledger.oldest_ts?.slice(0, 10));
|
|
21
|
+
}
|
|
22
|
+
const inputSchema = z.strictObject({
|
|
23
|
+
argus_dir: zArgusDir,
|
|
24
|
+
view: z.enum(['bearing', 'contracts', 'receipt', 'track_record', 'premises']),
|
|
25
|
+
id: zId.describe('Required when view = "receipt" or "premises".').optional(),
|
|
26
|
+
today_override: zDate.optional(),
|
|
27
|
+
});
|
|
28
|
+
export const recall = {
|
|
29
|
+
name: 'argus_recall',
|
|
30
|
+
description: "Read your own decision history: a single receipt, the open contracts, or your track record. Read-only. Track record reports sample-size-scaled frequency only — never a tier, score, or verdict about who you are.",
|
|
31
|
+
inputSchema,
|
|
32
|
+
outputSchema: ENVELOPE_OUTPUT_SCHEMA,
|
|
33
|
+
annotations: { title: 'Recall your history', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
34
|
+
handler: async (a) => {
|
|
35
|
+
try {
|
|
36
|
+
const dir = resolveToolArgusDir(a['argus_dir']);
|
|
37
|
+
const today = resolveToday({ override: a['today_override'] });
|
|
38
|
+
const view = String(a['view']);
|
|
39
|
+
if (view === 'receipt') {
|
|
40
|
+
const id = a['id'];
|
|
41
|
+
if (typeof id !== 'string' || !id) {
|
|
42
|
+
return toolError({ ok: false, tool: 'argus_recall', error_code: 'RECEIPT_NEEDS_ID', message: 'view "receipt" requires an id.', recovery: 'Pass the decision id.' });
|
|
43
|
+
}
|
|
44
|
+
const r = readReceipt(dir, id);
|
|
45
|
+
if (!r) {
|
|
46
|
+
return toolError({ ok: false, tool: 'argus_recall', error_code: 'RECEIPT_NOT_FOUND', message: `No receipt for "${id}".`, recovery: 'Check the id, or seal the decision first.' });
|
|
47
|
+
}
|
|
48
|
+
// The premise set is canonical — the receipt renders its summary from the fold (plan v5 §3.3).
|
|
49
|
+
const pInfo = receiptPremisesInfo(replayLedger(dir, today).contracts.get(id));
|
|
50
|
+
return envelope({
|
|
51
|
+
ok: true, tool: 'argus_recall', surface: 'Receipt recalled.',
|
|
52
|
+
next_actions: ['stop'], data: { receipt: r, receipt_text: renderReceipt(r, pInfo) },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const ledger = replayLedger(dir, today);
|
|
56
|
+
if (view === 'premises') {
|
|
57
|
+
const id = a['id'];
|
|
58
|
+
if (typeof id !== 'string' || !id) {
|
|
59
|
+
return toolError({ ok: false, tool: 'argus_recall', error_code: 'PREMISES_NEEDS_ID', message: 'view "premises" requires an id.', recovery: 'Pass the decision id.' });
|
|
60
|
+
}
|
|
61
|
+
const entry = ledger.contracts.get(id);
|
|
62
|
+
const list = entry?.premises ?? [];
|
|
63
|
+
if (list.length === 0) {
|
|
64
|
+
return envelope({
|
|
65
|
+
ok: true, tool: 'argus_recall',
|
|
66
|
+
surface: 'No premises tracked on this decision. Record the facts it rests on with argus_premises (op=add).',
|
|
67
|
+
next_actions: ['leave_as_is'],
|
|
68
|
+
data: { id, premises: [], today },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const rows = list.map((p) => {
|
|
72
|
+
const last = p.last_recheck?.ts ? p.last_recheck.ts.slice(0, 10) : null;
|
|
73
|
+
const daysStale = last ? Math.round((Date.parse(today) - Date.parse(last)) / 86400000) : null;
|
|
74
|
+
return {
|
|
75
|
+
ref: `P${p.ordinal}`, premise_id: p.premise_id, kind: p.kind, text: p.text,
|
|
76
|
+
status: p.status, external: p.external, load_bearing: p.load_bearing,
|
|
77
|
+
monitored: isMonitored(p),
|
|
78
|
+
// provenance — the declared reader of ai_original (plan v5 §6.4)
|
|
79
|
+
source: p.source,
|
|
80
|
+
...(p.ai_original && p.ai_original !== p.text ? { ai_original: p.ai_original, edited_by_user: true } : {}),
|
|
81
|
+
edits: p.amend_history.length,
|
|
82
|
+
// staleness, honestly (plan v5 §5-3): never pretend liveness
|
|
83
|
+
last_checked: last,
|
|
84
|
+
staleness: last === null ? 'never re-checked' : `${daysStale}d since last re-check`,
|
|
85
|
+
...(p.last_recheck ? { last_finding: p.last_recheck.finding, last_source: p.last_recheck.source, last_drifted: p.last_recheck.drifted } : {}),
|
|
86
|
+
due_for_recheck: isDueForRecheck(p, today),
|
|
87
|
+
...(p.resolved_decision ? { resolved_decision: p.resolved_decision } : {}),
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
const monitored = rows.filter((r) => r.monitored).length;
|
|
91
|
+
const due = rows.filter((r) => r.due_for_recheck).length;
|
|
92
|
+
return envelope({
|
|
93
|
+
ok: true, tool: 'argus_recall',
|
|
94
|
+
surface: `${rows.length} premise(s) on this decision — ${monitored} monitored, ${due} due for a reality re-check${due > 0 ? ' (argus_recheck)' : ''}.`,
|
|
95
|
+
next_actions: due > 0 ? ['argus_check_in', 'leave_as_is'] : ['leave_as_is'],
|
|
96
|
+
data: { id, premises: rows, today },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (view === 'bearing') {
|
|
100
|
+
// JSON side sorted too (P1-E7 / 12 §3.6): check_by ascending, so a
|
|
101
|
+
// past-due contract can never hide between far-future ones.
|
|
102
|
+
const open = [...ledger.contracts.values()]
|
|
103
|
+
.filter((c) => c.status === 'sealed')
|
|
104
|
+
.map((c) => ({ id: c.id, predicate: c.predicate, check_by: c.check_by }))
|
|
105
|
+
.sort(byCheckBy);
|
|
106
|
+
const surface = ledger.ids.size === 0
|
|
107
|
+
? 'Argus does not answer. It records a prediction + a check-by date, and meets reality on that date. Open your first decision with argus_open_decision.'
|
|
108
|
+
: `${open.length} open bearing(s).`;
|
|
109
|
+
const wake = wakeText(ledger, today, dir);
|
|
110
|
+
return envelope({ ok: true, tool: 'argus_recall', surface, next_actions: open.length ? ['argus_check_in'] : ['argus_open_decision'], data: { open, today, ...(wake ? { wake_text: wake } : {}) } });
|
|
111
|
+
}
|
|
112
|
+
if (view === 'contracts') {
|
|
113
|
+
const all = [...ledger.contracts.values()]
|
|
114
|
+
.map((c) => ({ id: c.id, status: c.status, predicate: c.predicate, check_by: c.check_by, outcome: c.outcome, dismiss_reason: c.dismiss_reason }))
|
|
115
|
+
.sort(byCheckBy);
|
|
116
|
+
// 60-row cap (12 §3.6) — the JSON stays a summary, not a wall.
|
|
117
|
+
const shown = all.slice(0, 60);
|
|
118
|
+
const wake = wakeText(ledger, today, dir);
|
|
119
|
+
return envelope({
|
|
120
|
+
ok: true, tool: 'argus_recall', surface: `${all.length} decision(s) on record.`, next_actions: ['stop'],
|
|
121
|
+
data: { contracts: shown, ...(all.length > shown.length ? { truncated: all.length - shown.length } : {}), today, ...(wake ? { wake_text: wake } : {}) },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// track_record — frequency only, sample-size caveated. No tier, no score (spine rule 2).
|
|
125
|
+
const s = ledger.stats;
|
|
126
|
+
const n = s.total_settled;
|
|
127
|
+
const freq = n === 0
|
|
128
|
+
? 'No settled decisions yet — nothing to summarize.'
|
|
129
|
+
: `Of ${n} settled: ${s.held} held, ${s.avoided} avoided, ${s.partial} partial.`;
|
|
130
|
+
// Premise-level attribution (plan v5 P2) — where accumulation compounds:
|
|
131
|
+
// COUNTS of settles where the user themselves named a broken premise.
|
|
132
|
+
// A frequency statement, never a diagnosis of the person.
|
|
133
|
+
const settled = [...ledger.contracts.values()].filter((c) => c.status === 'settled');
|
|
134
|
+
const missedOrPartial = settled.filter((c) => c.outcome === 'avoided' || c.outcome === 'partial');
|
|
135
|
+
const withBroken = missedOrPartial.filter((c) => c.broken_premise_id);
|
|
136
|
+
const premiseAttribution = withBroken.length > 0
|
|
137
|
+
? `Of ${missedOrPartial.length} settle(s) that did not hold, you attributed ${withBroken.length} to a named broken premise.`
|
|
138
|
+
: undefined;
|
|
139
|
+
return envelope({
|
|
140
|
+
ok: true, tool: 'argus_recall',
|
|
141
|
+
surface: premiseAttribution ? `${freq} ${premiseAttribution}` : freq,
|
|
142
|
+
next_actions: ['stop'],
|
|
143
|
+
data: {
|
|
144
|
+
judgment_tier: null, judgment_score: null, // drift-guard asserts these stay null
|
|
145
|
+
frequency_statement: freq,
|
|
146
|
+
...(premiseAttribution ? { premise_attribution: premiseAttribution, premise_attribution_counts: { not_held: missedOrPartial.length, with_named_broken_premise: withBroken.length } } : {}),
|
|
147
|
+
sample_size: n,
|
|
148
|
+
sample_size_caveat: n < 10 ? 'Sample is small — read this as history, not a pattern about you.' : undefined,
|
|
149
|
+
stats: s,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
return handleToolException('argus_recall', e);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { resolveToolArgusDir } from '../lib/argus-dir.js';
|
|
3
|
+
import { resolveToday } from '../lib/resolve-today.js';
|
|
4
|
+
import { replayLedger } from '../lib/ledger-replay.js';
|
|
5
|
+
import { deriveState, guardTransition } from '../lib/state-machine.js';
|
|
6
|
+
import { appendLedger } from '../lib/ledger-append.js';
|
|
7
|
+
import { resolvePremiseRef, matchingMonitoredPremises, normalizePremiseText } from '../lib/premises.js';
|
|
8
|
+
import { numericDrift } from '../lib/numeric-drift.js';
|
|
9
|
+
import { envelope, toolError } from '../lib/envelope.js';
|
|
10
|
+
import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir, zId, zDate } from './tool-types.js';
|
|
11
|
+
import { handleToolException } from './errors.js';
|
|
12
|
+
/**
|
|
13
|
+
* argus_recheck — re-check one premise against reality (plan v5 §2, §7.1).
|
|
14
|
+
*
|
|
15
|
+
* The HOST does the research (web search, a document, the user's report); this
|
|
16
|
+
* tool does the mechanical part only:
|
|
17
|
+
* - numeric premises: compares EXPLICIT numbers (never parses prose — the
|
|
18
|
+
* first-number-in-string bug class reads "2026년 기준금리 3.5%" as 2026)
|
|
19
|
+
* - text premises: records the host's provenance-armed factual assertion
|
|
20
|
+
* (`changed`) — a paraphrase is not a changed fact, so string comparison
|
|
21
|
+
* over-fires and is not used
|
|
22
|
+
* - provenance is REQUIRED on every recheck: a half-remembered value must
|
|
23
|
+
* never enter the reality record unlabeled
|
|
24
|
+
*
|
|
25
|
+
* When the fact drifted, the surface returns the handle — never a directive.
|
|
26
|
+
*/
|
|
27
|
+
const inputSchema = z.strictObject({
|
|
28
|
+
argus_dir: zArgusDir,
|
|
29
|
+
id: zId.describe('The decision id.'),
|
|
30
|
+
ref: z.string().max(64).describe('Which premise — ordinal ("P1"), premise_id, or unambiguous prefix.'),
|
|
31
|
+
finding: z.string().min(3).max(400).describe('The CURRENT state of the fact, one literal comparable sentence. e.g. "base rate 3.75% after a 25bp hike".'),
|
|
32
|
+
numeric_value: z.number().optional().describe('The fact\'s current number, named EXPLICITLY (e.g. 3.75). Never extracted from prose by regex. When present, drift is decided mechanically (>=10% move or sign flip).'),
|
|
33
|
+
changed: z.boolean().optional().describe('Text premises only: has the FACT materially changed vs the recorded baseline? A research finding about external reality (provenance required) — never a judgment of the user.'),
|
|
34
|
+
source: z.enum(['url', 'user_stated', 'host_reported']).describe('Where the finding comes from. host_reported = the model\'s own research without a citation — recorded honestly as such.'),
|
|
35
|
+
source_detail: z.string().max(300).optional().describe('URL or short citation when source="url".'),
|
|
36
|
+
apply_to_matching: z.boolean().default(false).describe('Also record this re-check on OTHER decisions whose monitored premise has the same normalized text (same fact, same evidence — an explicit mechanical fan-out, plan v5 P1).'),
|
|
37
|
+
today_override: zDate.optional(),
|
|
38
|
+
});
|
|
39
|
+
export const recheck = {
|
|
40
|
+
name: 'argus_recheck',
|
|
41
|
+
description: 'Re-check one premise of a decision against reality. The host researches the fact and passes the finding (with provenance); ' +
|
|
42
|
+
'the tool records it and decides drift mechanically — explicit numbers compare numerically, text premises take the host\'s asserted `changed`. ' +
|
|
43
|
+
'First re-check records the baseline and never alerts. If the fact drifted, the response says so and returns the handle — whether to revisit the decision stays the user\'s call.',
|
|
44
|
+
inputSchema,
|
|
45
|
+
outputSchema: ENVELOPE_OUTPUT_SCHEMA,
|
|
46
|
+
annotations: { title: 'Re-check a premise against reality', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
47
|
+
handler: async (a) => {
|
|
48
|
+
try {
|
|
49
|
+
const dir = resolveToolArgusDir(a['argus_dir']);
|
|
50
|
+
const id = String(a['id'] ?? '');
|
|
51
|
+
const today = resolveToday({ override: a['today_override'] });
|
|
52
|
+
const now = new Date().toISOString();
|
|
53
|
+
const state = replayLedger(dir, today);
|
|
54
|
+
const entry = state.contracts.get(id);
|
|
55
|
+
guardTransition(deriveState(entry, today), 'premise_recheck');
|
|
56
|
+
const premise = resolvePremiseRef(entry?.premises ?? [], String(a['ref']));
|
|
57
|
+
if (premise.kind !== 'premise') {
|
|
58
|
+
return toolError({ ok: false, tool: 'argus_recheck', error_code: 'NOT_RECHECKABLE', message: `P${premise.ordinal} is an open question — it is resolved by you, not re-checked against reality.`, recovery: 'Use argus_premises op="resolve" with your own call.' });
|
|
59
|
+
}
|
|
60
|
+
if (premise.status !== 'active') {
|
|
61
|
+
return toolError({ ok: false, tool: 'argus_recheck', error_code: 'PREMISE_RETIRED', message: `P${premise.ordinal} is ${premise.status}.`, recovery: 'Only active premises are re-checked. See argus_recall view="premises".' });
|
|
62
|
+
}
|
|
63
|
+
const finding = String(a['finding']);
|
|
64
|
+
const source = String(a['source']);
|
|
65
|
+
const sourceDetail = a['source_detail'];
|
|
66
|
+
const numericValue = a['numeric_value'];
|
|
67
|
+
const changed = a['changed'];
|
|
68
|
+
// ── drift decision (mechanical or host-asserted; plan v5 §7.1) ──
|
|
69
|
+
const prior = premise.last_recheck;
|
|
70
|
+
const baselineOnly = !prior;
|
|
71
|
+
let drifted = false;
|
|
72
|
+
let reason = 'baseline recorded';
|
|
73
|
+
let integrityNote;
|
|
74
|
+
if (!baselineOnly) {
|
|
75
|
+
const prevNum = prior.numeric_value;
|
|
76
|
+
if (typeof numericValue === 'number' && typeof prevNum === 'number') {
|
|
77
|
+
const d = numericDrift(prevNum, numericValue);
|
|
78
|
+
drifted = d.drifted;
|
|
79
|
+
reason = d.reason;
|
|
80
|
+
if (typeof changed === 'boolean' && changed !== drifted) {
|
|
81
|
+
integrityNote = `numeric comparison (${reason}) disagrees with the asserted changed=${changed} — the numbers win; both are on the record.`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (typeof changed === 'boolean') {
|
|
85
|
+
drifted = changed;
|
|
86
|
+
reason = changed ? 'the host asserts the fact changed (see source)' : 'the host asserts the fact is unchanged';
|
|
87
|
+
if (changed && normalizePremiseText(finding) === normalizePremiseText(prior.finding)) {
|
|
88
|
+
integrityNote = 'asserted changed=true but the finding text is identical to the recorded baseline — recorded as asserted, flagged here.';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
return toolError({
|
|
93
|
+
ok: false, tool: 'argus_recheck', error_code: 'RECHECK_NEEDS_ASSERTION',
|
|
94
|
+
message: 'A prior baseline exists but neither a comparable numeric_value nor a `changed` assertion was given.',
|
|
95
|
+
recovery: `Pass numeric_value (explicit number) for a numeric fact, or changed=true/false as your research finding vs the baseline: "${prior.finding}".`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const mkEvent = (targetId, targetPremiseId) => ({
|
|
100
|
+
id: targetId, event: 'premise_recheck', premise_id: targetPremiseId,
|
|
101
|
+
finding,
|
|
102
|
+
...(typeof numericValue === 'number' ? { numeric_value: numericValue } : {}),
|
|
103
|
+
drifted, baseline_only: baselineOnly, source,
|
|
104
|
+
...(sourceDetail ? { source_detail: sourceDetail } : {}),
|
|
105
|
+
});
|
|
106
|
+
const events = [mkEvent(id, premise.premise_id)];
|
|
107
|
+
// ── explicit cross-decision fan-out (same fact, same evidence) ──
|
|
108
|
+
const appliedTo = [];
|
|
109
|
+
if (a['apply_to_matching'] === true) {
|
|
110
|
+
for (const m of matchingMonitoredPremises(state, id, premise.text)) {
|
|
111
|
+
try {
|
|
112
|
+
guardTransition(deriveState(m.entry, today), 'premise_recheck');
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
continue;
|
|
116
|
+
} // closed decisions are skipped, never forced
|
|
117
|
+
events.push(mkEvent(m.entry.id, m.premise.premise_id));
|
|
118
|
+
appliedTo.push({ decision_id: m.entry.id, ref: `P${m.premise.ordinal}` });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
await appendLedger(dir, events, now);
|
|
122
|
+
const surface = baselineOnly
|
|
123
|
+
? `Baseline recorded for P${premise.ordinal}: "${finding}" (${source}). Re-check suggested again in 7 days.`
|
|
124
|
+
: drifted
|
|
125
|
+
? `The fact under P${premise.ordinal} changed: "${prior.finding}" → "${finding}" (${source}). Whether to revisit this decision is your call.`
|
|
126
|
+
: `P${premise.ordinal} unchanged (${source}).`;
|
|
127
|
+
return envelope({
|
|
128
|
+
ok: true, tool: 'argus_recheck',
|
|
129
|
+
surface,
|
|
130
|
+
// NEXT_ACTIONS is a closed enum (v4 §0.5-2: not extended) — tool
|
|
131
|
+
// discovery rides the surface text and descriptions instead.
|
|
132
|
+
next_actions: drifted ? ['argus_recall', 'leave_as_is'] : ['leave_as_is', 'stop'],
|
|
133
|
+
data: {
|
|
134
|
+
id, ref: `P${premise.ordinal}`, premise_id: premise.premise_id,
|
|
135
|
+
finding, drifted, baseline_only: baselineOnly, reason,
|
|
136
|
+
source, ...(sourceDetail ? { source_detail: sourceDetail } : {}),
|
|
137
|
+
...(integrityNote ? { integrity_note: integrityNote } : {}),
|
|
138
|
+
...(appliedTo.length ? { applied_to_matching: appliedTo } : {}),
|
|
139
|
+
ledger_events_written: events.map(() => 'premise_recheck'),
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
return handleToolException('argus_recheck', e);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
};
|