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,182 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { envelope, toolError } from '../lib/envelope.js';
|
|
4
|
+
import { ENVELOPE_OUTPUT_SCHEMA } from './tool-types.js';
|
|
5
|
+
import { handleToolException } from './errors.js';
|
|
6
|
+
import { ingest, scoreReviewability, routeLenses, buildExtractionPrompt, reviewabilityBand, LENSES, LENS_VERSION, REVIEW_SCHEMA_VERSION, } from '../lib/review/index.js';
|
|
7
|
+
/**
|
|
8
|
+
* argus_review — document Judgment Review parity with the webapp (design doc
|
|
9
|
+
* §"웹앱과 MCP는 같은 Judgment Receipt"). Same ingest / anchoring / reviewability
|
|
10
|
+
* / lens routing / SSOT prompt as src/lib/review — the ONLY difference is that
|
|
11
|
+
* here the host agent is the model, so the tool hands it the scaffold instead of
|
|
12
|
+
* calling an LLM. The falsifiable follow-up it produces is sealed through the
|
|
13
|
+
* existing argus_seal → argus_settle loop (one receipt machine, no second store).
|
|
14
|
+
*
|
|
15
|
+
* Read-only: it computes + returns; it does not write to .argus. The write
|
|
16
|
+
* happens at seal, where the user owns the prediction.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_DOC_BYTES = 400_000;
|
|
19
|
+
const UNIT_LIMIT = 160;
|
|
20
|
+
// Cap the units block so a large doc can't return a giant tool response
|
|
21
|
+
// (mcp-builder §CHARACTER_LIMIT). Whichever bound hits first — count or chars.
|
|
22
|
+
const CHAR_BUDGET = 20_000;
|
|
23
|
+
const CONCERNS = ['strategic_fit', 'evidence', 'stakeholder_objection', 'execution_risk', 'ai_answer_trust', 'full_judgment_review'];
|
|
24
|
+
const EXT_KIND = {
|
|
25
|
+
md: 'markdown', markdown: 'markdown', txt: 'txt', text: 'txt',
|
|
26
|
+
pdf: 'pdf', docx: 'docx', pptx: 'pptx',
|
|
27
|
+
};
|
|
28
|
+
const inputSchema = z.strictObject({
|
|
29
|
+
text: z.string().max(MAX_DOC_BYTES).describe('The document body to review (paste). Provide this OR file_path.').optional(),
|
|
30
|
+
file_path: z.string().max(1024).describe('Absolute path to a TEXT document (.md/.txt). Binary decks/PDFs degrade honestly — paste their text instead.').optional(),
|
|
31
|
+
source_kind: z.enum(['paste', 'markdown', 'txt', 'pdf', 'docx', 'pptx', 'transcript', 'llm_answer', 'pr_diff']).describe('Override the inferred source kind.').optional(),
|
|
32
|
+
title: z.string().max(300).optional(),
|
|
33
|
+
concerns: z.array(z.enum(CONCERNS)).max(3).describe('What to weight — drives lens routing.').optional(),
|
|
34
|
+
audience_hint: z.string().max(200).optional(),
|
|
35
|
+
biggest_worry: z.string().max(300).optional(),
|
|
36
|
+
stakes: z.enum(['low', 'medium', 'high']).describe('Optional stakes hint for lens routing (default medium).').optional(),
|
|
37
|
+
});
|
|
38
|
+
export const review = {
|
|
39
|
+
name: 'argus_review',
|
|
40
|
+
description: 'Review an EXISTING document (strategy memo / PRD / deck text / AI answer) for judgment risk. ' +
|
|
41
|
+
'Returns: a reviewability score+band, the routed review lenses, and the extraction prompt (which embeds the anchored source units + output schema) — then hands YOU (the model) the analysis to run. ' +
|
|
42
|
+
'Anchor every finding to the source; never deliver a verdict on the document. End by sealing ONE falsifiable follow-up via argus_seal. ' +
|
|
43
|
+
'Use for a document the user already wrote; to open a FRESH decision use argus_open_decision instead. Binary decks/PDFs degrade honestly — paste their text.',
|
|
44
|
+
inputSchema,
|
|
45
|
+
outputSchema: ENVELOPE_OUTPUT_SCHEMA,
|
|
46
|
+
annotations: { title: 'Review a document', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
47
|
+
handler: async (a) => {
|
|
48
|
+
try {
|
|
49
|
+
const filePath = typeof a['file_path'] === 'string' ? a['file_path'] : '';
|
|
50
|
+
let text = typeof a['text'] === 'string' ? a['text'] : '';
|
|
51
|
+
let inferredKind = a['source_kind'];
|
|
52
|
+
let title = typeof a['title'] === 'string' ? a['title'] : '';
|
|
53
|
+
if (!text && filePath) {
|
|
54
|
+
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
|
55
|
+
inferredKind = inferredKind ?? EXT_KIND[ext] ?? 'txt';
|
|
56
|
+
if (['pdf', 'docx', 'pptx'].includes(inferredKind)) {
|
|
57
|
+
return toolError({
|
|
58
|
+
ok: false, tool: 'argus_review', error_code: 'BINARY_UNSUPPORTED',
|
|
59
|
+
message: `${inferredKind.toUpperCase()} files aren't text-extracted in the MCP.`,
|
|
60
|
+
recovery: 'Paste the document text into `text`, or convert to markdown/txt first.',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const stat = fs.statSync(filePath);
|
|
65
|
+
if (stat.size > MAX_DOC_BYTES) {
|
|
66
|
+
return toolError({
|
|
67
|
+
ok: false, tool: 'argus_review', error_code: 'TOO_LARGE',
|
|
68
|
+
message: `Document exceeds ${MAX_DOC_BYTES} bytes.`,
|
|
69
|
+
recovery: 'Review the most decision-bearing section, or split the document.',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
73
|
+
if (!title)
|
|
74
|
+
title = filePath.split(/[\\/]/).pop() || '';
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return toolError({
|
|
78
|
+
ok: false, tool: 'argus_review', error_code: 'READ_FAILED',
|
|
79
|
+
message: `Could not read file: ${filePath}`,
|
|
80
|
+
recovery: 'Check the path (absolute), or paste the text into `text`.',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!text.trim() || text.trim().length < 20) {
|
|
85
|
+
return toolError({
|
|
86
|
+
ok: false, tool: 'argus_review', error_code: 'EMPTY',
|
|
87
|
+
message: 'No reviewable text was provided.',
|
|
88
|
+
recovery: 'Pass `text` (≥ 20 chars) or a readable `file_path`.',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const concerns = Array.isArray(a['concerns'])
|
|
92
|
+
? a['concerns']
|
|
93
|
+
: ['full_judgment_review'];
|
|
94
|
+
const artifact = ingest({ source_kind: inferredKind ?? 'paste', title, text, privacy_mode: 'receipt_only' });
|
|
95
|
+
// Honest degrade — never a confident review over unextractable input.
|
|
96
|
+
if (artifact.extraction_quality === 'unsupported' || artifact.units.length === 0) {
|
|
97
|
+
return envelope({
|
|
98
|
+
ok: true, tool: 'argus_review',
|
|
99
|
+
surface: '이 문서는 지금 상태로는 전체 검수가 어렵습니다. 무엇이 빠졌는지부터 확인하세요.',
|
|
100
|
+
next_actions: ['skip'],
|
|
101
|
+
data: {
|
|
102
|
+
needs_context: true,
|
|
103
|
+
extraction_quality: artifact.extraction_quality,
|
|
104
|
+
notes: artifact.extraction_notes,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const reviewability = scoreReviewability(artifact);
|
|
109
|
+
const band = reviewabilityBand(reviewability.score);
|
|
110
|
+
// Routing needs a profile; without an LLM pass we use a neutral default
|
|
111
|
+
// (disclosed) — the agent's own extraction refines it. Concerns still steer.
|
|
112
|
+
const profile = {
|
|
113
|
+
artifact_maturity: 'working_draft',
|
|
114
|
+
document_type: 'unknown',
|
|
115
|
+
intent: 'decide',
|
|
116
|
+
audience: 'unknown',
|
|
117
|
+
stakes: a['stakes'] ?? 'medium',
|
|
118
|
+
source_confidence: 0.3,
|
|
119
|
+
inferred: { document_type: true, intent: true, audience: true, stakes: true },
|
|
120
|
+
};
|
|
121
|
+
const routing = routeLenses(profile, artifact, { concerns, maxLensCalls: 7 });
|
|
122
|
+
const ctx = {
|
|
123
|
+
audience_hint: typeof a['audience_hint'] === 'string' ? a['audience_hint'] : undefined,
|
|
124
|
+
biggest_worry: typeof a['biggest_worry'] === 'string' ? a['biggest_worry'] : undefined,
|
|
125
|
+
concerns,
|
|
126
|
+
};
|
|
127
|
+
// Effective unit count: min of UNIT_LIMIT and however many fit the char budget.
|
|
128
|
+
let charAcc = 0;
|
|
129
|
+
let effLimit = 0;
|
|
130
|
+
for (const u of artifact.units) {
|
|
131
|
+
if (effLimit >= UNIT_LIMIT)
|
|
132
|
+
break;
|
|
133
|
+
charAcc += u.text.length + 48; // rough per-rendered-line overhead (id + kind + loc)
|
|
134
|
+
if (charAcc > CHAR_BUDGET && effLimit > 0)
|
|
135
|
+
break;
|
|
136
|
+
effLimit++;
|
|
137
|
+
}
|
|
138
|
+
const extraction = buildExtractionPrompt(artifact.units, ctx, effLimit);
|
|
139
|
+
const lenses = routing.selected.map((id) => ({
|
|
140
|
+
id,
|
|
141
|
+
label: LENSES[id].label,
|
|
142
|
+
purpose: LENSES[id].purpose,
|
|
143
|
+
review_questions: LENSES[id].review_questions,
|
|
144
|
+
avoid: LENSES[id].failure_modes,
|
|
145
|
+
}));
|
|
146
|
+
return envelope({
|
|
147
|
+
ok: true, tool: 'argus_review',
|
|
148
|
+
surface: `검수 준비 완료 — "${artifact.source_title}" · 검수 가능성 ${reviewability.score}/100 (${band}) · 렌즈 ${lenses.length}개. 아래 단위를 근거로, 렌즈별로 검토한 뒤 사람이 판단할 것과 반증 가능한 예측 하나를 뽑아 argus_seal로 봉인하세요.`,
|
|
149
|
+
next_actions: ['argus_seal', 'skip'],
|
|
150
|
+
data: {
|
|
151
|
+
schema_version: REVIEW_SCHEMA_VERSION,
|
|
152
|
+
lens_version: LENS_VERSION,
|
|
153
|
+
artifact_id: artifact.artifact_id,
|
|
154
|
+
source_title: artifact.source_title,
|
|
155
|
+
reviewability: { score: reviewability.score, band, reasons: reviewability.reasons },
|
|
156
|
+
structure: artifact.detected_structure,
|
|
157
|
+
routing: { selected: routing.selected, disclosure: routing.disclosure, note: '라우팅은 기본 프로파일 기준의 제안입니다 — 추출 단계에서 문서 프로파일을 확정하면 렌즈를 조정하세요.' },
|
|
158
|
+
lenses,
|
|
159
|
+
// The SSOT extraction prompt already embeds the anchored units + the
|
|
160
|
+
// output schema — the agent works off THIS single block for every
|
|
161
|
+
// stage (no separate units dump; the source text is heavy and should
|
|
162
|
+
// not be sent twice).
|
|
163
|
+
extraction_prompt: extraction,
|
|
164
|
+
units_shown: Math.min(artifact.units.length, effLimit),
|
|
165
|
+
units_total: artifact.units.length,
|
|
166
|
+
...(artifact.units.length > effLimit
|
|
167
|
+
? { units_truncated_note: `문서 단위 ${artifact.units.length}개 중 앞 ${effLimit}개만 실었습니다(응답 크기 제한). 더 검수하려면 뒷부분을 따로 넣으세요.` }
|
|
168
|
+
: {}),
|
|
169
|
+
protocol: [
|
|
170
|
+
'1) extraction_prompt(그 안의 units + 출력 스키마)를 적용해 문서 판단 지도(profile + claims/assumptions/decision_points)를 만든다.',
|
|
171
|
+
'2) lenses의 각 렌즈로 그 units를 근거로 검토한다 — 모든 finding은 unit을 근거로 하고, 산문에는 unit_id를 노출하지 않는다.',
|
|
172
|
+
'3) 사람이 직접 판단해야 할 항목(judgment obligations)을 분리한다. 평결하지 않는다.',
|
|
173
|
+
'4) 현실이 pass/fail로 답할 반증 가능한 예측 1개를 뽑아 argus_seal로 봉인한다 — 예측·pass/fail 조건·check_by는 사용자의 것이다.',
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
return handleToolException('argus_review', e);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
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 { writeSealReceipt } from '../lib/receipt.js';
|
|
10
|
+
import { premiseId, MAX_ACTIVE_PREMISES, MAX_LOAD_BEARING } from '../lib/premises.js';
|
|
11
|
+
import { pushToAccount } from '../lib/push-account.js';
|
|
12
|
+
import { ensurePrivacyGitignore } from '../lib/privacy.js';
|
|
13
|
+
import { renderSeal } from '../lib/render-receipt.js';
|
|
14
|
+
import { surfaceLocale } from '../lib/surfaces.js';
|
|
15
|
+
import { SCHEMA_VERSION } from '../lib/spine.js';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { envelope, toolError } from '../lib/envelope.js';
|
|
18
|
+
import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir, zId, zDate } from './tool-types.js';
|
|
19
|
+
import { handleToolException } from './errors.js';
|
|
20
|
+
const inputSchema = z.strictObject({
|
|
21
|
+
argus_dir: zArgusDir,
|
|
22
|
+
id: zId.describe('The id from argus_open_decision.'),
|
|
23
|
+
predicate: z.string().min(8).max(400).describe('A prediction reality can mark true/false. Good: "cutover downtime < 5 min". Bad: "it will go well".'),
|
|
24
|
+
check_by: zDate.describe('YYYY-MM-DD, a real future date — when you will come back to settle.'),
|
|
25
|
+
predicate_owner: z.enum(['user', 'ai_surfaced']).describe('Provenance. Never forge. "user" = the user wrote or affirmed it. "ai_surfaced" = Argus drafted, unconfirmed.'),
|
|
26
|
+
basis: z.enum(['judgment', 'luck', 'mixed', 'unsure']).optional(),
|
|
27
|
+
real_question: z.string().max(400).describe('The real question behind the answer (receipt).').optional(),
|
|
28
|
+
unverified_assumption: z.string().max(400).describe('The core assumption not yet verified (receipt).').optional(),
|
|
29
|
+
human_only: z.string().max(400).describe('What only a human can judge here (receipt).').optional(),
|
|
30
|
+
human_judgment: z.string().max(400).describe("The user's one-line call. MUST be the user's words — never an Argus-drafted line relabeled.").optional(),
|
|
31
|
+
today_override: zDate.optional(),
|
|
32
|
+
});
|
|
33
|
+
export const seal = {
|
|
34
|
+
name: 'argus_seal',
|
|
35
|
+
description: 'Seal a falsifiable prediction (predicate + check-by date) for an open decision. Captures the seal-time Judgment Receipt fields. Refuses an empty/non-falsifiable predicate or a non-future date. On success, data.seal_text is the sealing confirmation rendered for the user — show it to them verbatim.',
|
|
36
|
+
inputSchema,
|
|
37
|
+
outputSchema: ENVELOPE_OUTPUT_SCHEMA,
|
|
38
|
+
// openWorldHint: true — with ARGUS_TOKEN set, sealing also mirrors to the account.
|
|
39
|
+
annotations: { title: 'Seal a prediction', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
40
|
+
handler: async (a) => {
|
|
41
|
+
try {
|
|
42
|
+
const dir = resolveToolArgusDir(a['argus_dir']);
|
|
43
|
+
const id = String(a['id'] ?? '');
|
|
44
|
+
const today = resolveToday({ override: a['today_override'] });
|
|
45
|
+
const current = resolveContract(dir, id, today);
|
|
46
|
+
guardTransition(current.state, 'seal'); // throws DECISION_CLOSED / ILLEGAL_TRANSITION
|
|
47
|
+
const vErr = validateSeal(a['predicate'], a['check_by'], today);
|
|
48
|
+
if (vErr) {
|
|
49
|
+
return toolError({ ok: false, tool: 'argus_seal', error_code: vErr.code, message: vErr.message, recovery: vErr.recovery });
|
|
50
|
+
}
|
|
51
|
+
const predicate = String(a['predicate']);
|
|
52
|
+
const checkBy = String(a['check_by']);
|
|
53
|
+
const now = new Date().toISOString();
|
|
54
|
+
await ensurePrivacyGitignore(dir);
|
|
55
|
+
// seal-time receipt (the rich fields that make the receipt not blank)
|
|
56
|
+
const receipt = await writeSealReceipt(dir, {
|
|
57
|
+
id, predicate, check_by: checkBy,
|
|
58
|
+
real_question: a['real_question'],
|
|
59
|
+
unverified_assumption: a['unverified_assumption'],
|
|
60
|
+
human_only: a['human_only'],
|
|
61
|
+
human_judgment: a['human_judgment'],
|
|
62
|
+
basis: a['basis'],
|
|
63
|
+
}, now);
|
|
64
|
+
// bearing seed (so a due contract is visible even before the ledger is replayed elsewhere)
|
|
65
|
+
await atomicWriteJson(bearingPath(dir, id), {
|
|
66
|
+
v: SCHEMA_VERSION, id, contract_seed: { predicate, check_by: checkBy }, predicate_owner: a['predicate_owner'],
|
|
67
|
+
});
|
|
68
|
+
// ledger: self-create harvest if the decision was sealed without an explicit open
|
|
69
|
+
const events = [];
|
|
70
|
+
if (current.state === 'absent')
|
|
71
|
+
events.push({ id, event: 'harvest', decision: predicate });
|
|
72
|
+
events.push({ id, event: 'seal', predicate, check_by: checkBy, basis: a['basis'] });
|
|
73
|
+
// Promotion (plan v5 §5.4): the named unverified_assumption IS the first
|
|
74
|
+
// premise — the premise set is canonical, the seal field is its input
|
|
75
|
+
// alias. source='user' (receipt judgment fields are user-named),
|
|
76
|
+
// external=false until the user marks it (honest default: we cannot infer
|
|
77
|
+
// reality-checkability), load_bearing=true (it is the receipt headline).
|
|
78
|
+
// Skipped field ⇒ no promotion. Dedup + cap-safe: never fails the seal.
|
|
79
|
+
let promotedRef = null;
|
|
80
|
+
const ua = a['unverified_assumption'];
|
|
81
|
+
if (ua && ua.trim()) {
|
|
82
|
+
const existingPrems = current.entry?.premises ?? [];
|
|
83
|
+
const pid = premiseId(id, 'premise', ua);
|
|
84
|
+
const lbCount = existingPrems.filter((p) => p.status === 'active' && p.load_bearing).length;
|
|
85
|
+
const activeCount = existingPrems.filter((p) => p.status === 'active').length;
|
|
86
|
+
const isDup = existingPrems.some((p) => p.premise_id === pid);
|
|
87
|
+
if (!isDup && activeCount < MAX_ACTIVE_PREMISES) {
|
|
88
|
+
const ordinal = existingPrems.reduce((m, p) => Math.max(m, p.ordinal), 0) + 1;
|
|
89
|
+
events.push({
|
|
90
|
+
id, event: 'premise_add', premise_id: pid, ordinal,
|
|
91
|
+
kind: 'premise', text: ua.trim(),
|
|
92
|
+
external: false, load_bearing: lbCount < MAX_LOAD_BEARING,
|
|
93
|
+
source: 'user',
|
|
94
|
+
});
|
|
95
|
+
promotedRef = `P${ordinal}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await appendLedger(dir, events, now);
|
|
99
|
+
const namedAssumption = !receipt.skipped.includes('unverified_assumption');
|
|
100
|
+
const nudge = namedAssumption
|
|
101
|
+
? ''
|
|
102
|
+
: ' You sealed without naming the assumption it rests on — that\'s recorded as skipped, not hidden. You can still name it.';
|
|
103
|
+
// Opt-in: mirror the prediction to the user's account so the Companion
|
|
104
|
+
// Brief can email it at check-by. No token ⇒ silent local-only no-op;
|
|
105
|
+
// failure never affects the seal that already succeeded locally.
|
|
106
|
+
const sync = await pushToAccount({
|
|
107
|
+
action: 'seal', id, predicate, check_by: checkBy, sealed_at: now,
|
|
108
|
+
source_title: predicate.slice(0, 80),
|
|
109
|
+
real_question: a['real_question'],
|
|
110
|
+
human_judgment: a['human_judgment'],
|
|
111
|
+
});
|
|
112
|
+
// 3-state sync voice (11 S3): success speaks, no-token stays silent
|
|
113
|
+
// (local-only is the chosen default, not a failure), and a FAILURE with a
|
|
114
|
+
// token set must speak — the user believes an email is coming.
|
|
115
|
+
const syncLine = sync.synced
|
|
116
|
+
? ' Synced to your account — you\'ll get an email when it comes due.'
|
|
117
|
+
: sync.reason === 'no_token'
|
|
118
|
+
? ''
|
|
119
|
+
: ` (Account sync didn't go through — ${sync.reason}. Your seal is safe locally; the email reminder won't fire until it syncs. Try argus_sync later.)`;
|
|
120
|
+
// The sealing confirmation (P1-E2): the terminal twin of the webapp's
|
|
121
|
+
// seal certificate. surface stays the short model-facing line; seal_text
|
|
122
|
+
// is FOR THE USER (the tool description says: show it verbatim).
|
|
123
|
+
const seal_text = renderSeal({
|
|
124
|
+
predicate,
|
|
125
|
+
predicate_owner: a['predicate_owner'],
|
|
126
|
+
sealed_on: now.slice(0, 10),
|
|
127
|
+
check_by: checkBy,
|
|
128
|
+
today,
|
|
129
|
+
locale: surfaceLocale(dir),
|
|
130
|
+
});
|
|
131
|
+
return envelope({
|
|
132
|
+
ok: true, tool: 'argus_seal',
|
|
133
|
+
surface: `Sealed. "${predicate}" — reality answers on ${checkBy}. Come back then with argus_settle.${nudge}${syncLine}`,
|
|
134
|
+
next_actions: ['argus_check_in', 'stop'],
|
|
135
|
+
data: {
|
|
136
|
+
id, predicate, check_by: checkBy, predicate_owner: a['predicate_owner'],
|
|
137
|
+
seal_text,
|
|
138
|
+
status: 'sealed', ledger_events_written: events.map((e) => e.event),
|
|
139
|
+
skipped: receipt.skipped,
|
|
140
|
+
account_synced: sync.synced,
|
|
141
|
+
...(sync.synced ? {} : { account_sync_reason: sync.reason }),
|
|
142
|
+
// The named assumption now lives as a tracked premise (canonical set).
|
|
143
|
+
// Marking it external (argus_premises op=amend) arms reality re-checks.
|
|
144
|
+
...(promotedRef ? { premise_promoted: promotedRef } : {}),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
return handleToolException('argus_seal', e);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { resolveToolArgusDir } from '../lib/argus-dir.js';
|
|
2
|
+
import { resolveToday, asDate } from '../lib/resolve-today.js';
|
|
3
|
+
import { resolveContract } from '../lib/resolve-contract.js';
|
|
4
|
+
import { guardTransition } from '../lib/state-machine.js';
|
|
5
|
+
import { appendLedger } from '../lib/ledger-append.js';
|
|
6
|
+
import { writeSettleReceipt } from '../lib/receipt.js';
|
|
7
|
+
import { pushToAccount } from '../lib/push-account.js';
|
|
8
|
+
import { elicit, canElicit } from '../lib/elicit.js';
|
|
9
|
+
import { renderReceipt } from '../lib/render-receipt.js';
|
|
10
|
+
import { resolvePremiseRef, receiptPremisesInfo } from '../lib/premises.js';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { envelope, toolError } from '../lib/envelope.js';
|
|
13
|
+
import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir, zId, zDate } from './tool-types.js';
|
|
14
|
+
import { handleToolException } from './errors.js';
|
|
15
|
+
const inputSchema = z.strictObject({
|
|
16
|
+
argus_dir: zArgusDir,
|
|
17
|
+
id: zId,
|
|
18
|
+
outcome: z.enum(['held', 'avoided', 'partial', 'still_pending']).describe("What reality did to the prediction. Record the user's words — never infer. If omitted, Argus asks the user directly (elicitation) on hosts that support it.").optional(),
|
|
19
|
+
outcome_source: z.literal('user_stated').describe('Single value "user_stated". An AI-inferred outcome cannot be expressed.'),
|
|
20
|
+
what_happened: z.string().min(1).max(600),
|
|
21
|
+
broken_premise_ref: z.string().max(64).optional().describe('Optional, USER-attributed: which tracked premise (ordinal like "P1"), if any, broke and drove the outcome. Never inferred by the model — ask, or omit.'),
|
|
22
|
+
today_override: zDate.optional(),
|
|
23
|
+
});
|
|
24
|
+
export const settle = {
|
|
25
|
+
name: 'argus_settle',
|
|
26
|
+
description: 'Settle a sealed decision against reality and issue a Judgment Receipt with zero AI verdict. Hard-errors if there is no prior seal. The outcome is the user\'s — recorded, never inferred.',
|
|
27
|
+
inputSchema,
|
|
28
|
+
outputSchema: ENVELOPE_OUTPUT_SCHEMA,
|
|
29
|
+
// openWorldHint: true — with ARGUS_TOKEN set, settling also mirrors to the account.
|
|
30
|
+
// idempotentHint:false (11 S7) — a repeat settle is NOT a no-op: it hard-errors
|
|
31
|
+
// ALREADY_SETTLED (append-only ledger), so the hint was a false signal to hosts.
|
|
32
|
+
annotations: { title: 'Settle against reality', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
33
|
+
handler: async (a) => {
|
|
34
|
+
try {
|
|
35
|
+
const dir = resolveToolArgusDir(a['argus_dir']);
|
|
36
|
+
const id = String(a['id'] ?? '');
|
|
37
|
+
const today = resolveToday({ override: a['today_override'] });
|
|
38
|
+
const current = resolveContract(dir, id, today);
|
|
39
|
+
guardTransition(current.state, 'settle'); // NO_PRIOR_SEAL / ALREADY_SETTLED / DECISION_CLOSED
|
|
40
|
+
// Outcome is the user's — recorded, never inferred. If the model didn't
|
|
41
|
+
// supply it, ask the USER directly with a structured choice (spine-safe:
|
|
42
|
+
// this is reality, not a verdict). Falls back to requiring it on hosts
|
|
43
|
+
// without elicitation.
|
|
44
|
+
let outcome = a['outcome'];
|
|
45
|
+
if (!outcome && canElicit()) {
|
|
46
|
+
const picked = await elicit('현실이 어떻게 답했나요? (What did reality do?)', {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
outcome: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
enum: ['held', 'avoided', 'partial', 'still_pending'],
|
|
52
|
+
enumNames: ['그렇게 됐다 (held)', '피했다 (avoided)', '부분적으로 (partial)', '아직 불분명 (still pending)'],
|
|
53
|
+
description: 'What reality did to your sealed prediction.',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
required: ['outcome'],
|
|
57
|
+
});
|
|
58
|
+
const v = picked?.['outcome'];
|
|
59
|
+
if (v === 'held' || v === 'avoided' || v === 'partial' || v === 'still_pending')
|
|
60
|
+
outcome = v;
|
|
61
|
+
}
|
|
62
|
+
if (!outcome) {
|
|
63
|
+
return toolError({
|
|
64
|
+
ok: false, tool: 'argus_settle', error_code: 'OUTCOME_REQUIRED',
|
|
65
|
+
message: 'Reality has to answer: held, avoided, partial, or still_pending.',
|
|
66
|
+
recovery: 'Ask the user what actually happened and pass it as `outcome` — never infer it.',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const checkBy = asDate(current.check_by);
|
|
70
|
+
if (outcome === 'still_pending' && checkBy && checkBy > today) {
|
|
71
|
+
return toolError({
|
|
72
|
+
ok: false, tool: 'argus_settle', error_code: 'PREMATURE_SETTLE',
|
|
73
|
+
message: `Not due yet (check-by ${checkBy}, today ${today}).`,
|
|
74
|
+
recovery: 'Wait for the check-by date, or amend the date if the timeline changed.',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// Premise-level attribution (plan v5 P2) — the user's own read of WHICH
|
|
78
|
+
// premise broke. Counts feed track_record frequency statements; never a
|
|
79
|
+
// grade. An invalid ref fails loudly rather than mis-attributing.
|
|
80
|
+
let brokenPremiseId;
|
|
81
|
+
let brokenPremiseRef;
|
|
82
|
+
const bpr = a['broken_premise_ref'];
|
|
83
|
+
if (typeof bpr === 'string' && bpr.trim()) {
|
|
84
|
+
const p = resolvePremiseRef(current.entry?.premises ?? [], bpr); // throws NO_SUCH_PREMISE/AMBIGUOUS_REF
|
|
85
|
+
brokenPremiseId = p.premise_id;
|
|
86
|
+
brokenPremiseRef = `P${p.ordinal}`;
|
|
87
|
+
}
|
|
88
|
+
const now = new Date().toISOString();
|
|
89
|
+
await appendLedger(dir, [{ id, event: 'settle', outcome, decision: a['what_happened'], ...(brokenPremiseId ? { broken_premise_id: brokenPremiseId } : {}) }], now);
|
|
90
|
+
const receipt = await writeSettleReceipt(dir, id, { what_happened: String(a['what_happened']), outcome, settled_at: now }, { predicate: current.predicate, check_by: current.check_by });
|
|
91
|
+
// Mirror the outcome to the account (opt-in) so a synced prediction stops
|
|
92
|
+
// being "due" — otherwise the Companion Brief would keep re-nudging it.
|
|
93
|
+
// No-op when there's no token or the id was never synced.
|
|
94
|
+
const sync = await pushToAccount({
|
|
95
|
+
action: 'settle', id, outcome, what_happened: String(a['what_happened']), settled_at: now,
|
|
96
|
+
});
|
|
97
|
+
// 3-state sync voice (11 S3, same pattern as seal): silence is only honest
|
|
98
|
+
// for the no-token default — a failed mirror means the account keeps
|
|
99
|
+
// listing this as due (and may re-email it), so say so.
|
|
100
|
+
const syncLine = sync.synced
|
|
101
|
+
? ''
|
|
102
|
+
: sync.reason === 'no_token'
|
|
103
|
+
? ''
|
|
104
|
+
: ` (Account sync didn't go through — ${sync.reason}. Your settlement is safe locally; the account may keep listing this as due until it syncs. Try argus_sync later.)`;
|
|
105
|
+
return envelope({
|
|
106
|
+
ok: true, tool: 'argus_settle',
|
|
107
|
+
surface: 'Settled. The receipt records what you predicted and what reality did — no grade.' + syncLine,
|
|
108
|
+
next_actions: ['argus_recall', 'stop'],
|
|
109
|
+
data: {
|
|
110
|
+
id, outcome, outcome_source: 'user_stated',
|
|
111
|
+
assumption_held: receipt.assumption_held,
|
|
112
|
+
...(brokenPremiseRef ? { broken_premise: brokenPremiseRef, broken_premise_source: 'user_stated' } : {}),
|
|
113
|
+
ai_verdict: null,
|
|
114
|
+
account_synced: sync.synced,
|
|
115
|
+
...(sync.synced ? {} : { account_sync_reason: sync.reason }),
|
|
116
|
+
receipt,
|
|
117
|
+
// The premise set is canonical — the receipt's summary renders from the fold (plan v5 §3.3).
|
|
118
|
+
receipt_text: renderReceipt(receipt, receiptPremisesInfo(current.entry)),
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
return handleToolException('argus_settle', e);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { envelope, toolError } from '../lib/envelope.js';
|
|
3
|
+
import { ENVELOPE_OUTPUT_SCHEMA, zArgusDir } from './tool-types.js';
|
|
4
|
+
import { handleToolException } from './errors.js';
|
|
5
|
+
import { fetchAccountReceipts } from '../lib/push-account.js';
|
|
6
|
+
import { resolveToolArgusDir } from '../lib/argus-dir.js';
|
|
7
|
+
import { resolveToday } from '../lib/resolve-today.js';
|
|
8
|
+
import { replayLedger } from '../lib/ledger-replay.js';
|
|
9
|
+
import { surfacesFor } from '../lib/surfaces.js';
|
|
10
|
+
/**
|
|
11
|
+
* argus_sync — the explicit, bidirectional bridge (design doc §연결 방식).
|
|
12
|
+
*
|
|
13
|
+
* PUSH is automatic: argus_seal / argus_settle already mirror to the account
|
|
14
|
+
* when ARGUS_TOKEN is set. PULL is this tool: it lists your account receipts —
|
|
15
|
+
* live judgments + what's due — so the terminal can settle without the web app.
|
|
16
|
+
* Read-only and opt-in; with no token it explains how to connect.
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_LIMIT = 50;
|
|
19
|
+
const MAX_LIMIT = 200;
|
|
20
|
+
const inputSchema = z.strictObject({
|
|
21
|
+
argus_dir: zArgusDir,
|
|
22
|
+
due_only: z.boolean().default(false).describe('List only receipts whose check-by date has arrived.'),
|
|
23
|
+
limit: z.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_LIMIT).describe(`Max receipts to list (default ${DEFAULT_LIMIT}). Due items are ordered first.`),
|
|
24
|
+
});
|
|
25
|
+
export const sync = {
|
|
26
|
+
name: 'argus_sync',
|
|
27
|
+
description: 'Pull your Argus account receipts into the terminal — live judgments and what is due. ' +
|
|
28
|
+
'Returns: receipts (id, local_id, settle_path, title, state, next_check_by, due, open_predicates) with due items first, plus total/due/has_more. ' +
|
|
29
|
+
'Account ids carry an mcp_ prefix for terminal-sealed judgments: settle those with argus_settle using local_id (NOT the account id). ' +
|
|
30
|
+
'Web-sealed judgments (local_id null) settle in the web dashboard. ' +
|
|
31
|
+
'A terminal-sealed judgment already settled on the web is flagged settled_in_account:true — record the same outcome locally with argus_settle if you want it in this ledger too. ' +
|
|
32
|
+
'Seals/settles already push to the account automatically; this is the READ side. Use due_only:true to see just what needs settling. Requires ARGUS_TOKEN (Settings → sync token).',
|
|
33
|
+
inputSchema,
|
|
34
|
+
outputSchema: ENVELOPE_OUTPUT_SCHEMA,
|
|
35
|
+
annotations: { title: 'Sync account receipts', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
36
|
+
handler: async (a) => {
|
|
37
|
+
try {
|
|
38
|
+
const pull = await fetchAccountReceipts();
|
|
39
|
+
if (!pull.ok && pull.reason === 'no_token') {
|
|
40
|
+
return toolError({
|
|
41
|
+
ok: false, tool: 'argus_sync', error_code: 'NOT_CONNECTED',
|
|
42
|
+
message: 'This terminal is not connected to an Argus account.',
|
|
43
|
+
recovery: 'Issue a token in the web app (Settings → sync token) and set ARGUS_TOKEN in your MCP config.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (!pull.ok) {
|
|
47
|
+
return toolError({
|
|
48
|
+
ok: false, tool: 'argus_sync', error_code: 'SYNC_FAILED',
|
|
49
|
+
message: `Could not reach the account (${pull.reason}).`,
|
|
50
|
+
recovery: 'Check your network / ARGUS_API_URL, then try again. Local seals are unaffected.',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const dueOnly = a['due_only'] === true;
|
|
54
|
+
const rawLimit = typeof a['limit'] === 'number' ? Math.floor(a['limit']) : DEFAULT_LIMIT;
|
|
55
|
+
const limit = Math.max(1, Math.min(MAX_LIMIT, rawLimit));
|
|
56
|
+
const matched = dueOnly ? pull.receipts.filter((r) => r.due) : pull.receipts;
|
|
57
|
+
const receipts = matched.slice(0, limit);
|
|
58
|
+
const dueCount = pull.receipts.filter((r) => r.due).length;
|
|
59
|
+
const truncated = matched.length > receipts.length;
|
|
60
|
+
// ④ Reverse cross-check (best-effort, read-only): a judgment sealed here
|
|
61
|
+
// but settled in the WEB stays 'sealed' in the local ledger forever, so
|
|
62
|
+
// check_in / recall keep citing it as due. Compare account state against
|
|
63
|
+
// the local ledger and FLAG the mismatch — never auto-settle locally
|
|
64
|
+
// (recording an outcome without the user's own words would be a machine
|
|
65
|
+
// settlement on an append-only ledger; the user runs argus_settle).
|
|
66
|
+
let localContracts = null;
|
|
67
|
+
let boundDir = null;
|
|
68
|
+
try {
|
|
69
|
+
boundDir = resolveToolArgusDir(a['argus_dir']);
|
|
70
|
+
localContracts = replayLedger(boundDir, resolveToday({})).contracts;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
localContracts = null; // no local dir bound — account-only listing, skip the cross-check
|
|
74
|
+
}
|
|
75
|
+
// Locale brain (P1-E1): surface strings come from the {ko,en} dictionary,
|
|
76
|
+
// picked by the config's locale. No bound dir / no config → base 'en'.
|
|
77
|
+
const S = surfacesFor(boundDir).sync;
|
|
78
|
+
const settledInAccount = (accountId, accountState) => {
|
|
79
|
+
if (!localContracts || accountState !== 'settled' || !accountId.startsWith('mcp_'))
|
|
80
|
+
return false;
|
|
81
|
+
const entry = localContracts.get(accountId.slice(4));
|
|
82
|
+
return entry?.status === 'sealed'; // sealed locally (incl. derived-due) yet already settled in the account
|
|
83
|
+
};
|
|
84
|
+
const settledInAccountCount = pull.receipts.filter((r) => settledInAccount(r.id, r.state)).length;
|
|
85
|
+
const baseSurface = dueCount > 0
|
|
86
|
+
? S.live_with_due(pull.receipts.length, dueCount)
|
|
87
|
+
: S.live_no_due(pull.receipts.length);
|
|
88
|
+
const crossCheckLine = settledInAccountCount > 0
|
|
89
|
+
? S.settled_on_web(settledInAccountCount)
|
|
90
|
+
: '';
|
|
91
|
+
return envelope({
|
|
92
|
+
ok: true, tool: 'argus_sync',
|
|
93
|
+
surface: baseSurface + crossCheckLine,
|
|
94
|
+
next_actions: dueCount > 0 ? ['argus_settle', 'stop'] : ['stop'],
|
|
95
|
+
data: {
|
|
96
|
+
total: pull.receipts.length,
|
|
97
|
+
due: dueCount,
|
|
98
|
+
count: receipts.length,
|
|
99
|
+
has_more: truncated,
|
|
100
|
+
...(truncated ? { truncation_note: S.truncation(receipts.length, matched.length) } : {}),
|
|
101
|
+
receipts: receipts.map((r) => {
|
|
102
|
+
// Terminal-sealed judgments live in the account under an `mcp_` prefix
|
|
103
|
+
// (webapp api/mcp/seal rowId). Settling with the ACCOUNT id always
|
|
104
|
+
// fails (NO_PRIOR_SEAL: the local ledger knows the unprefixed id), so
|
|
105
|
+
// hand the caller the exact id argus_settle expects — or route
|
|
106
|
+
// web-sealed rows to the web dashboard.
|
|
107
|
+
const localId = r.id.startsWith('mcp_') ? r.id.slice(4) : null;
|
|
108
|
+
return {
|
|
109
|
+
id: r.id,
|
|
110
|
+
local_id: localId,
|
|
111
|
+
settle_path: localId ? 'argus_settle (use local_id)' : 'webapp',
|
|
112
|
+
title: r.source_title,
|
|
113
|
+
state: r.state,
|
|
114
|
+
next_check_by: r.next_check_by,
|
|
115
|
+
due: r.due,
|
|
116
|
+
open_predicates: r.open_predicates,
|
|
117
|
+
// Settled in the account (web) while the local ledger still says
|
|
118
|
+
// sealed. Flag only — the local record stays the user's to write.
|
|
119
|
+
...(settledInAccount(r.id, r.state) ? { settled_in_account: true } : {}),
|
|
120
|
+
};
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
return handleToolException('argus_sync', e);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// ── Shared field builders (DRY — argus_dir / id / date recur on every tool) ──
|
|
3
|
+
// argus_dir is OPTIONAL: omit it to use the ARGUS_DIR env var from your MCP
|
|
4
|
+
// config (the ergonomic default — set once, never pass again). A per-call value
|
|
5
|
+
// still wins. Resolution + validation live in resolveToolArgusDir.
|
|
6
|
+
export const zArgusDir = z
|
|
7
|
+
.string()
|
|
8
|
+
.describe('Absolute path to the .argus directory (no ".."). Omit to use the ARGUS_DIR env var from your MCP config.')
|
|
9
|
+
.optional();
|
|
10
|
+
export const zId = z.string().regex(/^[A-Za-z0-9._-]+$/, 'id may only contain A-Z a-z 0-9 . _ -');
|
|
11
|
+
export const zDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'must be YYYY-MM-DD');
|
|
12
|
+
/** JSON Schema for tools/list, generated from the Zod source (drop $schema noise). */
|
|
13
|
+
export function toolJsonSchema(schema) {
|
|
14
|
+
const json = z.toJSONSchema(schema);
|
|
15
|
+
delete json['$schema'];
|
|
16
|
+
return json;
|
|
17
|
+
}
|
|
18
|
+
/** Shared envelope output schema fragment (structuredContent contract). */
|
|
19
|
+
export const ENVELOPE_OUTPUT_SCHEMA = {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
ok: { type: 'boolean' },
|
|
23
|
+
tool: { type: 'string' },
|
|
24
|
+
surface: { type: 'string' },
|
|
25
|
+
next_actions: { type: 'array', items: { type: 'string' } },
|
|
26
|
+
data: { type: 'object' },
|
|
27
|
+
over_fire_gate: { type: 'object' },
|
|
28
|
+
error_code: { type: 'string' },
|
|
29
|
+
message: { type: 'string' },
|
|
30
|
+
},
|
|
31
|
+
required: ['ok', 'tool'],
|
|
32
|
+
};
|