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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +168 -0
  3. package/SECURITY.md +35 -0
  4. package/dist/index.js +13 -0
  5. package/dist/lib/argus-dir.js +85 -0
  6. package/dist/lib/atomic-write.js +19 -0
  7. package/dist/lib/continuity.js +31 -0
  8. package/dist/lib/deBom.js +3 -0
  9. package/dist/lib/discipline.js +42 -0
  10. package/dist/lib/due-note.js +54 -0
  11. package/dist/lib/elicit.js +30 -0
  12. package/dist/lib/envelope.js +13 -0
  13. package/dist/lib/layout.js +32 -0
  14. package/dist/lib/ledger-append.js +27 -0
  15. package/dist/lib/ledger-replay.js +295 -0
  16. package/dist/lib/locale.js +20 -0
  17. package/dist/lib/log.js +14 -0
  18. package/dist/lib/numeric-drift.js +32 -0
  19. package/dist/lib/overfire-gate.js +39 -0
  20. package/dist/lib/premises.js +153 -0
  21. package/dist/lib/privacy.js +22 -0
  22. package/dist/lib/push-account.js +70 -0
  23. package/dist/lib/receipt.js +83 -0
  24. package/dist/lib/render-receipt.js +144 -0
  25. package/dist/lib/resolve-contract.js +17 -0
  26. package/dist/lib/resolve-today.js +47 -0
  27. package/dist/lib/review/ids.js +29 -0
  28. package/dist/lib/review/index.js +18 -0
  29. package/dist/lib/review/ingest.js +294 -0
  30. package/dist/lib/review/lenses.js +132 -0
  31. package/dist/lib/review/prompts.js +155 -0
  32. package/dist/lib/review/render.js +77 -0
  33. package/dist/lib/review/reviewability.js +83 -0
  34. package/dist/lib/review/routing.js +147 -0
  35. package/dist/lib/review/schema.js +36 -0
  36. package/dist/lib/safe-path.js +70 -0
  37. package/dist/lib/spine.js +79 -0
  38. package/dist/lib/state-machine.js +80 -0
  39. package/dist/lib/surfaces.js +106 -0
  40. package/dist/lib/validate-crux.js +35 -0
  41. package/dist/lib/validate-seal.js +48 -0
  42. package/dist/prompts.js +72 -0
  43. package/dist/resources.js +122 -0
  44. package/dist/server.js +107 -0
  45. package/dist/tools/amend-dismiss.js +90 -0
  46. package/dist/tools/check-in.js +158 -0
  47. package/dist/tools/errors.js +23 -0
  48. package/dist/tools/index.js +14 -0
  49. package/dist/tools/init-config.js +99 -0
  50. package/dist/tools/open-decision.js +128 -0
  51. package/dist/tools/premises.js +209 -0
  52. package/dist/tools/recall.js +157 -0
  53. package/dist/tools/recheck.js +147 -0
  54. package/dist/tools/review.js +182 -0
  55. package/dist/tools/seal.js +152 -0
  56. package/dist/tools/settle.js +126 -0
  57. package/dist/tools/sync.js +129 -0
  58. package/dist/tools/tool-types.js +32 -0
  59. package/package.json +64 -0
package/dist/server.js ADDED
@@ -0,0 +1,107 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
3
+ import { TOOLS, TOOL_MAP } from './tools/index.js';
4
+ import { toolJsonSchema } from './tools/tool-types.js';
5
+ import { listResources, listResourceTemplates, readResource } from './resources.js';
6
+ import { listPrompts, getPrompt } from './prompts.js';
7
+ import { SERVER_INSTRUCTIONS } from './lib/spine.js';
8
+ import { setElicitor } from './lib/elicit.js';
9
+ import { appendDueNote } from './lib/due-note.js';
10
+ import { logError } from './lib/log.js';
11
+ import { readFileSync } from 'node:fs';
12
+ // Single version source — package.json (the '1.0.0' literal had drifted from
13
+ // 1.3.0). Both src/server.ts (tests) and dist/server.js sit one level below it.
14
+ function readPackageVersion() {
15
+ try {
16
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
17
+ return pkg.version ?? '0.0.0';
18
+ }
19
+ catch {
20
+ return '0.0.0'; // never block startup on a packaging quirk
21
+ }
22
+ }
23
+ /**
24
+ * Argus MCP server (blueprint §4). v1 surface = Tools only — the universal
25
+ * floor that works on every host. The spine bias is carried once by the
26
+ * `instructions` field (the one spec-sanctioned home for the killed
27
+ * paste-prompt), rendered from the single spine source. Resources and Prompts
28
+ * are Phase 2; their capabilities are NOT declared until their handlers exist,
29
+ * so a host never probes a no-op.
30
+ */
31
+ export async function createServer() {
32
+ const server = new Server({ name: 'argus-decision-mcp', version: readPackageVersion() }, {
33
+ // Capabilities are declared only for primitives whose handlers exist, so
34
+ // a host never probes a no-op (addendum J). `elicitation` is a client
35
+ // capability we USE, not a server one we serve — advertised so the SDK
36
+ // permits elicitInput; tools degrade to text when the host lacks it.
37
+ capabilities: { tools: {}, resources: {}, prompts: {} },
38
+ instructions: SERVER_INSTRUCTIONS,
39
+ });
40
+ setElicitor((message, requestedSchema) => server.elicitInput({ message, requestedSchema }));
41
+ // Resources — read-only context (blueprint §4.3).
42
+ server.setRequestHandler(ListResourcesRequestSchema, async () => listResources());
43
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => listResourceTemplates());
44
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => readResource(req.params.uri));
45
+ // Prompts — user-triggered discipline rituals (blueprint §4.2).
46
+ server.setRequestHandler(ListPromptsRequestSchema, async () => listPrompts());
47
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => getPrompt(req.params.name, req.params.arguments));
48
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
49
+ tools: TOOLS.map((t) => ({
50
+ name: t.name,
51
+ description: t.description,
52
+ // JSON Schema generated from the Zod source of truth (no hand-kept copy).
53
+ inputSchema: toolJsonSchema(t.inputSchema),
54
+ ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
55
+ ...(t.annotations ? { annotations: t.annotations } : {}),
56
+ })),
57
+ }));
58
+ // Serialize tool calls so concurrent invocations can't interleave a
59
+ // read-replay-then-append against the same ledger (real hosts already wait
60
+ // for each response; this removes the foot-gun for batched/parallel clients).
61
+ let chain = Promise.resolve();
62
+ const serialize = (fn) => {
63
+ const run = chain.then(fn, fn);
64
+ chain = run.then(() => undefined, () => undefined);
65
+ return run;
66
+ };
67
+ // The low-level SDK handler's expected return is a broad ServerResult union
68
+ // (incl. a task-augmented variant) our envelope type isn't nominally part of —
69
+ // `any` is the sanctioned boundary here; every return below is a real
70
+ // McpToolResult, built by the typed helpers.
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
73
+ const { name, arguments: args } = request.params;
74
+ const tool = TOOL_MAP.get(name);
75
+ if (!tool) {
76
+ return {
77
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, error_code: 'UNKNOWN_TOOL', message: `Unknown tool: ${name}` }) }],
78
+ isError: true,
79
+ };
80
+ }
81
+ // Runtime input validation from the Zod source (mcp-builder best-practices).
82
+ // A schema failure is a client bug → a clean, actionable tool-result error
83
+ // (not a protocol crash); the handler only ever sees validated, default-
84
+ // applied args.
85
+ const parsed = tool.inputSchema.safeParse(args ?? {});
86
+ if (!parsed.success) {
87
+ const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ');
88
+ return {
89
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, tool: name, error_code: 'INVALID_INPUT', message: `Invalid arguments — ${issues}` }) }],
90
+ isError: true,
91
+ };
92
+ }
93
+ try {
94
+ const result = await serialize(() => tool.handler(parsed.data));
95
+ return appendDueNote(name, parsed.data, result);
96
+ }
97
+ catch (e) {
98
+ // Last-resort guard — individual handlers already map their own errors.
99
+ logError(`[${name}] escaped handler`, e);
100
+ return {
101
+ content: [{ type: 'text', text: JSON.stringify({ ok: false, error_code: 'INTERNAL_ERROR', message: String(e) }) }],
102
+ isError: true,
103
+ };
104
+ }
105
+ });
106
+ return server;
107
+ }
@@ -0,0 +1,90 @@
1
+ import { atomicWriteJson } from '../lib/atomic-write.js';
2
+ import { bearingPath } from '../lib/layout.js';
3
+ import { resolveToolArgusDir } from '../lib/argus-dir.js';
4
+ import { resolveToday } from '../lib/resolve-today.js';
5
+ import { resolveContract } from '../lib/resolve-contract.js';
6
+ import { guardTransition } from '../lib/state-machine.js';
7
+ import { validateSeal } from '../lib/validate-seal.js';
8
+ import { appendLedger } from '../lib/ledger-append.js';
9
+ import { SCHEMA_VERSION } from '../lib/spine.js';
10
+ import { z } from 'zod';
11
+ import { envelope, toolError } from '../lib/envelope.js';
12
+ import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir, zId, zDate } from './tool-types.js';
13
+ import { handleToolException } from './errors.js';
14
+ export const amend = {
15
+ name: 'argus_amend',
16
+ description: 'Adjust an open or not-yet-due sealed decision\'s predicate or check-by date. Refused once the check-by date has arrived (no moving the goalpost after the fact).',
17
+ inputSchema: z.strictObject({
18
+ argus_dir: zArgusDir,
19
+ id: zId,
20
+ predicate: z.string().min(8).max(400).optional(),
21
+ check_by: zDate.optional(),
22
+ today_override: zDate.optional(),
23
+ }),
24
+ outputSchema: ENVELOPE_OUTPUT_SCHEMA,
25
+ annotations: { title: 'Amend a decision', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
26
+ handler: async (a) => {
27
+ try {
28
+ const dir = resolveToolArgusDir(a['argus_dir']);
29
+ const id = String(a['id'] ?? '');
30
+ const today = resolveToday({ override: a['today_override'] });
31
+ const current = resolveContract(dir, id, today);
32
+ guardTransition(current.state, 'amend'); // GOALPOST_MOVED / DECISION_CLOSED / ILLEGAL_TRANSITION
33
+ const predicate = a['predicate'] ?? current.predicate;
34
+ const checkBy = a['check_by'] ?? current.check_by;
35
+ if (a['check_by'] != null || a['predicate'] != null) {
36
+ const vErr = validateSeal(predicate, checkBy, today);
37
+ if (vErr)
38
+ return toolError({ ok: false, tool: 'argus_amend', error_code: vErr.code, message: vErr.message, recovery: vErr.recovery });
39
+ }
40
+ const now = new Date().toISOString();
41
+ await appendLedger(dir, [{ id, event: 'amend', predicate: a['predicate'], check_by: a['check_by'] }], now);
42
+ if (predicate && checkBy) {
43
+ await atomicWriteJson(bearingPath(dir, id), { v: SCHEMA_VERSION, id, contract_seed: { predicate, check_by: checkBy } });
44
+ }
45
+ return envelope({
46
+ ok: true, tool: 'argus_amend',
47
+ surface: `Amended. Now: "${predicate}" — check-by ${checkBy}.`,
48
+ next_actions: ['argus_check_in', 'stop'],
49
+ data: { id, predicate, check_by: checkBy },
50
+ });
51
+ }
52
+ catch (e) {
53
+ return handleToolException('argus_amend', e);
54
+ }
55
+ },
56
+ };
57
+ export const dismiss = {
58
+ name: 'argus_dismiss',
59
+ description: 'Close a decision without settling it — the user moved on, decided elsewhere, or it became irrelevant. Terminal; not reopened.',
60
+ inputSchema: z.strictObject({
61
+ argus_dir: zArgusDir,
62
+ id: zId,
63
+ dismiss_reason: z.enum(['became_irrelevant', 'decided_elsewhere', 'changed_mind', 'other']),
64
+ note: z.string().max(300).optional(),
65
+ today_override: zDate.optional(),
66
+ }),
67
+ outputSchema: ENVELOPE_OUTPUT_SCHEMA,
68
+ // idempotentHint:false (11 S7) — a repeat dismiss hard-errors DECISION_CLOSED.
69
+ annotations: { title: 'Dismiss a decision', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
70
+ handler: async (a) => {
71
+ try {
72
+ const dir = resolveToolArgusDir(a['argus_dir']);
73
+ const id = String(a['id'] ?? '');
74
+ const today = resolveToday({ override: a['today_override'] });
75
+ const current = resolveContract(dir, id, today);
76
+ guardTransition(current.state, 'dismiss');
77
+ const now = new Date().toISOString();
78
+ await appendLedger(dir, [{ id, event: 'dismiss', dismiss_reason: a['dismiss_reason'], decision: a['note'] }], now);
79
+ return envelope({
80
+ ok: true, tool: 'argus_dismiss',
81
+ surface: 'Dismissed. Closed without a verdict.',
82
+ next_actions: ['stop'],
83
+ data: { id, dismiss_reason: a['dismiss_reason'] },
84
+ });
85
+ }
86
+ catch (e) {
87
+ return handleToolException('argus_dismiss', e);
88
+ }
89
+ },
90
+ };
@@ -0,0 +1,158 @@
1
+ import { resolveToolArgusDir } from '../lib/argus-dir.js';
2
+ import { resolveToday, asDate } from '../lib/resolve-today.js';
3
+ import { replayLedger, bearingContracts } from '../lib/ledger-replay.js';
4
+ import { duePremises, groupDuePremises } from '../lib/premises.js';
5
+ import { readReceipt, SKIPPED } from '../lib/receipt.js';
6
+ import { surfacesFor } from '../lib/surfaces.js';
7
+ import { z } from 'zod';
8
+ import { envelope } from '../lib/envelope.js';
9
+ import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir, zDate } from './tool-types.js';
10
+ import { handleToolException } from './errors.js';
11
+ const inputSchema = z.strictObject({
12
+ argus_dir: zArgusDir,
13
+ include_upcoming_days: z.number().int().min(0).max(30).default(0).describe('Also list sealed contracts coming due within N days (informational — nothing to settle yet).'),
14
+ today_override: zDate.optional(),
15
+ });
16
+ export const checkIn = {
17
+ name: 'argus_check_in',
18
+ description: 'Return decision contracts whose check-by date has arrived (and optionally upcoming ones). A return nudge — reads and routes to argus_settle. If nothing is due, it says so and stops; it does not manufacture engagement.',
19
+ inputSchema,
20
+ outputSchema: ENVELOPE_OUTPUT_SCHEMA,
21
+ annotations: { title: 'Check what is due', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
22
+ handler: async (a) => {
23
+ try {
24
+ const dir = resolveToolArgusDir(a['argus_dir']);
25
+ const today = resolveToday({ override: a['today_override'] });
26
+ const ledger = replayLedger(dir, today);
27
+ const seeds = bearingContracts(dir, today, ledger);
28
+ const dueMap = new Map();
29
+ for (const c of ledger.overdue) {
30
+ dueMap.set(c.id, { id: c.id, predicate: c.text, check_by: c.date, days_overdue: daysBetween(c.date, today), source: 'ledger' });
31
+ }
32
+ for (const s of seeds) {
33
+ if (!dueMap.has(s.id))
34
+ dueMap.set(s.id, { id: s.id, predicate: s.predicate, check_by: s.check_by, days_overdue: daysBetween(s.date, today), source: 'bearing' });
35
+ }
36
+ const due = Array.from(dueMap.values()).sort((x, y) => x.check_by < y.check_by ? -1 : 1);
37
+ // Locale brain (P1-E1): all check_in surface strings come from the
38
+ // {ko,en} dictionary, picked by the config's locale.
39
+ const S = surfacesFor(dir).checkin;
40
+ // 닻 거울 (P1-E3): each due item carries its seal date + the user's OWN
41
+ // seal-time words (receipt.human_judgment; omitted when skipped). The
42
+ // mirror is recognition by date arithmetic, never a welcome greeting —
43
+ // and the quote is the user's sentence, not a machine verdict.
44
+ const dueEnriched = due.map((d) => {
45
+ const receipt = readReceipt(dir, d.id);
46
+ if (!receipt?.created_at)
47
+ return d;
48
+ const sealed_at = String(receipt.created_at).slice(0, 10);
49
+ const words = typeof receipt.human_judgment === 'string' &&
50
+ receipt.human_judgment.trim().length > 0 &&
51
+ receipt.human_judgment !== SKIPPED
52
+ ? receipt.human_judgment.trim()
53
+ : undefined;
54
+ return {
55
+ ...d,
56
+ sealed_at,
57
+ days_since_seal: daysBetween(sealed_at, today),
58
+ ...(words ? { your_words_then: words } : {}),
59
+ };
60
+ });
61
+ // include_upcoming_days, actually implemented (11 S2 — an accepted-then-
62
+ // discarded argument is a silent lie in the schema). Sealed contracts whose
63
+ // check-by falls within the window: informational only, nothing to settle.
64
+ const upDays = typeof a['include_upcoming_days'] === 'number'
65
+ ? Math.max(0, Math.min(30, Math.floor(a['include_upcoming_days'])))
66
+ : 0;
67
+ const upcoming = [];
68
+ if (upDays > 0) {
69
+ const horizon = addDays(today, upDays);
70
+ for (const [cid, entry] of ledger.contracts.entries()) {
71
+ if (entry.status !== 'sealed' || dueMap.has(cid))
72
+ continue;
73
+ const date = asDate(entry.check_by);
74
+ if (date && date > today && date <= horizon) {
75
+ upcoming.push({ id: cid, predicate: entry.text || '', check_by: date });
76
+ }
77
+ }
78
+ upcoming.sort((x, y) => (x.check_by < y.check_by ? -1 : 1));
79
+ }
80
+ const upcomingLine = upcoming.length > 0
81
+ ? S.upcoming(upcoming.length, upDays)
82
+ : '';
83
+ // Ledger-corruption disclosure (11 P2-8): dropped_lines was counted in
84
+ // data.integrity but never SAID. Silence is not kindness — one factual
85
+ // sentence + the backup handle. No blame, no gate.
86
+ const integrityLine = ledger.integrity.dropped_lines > 0
87
+ ? S.dropped_lines(ledger.integrity.dropped_lines)
88
+ : '';
89
+ // Living premises: monitored facts due for a reality re-check, grouped so
90
+ // the same fact under several decisions is ONE re-check (plan v5 P1/P5).
91
+ const TOP = 5;
92
+ const premiseGroups = groupDuePremises(duePremises(ledger));
93
+ const duePrem = premiseGroups.slice(0, TOP).map((g) => ({
94
+ fact: g.text,
95
+ decisions: g.premises.map((p) => ({ decision_id: p.decision_id, decision: p.decision_text, ref: `P${p.ordinal}`, staleness: p.days_stale === null ? 'never re-checked' : `${p.days_stale}d` })),
96
+ }));
97
+ if (due.length === 0 && premiseGroups.length === 0) {
98
+ // Static hint, no network (P1-E4 ③ / master §5-18): check_in stays a
99
+ // local, deterministic read — but a token means the user ALSO seals in
100
+ // their account (web), and "nothing" here must not read as "nothing
101
+ // anywhere". One sentence, argus_sync is the one place that looks.
102
+ const accountHint = (process.env.ARGUS_TOKEN || '').trim()
103
+ ? S.account_hint
104
+ : '';
105
+ return envelope({
106
+ ok: true, tool: 'argus_check_in',
107
+ surface: S.nothing_due + accountHint + upcomingLine + integrityLine,
108
+ next_actions: ['stop'],
109
+ data: { due: [], due_count: 0, due_premises: [], due_premise_count: 0, ...(upDays > 0 ? { upcoming } : {}), today },
110
+ });
111
+ }
112
+ const parts = [];
113
+ if (due.length > 0) {
114
+ // 닻 거울: the OLDEST due item's seal-time words lead the surface
115
+ // (one quote only — the rest stay in data, no surface bloat). Falls
116
+ // back to the count-only line when there are no words to mirror.
117
+ const oldest = dueEnriched[0];
118
+ parts.push(oldest?.your_words_then && typeof oldest.days_since_seal === 'number'
119
+ ? S.anchor_mirror(oldest.days_since_seal, due.length, clip(oldest.your_words_then, 200))
120
+ : S.due_contracts(due.length));
121
+ }
122
+ if (premiseGroups.length > 0)
123
+ parts.push(S.due_premises(premiseGroups.length));
124
+ return envelope({
125
+ ok: true, tool: 'argus_check_in',
126
+ surface: parts.join(' ') + upcomingLine + integrityLine,
127
+ next_actions: due.length > 0 ? ['argus_settle'] : ['argus_recall'],
128
+ data: {
129
+ due: dueEnriched, due_count: due.length,
130
+ due_premises: duePrem, due_premise_count: premiseGroups.length,
131
+ ...(premiseGroups.length > TOP ? { due_premises_truncated: `${premiseGroups.length} groups, showing ${TOP}` } : {}),
132
+ ...(upDays > 0 ? { upcoming } : {}),
133
+ today, integrity: ledger.integrity,
134
+ },
135
+ });
136
+ }
137
+ catch (e) {
138
+ return handleToolException('argus_check_in', e);
139
+ }
140
+ },
141
+ };
142
+ function daysBetween(from, to) {
143
+ const a = Date.parse(from + 'T00:00:00Z');
144
+ const b = Date.parse(to + 'T00:00:00Z');
145
+ if (Number.isNaN(a) || Number.isNaN(b))
146
+ return 0;
147
+ return Math.round((b - a) / 86400000);
148
+ }
149
+ /** Keep the mirrored quote a quote, not a wall — the full text stays in data. */
150
+ function clip(s, max) {
151
+ return s.length <= max ? s : s.slice(0, max - 1) + '…';
152
+ }
153
+ function addDays(day, days) {
154
+ const t = Date.parse(day + 'T00:00:00Z');
155
+ if (Number.isNaN(t))
156
+ return day;
157
+ return new Date(t + days * 86400000).toISOString().slice(0, 10);
158
+ }
@@ -0,0 +1,23 @@
1
+ import { toolError } from '../lib/envelope.js';
2
+ import { ArgusDirError } from '../lib/argus-dir.js';
3
+ import { PathSafetyError } from '../lib/safe-path.js';
4
+ import { GuardError } from '../lib/state-machine.js';
5
+ import { logError } from '../lib/log.js';
6
+ /**
7
+ * Map a thrown exception to a spine-safe tool error envelope. Known typed
8
+ * errors carry their own code + recovery hint; anything else is an internal
9
+ * error (logged to stderr, never stdout).
10
+ */
11
+ export function handleToolException(tool, e) {
12
+ if (e instanceof ArgusDirError) {
13
+ return toolError({ ok: false, tool, error_code: e.code, message: e.message, recovery: 'Pass an absolute .argus path with no "..".' });
14
+ }
15
+ if (e instanceof PathSafetyError) {
16
+ return toolError({ ok: false, tool, error_code: e.code, message: e.message, recovery: 'Use ids/labels matching [A-Za-z0-9._-] only.' });
17
+ }
18
+ if (e instanceof GuardError) {
19
+ return toolError({ ok: false, tool, error_code: e.code, message: e.message, recovery: e.recovery });
20
+ }
21
+ logError(`[${tool}] unhandled`, e);
22
+ return toolError({ ok: false, tool, error_code: 'INTERNAL_ERROR', message: String(e instanceof Error ? e.message : e) });
23
+ }
@@ -0,0 +1,14 @@
1
+ import { openDecision } from './open-decision.js';
2
+ import { seal } from './seal.js';
3
+ import { settle } from './settle.js';
4
+ import { checkIn } from './check-in.js';
5
+ import { recall } from './recall.js';
6
+ import { amend, dismiss } from './amend-dismiss.js';
7
+ import { init, config } from './init-config.js';
8
+ import { review } from './review.js';
9
+ import { sync } from './sync.js';
10
+ import { premises } from './premises.js';
11
+ import { recheck } from './recheck.js';
12
+ /** The full registered tool set. There is deliberately no verdict/grade/score tool. */
13
+ export const TOOLS = [openDecision, review, premises, seal, recheck, settle, checkIn, recall, sync, amend, dismiss, init, config];
14
+ export const TOOL_MAP = new Map(TOOLS.map((t) => [t.name, t]));
@@ -0,0 +1,99 @@
1
+ import fs from 'fs/promises';
2
+ import fsSync from 'fs';
3
+ import yaml from 'js-yaml';
4
+ import { configPath, sessionsRoot, ledgerDir } from '../lib/layout.js';
5
+ import { atomicWriteText } from '../lib/atomic-write.js';
6
+ import { detectLocale } from '../lib/locale.js';
7
+ import { resolveToolArgusDir, writeBoundMarker } from '../lib/argus-dir.js';
8
+ import { ensurePrivacyGitignore } from '../lib/privacy.js';
9
+ import { replayLedger } from '../lib/ledger-replay.js';
10
+ import { resolveToday } from '../lib/resolve-today.js';
11
+ import { SCHEMA_VERSION } from '../lib/spine.js';
12
+ import { z } from 'zod';
13
+ import { envelope, toolError } from '../lib/envelope.js';
14
+ import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir } from './tool-types.js';
15
+ import { handleToolException } from './errors.js';
16
+ function readConfig(dir) {
17
+ try {
18
+ return yaml.load(fsSync.readFileSync(configPath(dir), 'utf8'));
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export const init = {
25
+ name: 'argus_init',
26
+ description: 'Initialize the .argus directory (sessions, ledger, config, privacy .gitignore) and bind it for resource reads. Call once before other tools. Safe to call again.',
27
+ inputSchema: z.strictObject({ argus_dir: zArgusDir }),
28
+ outputSchema: ENVELOPE_OUTPUT_SCHEMA,
29
+ annotations: { title: 'Initialize Argus', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
30
+ handler: async (a) => {
31
+ try {
32
+ const dir = resolveToolArgusDir(a['argus_dir']);
33
+ await fs.mkdir(sessionsRoot(dir), { recursive: true });
34
+ await fs.mkdir(ledgerDir(dir), { recursive: true });
35
+ await ensurePrivacyGitignore(dir);
36
+ writeBoundMarker(dir);
37
+ if (!fsSync.existsSync(configPath(dir))) {
38
+ const cfg = { schema_version: SCHEMA_VERSION, locale: detectLocale(dir), boss: null, team: null, archive: null };
39
+ await atomicWriteText(configPath(dir), yaml.dump(cfg));
40
+ }
41
+ const today = resolveToday({});
42
+ const empty = replayLedger(dir, today).ids.size === 0;
43
+ // TZ visibility (12 §3.3): expose today + tz so an install in KST notices
44
+ // "today is yesterday" immediately instead of at the first missed check-in.
45
+ // Default stays UTC (blueprint M4 determinism) — this is disclosure only.
46
+ const tz = process.env['ARGUS_TZ'] || 'UTC (set ARGUS_TZ to change)';
47
+ return envelope({
48
+ ok: true, tool: 'argus_init',
49
+ surface: empty
50
+ ? 'Argus is ready. It does not give answers — it records a prediction + a check-by date and meets reality on that date. Open your first decision with argus_open_decision.'
51
+ : 'Argus is ready.',
52
+ next_actions: empty ? ['argus_open_decision'] : ['argus_check_in'],
53
+ data: { initialized: true, argus_dir: dir, today, tz },
54
+ });
55
+ }
56
+ catch (e) {
57
+ return handleToolException('argus_init', e);
58
+ }
59
+ },
60
+ };
61
+ export const config = {
62
+ name: 'argus_config',
63
+ description: 'Read or update non-spine settings (locale, boss, team, archive). Passing only argus_dir reads; passing fields merges and writes. There is no setting that turns off falsifiability, seal-before-settle, or honest provenance — the spine is not configurable.',
64
+ inputSchema: z.strictObject({
65
+ argus_dir: zArgusDir,
66
+ locale: z.enum(['ko', 'en']).optional(),
67
+ boss: z.string().optional(),
68
+ team: z.string().optional(),
69
+ archive: z.boolean().optional(),
70
+ }),
71
+ outputSchema: ENVELOPE_OUTPUT_SCHEMA,
72
+ annotations: { title: 'Read/update settings', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
73
+ handler: async (a) => {
74
+ try {
75
+ const dir = resolveToolArgusDir(a['argus_dir']);
76
+ const writeKeys = ['locale', 'boss', 'team', 'archive'].filter((k) => k in a);
77
+ const existing = readConfig(dir) ?? { schema_version: SCHEMA_VERSION, locale: detectLocale(dir), boss: null, team: null, archive: null };
78
+ if (writeKeys.length === 0) {
79
+ return envelope({ ok: true, tool: 'argus_config', surface: 'Config read.', next_actions: ['stop'], data: { config: existing, existed: !!readConfig(dir) } });
80
+ }
81
+ if ('locale' in a && a['locale'] !== 'ko' && a['locale'] !== 'en') {
82
+ return toolError({ ok: false, tool: 'argus_config', error_code: 'INVALID_LOCALE', message: 'locale must be "ko" or "en".' });
83
+ }
84
+ const merged = {
85
+ ...existing,
86
+ schema_version: SCHEMA_VERSION,
87
+ ...(('locale' in a) ? { locale: a['locale'] } : {}),
88
+ ...(('boss' in a) ? { boss: a['boss'] } : {}),
89
+ ...(('team' in a) ? { team: a['team'] } : {}),
90
+ ...(('archive' in a) ? { archive: a['archive'] } : {}),
91
+ };
92
+ await atomicWriteText(configPath(dir), yaml.dump(merged));
93
+ return envelope({ ok: true, tool: 'argus_config', surface: 'Config updated.', next_actions: ['stop'], data: { config: merged } });
94
+ }
95
+ catch (e) {
96
+ return handleToolException('argus_config', e);
97
+ }
98
+ },
99
+ };
@@ -0,0 +1,128 @@
1
+ import { atomicWriteJson } from '../lib/atomic-write.js';
2
+ import { sessionFilePath } from '../lib/layout.js';
3
+ import { resolveToolArgusDir } from '../lib/argus-dir.js';
4
+ import { resolveToday } from '../lib/resolve-today.js';
5
+ import { resolveContract } from '../lib/resolve-contract.js';
6
+ import { overfireGate } from '../lib/overfire-gate.js';
7
+ import { validateCrux } from '../lib/validate-crux.js';
8
+ import { computeContinuity } from '../lib/continuity.js';
9
+ import { appendLedger } from '../lib/ledger-append.js';
10
+ import { ensurePrivacyGitignore } from '../lib/privacy.js';
11
+ import { SCHEMA_VERSION } from '../lib/spine.js';
12
+ import { z } from 'zod';
13
+ import { envelope, toolError } from '../lib/envelope.js';
14
+ import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir, zId, zDate } from './tool-types.js';
15
+ import { handleToolException } from './errors.js';
16
+ const inputSchema = z.strictObject({
17
+ argus_dir: zArgusDir,
18
+ id: zId.min(1).max(128).describe('Single identifier for this decision (used as the file segment). A new decision gets a new id.'),
19
+ decision: z.string().min(1).max(600).describe('The choice the user actually faces, in one neutral sentence. A choice, not an opinion.'),
20
+ stakes: z.enum(['trivial', 'low', 'moderate', 'high']).describe('Cost of being wrong. If between two, pick the lower (restraint default).'),
21
+ reversibility: z.enum(['one_way_door', 'costly_to_reverse', 'easily_reversible']),
22
+ status_quo: z.string().min(1).max(300).describe('What happens if nothing is done — so "leave_as_is" is always a real option.'),
23
+ already_decided: z.boolean().default(false),
24
+ user_question: z.string().max(600).optional(),
25
+ crux_question: z.string().max(400).describe('The ONE neutral load-bearing question, phrased as a question. Never a fork, never a lean.').optional(),
26
+ load_bearing_assumption: z.string().max(400).describe('The single assumption the decision rests on (neutral).').optional(),
27
+ related_to: z.array(zId).max(20).describe('Ids of past decisions the user considers similar — surfaces a frequency-only track record, never a verdict.').optional(),
28
+ today_override: zDate.optional(),
29
+ });
30
+ // Restraint reasons → human sentences (11 P2-1/S6). Each names WHY no fork is
31
+ // manufactured; the caller appends the fixed handle-return coda. 'flat' is kept
32
+ // for forward-compat even though the current gate never emits it.
33
+ const REASON_LINE = {
34
+ vent: 'This reads like something to say out loud, not a fork to force.',
35
+ factual: 'This is a question with an answer, not a decision to open.',
36
+ already_closed: 'You already made this call. Argus does not reopen it.',
37
+ flat: 'The options are close to even — no load-bearing question to manufacture.',
38
+ reversible_low_stakes: 'Cheap to undo and little at stake — trying it IS the test.',
39
+ low_stakes: 'Little rides on this — the steady move is to leave it as is.',
40
+ };
41
+ export const openDecision = {
42
+ name: 'argus_open_decision',
43
+ description: 'Open a consequential decision. Runs a fire-or-not restraint gate FIRST; if it fires, surfaces at most one neutral crux question and a "leave as is" option. Never a fork, never a verdict, never a lean. On flat/low-stakes/reversible/closed decisions it returns restraint.',
44
+ inputSchema,
45
+ outputSchema: ENVELOPE_OUTPUT_SCHEMA,
46
+ annotations: { title: 'Open a decision', 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 current = resolveContract(dir, id, today);
53
+ if (a['already_decided'] === true && (current.state === 'sealed' || current.state === 'due' || current.state === 'settled')) {
54
+ return toolError({
55
+ ok: false, tool: 'argus_open_decision', error_code: 'ALREADY_CLOSED',
56
+ message: 'This decision is already underway or closed.',
57
+ recovery: 'To check reality, call argus_settle. Closed decisions are not reopened.',
58
+ });
59
+ }
60
+ const signals = {
61
+ stakes: a['stakes'],
62
+ reversibility: a['reversibility'],
63
+ already_decided: a['already_decided'] === true,
64
+ };
65
+ const gate = overfireGate(signals);
66
+ const now = new Date().toISOString();
67
+ // Always log the gate inputs for post-hoc accuracy measurement (M2).
68
+ await appendLedger(dir, [{ id, event: 'gate_input', gate: { ...signals, verdict: gate.reason } }], now);
69
+ if (gate.response === 'reconfirm') {
70
+ return envelope({
71
+ ok: true, tool: 'argus_open_decision',
72
+ surface: 'These signals look contradictory (high stakes yet easily reversible). Re-confirm stakes and reversibility before going further.',
73
+ next_actions: ['argus_open_decision', 'leave_as_is'],
74
+ over_fire_gate: { fired: false, reason: gate.reason },
75
+ data: { id, crux_question: null, restraint_option: a['status_quo'], fork_emitted: false, harvest_written: false },
76
+ });
77
+ }
78
+ if (!gate.fire) {
79
+ return envelope({
80
+ ok: true, tool: 'argus_open_decision',
81
+ // Human sentence, not a snake_case enum (11 P2-1). Contract (§4): the
82
+ // line ENDS by naming the option and returning the handle — never a
83
+ // directive ("leave it") issued in the user's stead.
84
+ surface: `${REASON_LINE[gate.reason] ?? 'No fork to manufacture here.'} Leaving it as is stays a real option.`,
85
+ next_actions: ['leave_as_is', 'skip'],
86
+ over_fire_gate: { fired: false, reason: gate.reason },
87
+ data: { id, crux_question: null, restraint_option: a['status_quo'], fork_emitted: false, harvest_written: false },
88
+ });
89
+ }
90
+ // FIRE: validate any model-supplied crux, persist the harvest.
91
+ const cruxErr = validateCrux(a['crux_question']);
92
+ if (cruxErr) {
93
+ return toolError({ ok: false, tool: 'argus_open_decision', error_code: cruxErr.code, message: cruxErr.message, recovery: cruxErr.recovery });
94
+ }
95
+ await ensurePrivacyGitignore(dir);
96
+ await atomicWriteJson(sessionFilePath(dir, id), {
97
+ v: SCHEMA_VERSION, id, problem_text: a['decision'], status_quo: a['status_quo'],
98
+ load_bearing_assumption: a['load_bearing_assumption'] ?? null, created_at: now,
99
+ });
100
+ await appendLedger(dir, [{ id, event: 'harvest', decision: a['decision'] }], now);
101
+ const crux = a['crux_question'] ?? null;
102
+ const relatedIds = Array.isArray(a['related_to']) ? a['related_to'] : [];
103
+ const continuity = relatedIds.length ? computeContinuity(dir, relatedIds) : undefined;
104
+ return envelope({
105
+ ok: true, tool: 'argus_open_decision',
106
+ surface: crux
107
+ ? `Opened. The one question that decides this: ${crux}`
108
+ : 'Opened. Surface exactly ONE neutral crux question (a question, not a fork or a lean), then seal a falsifiable prediction.',
109
+ next_actions: ['argus_seal', 'leave_as_is', 'skip'],
110
+ over_fire_gate: { fired: true, reason: gate.reason },
111
+ data: {
112
+ id,
113
+ crux_question: crux,
114
+ crux_question_provenance: crux ? 'ai_surfaced' : undefined,
115
+ load_bearing_assumption: a['load_bearing_assumption'] ?? null,
116
+ restraint_option: a['status_quo'],
117
+ fork_emitted: false,
118
+ harvest_written: true,
119
+ continuity,
120
+ lean_disclosure: 'Naming the load-bearing question points faintly at the flip; that residual lean is a known limit, not a verdict.',
121
+ },
122
+ });
123
+ }
124
+ catch (e) {
125
+ return handleToolException('argus_open_decision', e);
126
+ }
127
+ },
128
+ };