@adrata/adrata-mcp 1.0.49 → 1.0.50

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.
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Proposition tools, the one graph read, and Galaxy document filing.
3
+ *
4
+ * A PROPOSITION is a claim about the world we act on ("enterprise buyers will
5
+ * pay for provable control") — the theory-of-change assumption, the leap of
6
+ * faith, the forecast. It is SHARED: many decisions and strategies rest on
7
+ * it, evidence updates it in one place, and when it falls everything that
8
+ * assumed it lights up (`standing: rests_on_falsified` on a linked decision,
9
+ * `at_risk` on a linked strategy, both derived on read). Its doctrine:
10
+ *
11
+ * - A proposition with no FALSIFIER is a slogan; the falsifier is required.
12
+ * - `confidence` on the row is the latest READING's value. It is never edited
13
+ * directly: every change is a reading with its evidence and source, so the
14
+ * history is a calibration record.
15
+ * - Moving to `contested` or `falsified` needs a reading with evidence; the
16
+ * API refuses the status change without one.
17
+ * - A replacement is a NEW proposition with `supersedesPropositionId`; the
18
+ * old row keeps its readings and its discussion.
19
+ *
20
+ * The GRAPH read is one route, any root (`<kind>:<id>`), walked both ways to
21
+ * a depth, as JSON or as a mermaid document an agent can paste into a Stream,
22
+ * a card or a decision body.
23
+ *
24
+ * DOCUMENTS: every noun can file a Galaxy document with a role (memo,
25
+ * outcome, evidence, source, artifact) and a reason, on
26
+ * `POST/DELETE /{family}/{id}/documents[/{documentId}]`.
27
+ *
28
+ * See work-thread-tools.js for the model and CONTRACT.md ("Propositions: the
29
+ * fifth noun", "Thread graphs you can generate", "Galaxy, and the ontology as
30
+ * the core of the system") for the routes.
31
+ */
32
+
33
+ import {
34
+ AUDIENCES,
35
+ DOCUMENT_FAMILY_PATHS,
36
+ DOCUMENT_ROLES,
37
+ DOCUMENT_TARGET_TYPES,
38
+ GOVERNED_NOTE,
39
+ PROPOSITION_EVIDENCE_STATUSES,
40
+ PROPOSITION_LINK_TARGET_TYPES,
41
+ PROPOSITION_STATUSES,
42
+ THREAD_GRAPH_DEFAULT_DEPTH,
43
+ THREAD_GRAPH_FORMATS,
44
+ THREAD_GRAPH_MAX_NODES,
45
+ THREAD_GRAPH_ROOT_KINDS,
46
+ audienceQuery,
47
+ buildQuery,
48
+ compactBody,
49
+ governedArgs,
50
+ mermaidText,
51
+ parseGraphRoot,
52
+ validateBody,
53
+ validateConfidence,
54
+ validateDate,
55
+ validateDepth,
56
+ validateEnum,
57
+ validateReason,
58
+ validateStateReason,
59
+ } from './shared.js';
60
+
61
+ const BASE = '/api/v1/work-propositions';
62
+ const GRAPH_PATH = '/api/v1/work-threads/graph';
63
+
64
+ /** A required free-text field: non-blank, under the body ceiling. Trimmed. */
65
+ function requiredText(value, field) {
66
+ const text = validateBody(value, field).trim();
67
+ if (!text) throw new Error(`${field} is required and cannot be blank`);
68
+ return text;
69
+ }
70
+
71
+ export function registerWorkPropositionTools(
72
+ server,
73
+ { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope = () => undefined }
74
+ ) {
75
+ server.tool(
76
+ 'list_propositions',
77
+ 'List propositions — the claims about the world we act on — each with its statement, status (proposed | supported | contested | falsified | superseded), current confidence 0-1 (the latest reading), its falsifier, audience, review date and how many goals, decisions, ideas and indicators rest on it. Pass targetType + targetId to see what one node ASSUMES. Read supersedesPropositionId to walk a replaced claim.',
78
+ {
79
+ status: z.enum(PROPOSITION_STATUSES).optional().describe('Narrow to one lifecycle status.'),
80
+ audience: z.array(z.enum(AUDIENCES)).optional().describe('Narrow to these audiences. Omit for all.'),
81
+ targetType: z.enum(PROPOSITION_LINK_TARGET_TYPES).optional().describe('With targetId: only the propositions this node rests on.'),
82
+ targetId: z.string().optional().describe('With targetType: the node id.'),
83
+ },
84
+ async (args) => {
85
+ if ((args.targetType && !args.targetId) || (!args.targetType && args.targetId)) {
86
+ throw new Error('targetType and targetId go together');
87
+ }
88
+ const query = buildQuery({
89
+ status: args.status,
90
+ audience: audienceQuery(args.audience),
91
+ targetType: args.targetType,
92
+ targetId: args.targetId,
93
+ });
94
+ const data = await api('GET', `${BASE}${query}`);
95
+ const items = data?.data || [];
96
+ return ok({ count: items.length, items });
97
+ }
98
+ );
99
+
100
+ server.tool(
101
+ 'get_proposition',
102
+ 'Read one proposition whole: statement, rationale, falsifier, status, the confidence HISTORY as readings (each with its evidence, source and when it was observed; the latest is current), everything that rests on it (the linked goals, decisions, ideas and indicators with their reasons), the Galaxy documents filed on it, and the proposition it supersedes or is superseded by.',
103
+ {
104
+ propositionId: z.string().describe('Proposition id from list_propositions.'),
105
+ },
106
+ async (args) => {
107
+ const data = await api('GET', `${BASE}/${encodeURIComponent(args.propositionId)}`);
108
+ const proposition = data?.data ?? null;
109
+ if (!proposition) return ok({ proposition: null });
110
+ const readings = Array.isArray(proposition.readings) ? proposition.readings : [];
111
+ const links = Array.isArray(proposition.links) ? proposition.links : [];
112
+ return ok({ readingCount: readings.length, linkCount: links.length, proposition });
113
+ }
114
+ );
115
+
116
+ server.tool(
117
+ 'get_proposition_calibration',
118
+ 'Read how well a person\'s stated confidence on propositions has matched how they resolved: the count stated, how many have resolved (supported or falsified), a Brier score once ten have resolved (null before that, with the count shown instead), and the split. Stated confidence at first reading is what is scored. Omit userId for the caller. Decision quality and outcome quality are shown side by side, never merged.',
119
+ {
120
+ userId: z.string().optional().describe('Whose calibration. Defaults to the caller.'),
121
+ },
122
+ async (args) => {
123
+ const query = buildQuery({ userId: args.userId });
124
+ const data = await api('GET', `${BASE}/calibration${query}`);
125
+ return ok({ userId: args.userId ?? null, calibration: data?.data ?? null });
126
+ }
127
+ );
128
+
129
+ server.tool(
130
+ 'state_proposition',
131
+ `State a proposition — a claim about the world we act on — as PROPOSED: the statement as one sentence, the rationale, the FALSIFIER (the observation that would show it false; required, because a proposition with no falsifier is a slogan), the initial confidence 0-1 (recorded as the first reading, so calibration can score it later), audience and a review date. To replace a claim, state a NEW one with supersedesPropositionId — the old one moves to superseded and keeps its readings; never edit the old statement. Link what rests on it with link_proposition afterwards.${GOVERNED_NOTE}`,
132
+ {
133
+ statement: z.string().describe('The claim as one sentence, e.g. "sellers will not type into a CRM".'),
134
+ rationale: z.string().optional().describe('Why we believe it, markdown. Defaults to empty.'),
135
+ confidence: z.number().min(0).max(1).optional().describe('Stated probability it is true, 0-1. Becomes the first reading.'),
136
+ falsifier: z.string().describe('The observation that would show it false. Required.'),
137
+ audience: z.enum(AUDIENCES).describe('Who this claim is about or for.'),
138
+ reviewOn: z.string().optional().describe('When to re-read the claim against the evidence, YYYY-MM-DD.'),
139
+ supersedesPropositionId: z.string().optional().describe('The proposition this one replaces; it moves to superseded.'),
140
+ ...governedArgs(z),
141
+ },
142
+ async (args) => {
143
+ validateEnum(args.audience, AUDIENCES, 'audience');
144
+ const body = compactBody({
145
+ statement: requiredText(args.statement, 'statement'),
146
+ rationale: args.rationale === undefined ? undefined : validateBody(args.rationale, 'rationale'),
147
+ confidence: validateConfidence(args.confidence),
148
+ falsifier: requiredText(args.falsifier, 'falsifier'),
149
+ audience: args.audience,
150
+ reviewOn: validateDate(args.reviewOn, 'reviewOn'),
151
+ supersedesPropositionId: args.supersedesPropositionId,
152
+ });
153
+ const preview = validateApiBridgeRequest({
154
+ method: 'POST',
155
+ path: BASE,
156
+ dryRun: args.dryRun,
157
+ approved: args.approved,
158
+ reason: args.reason,
159
+ idempotencyKey: args.idempotencyKey,
160
+ grantedScope: getGrantedScope(),
161
+ });
162
+ if (preview?.dryRun) return ok({ ...preview, wouldCreate: body });
163
+ const data = await api('POST', BASE, { body, headers: buildMutationHeaders(args) });
164
+ return ok({ created: true, proposition: data?.data });
165
+ }
166
+ );
167
+
168
+ server.tool(
169
+ 'record_proposition_reading',
170
+ `Append a reading to a proposition: the confidence 0-1 you now hold, the EVIDENCE that moved it (required — a confidence change with no evidence is a mood), the source the evidence was read from, and when it was observed (defaults to now). The row's confidence becomes this value; it is never edited any other way, so the readings ARE the calibration record. Readings are appended, never edited — a wrong reading is corrected by a new one that says so. Each call adds one row; do not retry with a fresh idempotencyKey. Record a reading BEFORE moving a proposition to contested or falsified: the API refuses those without one.${GOVERNED_NOTE}`,
171
+ {
172
+ propositionId: z.string().describe('Proposition id from list_propositions.'),
173
+ confidence: z.number().min(0).max(1).describe('The probability you now hold that the claim is true, 0-1.'),
174
+ evidence: z.string().describe('What was observed that moved the confidence. Required. Markdown.'),
175
+ source: z.string().describe('Where the evidence came from — a call, a document, a query, a run id. Required.'),
176
+ observedAt: z.string().optional().describe('ISO-8601 timestamp of the observation. Defaults to now.'),
177
+ ...governedArgs(z),
178
+ },
179
+ async (args) => {
180
+ if (args.confidence === undefined || args.confidence === null) throw new Error('confidence is required, a number from 0 to 1');
181
+ const confidence = validateConfidence(args.confidence);
182
+ const evidence = requiredText(args.evidence, 'evidence');
183
+ const source = requiredText(args.source, 'source');
184
+ const path = `${BASE}/${encodeURIComponent(args.propositionId)}/readings`;
185
+ const body = compactBody({ confidence, evidence, source, observedAt: args.observedAt });
186
+ const preview = validateApiBridgeRequest({
187
+ method: 'POST',
188
+ path,
189
+ dryRun: args.dryRun,
190
+ approved: args.approved,
191
+ reason: args.reason,
192
+ idempotencyKey: args.idempotencyKey,
193
+ grantedScope: getGrantedScope(),
194
+ });
195
+ if (preview?.dryRun) return ok({ ...preview, wouldCreate: { propositionId: args.propositionId, ...body } });
196
+ const data = await api('POST', path, { body, headers: buildMutationHeaders(args) });
197
+ return ok({ recorded: true, propositionId: args.propositionId, proposition: data?.data });
198
+ }
199
+ );
200
+
201
+ server.tool(
202
+ 'set_proposition_status',
203
+ `Move a proposition through its lifecycle: proposed → supported, or to contested (the evidence cuts both ways) or falsified (the falsifier was observed). contested and falsified REQUIRE a reading with evidence first — record_proposition_reading, then this — and the API refuses the move without one. A falsified proposition marks every linked decision standing: rests_on_falsified and every linked strategy at_risk on read: that is the point of the noun. For superseded, prefer state_proposition with supersedesPropositionId, which does it in one transaction. The reason is recorded on the transition and doubles as the audit reason.${GOVERNED_NOTE}`,
204
+ {
205
+ propositionId: z.string().describe('Proposition id from list_propositions.'),
206
+ status: z.enum(PROPOSITION_STATUSES).describe('The new status.'),
207
+ ...governedArgs(z),
208
+ reason: z.string().describe('Why the status changes. Recorded on the transition; also the audit reason.'),
209
+ },
210
+ async (args) => {
211
+ validateEnum(args.status, PROPOSITION_STATUSES, 'status');
212
+ const reason = validateStateReason(args.status, args.reason);
213
+ const path = `${BASE}/${encodeURIComponent(args.propositionId)}`;
214
+ const body = { status: args.status, reason };
215
+ const preview = validateApiBridgeRequest({
216
+ method: 'PATCH',
217
+ path,
218
+ dryRun: args.dryRun,
219
+ approved: args.approved,
220
+ reason,
221
+ idempotencyKey: args.idempotencyKey,
222
+ grantedScope: getGrantedScope(),
223
+ });
224
+ if (preview?.dryRun) {
225
+ return ok({
226
+ ...preview,
227
+ wouldChange: { propositionId: args.propositionId, ...body },
228
+ needsReadingWithEvidence: PROPOSITION_EVIDENCE_STATUSES.has(args.status),
229
+ });
230
+ }
231
+ const data = await api('PATCH', path, { body, headers: buildMutationHeaders({ ...args, reason }) });
232
+ return ok({ updated: true, proposition: data?.data });
233
+ }
234
+ );
235
+
236
+ server.tool(
237
+ 'link_proposition',
238
+ `Say that a node ASSUMES this proposition: a goal (or strategy), a decision, an idea or an indicator rests on this claim. An edge with a reason; the target does not change. When the proposition is falsified, the linked decision and strategy are marked on read. Linking a pair that is already linked replaces the reason (an upsert on the pair). The reason is recorded on the link and doubles as the audit reason.${GOVERNED_NOTE}`,
239
+ {
240
+ propositionId: z.string().describe('Proposition id from list_propositions.'),
241
+ targetType: z.enum(PROPOSITION_LINK_TARGET_TYPES).describe('What kind of node rests on the claim.'),
242
+ targetId: z.string().describe('The node id (from list_goals, list_decisions, list_ideas or list_indicators).'),
243
+ ...governedArgs(z),
244
+ reason: z.string().describe('How that node rests on this claim, in a sentence. Recorded on the link; also the audit reason.'),
245
+ },
246
+ async (args) => {
247
+ validateEnum(args.targetType, PROPOSITION_LINK_TARGET_TYPES, 'targetType');
248
+ if (!String(args.targetId ?? '').trim()) throw new Error('targetId is required');
249
+ const reason = validateReason(args.reason);
250
+ const path = `${BASE}/${encodeURIComponent(args.propositionId)}/links`;
251
+ const body = { targetType: args.targetType, targetId: args.targetId, reason };
252
+ const preview = validateApiBridgeRequest({
253
+ method: 'POST',
254
+ path,
255
+ dryRun: args.dryRun,
256
+ approved: args.approved,
257
+ reason,
258
+ idempotencyKey: args.idempotencyKey,
259
+ grantedScope: getGrantedScope(),
260
+ });
261
+ if (preview?.dryRun) return ok({ ...preview, wouldLink: { propositionId: args.propositionId, ...body } });
262
+ const data = await api('POST', path, { body, headers: buildMutationHeaders({ ...args, reason }) });
263
+ return ok({
264
+ linked: true,
265
+ propositionId: args.propositionId,
266
+ targetType: args.targetType,
267
+ targetId: args.targetId,
268
+ proposition: data?.data,
269
+ });
270
+ }
271
+ );
272
+
273
+ server.tool(
274
+ 'unlink_proposition',
275
+ `Remove the ASSUMES edge between a proposition and one node. Both are untouched — only the edge and the reason recorded on it go. Use this when a link was drawn in error. To say a claim no longer holds, do not unlink it: record a reading and move the status, so what rested on it is marked rather than forgotten. Removing an edge that is already gone is a no-op.${GOVERNED_NOTE}`,
276
+ {
277
+ propositionId: z.string().describe('Proposition id from list_propositions.'),
278
+ targetType: z.enum(PROPOSITION_LINK_TARGET_TYPES).describe('The kind of node the edge points at.'),
279
+ targetId: z.string().describe('The node id.'),
280
+ ...governedArgs(z),
281
+ },
282
+ async (args) => {
283
+ validateEnum(args.targetType, PROPOSITION_LINK_TARGET_TYPES, 'targetType');
284
+ if (!String(args.targetId ?? '').trim()) throw new Error('targetId is required');
285
+ const path = `${BASE}/${encodeURIComponent(args.propositionId)}/links/${encodeURIComponent(args.targetType)}/${encodeURIComponent(args.targetId)}`;
286
+ const preview = validateApiBridgeRequest({
287
+ method: 'DELETE',
288
+ path,
289
+ dryRun: args.dryRun,
290
+ approved: args.approved,
291
+ reason: args.reason,
292
+ idempotencyKey: args.idempotencyKey,
293
+ grantedScope: getGrantedScope(),
294
+ });
295
+ if (preview?.dryRun) {
296
+ return ok({
297
+ ...preview,
298
+ wouldChange: { propositionId: args.propositionId, targetType: args.targetType, targetId: args.targetId, removed: true },
299
+ });
300
+ }
301
+ const data = await api('DELETE', path, { headers: buildMutationHeaders(args) });
302
+ return ok({
303
+ unlinked: true,
304
+ propositionId: args.propositionId,
305
+ targetType: args.targetType,
306
+ targetId: args.targetId,
307
+ proposition: data?.data,
308
+ });
309
+ }
310
+ );
311
+
312
+ server.tool(
313
+ 'get_thread_graph',
314
+ `Generate the THREAD GRAPH around any node — the goal graph the owner drew, and its cousins — with no new storage. root is <kind>:<id> with kind one of ${THREAD_GRAPH_ROOT_KINDS.join(' | ')}; the walk goes both directions to depth (default ${THREAD_GRAPH_DEFAULT_DEPTH}, capped at ${THREAD_GRAPH_MAX_NODES} nodes). format json returns nodes [{key, kind, id, shortId, title, status|state|standing, audience}] and edges [{from, to, relation}] with relation in serves | measured_by | decided_by | realizes | relates | supersedes | amends | refines | depends_on | conflicts_with | assumes. format mermaid returns a graph LR document under \`mermaid\` with the same nodes and labelled edges — paste it into a Stream, a card or a decision body. shortId (G18, P3, D7, I2, X5) is stable per workspace, so it means the same node tomorrow and in an export.`,
315
+ {
316
+ root: z.string().describe('<kind>:<id>, e.g. goal:01J… or decision:01J…'),
317
+ depth: z.number().int().min(1).optional().describe(`How many edges out from the root, both directions. Default ${THREAD_GRAPH_DEFAULT_DEPTH}.`),
318
+ format: z.enum(THREAD_GRAPH_FORMATS).optional().describe('json (default) or mermaid.'),
319
+ },
320
+ async (args) => {
321
+ const { root } = parseGraphRoot(args.root);
322
+ const depth = validateDepth(args.depth);
323
+ const format = args.format === undefined ? undefined : validateEnum(args.format, THREAD_GRAPH_FORMATS, 'format');
324
+ const query = buildQuery({ root, depth, format });
325
+ const data = await api('GET', `${GRAPH_PATH}${query}`);
326
+ if (format === 'mermaid') {
327
+ const mermaid = mermaidText(data);
328
+ if (mermaid === null) throw new Error('the API returned no mermaid document for this root');
329
+ return ok({ root, depth: depth ?? THREAD_GRAPH_DEFAULT_DEPTH, format, mermaid });
330
+ }
331
+ const graph = data?.data ?? null;
332
+ if (!graph) return ok({ root, graph: null });
333
+ const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
334
+ const edges = Array.isArray(graph.edges) ? graph.edges : [];
335
+ return ok({ root, depth: depth ?? THREAD_GRAPH_DEFAULT_DEPTH, format: 'json', nodeCount: nodes.length, edgeCount: edges.length, graph });
336
+ }
337
+ );
338
+
339
+ server.tool(
340
+ 'file_document',
341
+ `File a Galaxy document on a thread noun with a ROLE: a decision's memo, a proposition's evidence file, an Oasis Thread's outcome artifact, an indicator's source spreadsheet, an idea's source. family is the noun (goal | proposition | decision | indicator | idea), id its id, documentId the Galaxy document, role one of memo | outcome | evidence | source | artifact. The document is linked, never copied or moved; it comes back on the noun's detail as documents[]. Filing the same document on the same node again replaces the role and reason (an upsert on the pair). The reason is recorded on the link and doubles as the audit reason.${GOVERNED_NOTE}`,
342
+ {
343
+ family: z.enum(DOCUMENT_TARGET_TYPES).describe('The noun the document is filed on.'),
344
+ id: z.string().describe('The noun\'s id (from list_goals, list_propositions, list_decisions, list_indicators or list_ideas).'),
345
+ documentId: z.string().describe('The Galaxy document id.'),
346
+ role: z.enum(DOCUMENT_ROLES).describe('memo | outcome | evidence | source | artifact.'),
347
+ ...governedArgs(z),
348
+ reason: z.string().describe('Why this document belongs on this node, in a sentence. Recorded on the link; also the audit reason.'),
349
+ },
350
+ async (args) => {
351
+ validateEnum(args.family, DOCUMENT_TARGET_TYPES, 'family');
352
+ validateEnum(args.role, DOCUMENT_ROLES, 'role');
353
+ if (!String(args.id ?? '').trim()) throw new Error('id is required');
354
+ if (!String(args.documentId ?? '').trim()) throw new Error('documentId is required');
355
+ const reason = validateReason(args.reason);
356
+ const path = `${DOCUMENT_FAMILY_PATHS[args.family]}/${encodeURIComponent(args.id)}/documents`;
357
+ const body = { documentId: args.documentId, role: args.role, reason };
358
+ const preview = validateApiBridgeRequest({
359
+ method: 'POST',
360
+ path,
361
+ dryRun: args.dryRun,
362
+ approved: args.approved,
363
+ reason,
364
+ idempotencyKey: args.idempotencyKey,
365
+ grantedScope: getGrantedScope(),
366
+ });
367
+ if (preview?.dryRun) return ok({ ...preview, wouldLink: { family: args.family, id: args.id, ...body } });
368
+ const data = await api('POST', path, { body, headers: buildMutationHeaders({ ...args, reason }) });
369
+ return ok({ filed: true, family: args.family, id: args.id, documentId: args.documentId, role: args.role, target: data?.data });
370
+ }
371
+ );
372
+
373
+ server.tool(
374
+ 'unfile_document',
375
+ `Remove one Galaxy document from a thread noun. The document itself is untouched in Galaxy and the noun is untouched — only the link, its role and its reason go. Use this when a document was filed on the wrong node; to replace a role, file_document again instead. Removing a link that is already gone is a no-op.${GOVERNED_NOTE}`,
376
+ {
377
+ family: z.enum(DOCUMENT_TARGET_TYPES).describe('The noun the document is filed on.'),
378
+ id: z.string().describe('The noun\'s id.'),
379
+ documentId: z.string().describe('The Galaxy document id to unfile.'),
380
+ ...governedArgs(z),
381
+ },
382
+ async (args) => {
383
+ validateEnum(args.family, DOCUMENT_TARGET_TYPES, 'family');
384
+ if (!String(args.id ?? '').trim()) throw new Error('id is required');
385
+ if (!String(args.documentId ?? '').trim()) throw new Error('documentId is required');
386
+ const path = `${DOCUMENT_FAMILY_PATHS[args.family]}/${encodeURIComponent(args.id)}/documents/${encodeURIComponent(args.documentId)}`;
387
+ const preview = validateApiBridgeRequest({
388
+ method: 'DELETE',
389
+ path,
390
+ dryRun: args.dryRun,
391
+ approved: args.approved,
392
+ reason: args.reason,
393
+ idempotencyKey: args.idempotencyKey,
394
+ grantedScope: getGrantedScope(),
395
+ });
396
+ if (preview?.dryRun) {
397
+ return ok({ ...preview, wouldChange: { family: args.family, id: args.id, documentId: args.documentId, removed: true } });
398
+ }
399
+ const data = await api('DELETE', path, { headers: buildMutationHeaders(args) });
400
+ return ok({ unfiled: true, family: args.family, id: args.id, documentId: args.documentId, target: data?.data });
401
+ }
402
+ );
403
+ }
404
+
405
+ export const WORK_PROPOSITION_TOOL_NAMES = [
406
+ 'list_propositions',
407
+ 'get_proposition',
408
+ 'get_proposition_calibration',
409
+ 'state_proposition',
410
+ 'record_proposition_reading',
411
+ 'set_proposition_status',
412
+ 'link_proposition',
413
+ 'unlink_proposition',
414
+ 'get_thread_graph',
415
+ 'file_document',
416
+ 'unfile_document',
417
+ ];
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Thread tools for the Adrata MCP server — Goals, Propositions, Indicators,
3
+ * Decisions, Ideas, and the Discussion that runs under all five. The
4
+ * "threads" family.
5
+ *
6
+ * The design: `spoq/epics/backlog/starfield-ideas/CONTRACT.md`. The short form
7
+ * an agent needs before touching these:
8
+ *
9
+ * - SIX NOUNS. Goal (a goal, or a STRATEGY under one — a bet with a
10
+ * hypothesis, a falsifier and a confidence). Indicator (a named measure
11
+ * with a definition, unit, direction and target, plus readings). Decision
12
+ * (a ruling written when made, never rewritten; reversals are NEW
13
+ * decisions that supersede). Idea (a big idea with the vector it arrived
14
+ * on and its source; its states are a history, not a flag). Scope/Card
15
+ * (the existing initiative → epic → feature → card; untouched here).
16
+ * Discussion (threaded, anchored, resolvable posts on any of the four new
17
+ * nouns).
18
+ * - FIVE EDGES, all joins with a reason: goal → scope (this initiative
19
+ * realizes this goal), goal → idea (this idea serves it), idea → scope /
20
+ * card / idea (realization and interconnection), decision → any node (this
21
+ * ruling shaped that node), indicator → goal (this measure says whether
22
+ * that goal is on track).
23
+ * - AUDIENCE is on every new noun: seller | manager | leader are who Adrata
24
+ * serves; builder is us building Adrata, Starfield and Portals; company is
25
+ * the whole business. It is a LENS — the API never hides by it.
26
+ * - VECTOR is where an idea came from (owner | customer | buyer_demand |
27
+ * competitor | keynote | internal_audit | technology | regulation) and is
28
+ * required with a source on capture.
29
+ * - NOTHING here is a board citizen: no column, no position on a board, no
30
+ * stored count. PROGRESS IS DERIVED on read from the cards reachable
31
+ * through a node's scopes and direct links, as COUNTS, never a percentage
32
+ * (hierarchy Decision 4). Indicator status is derived from the latest
33
+ * reading against target and expected. Nothing is rewritten: every field
34
+ * edit appends to the change log (list_thread_changes).
35
+ * - Promotion of an idea to an epic is `add_to_roadmap` then `link_idea`;
36
+ * there is deliberately no promote verb here.
37
+ * - Decisions form a GRAPH: typed edges between decisions (supersedes |
38
+ * amends | refines | depends_on | conflicts_with | relates_to). refines is
39
+ * the sub-decision edge; conflicts_with records two live rulings the owner
40
+ * has not picked between. get_decision_graph walks it.
41
+ * - A PROPOSITION is the fifth noun: a claim about the world we act on,
42
+ * with a required falsifier and a confidence that moves only through a
43
+ * reading with evidence. Goals, decisions, ideas and indicators ASSUME
44
+ * it (link_proposition); when it is falsified, what rests on it is marked
45
+ * on read. get_thread_graph draws any node's neighbourhood, as JSON or
46
+ * mermaid. Every noun can file a Galaxy document with a role
47
+ * (file_document).
48
+ *
49
+ * Writes follow the same governed contract as every write on this server:
50
+ * dryRun defaults to TRUE; a live write needs dryRun:false AND approved:true
51
+ * AND a reason AND an idempotencyKey.
52
+ *
53
+ * The family is split by noun so no file passes the tools/ size ceiling:
54
+ * work-goal-tools.js, work-indicator-tools.js, work-decision-tools.js,
55
+ * work-idea-tools.js, work-discussion-tools.js, work-proposition-tools.js
56
+ * (propositions, the graph read and document filing), with the vocabularies
57
+ * and the pure helpers in shared.js. This module is the one registration point.
58
+ */
59
+
60
+ import { registerWorkGoalTools, WORK_GOAL_TOOL_NAMES } from './work-goal-tools.js';
61
+ import { registerWorkIndicatorTools, WORK_INDICATOR_TOOL_NAMES } from './work-indicator-tools.js';
62
+ import { registerWorkDecisionTools, WORK_DECISION_TOOL_NAMES } from './work-decision-tools.js';
63
+ import { registerWorkIdeaTools, WORK_IDEA_TOOL_NAMES } from './work-idea-tools.js';
64
+ import { registerWorkDiscussionTools, WORK_DISCUSSION_TOOL_NAMES } from './work-discussion-tools.js';
65
+ import { registerWorkPropositionTools, WORK_PROPOSITION_TOOL_NAMES } from './work-proposition-tools.js';
66
+
67
+ export {
68
+ GOVERNED_NOTE,
69
+ AUDIENCES,
70
+ VECTORS,
71
+ GOAL_LEVELS,
72
+ GOAL_STATUSES,
73
+ IDEA_STATES,
74
+ DECISION_STATUSES,
75
+ POSITIONS,
76
+ INDICATOR_KINDS,
77
+ INDICATOR_DIRECTIONS,
78
+ DOORS,
79
+ DECISION_LINK_TARGET_TYPES,
80
+ DECISION_RELATIONS,
81
+ THREAD_TARGET_TYPES,
82
+ INDICATOR_STATUSES,
83
+ PROPOSITION_STATUSES,
84
+ PROPOSITION_LINK_TARGET_TYPES,
85
+ PROPOSITION_EVIDENCE_STATUSES,
86
+ DOCUMENT_TARGET_TYPES,
87
+ DOCUMENT_ROLES,
88
+ DOCUMENT_FAMILY_PATHS,
89
+ THREAD_GRAPH_ROOT_KINDS,
90
+ THREAD_GRAPH_RELATIONS,
91
+ THREAD_GRAPH_FORMATS,
92
+ THREAD_GRAPH_DEFAULT_DEPTH,
93
+ THREAD_GRAPH_MAX_NODES,
94
+ REASON_REQUIRED_STATES,
95
+ MAX_TITLE_CHARS,
96
+ MAX_REASON_CHARS,
97
+ MAX_BODY_CHARS,
98
+ indicatorStatus,
99
+ positionsTally,
100
+ summarizeThread,
101
+ validateTitle,
102
+ validateBody,
103
+ validateReason,
104
+ validateConfidence,
105
+ validateDate,
106
+ validateEnum,
107
+ validateGoalLevel,
108
+ validateStateReason,
109
+ pickExactlyOne,
110
+ parseGraphRoot,
111
+ validateDepth,
112
+ mermaidText,
113
+ audienceQuery,
114
+ buildQuery,
115
+ } from './shared.js';
116
+
117
+ /**
118
+ * Register every thread tool.
119
+ *
120
+ * @param {McpServer} server
121
+ * @param {object} deps - { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope }
122
+ */
123
+ export function registerWorkThreadTools(
124
+ server,
125
+ { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope = () => undefined }
126
+ ) {
127
+ const deps = { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope };
128
+ registerWorkGoalTools(server, deps);
129
+ registerWorkIndicatorTools(server, deps);
130
+ registerWorkDecisionTools(server, deps);
131
+ registerWorkIdeaTools(server, deps);
132
+ registerWorkDiscussionTools(server, deps);
133
+ registerWorkPropositionTools(server, deps);
134
+ }
135
+
136
+ /** Tool names registered here, for the tier map and the toolset manifest. */
137
+ export const WORK_THREAD_TOOL_NAMES = [
138
+ ...WORK_GOAL_TOOL_NAMES,
139
+ ...WORK_INDICATOR_TOOL_NAMES,
140
+ ...WORK_DECISION_TOOL_NAMES,
141
+ ...WORK_IDEA_TOOL_NAMES,
142
+ ...WORK_DISCUSSION_TOOL_NAMES,
143
+ ...WORK_PROPOSITION_TOOL_NAMES,
144
+ ];
145
+
146
+ /** The writes shaped as upserts (ON CONFLICT, or one row per (decision, user)); the annotations file lists these as idempotent. */
147
+ export const WORK_THREAD_UPSERT_WRITES = [
148
+ 'link_goal',
149
+ 'link_decision',
150
+ 'link_idea',
151
+ 'take_decision_position',
152
+ 'resolve_discussion',
153
+ 'relate_decisions',
154
+ 'unrelate_decisions',
155
+ // Propositions: the assumes edge is keyed by (proposition, target); a
156
+ // document filing by (target, document). A repeat link/file rewrites the
157
+ // same row; a repeat unlink/unfile deletes a row already gone.
158
+ 'link_proposition',
159
+ 'unlink_proposition',
160
+ 'file_document',
161
+ 'unfile_document',
162
+ ];
163
+
164
+ /** Reads, by the read-only name rule the annotations use. */
165
+ export const WORK_THREAD_READ_TOOLS = WORK_THREAD_TOOL_NAMES.filter((n) => /^(list_|get_)/.test(n));