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
@@ -0,0 +1,70 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ /**
4
+ * Single source of truth for path safety (blueprint §3.4 / addendum M5).
5
+ *
6
+ * Every path segment that originates from a tool argument, a ledger id, a
7
+ * receipt label, or a directory name read off disk MUST pass through
8
+ * `safeSegment` before it is joined, and every resulting path MUST be checked
9
+ * with `assertInside`. No inline `..` guards anywhere else — that scatter is
10
+ * exactly what let the traversal bug survive.
11
+ */
12
+ // A single path segment: letters, digits, dot, underscore, hyphen.
13
+ // Rejects separators (/ \), '..', '.', percent-encoding, NUL, etc.
14
+ const SEGMENT = /^[A-Za-z0-9._-]+$/;
15
+ export function safeSegment(raw, kind = 'segment') {
16
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > 128) {
17
+ throw new PathSafetyError(`invalid_${kind}`, `${kind} must be a 1-128 char string`);
18
+ }
19
+ if (raw === '.' || raw === '..' || !SEGMENT.test(raw)) {
20
+ throw new PathSafetyError(`invalid_${kind}`, `${kind} must match [A-Za-z0-9._-] and not be '.'/'..'`);
21
+ }
22
+ // Defense in depth: reject any percent-encoded or NUL byte that slipped the regex.
23
+ if (raw.includes('\0') || /%2e/i.test(raw) || /%2f/i.test(raw) || /%5c/i.test(raw)) {
24
+ throw new PathSafetyError(`invalid_${kind}`, `${kind} contains an encoded separator`);
25
+ }
26
+ return raw;
27
+ }
28
+ /**
29
+ * Resolve `candidate` and assert it is `root` itself or strictly inside `root`.
30
+ * Uses realpath when the path exists so symlink/junction escapes (Windows) are
31
+ * caught, falling back to lexical resolution for not-yet-created paths.
32
+ */
33
+ export function assertInside(root, candidate) {
34
+ const realRoot = realpathOrResolve(root);
35
+ const realCand = realpathOrResolve(candidate);
36
+ const rootWithSep = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
37
+ if (realCand !== realRoot && !realCand.startsWith(rootWithSep)) {
38
+ throw new PathSafetyError('path_escape_blocked', `path escapes root: ${candidate}`);
39
+ }
40
+ return realCand;
41
+ }
42
+ function realpathOrResolve(p) {
43
+ // Resolve the deepest existing ancestor via realpath (so a symlinked/junction
44
+ // ancestor cannot be used to escape), then re-attach the not-yet-existing
45
+ // lexical tail.
46
+ let dir = path.resolve(p);
47
+ const tail = [];
48
+ for (let i = 0; i < 4096; i++) {
49
+ try {
50
+ const real = fs.realpathSync.native(dir);
51
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
52
+ }
53
+ catch {
54
+ tail.push(path.basename(dir));
55
+ const parent = path.dirname(dir);
56
+ if (parent === dir)
57
+ break; // reached filesystem root
58
+ dir = parent;
59
+ }
60
+ }
61
+ return path.resolve(p);
62
+ }
63
+ export class PathSafetyError extends Error {
64
+ code;
65
+ constructor(code, message) {
66
+ super(message);
67
+ this.name = 'PathSafetyError';
68
+ this.code = code;
69
+ }
70
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Single source of truth for the Argus spine (blueprint §3.6 + addendum G/K).
3
+ *
4
+ * The spine is enforced by STRUCTURE, not prose: there is no verdict tool,
5
+ * settle hard-errors without a prior seal, malformed seals are refused. The
6
+ * little prose that remains (restraint framing, crux rules) lives ONLY here so
7
+ * the three surfaces (webapp / plugin / mcp) cannot drift. The MCP server
8
+ * `instructions` field and any prompt text are rendered from this object — they
9
+ * are never re-typed elsewhere.
10
+ */
11
+ /**
12
+ * The closed set of follow-up actions a tool may suggest. There is, by
13
+ * construction, no 'verdict' / 'recommend' / 'decide' / 'advise' member — a
14
+ * verdict cannot be expressed as a next action because the type cannot name it.
15
+ * The drift-guard test asserts this list contains no judgment verb.
16
+ */
17
+ export const NEXT_ACTIONS = [
18
+ 'argus_open_decision',
19
+ 'argus_seal',
20
+ 'argus_settle',
21
+ 'argus_check_in',
22
+ 'argus_recall',
23
+ 'argus_config',
24
+ 'skip',
25
+ 'leave_as_is',
26
+ 'stop',
27
+ ];
28
+ /** Verbs that must never appear in a tool name or a next-action (drift-guard). */
29
+ export const FORBIDDEN_VERDICT_VERBS = [
30
+ 'verdict',
31
+ 'recommend',
32
+ 'decide',
33
+ 'advise',
34
+ 'grade',
35
+ 'score',
36
+ 'judge',
37
+ 'rank',
38
+ ];
39
+ /**
40
+ * The schema keys that, if present on `argus_open_decision`'s output, would let
41
+ * a fork or a lean be expressed. The drift-guard test asserts their ABSENCE.
42
+ */
43
+ export const FORBIDDEN_FORK_KEYS = ['options', 'poles', 'lean', 'tilt', 'recommendation'];
44
+ /**
45
+ * Server `instructions` string returned at `initialize` (addendum G). This is
46
+ * the one spec-sanctioned, host-universal place for spine bias — loaded once on
47
+ * connect, before any tool call. It is RESTRAINT FRAMING, not a pasted
48
+ * system-prompt: it tells the model what Argus refuses, not how to think.
49
+ */
50
+ export const SERVER_INSTRUCTIONS = [
51
+ 'Argus records decisions; it does not judge them.',
52
+ '',
53
+ 'When the user calls a tool, honor these invariants:',
54
+ '- Never deliver a verdict on the user\'s decision (no "you were right/wrong", no "the stronger case is X", no disclaimed lean). There is no verdict tool because there is no verdict to give.',
55
+ '- Surface at most ONE neutral crux question — phrased as a question, never a two-pole fork, never a recommendation. On a flat, low-stakes, reversible, or already-closed decision, prefer restraint ("leave it as is") over manufacturing a question.',
56
+ '- A prediction is the user\'s. Seal it with a falsifiable predicate and a check-by date; at that date, record what reality did. Never infer the outcome — ask, and record what the user says.',
57
+ '- Authorship is honest: a sentence the user wrote is theirs; a sentence Argus surfaced is tagged ai_surfaced. Never relabel an Argus-drafted line as the user\'s.',
58
+ '- A consequential decision rests on premises. Record them (argus_premises) before sealing; the user corrects what you drafted — their edit is part of the record. Re-check a load-bearing external fact against reality (argus_recheck, with provenance); when it changed, say so and return the handle — whether to revisit is the user\'s call. On trivial decisions, skip premises entirely.',
59
+ '- When opening a decision similar to past ones, pass their ids as related_to — history is frequency, never a verdict.',
60
+ '- When the user names which premise broke at settle time, pass broken_premise_ref — never infer it.',
61
+ '- At the start of a session, call argus_check_in once when an .argus ledger exists — it reports what is due and stops.',
62
+ '',
63
+ 'Argus surfaces one question and names any faint lean as a known limit. It does not claim to be free of judgment — that is an asymptote, not a promise.',
64
+ ].join('\n');
65
+ /** The schema version stamped on every persisted record (addendum N1). */
66
+ export const SCHEMA_VERSION = 1;
67
+ /**
68
+ * Invariants the drift-guard test pins. If any surface diverges, CI fails.
69
+ * (Mirrors the CLAUDE.md "single source of truth for prompts" rule.)
70
+ */
71
+ export const SPINE_INVARIANTS = {
72
+ nextActions: NEXT_ACTIONS,
73
+ forbiddenVerdictVerbs: FORBIDDEN_VERDICT_VERBS,
74
+ forbiddenForkKeys: FORBIDDEN_FORK_KEYS,
75
+ serverInstructions: SERVER_INSTRUCTIONS,
76
+ schemaVersion: SCHEMA_VERSION,
77
+ /** Every settled receipt asserts this literal — reality settles, the model never grades. */
78
+ aiVerdict: null,
79
+ };
@@ -0,0 +1,80 @@
1
+ import { asDate } from './resolve-today.js';
2
+ export function deriveState(entry, today) {
3
+ if (!entry)
4
+ return 'absent';
5
+ switch (entry.status) {
6
+ case 'candidate': return 'opened';
7
+ case 'settled': return 'settled';
8
+ case 'dismissed': return 'dismissed';
9
+ case 'sealed': {
10
+ const d = asDate(entry.check_by);
11
+ return d && d <= today ? 'due' : 'sealed';
12
+ }
13
+ default: return 'absent';
14
+ }
15
+ }
16
+ /** Which events each state accepts. `harvest` from `absent` opens; re-harvest is an idempotent no-op handled by the caller. */
17
+ const ALLOWED = {
18
+ // premise_* NEVER self-creates (unlike seal's B1) — a premise belongs to a
19
+ // decision's narrative, so absent refuses it (plan v5 §6.2).
20
+ absent: new Set(['harvest', 'seal', 'settle']), // seal/settle self-create (B1); settle then still needs a seal (see guard)
21
+ opened: new Set(['seal', 'amend', 'dismiss', 'premise_add', 'premise_amend', 'premise_recheck', 'premise_resolve']),
22
+ sealed: new Set(['amend', 'dismiss', 'settle', 'premise_add', 'premise_amend', 'premise_recheck', 'premise_resolve']),
23
+ // due: no premise_add (retroactive premise-planting rigs calibration) and no
24
+ // premise_amend (retiring the premise that's about to be proven wrong is the
25
+ // goalpost guard one level down) — recheck/resolve stay open (plan v5 §6.2).
26
+ due: new Set(['dismiss', 'settle', 'premise_recheck', 'premise_resolve']), // no amend once due — goalpost guard (m4)
27
+ settled: new Set([]), // terminal — no reopen (mirror clause)
28
+ dismissed: new Set([]), // terminal
29
+ };
30
+ export class GuardError extends Error {
31
+ code;
32
+ recovery;
33
+ constructor(code, message, recovery) {
34
+ super(message);
35
+ this.name = 'GuardError';
36
+ this.code = code;
37
+ this.recovery = recovery;
38
+ }
39
+ }
40
+ /**
41
+ * Throw if `event` is illegal from the decision's current derived state.
42
+ * Encodes the spine's structural refusals:
43
+ * - settle without a prior seal → NO_PRIOR_SEAL
44
+ * - settle an already-settled → ALREADY_SETTLED
45
+ * - amend after check_by (due) → GOALPOST_MOVED
46
+ * - any event on a closed decision → DECISION_CLOSED
47
+ */
48
+ export function guardTransition(current, event) {
49
+ // settle is special: it must have a real seal behind it, not a self-created shell.
50
+ if (event === 'settle') {
51
+ if (current === 'opened' || current === 'absent') {
52
+ throw new GuardError('NO_PRIOR_SEAL', 'Cannot settle a decision that was never sealed.', 'Call argus_seal with a falsifiable predicate and a check-by date first. ' +
53
+ "(If this id came from argus_sync and starts with 'mcp_', use the id without that prefix — see the receipt's local_id; a web-sealed prediction settles in the web app, not here.)");
54
+ }
55
+ if (current === 'settled') {
56
+ throw new GuardError('ALREADY_SETTLED', 'This decision is already settled (append-only — no re-judging).', 'Use argus_recall to read the receipt.');
57
+ }
58
+ if (current === 'dismissed') {
59
+ throw new GuardError('DECISION_CLOSED', 'This decision was dismissed.', 'Open a new decision if reality changed.');
60
+ }
61
+ return; // sealed | due → settle OK
62
+ }
63
+ if (event === 'amend' && current === 'due') {
64
+ throw new GuardError('GOALPOST_MOVED', 'Cannot move the check-by date once it has arrived.', 'Settle the decision against reality instead.');
65
+ }
66
+ // Premise writes lock once the check-by has arrived: adding a premise after
67
+ // the fact plants retroactive support, and editing/retiring one right before
68
+ // settlement is the goalpost guard one level down (plan v5 §6.2).
69
+ if ((event === 'premise_add' || event === 'premise_amend') && current === 'due') {
70
+ throw new GuardError('PREMISE_LOCKED', 'Premises are locked once the check-by date has arrived.', 'Settle the decision against reality first (argus_settle); the premise record stays as it was when you committed.');
71
+ }
72
+ if ((current === 'settled' || current === 'dismissed')) {
73
+ throw new GuardError('DECISION_CLOSED', `This decision is ${current}; it cannot accept a ${event}.`, 'Open a new decision instead — closed decisions are not reopened.');
74
+ }
75
+ if (!ALLOWED[current].has(event)) {
76
+ throw new GuardError('ILLEGAL_TRANSITION', `A '${event}' is not allowed from state '${current}'.`, event === 'seal' || event.startsWith('premise_')
77
+ ? 'Open the decision first with argus_open_decision.'
78
+ : undefined);
79
+ }
80
+ }
@@ -0,0 +1,106 @@
1
+ import fs from 'fs';
2
+ import { configPath } from './layout.js';
3
+ export function surfaceLocale(argusDir) {
4
+ if (!argusDir)
5
+ return 'en';
6
+ try {
7
+ const cfg = fs.readFileSync(configPath(argusDir), 'utf8');
8
+ const m = cfg.match(/^locale:\s*(ko|en)\b/m);
9
+ if (m)
10
+ return m[1];
11
+ }
12
+ catch { /* no config yet → base voice */ }
13
+ return 'en';
14
+ }
15
+ export const SURFACES = {
16
+ en: {
17
+ checkin: {
18
+ nothing_due: 'Nothing is due. Nothing to nudge.',
19
+ account_hint: ' This reads the local ledger only — judgments sealed in your account: argus_sync shows them.',
20
+ upcoming: (n, days) => ` ${n} coming due within ${days} day(s) — informational, nothing to settle yet.`,
21
+ due_contracts: (n) => `${n} decision contract(s) past check-by — time to check them against reality (argus_settle).`,
22
+ anchor_mirror: (days, n, words) => `${days} day(s) since you sealed — ${n} contract(s) past check-by. Your words then: '${words}' All that's left is to record what reality did (argus_settle).`,
23
+ due_premises: (n) => `${n} premise fact(s) due for a reality re-check (argus_recheck).`,
24
+ dropped_lines: (n) => ` ${n} ledger line(s) could not be read (possibly a crash artifact). The record is append-only, so the rest is intact — keep a backup of ledger.jsonl.`,
25
+ },
26
+ sync: {
27
+ live_with_due: (total, due) => `${total} live judgment(s) in your account · ${due} past check-by. ` +
28
+ 'Terminal-sealed ones settle here via argus_settle with local_id; web-sealed ones settle in the web dashboard.',
29
+ live_no_due: (total) => `${total} live judgment(s) in your account. Nothing past its check-by.`,
30
+ settled_on_web: (n) => ` ${n} already settled on the web — to keep them in this ledger too, record the same outcome with argus_settle.`,
31
+ truncation: (shown, matched) => `Showing ${shown} of ${matched}. Raise limit or narrow with due_only.`,
32
+ },
33
+ seal: {
34
+ header: 'ARGUS · SEALED',
35
+ owner_user: 'These words are yours.',
36
+ owner_ai: 'Argus drafted these words — you have not yet made them yours.',
37
+ sealed_label: 'Sealed',
38
+ answers_label: 'Reality answers',
39
+ days_out: (n) => `(${n} day${n === 1 ? '' : 's'} out)`,
40
+ closing: [
41
+ 'This stays shut until then. What gets written next is not',
42
+ 'a grade — it is what actually happened.',
43
+ ],
44
+ footer: 'argus · anchor down ⚓',
45
+ },
46
+ wake: {
47
+ header: 'ARGUS · WAKE',
48
+ counts: (total, sealed, settled) => `decisions ${total} · sealed ${sealed} · settled ${settled}`,
49
+ overdue_group: (n) => `past check-by (${n})`,
50
+ overdue_hint: '← argus_settle',
51
+ days_past: (n) => `${n}d past`,
52
+ waiting_group: (n) => `waiting on reality (${n})`,
53
+ answer_on: (date) => `due ${date}`,
54
+ settled_group: (n, held, avoided, partial) => `settled (${n}) — held ${held} · avoided ${avoided} · partial ${partial}`,
55
+ more: (n) => `… (+${n})`,
56
+ record_since: (date) => `on record since ${date}`,
57
+ },
58
+ },
59
+ ko: {
60
+ checkin: {
61
+ nothing_due: '확인할 차례가 된 것은 없습니다. 조를 것도 없습니다.',
62
+ account_hint: ' 이건 로컬 원장만 읽습니다 — 계정에 봉인한 판단은 argus_sync로 볼 수 있습니다.',
63
+ upcoming: (n, days) => ` ${days}일 안에 확인일이 오는 것 ${n}건 — 참고용이고, 아직 정산할 건 아닙니다.`,
64
+ due_contracts: (n) => `계약 ${n}건이 확인일을 지났습니다 — 현실과 대조할 차례입니다 (argus_settle).`,
65
+ anchor_mirror: (days, n, words) => `봉인 후 ${days}일 — 계약 ${n}건이 확인일을 지났습니다. 그때 당신은 이렇게 적었습니다: '${words}' 현실이 어떻게 답했는지만 기록하면 됩니다 (argus_settle).`,
66
+ due_premises: (n) => `전제 사실 ${n}건이 현실 재확인 차례입니다 (argus_recheck).`,
67
+ dropped_lines: (n) => ` 원장에서 읽지 못한 줄이 ${n}개 있습니다(크래시 흔적일 수 있음). 기록은 append-only라 나머지는 안전합니다 — ledger.jsonl을 백업해 두세요.`,
68
+ },
69
+ sync: {
70
+ live_with_due: (total, due) => `계정에 살아 있는 판단 ${total}개 · 확인할 차례 ${due}개. ` +
71
+ '이 터미널에서 봉인한 것은 local_id로 argus_settle, 웹에서 봉인한 것은 웹 대시보드에서 정산하세요.',
72
+ live_no_due: (total) => `계정에 살아 있는 판단 ${total}개. 확인할 차례가 된 것은 없습니다.`,
73
+ settled_on_web: (n) => ` 웹에서 이미 정산된 것 ${n}건 — 로컬 원장에도 남기려면 argus_settle로 같은 outcome을 기록하세요.`,
74
+ truncation: (shown, matched) => `${matched}개 중 ${shown}개만 표시. limit을 올리거나 due_only로 좁히세요.`,
75
+ },
76
+ seal: {
77
+ header: 'ARGUS · 봉인',
78
+ owner_user: '이 문장은 당신의 것입니다.',
79
+ owner_ai: 'Argus가 초안한 문장입니다 — 아직 당신이 확언하지 않았습니다.',
80
+ sealed_label: '봉인',
81
+ answers_label: '현실의 답',
82
+ days_out: (n) => `(${n}일 뒤)`,
83
+ closing: [
84
+ '그날까지 이 봉인은 닫혀 있습니다. 날짜가 오면 여기 기록될',
85
+ '것은 평가가 아니라 — 실제로 일어난 일입니다.',
86
+ ],
87
+ footer: 'argus · 닻 내림 ⚓',
88
+ },
89
+ wake: {
90
+ header: 'ARGUS · 항적',
91
+ counts: (total, sealed, settled) => `결정 ${total} · 봉인 중 ${sealed} · 정산 ${settled}`,
92
+ overdue_group: (n) => `확인일 지남 (${n})`,
93
+ overdue_hint: '← argus_settle',
94
+ days_past: (n) => `${n}일 경과`,
95
+ waiting_group: (n) => `현실을 기다리는 중 (${n})`,
96
+ answer_on: (date) => `답 ${date}`,
97
+ settled_group: (n, held, avoided, partial) => `정산됨 (${n}) — held ${held} · avoided ${avoided} · partial ${partial}`,
98
+ more: (n) => `… (+${n})`,
99
+ record_since: (date) => `기록 시작 ${date} 부터`,
100
+ },
101
+ },
102
+ };
103
+ /** Convenience: resolve the dictionary for a dir in one call. */
104
+ export function surfacesFor(argusDir) {
105
+ return SURFACES[surfaceLocale(argusDir)];
106
+ }
@@ -0,0 +1,35 @@
1
+ // Directional / recommendation tells. NOTE: the old `i('| w)?d` alternation
2
+ // also matched the bare word "id" ("user id" flagged a neutral question as
3
+ // CRUX_CARRIES_LEAN — 11 P1-3); decomposed to the two real tells.
4
+ const LEAN = /\b(you should|i'd|i would|the (stronger|better|safer|smarter) (case|choice|option|move|bet)|most (teams|people|founders)|the right (call|move|choice)|go with|lean(s)? toward|my (recommendation|advice|take)|honestly,? (i|you)|if i were you)\b/i;
5
+ // Two-pole fork tell ("A or B?" framed as the question).
6
+ const FORK = /\b(a or b|option (a|b|1|2)|either\b.*\bor\b.*\?)/i;
7
+ export function validateCrux(crux) {
8
+ if (typeof crux !== 'string')
9
+ return null;
10
+ const t = crux.trim();
11
+ if (!t)
12
+ return null;
13
+ if (!t.endsWith('?')) {
14
+ return {
15
+ code: 'CRUX_NOT_A_QUESTION',
16
+ message: 'A crux must be phrased as a single neutral question.',
17
+ recovery: 'End it with "?" and remove any statement of which way to go.',
18
+ };
19
+ }
20
+ if (LEAN.test(t)) {
21
+ return {
22
+ code: 'CRUX_CARRIES_LEAN',
23
+ message: 'The crux carries a directional lean — that is a verdict in disguise.',
24
+ recovery: 'Name the assumption neutrally as a question; do not say which side is stronger.',
25
+ };
26
+ }
27
+ if (FORK.test(t)) {
28
+ return {
29
+ code: 'CRUX_CARRIES_LEAN',
30
+ message: 'The crux is shaped as a two-pole fork.',
31
+ recovery: 'Ask the single load-bearing question, not "A or B?".',
32
+ };
33
+ }
34
+ return null;
35
+ }
@@ -0,0 +1,48 @@
1
+ import { asDate } from './resolve-today.js';
2
+ // Obvious non-falsifiable vibes. Weak/advisory only.
3
+ const VIBE = /\b(go well|be fine|be good|be great|work out|feel right|be successful|do better|improve somehow)\b/i;
4
+ // Korean vibe-predicates (12 P1-4): "잘 될 것 같다 아마도" sealed — and was
5
+ // congratulated — because the list was English-only. Same weak/advisory status;
6
+ // no \b (word boundaries don't work for Hangul). NOT a hard gate (§5-14).
7
+ const VIBE_KO = /(잘\s*될|잘\s*풀릴|괜찮을|좋아질|나아질)\s*(것|거)\s*(같|이)|아마도|어떻게든\s*(될|되)/;
8
+ export function validateSeal(predicate, checkBy, today) {
9
+ if (typeof predicate !== 'string' || predicate.trim().length < 8) {
10
+ return {
11
+ code: 'EMPTY_PREDICATE',
12
+ message: 'A seal needs a checkable statement (at least 8 characters).',
13
+ recovery: 'Write a prediction reality can mark true or false, e.g. "cutover downtime < 5 min".',
14
+ };
15
+ }
16
+ const date = asDate(checkBy);
17
+ if (!date) {
18
+ return {
19
+ code: 'BAD_CHECK_BY',
20
+ message: 'check_by must be a real date in YYYY-MM-DD form.',
21
+ recovery: 'Pick the date when reality will answer, e.g. "2026-09-01".',
22
+ };
23
+ }
24
+ if (date <= today) {
25
+ return {
26
+ code: 'BAD_CHECK_BY',
27
+ message: `check_by (${date}) must be in the future (today is ${today}).`,
28
+ recovery: 'Pick a future date — the check-by is when you will come back to settle.',
29
+ };
30
+ }
31
+ if (VIBE_KO.test(predicate)) {
32
+ return {
33
+ code: 'NOT_FALSIFIABLE',
34
+ message: '이건 기분이지 확인 가능한 예측이 아닙니다.',
35
+ recovery: '숫자·임계값·관찰 가능한 사건으로 다시 적어주세요. (휴리스틱 — 놓칠 수 있음)',
36
+ weak: true,
37
+ };
38
+ }
39
+ if (VIBE.test(predicate)) {
40
+ return {
41
+ code: 'NOT_FALSIFIABLE',
42
+ message: 'This reads like a vibe, not a checkable prediction.',
43
+ recovery: 'Re-state it with a number, threshold, or observable event. (Heuristic — may miss cases.)',
44
+ weak: true,
45
+ };
46
+ }
47
+ return null;
48
+ }
@@ -0,0 +1,72 @@
1
+ import { BIND_DISCIPLINE, SETTLE_DISCIPLINE, REFRAME_DISCIPLINE, REVIEW_DISCIPLINE } from './lib/discipline.js';
2
+ import { resolveArgusDirForResource } from './lib/argus-dir.js';
3
+ import { resolveToday } from './lib/resolve-today.js';
4
+ import { replayLedger, bearingContracts } from './lib/ledger-replay.js';
5
+ import { duePremises, groupDuePremises } from './lib/premises.js';
6
+ /**
7
+ * MCP Prompts (blueprint §4.2). The discipline shipped as user-triggered
8
+ * rituals instead of a pasted system prompt. /argus-settle reads the due
9
+ * contracts at GetPrompt time and bakes them into the message so the model
10
+ * settles against the real ledger.
11
+ */
12
+ export const PROMPTS = [
13
+ { name: 'argus-bind', title: 'Argus: bind a decision', description: 'Run the fire-gate → one neutral question → seal a falsifiable bet ritual.', arguments: [{ name: 'decision', description: 'The decision you are facing.', required: false }] },
14
+ { name: 'argus-settle', title: 'Argus: settle against reality', description: 'Check decisions whose check-by date has arrived against what actually happened.', arguments: [] },
15
+ { name: 'argus-reframe', title: 'Argus: reframe the question', description: 'Surface the hidden assumptions in your question before you ask the AI.', arguments: [{ name: 'question', description: 'The question or problem to sharpen.', required: false }] },
16
+ { name: 'argus-review', title: 'Argus: review a document', description: 'Review an existing strategy memo / PRD / deck text / AI answer for judgment risk, anchored to the source.', arguments: [{ name: 'file_path', description: 'Path to a .md/.txt document to review (optional).', required: false }] },
17
+ ];
18
+ export function listPrompts() {
19
+ return { prompts: PROMPTS.map((p) => ({ name: p.name, title: p.title, description: p.description, arguments: p.arguments })) };
20
+ }
21
+ function userText(text) {
22
+ return { role: 'user', content: { type: 'text', text } };
23
+ }
24
+ export function getPrompt(name, args) {
25
+ if (name === 'argus-bind') {
26
+ const decision = args?.['decision'];
27
+ return {
28
+ description: 'Argus bind ritual',
29
+ messages: [userText(BIND_DISCIPLINE + (decision ? `\n\nThe decision: ${decision}` : ''))],
30
+ };
31
+ }
32
+ if (name === 'argus-reframe') {
33
+ const question = args?.['question'];
34
+ return {
35
+ description: 'Argus reframe lens',
36
+ messages: [userText(REFRAME_DISCIPLINE + (question ? `\n\nThe question: ${question}` : ''))],
37
+ };
38
+ }
39
+ if (name === 'argus-review') {
40
+ const filePath = args?.['file_path'];
41
+ return {
42
+ description: 'Argus review ritual',
43
+ messages: [userText(REVIEW_DISCIPLINE + (filePath ? `\n\nThe document: ${filePath}` : ''))],
44
+ };
45
+ }
46
+ if (name === 'argus-settle') {
47
+ const dir = resolveArgusDirForResource();
48
+ let context = '';
49
+ if (dir) {
50
+ const today = resolveToday({});
51
+ const l = replayLedger(dir, today);
52
+ const seeds = bearingContracts(dir, today, l);
53
+ const due = [
54
+ ...l.overdue.map((c) => ({ id: c.id, predicate: c.text, check_by: c.date })),
55
+ ...seeds.filter((s) => !l.contracts.has(s.id)).map((s) => ({ id: s.id, predicate: s.predicate, check_by: s.check_by })),
56
+ ];
57
+ context = due.length
58
+ ? '\n\nDue now:\n' + due.map((d) => `- [${d.id}] "${d.predicate}" (check-by ${d.check_by})`).join('\n')
59
+ : '\n\nNothing is due right now.';
60
+ // Living premises (plan v5 §0.6-U2): the settle ritual also covers the
61
+ // facts open decisions rest on — the recheck choreography lives HERE:
62
+ // research the current fact, then record it with provenance.
63
+ const premGroups = groupDuePremises(duePremises(l));
64
+ if (premGroups.length > 0) {
65
+ context += '\n\nPremises due for a reality re-check — for each: research the CURRENT fact (a web search, or ask the user), then call argus_recheck with the finding, an explicit numeric_value when the fact is a number, and its source (url | user_stated | host_reported):\n'
66
+ + premGroups.slice(0, 5).map((g) => `- "${g.text}" — under: ${g.premises.map((p) => `[${p.decision_id}] P${p.ordinal}`).join(', ')}${g.premises.length > 1 ? ' (one fact — re-check once with apply_to_matching)' : ''}`).join('\n');
67
+ }
68
+ }
69
+ return { description: 'Argus settle ritual', messages: [userText(SETTLE_DISCIPLINE + context)] };
70
+ }
71
+ return { description: 'unknown prompt', messages: [userText(`Unknown prompt: ${name}`)] };
72
+ }
@@ -0,0 +1,122 @@
1
+ import { resolveArgusDirForResource } from './lib/argus-dir.js';
2
+ import { resolveToday } from './lib/resolve-today.js';
3
+ import { replayLedger, bearingContracts } from './lib/ledger-replay.js';
4
+ import { readReceipt } from './lib/receipt.js';
5
+ import { safeSegment } from './lib/safe-path.js';
6
+ import { logDebug } from './lib/log.js';
7
+ import { duePremises, groupDuePremises, isMonitored, isDueForRecheck } from './lib/premises.js';
8
+ /**
9
+ * MCP Resources (blueprint §4.3). Read-only context the host can auto-inject —
10
+ * the ledger, the due contracts, a receipt, the current bearing. They are
11
+ * resources (not read-tools) because they are app-injected context, and being
12
+ * read-only is itself the proof that the reading surface cannot write a verdict.
13
+ *
14
+ * No per-call argument channel exists, so the dir is resolved from ARGUS_DIR.
15
+ * When unbound, every resource degrades cleanly to an {unbound} payload rather
16
+ * than throwing.
17
+ */
18
+ const JSON_MIME = 'application/json';
19
+ export const STATIC_RESOURCES = [
20
+ { uri: 'argus://ledger', name: 'Argus ledger', description: 'Full replayed state of all decisions (stats, contracts, integrity).', mimeType: JSON_MIME },
21
+ { uri: 'argus://contracts/due', name: 'Due contracts', description: 'Decision contracts past their check-by date — the return-loop context.', mimeType: JSON_MIME },
22
+ { uri: 'argus://bearing/current', name: 'Current bearing', description: 'Open (sealed, not yet settled) decisions.', mimeType: JSON_MIME },
23
+ { uri: 'argus://premises/due', name: 'Premises due for a re-check', description: 'Monitored premises of sealed decisions whose facts should be re-checked against reality (grouped: the same fact under several decisions is one re-check) — the living-premises return-loop context.', mimeType: JSON_MIME },
24
+ ];
25
+ export const RESOURCE_TEMPLATES = [
26
+ { uriTemplate: 'argus://receipts/{id}', name: 'Judgment Receipt', description: 'The receipt for one decision id.', mimeType: JSON_MIME },
27
+ { uriTemplate: 'argus://premises/{id}', name: 'Decision premises', description: 'The tracked premises (facts + open questions) of one decision id, with provenance and staleness.', mimeType: JSON_MIME },
28
+ ];
29
+ function unbound(uri) {
30
+ return { uri, mimeType: JSON_MIME, text: JSON.stringify({ unbound: true, hint: 'Set the ARGUS_DIR env var (or call argus_init with an absolute argus_dir) so resources can resolve a project.' }) };
31
+ }
32
+ export function listResources() {
33
+ return { resources: STATIC_RESOURCES.map((r) => ({ ...r })) };
34
+ }
35
+ export function listResourceTemplates() {
36
+ return { resourceTemplates: RESOURCE_TEMPLATES.map((r) => ({ ...r })) };
37
+ }
38
+ export function readResource(uri) {
39
+ const dir = resolveArgusDirForResource();
40
+ if (!dir) {
41
+ logDebug(`resource ${uri} requested while unbound`);
42
+ return { contents: [unbound(uri)] };
43
+ }
44
+ const today = resolveToday({});
45
+ const payload = computePayload(uri, dir, today);
46
+ return { contents: [{ uri, mimeType: JSON_MIME, text: JSON.stringify(payload, null, 2) }] };
47
+ }
48
+ function computePayload(uri, dir, today) {
49
+ if (uri === 'argus://ledger') {
50
+ const l = replayLedger(dir, today);
51
+ return {
52
+ today,
53
+ stats: l.stats,
54
+ integrity: l.integrity,
55
+ contracts: [...l.contracts.values()].map((c) => ({ id: c.id, status: c.status, predicate: c.predicate, check_by: c.check_by, outcome: c.outcome })),
56
+ };
57
+ }
58
+ if (uri === 'argus://contracts/due') {
59
+ const l = replayLedger(dir, today);
60
+ const seeds = bearingContracts(dir, today, l);
61
+ const due = [
62
+ ...l.overdue.map((c) => ({ id: c.id, predicate: c.text, check_by: c.date, source: 'ledger' })),
63
+ ...seeds.filter((s) => !l.contracts.has(s.id)).map((s) => ({ id: s.id, predicate: s.predicate, check_by: s.check_by, source: 'bearing' })),
64
+ ];
65
+ return { today, due, due_count: due.length, next_action: due.length ? 'argus_settle' : null };
66
+ }
67
+ if (uri === 'argus://bearing/current') {
68
+ const l = replayLedger(dir, today);
69
+ const open = [...l.contracts.values()].filter((c) => c.status === 'sealed').map((c) => ({ id: c.id, predicate: c.predicate, check_by: c.check_by }));
70
+ return { today, open };
71
+ }
72
+ if (uri === 'argus://premises/due') {
73
+ const l = replayLedger(dir, today);
74
+ const groups = groupDuePremises(duePremises(l));
75
+ const TOP = 5; // power-user cap (plan v5 P5) — counts stay honest
76
+ return {
77
+ today,
78
+ groups: groups.slice(0, TOP).map((g) => ({
79
+ fact: g.text,
80
+ 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` })),
81
+ })),
82
+ group_count: groups.length,
83
+ has_more: groups.length > TOP,
84
+ next_action: groups.length ? 'argus_recheck' : null,
85
+ };
86
+ }
87
+ const pm = uri.match(/^argus:\/\/premises\/(.+)$/);
88
+ if (pm && pm[1] !== 'due') {
89
+ let id;
90
+ try {
91
+ id = safeSegment(decodeURIComponent(pm[1]), 'id');
92
+ }
93
+ catch {
94
+ return { error: 'invalid_id' };
95
+ }
96
+ const l = replayLedger(dir, today);
97
+ const list = l.contracts.get(id)?.premises ?? [];
98
+ return {
99
+ id, today,
100
+ premises: list.map((p) => ({
101
+ ref: `P${p.ordinal}`, kind: p.kind, text: p.text, status: p.status,
102
+ source: p.source, ...(p.ai_original && p.ai_original !== p.text ? { ai_original: p.ai_original } : {}),
103
+ monitored: isMonitored(p), due_for_recheck: isDueForRecheck(p, today),
104
+ last_checked: p.last_recheck?.ts?.slice(0, 10) ?? null,
105
+ ...(p.resolved_decision ? { resolved_decision: p.resolved_decision } : {}),
106
+ })),
107
+ };
108
+ }
109
+ const m = uri.match(/^argus:\/\/receipts\/(.+)$/);
110
+ if (m) {
111
+ let id;
112
+ try {
113
+ id = safeSegment(decodeURIComponent(m[1]), 'id');
114
+ }
115
+ catch {
116
+ return { error: 'invalid_id' };
117
+ }
118
+ const r = readReceipt(dir, id);
119
+ return r ?? { error: 'not_found', id };
120
+ }
121
+ return { error: 'unknown_resource', uri };
122
+ }