@adrata/adrata-mcp 1.0.49 → 1.0.51

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,368 @@
1
+ /**
2
+ * Decision tools: a ruling with context, choice, rationale and consequences,
3
+ * in the register's own doctrine — written when made, never rewritten,
4
+ * reversals are NEW decisions that supersede the old. People take a position
5
+ * (support / concern / object) and discuss it before and after. See
6
+ * work-thread-tools.js for the model and CONTRACT.md for the routes.
7
+ *
8
+ * Ownership (company/decisions/2026-08-06-spoq-roadmap-sync.md, applied): a
9
+ * decision with `sourcePath` set is git-owned for title, body, status,
10
+ * decided_on and door — the import script writes those and the API answers
11
+ * 409 naming the file if a tool tries. Positions, discussion, links,
12
+ * confidence, reversal condition, review date and outcome are Starfield-owned
13
+ * on every decision. An app-born decision that reaches `decided` comes back
14
+ * with `gitHalf` text telling the caller to write the company/decisions/ file,
15
+ * exactly as add_to_roadmap does for epics; surface it, do not swallow it.
16
+ */
17
+
18
+ import {
19
+ AUDIENCES,
20
+ DECISION_LINK_TARGET_TYPES,
21
+ DECISION_RELATIONS,
22
+ DECISION_STATUSES,
23
+ DOORS,
24
+ GOVERNED_NOTE,
25
+ OUTCOME_VERDICTS,
26
+ POSITIONS,
27
+ audienceQuery,
28
+ buildQuery,
29
+ compactBody,
30
+ governedArgs,
31
+ tallyFrom,
32
+ validateBody,
33
+ validateConfidence,
34
+ validateDate,
35
+ validateEnum,
36
+ validateReason,
37
+ validateStateReason,
38
+ validateTitle,
39
+ } from './shared.js';
40
+
41
+ const BASE = '/api/v1/work-decisions';
42
+
43
+ export function registerWorkDecisionTools(
44
+ server,
45
+ { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope = () => undefined }
46
+ ) {
47
+ server.tool(
48
+ 'list_decisions',
49
+ 'List decisions — each with its status (proposed | discussing | decided | superseded | reversed), door (one_way = hard to reverse, two_way = cheap to reverse), audience, a positions tally {support, concern, object} and how many discussion posts it has. Pass targetType + targetId to see the decisions that shaped one goal, idea, scope, card or indicator. Reversals are new decisions, and one may replace several at once: read the supersedes edges (decision_graph, or the detail\'s parents) to walk the chain — supersedesDecisionId is only a fast pointer at the most recent predecessor.',
50
+ {
51
+ status: z.enum(DECISION_STATUSES).optional().describe('Narrow to one lifecycle status.'),
52
+ audience: z.array(z.enum(AUDIENCES)).optional().describe('Narrow to these audiences. Omit for all.'),
53
+ targetType: z.enum(DECISION_LINK_TARGET_TYPES).optional().describe('With targetId: only decisions linked to this node.'),
54
+ targetId: z.string().optional().describe('With targetType: the node id.'),
55
+ },
56
+ async (args) => {
57
+ if ((args.targetType && !args.targetId) || (!args.targetType && args.targetId)) {
58
+ throw new Error('targetType and targetId go together');
59
+ }
60
+ const query = buildQuery({
61
+ status: args.status,
62
+ audience: audienceQuery(args.audience),
63
+ targetType: args.targetType,
64
+ targetId: args.targetId,
65
+ });
66
+ const data = await api('GET', `${BASE}${query}`);
67
+ const items = data?.data || [];
68
+ return ok({ count: items.length, items });
69
+ }
70
+ );
71
+
72
+ server.tool(
73
+ 'get_decision',
74
+ 'Read one decision whole: title, body (context, choice, rationale, consequences), alternatives considered, door, confidence at decision time, reversal condition, review date, outcome if recorded, the nodes it links to (with titles), who holds which position, and the last 50 discussion posts. `tally` is the support/concern/object count.',
75
+ {
76
+ decisionId: z.string().describe('Decision id from list_decisions.'),
77
+ },
78
+ async (args) => {
79
+ const data = await api('GET', `${BASE}/${encodeURIComponent(args.decisionId)}`);
80
+ const decision = data?.data ?? null;
81
+ if (!decision) return ok({ decision: null, tally: null });
82
+ return ok({ tally: decision.tally ?? tallyFrom(decision.positions), decision });
83
+ }
84
+ );
85
+
86
+ server.tool(
87
+ 'record_decision',
88
+ `Record a decision as PROPOSED: title, body in the register's shape (context, choice, rationale, consequences), audience, door (one_way | two_way), the ALTERNATIVES considered (a decision with no alternatives is a fact, not a decision), the decider's confidence 0-1 (scored later against the outcome), the reversal condition (what observation would change the call) and a review date. To reverse or replace an existing decision, record a NEW one with supersedesDecisionId — the old one moves to superseded in the same transaction; never edit the old text. sourcePath is for imports from company/decisions/ only; a decision born here has none and the API will hand you the git half when it is decided.${GOVERNED_NOTE}`,
89
+ {
90
+ title: z.string().describe('The ruling as a sentence, at most 160 characters.'),
91
+ body: z.string().describe('Markdown: context, choice, rationale, consequences.'),
92
+ audience: z.enum(AUDIENCES).describe('Who this decision is for.'),
93
+ door: z.enum(DOORS).optional().describe('one_way = hard to reverse, decide slowly; two_way = cheap to reverse. Default two_way.'),
94
+ alternatives: z.string().optional().describe('Markdown list of the options considered and why each lost.'),
95
+ confidence: z.number().min(0).max(1).optional().describe('Decider\'s confidence at decision time, 0-1. Frozen once decided.'),
96
+ reversalCondition: z.string().optional().describe('What observation would change this call.'),
97
+ reviewOn: z.string().optional().describe('When to re-read the call against the outcome, YYYY-MM-DD.'),
98
+ supersedesDecisionId: z.string().optional().describe('The decision this one replaces; it moves to superseded.'),
99
+ sourcePath: z.string().optional().describe('Imports only: the company/decisions/ file. Omit for a decision born here.'),
100
+ ...governedArgs(z),
101
+ },
102
+ async (args) => {
103
+ const body = compactBody({
104
+ title: validateTitle(args.title),
105
+ body: validateBody(args.body),
106
+ audience: args.audience,
107
+ door: args.door,
108
+ alternatives: args.alternatives,
109
+ confidence: validateConfidence(args.confidence),
110
+ reversalCondition: args.reversalCondition,
111
+ reviewOn: validateDate(args.reviewOn, 'reviewOn'),
112
+ supersedesDecisionId: args.supersedesDecisionId,
113
+ sourcePath: args.sourcePath,
114
+ });
115
+ const preview = validateApiBridgeRequest({
116
+ method: 'POST',
117
+ path: BASE,
118
+ dryRun: args.dryRun,
119
+ approved: args.approved,
120
+ reason: args.reason,
121
+ idempotencyKey: args.idempotencyKey,
122
+ grantedScope: getGrantedScope(),
123
+ });
124
+ if (preview?.dryRun) return ok({ ...preview, wouldCreate: body });
125
+ const data = await api('POST', BASE, { body, headers: buildMutationHeaders(args) });
126
+ return ok({ created: true, decision: data?.data, gitHalf: data?.gitHalf ?? data?.data?.gitHalf ?? null });
127
+ }
128
+ );
129
+
130
+ server.tool(
131
+ 'set_decision_status',
132
+ `Move a decision through its lifecycle: proposed → discussing → decided, or to superseded / reversed (prefer record_decision with supersedesDecisionId for those — a reversal is a new decision). decided needs decidedOn (defaults to today) and records the caller as the decider. A decision with sourcePath is git-owned for status and the API answers 409 naming the file; change the file instead. When an app-born decision becomes decided, the response carries gitHalf: write the company/decisions/ file. The reason is recorded on the transition and doubles as the audit reason.${GOVERNED_NOTE}`,
133
+ {
134
+ decisionId: z.string().describe('Decision id from list_decisions.'),
135
+ status: z.enum(DECISION_STATUSES).describe('The new status.'),
136
+ decidedOn: z.string().optional().describe('For decided: the day it was decided, YYYY-MM-DD. Defaults to today.'),
137
+ ...governedArgs(z),
138
+ reason: z.string().describe('Why the status changes. Recorded on the transition; also the audit reason.'),
139
+ },
140
+ async (args) => {
141
+ validateEnum(args.status, DECISION_STATUSES, 'status');
142
+ const reason = validateStateReason(args.status, args.reason);
143
+ const path = `${BASE}/${encodeURIComponent(args.decisionId)}`;
144
+ const body = compactBody({
145
+ status: args.status,
146
+ reason,
147
+ decidedOn: validateDate(args.decidedOn, 'decidedOn'),
148
+ });
149
+ const preview = validateApiBridgeRequest({
150
+ method: 'PATCH',
151
+ path,
152
+ dryRun: args.dryRun,
153
+ approved: args.approved,
154
+ reason,
155
+ idempotencyKey: args.idempotencyKey,
156
+ grantedScope: getGrantedScope(),
157
+ });
158
+ if (preview?.dryRun) return ok({ ...preview, wouldChange: { decisionId: args.decisionId, ...body } });
159
+ const data = await api('PATCH', path, { body, headers: buildMutationHeaders({ ...args, reason }) });
160
+ return ok({
161
+ updated: true,
162
+ decision: data?.data,
163
+ gitHalf: data?.gitHalf ?? data?.data?.gitHalf ?? null,
164
+ });
165
+ }
166
+ );
167
+
168
+ server.tool(
169
+ 'record_decision_outcome',
170
+ `Record what ACTUALLY happened after a decided decision — once, separately from the decision text, because decision quality and outcome quality are different things and the confidence stated at decision time is scored against this. The verdict (held | partly | did_not_hold) is what calibration scores: without it the outcome is prose the Brier score cannot read, so it is required here. Only after status is decided; the API refuses a second outcome. Write what was observed, not a re-argument of the call.${GOVERNED_NOTE}`,
171
+ {
172
+ decisionId: z.string().describe('Decision id from list_decisions.'),
173
+ verdict: z.enum(OUTCOME_VERDICTS).describe('Did the decision\'s own prediction hold? held | partly | did_not_hold. Calibration needs it.'),
174
+ outcome: z.string().describe('What happened, as observed. Markdown.'),
175
+ ...governedArgs(z),
176
+ },
177
+ async (args) => {
178
+ validateEnum(args.verdict, OUTCOME_VERDICTS, 'verdict');
179
+ const outcome = validateBody(args.outcome, 'outcome');
180
+ if (!outcome.trim()) throw new Error('outcome is required and cannot be blank');
181
+ const path = `${BASE}/${encodeURIComponent(args.decisionId)}/outcome`;
182
+ const body = { verdict: args.verdict, outcome };
183
+ const preview = validateApiBridgeRequest({
184
+ method: 'POST',
185
+ path,
186
+ dryRun: args.dryRun,
187
+ approved: args.approved,
188
+ reason: args.reason,
189
+ idempotencyKey: args.idempotencyKey,
190
+ grantedScope: getGrantedScope(),
191
+ });
192
+ if (preview?.dryRun) return ok({ ...preview, wouldChange: { decisionId: args.decisionId, ...body } });
193
+ const data = await api('POST', path, { body, headers: buildMutationHeaders(args) });
194
+ return ok({ recorded: true, decision: data?.data });
195
+ }
196
+ );
197
+
198
+ server.tool(
199
+ 'take_decision_position',
200
+ `Take the CALLER'S position on a decision: support, concern or object, with an optional note. One position per person per decision — calling again replaces it (an upsert), so changing your mind is the same call. "We all discussed it" is this record plus the discussion; take a position honestly and put the argument in post_discussion.${GOVERNED_NOTE}`,
201
+ {
202
+ decisionId: z.string().describe('Decision id from list_decisions.'),
203
+ position: z.enum(POSITIONS).describe('support | concern | object.'),
204
+ note: z.string().optional().describe('One line on why. The argument itself belongs in post_discussion.'),
205
+ ...governedArgs(z),
206
+ },
207
+ async (args) => {
208
+ validateEnum(args.position, POSITIONS, 'position');
209
+ const path = `${BASE}/${encodeURIComponent(args.decisionId)}/position`;
210
+ const body = compactBody({ position: args.position, note: args.note });
211
+ const preview = validateApiBridgeRequest({
212
+ method: 'PUT',
213
+ path,
214
+ dryRun: args.dryRun,
215
+ approved: args.approved,
216
+ reason: args.reason,
217
+ idempotencyKey: args.idempotencyKey,
218
+ grantedScope: getGrantedScope(),
219
+ });
220
+ if (preview?.dryRun) return ok({ ...preview, wouldChange: { decisionId: args.decisionId, ...body } });
221
+ const data = await api('PUT', path, { body, headers: buildMutationHeaders(args) });
222
+ const decision = data?.data;
223
+ return ok({
224
+ taken: true,
225
+ decisionId: args.decisionId,
226
+ position: args.position,
227
+ tally: decision?.tally ?? tallyFrom(decision?.positions),
228
+ });
229
+ }
230
+ );
231
+
232
+ server.tool(
233
+ 'link_decision',
234
+ `Say that this decision SHAPED a node: a goal, an idea, a scope (initiative/epic), a card (work_item) or an indicator. An edge with a reason; the target does not change. Linking a pair that is already linked replaces the reason (an upsert). The reason is recorded on the link and doubles as the audit reason.${GOVERNED_NOTE}`,
235
+ {
236
+ decisionId: z.string().describe('Decision id from list_decisions.'),
237
+ targetType: z.enum(DECISION_LINK_TARGET_TYPES).describe('What kind of node the decision shaped.'),
238
+ targetId: z.string().describe('The node id (from list_goals, list_ideas, list_work_scopes, a card id, or list_indicators).'),
239
+ ...governedArgs(z),
240
+ reason: z.string().describe('How the decision shaped this node, in a sentence. Recorded on the link; also the audit reason.'),
241
+ },
242
+ async (args) => {
243
+ validateEnum(args.targetType, DECISION_LINK_TARGET_TYPES, 'targetType');
244
+ if (!String(args.targetId ?? '').trim()) throw new Error('targetId is required');
245
+ const reason = validateReason(args.reason);
246
+ const path = `${BASE}/${encodeURIComponent(args.decisionId)}/links`;
247
+ const body = { targetType: args.targetType, targetId: args.targetId, reason };
248
+ const preview = validateApiBridgeRequest({
249
+ method: 'POST',
250
+ path,
251
+ dryRun: args.dryRun,
252
+ approved: args.approved,
253
+ reason,
254
+ idempotencyKey: args.idempotencyKey,
255
+ grantedScope: getGrantedScope(),
256
+ });
257
+ if (preview?.dryRun) return ok({ ...preview, wouldLink: { decisionId: args.decisionId, ...body } });
258
+ const data = await api('POST', path, { body, headers: buildMutationHeaders({ ...args, reason }) });
259
+ return ok({ linked: true, decisionId: args.decisionId, targetType: args.targetType, targetId: args.targetId, decision: data?.data });
260
+ }
261
+ );
262
+
263
+ server.tool(
264
+ 'get_decision_graph',
265
+ 'Read the decision GRAPH around one decision: the decisions it relates to and those that relate to it, each edge typed (supersedes | amends | refines | depends_on | conflicts_with | relates_to) with its reason. refines edges are the sub-decisions: a big ruling broken into the smaller calls that implement it. conflicts_with edges are two live rulings the owner has not picked between — read those before acting on either. Use this to walk a decision chain instead of paging through list_decisions.',
266
+ {
267
+ decisionId: z.string().describe('Decision id from list_decisions; the graph is centred on it.'),
268
+ },
269
+ async (args) => {
270
+ const data = await api('GET', `${BASE}/${encodeURIComponent(args.decisionId)}/graph`);
271
+ const graph = data?.data ?? null;
272
+ if (!graph) return ok({ graph: null });
273
+ const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
274
+ const edges = Array.isArray(graph.edges) ? graph.edges : Array.isArray(graph.relations) ? graph.relations : [];
275
+ return ok({ decisionId: args.decisionId, nodeCount: nodes.length, edgeCount: edges.length, graph });
276
+ }
277
+ );
278
+
279
+ server.tool(
280
+ 'relate_decisions',
281
+ `Draw a typed edge from one decision to another. refines = the related decision is a SUB-DECISION of this one (a smaller call that implements the bigger ruling). supersedes = this decision replaces the target, and the target moves to superseded exactly as record_decision with supersedesDecisionId does. amends = this one changes part of the target without replacing it. depends_on = this one only holds while the target does. conflicts_with = two LIVE rulings the owner has not picked between — record the conflict rather than silently choosing. relates_to = connected, none of the above. The pair plus the relation is the key: relating the same pair the same way again replaces the reason (an upsert). The reason is recorded on the edge and doubles as the audit reason.${GOVERNED_NOTE}`,
282
+ {
283
+ decisionId: z.string().describe('The decision the edge starts from, from list_decisions.'),
284
+ relatedDecisionId: z.string().describe('The decision the edge points at.'),
285
+ relation: z.enum(DECISION_RELATIONS).describe('supersedes | amends | refines | depends_on | conflicts_with | relates_to.'),
286
+ ...governedArgs(z),
287
+ reason: z.string().describe('Why they relate this way, in a sentence. Recorded on the edge; also the audit reason.'),
288
+ },
289
+ async (args) => {
290
+ validateEnum(args.relation, DECISION_RELATIONS, 'relation');
291
+ if (!String(args.relatedDecisionId ?? '').trim()) throw new Error('relatedDecisionId is required');
292
+ if (args.relatedDecisionId === args.decisionId) throw new Error('a decision cannot relate to itself');
293
+ const reason = validateReason(args.reason);
294
+ const path = `${BASE}/${encodeURIComponent(args.decisionId)}/relations`;
295
+ const body = { relatedDecisionId: args.relatedDecisionId, relation: args.relation, reason };
296
+ const preview = validateApiBridgeRequest({
297
+ method: 'POST',
298
+ path,
299
+ dryRun: args.dryRun,
300
+ approved: args.approved,
301
+ reason,
302
+ idempotencyKey: args.idempotencyKey,
303
+ grantedScope: getGrantedScope(),
304
+ });
305
+ if (preview?.dryRun) return ok({ ...preview, wouldLink: { decisionId: args.decisionId, ...body } });
306
+ const data = await api('POST', path, { body, headers: buildMutationHeaders({ ...args, reason }) });
307
+ return ok({
308
+ related: true,
309
+ decisionId: args.decisionId,
310
+ relatedDecisionId: args.relatedDecisionId,
311
+ relation: args.relation,
312
+ decision: data?.data,
313
+ });
314
+ }
315
+ );
316
+
317
+ server.tool(
318
+ 'unrelate_decisions',
319
+ `Remove one typed edge between two decisions. Both decisions are untouched — only the edge goes, and only the edge with exactly this relation; a pair related two ways keeps the other edge. Use this when an edge was drawn in error. To say a ruling no longer applies, do not unrelate it: record the superseding decision instead, so the history stays. Removing an edge that is already gone is a no-op.${GOVERNED_NOTE}`,
320
+ {
321
+ decisionId: z.string().describe('The decision the edge starts from.'),
322
+ relatedDecisionId: z.string().describe('The decision the edge points at.'),
323
+ relation: z.enum(DECISION_RELATIONS).describe('The relation of the edge to remove.'),
324
+ ...governedArgs(z),
325
+ },
326
+ async (args) => {
327
+ validateEnum(args.relation, DECISION_RELATIONS, 'relation');
328
+ if (!String(args.relatedDecisionId ?? '').trim()) throw new Error('relatedDecisionId is required');
329
+ const path = `${BASE}/${encodeURIComponent(args.decisionId)}/relations/${encodeURIComponent(args.relatedDecisionId)}/${encodeURIComponent(args.relation)}`;
330
+ const preview = validateApiBridgeRequest({
331
+ method: 'DELETE',
332
+ path,
333
+ dryRun: args.dryRun,
334
+ approved: args.approved,
335
+ reason: args.reason,
336
+ idempotencyKey: args.idempotencyKey,
337
+ grantedScope: getGrantedScope(),
338
+ });
339
+ if (preview?.dryRun) {
340
+ return ok({
341
+ ...preview,
342
+ wouldChange: { decisionId: args.decisionId, relatedDecisionId: args.relatedDecisionId, relation: args.relation, removed: true },
343
+ });
344
+ }
345
+ const data = await api('DELETE', path, { headers: buildMutationHeaders(args) });
346
+ return ok({
347
+ unrelated: true,
348
+ decisionId: args.decisionId,
349
+ relatedDecisionId: args.relatedDecisionId,
350
+ relation: args.relation,
351
+ decision: data?.data,
352
+ });
353
+ }
354
+ );
355
+ }
356
+
357
+ export const WORK_DECISION_TOOL_NAMES = [
358
+ 'list_decisions',
359
+ 'get_decision',
360
+ 'get_decision_graph',
361
+ 'record_decision',
362
+ 'set_decision_status',
363
+ 'record_decision_outcome',
364
+ 'take_decision_position',
365
+ 'link_decision',
366
+ 'relate_decisions',
367
+ 'unrelate_decisions',
368
+ ];
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Discussion and history tools: the threaded, anchored, resolvable posts that
3
+ * run under every thread noun (goal | idea | decision | indicator), and the
4
+ * field-change log that is the git half of the same nouns — who changed what,
5
+ * when, and what it said before. See work-thread-tools.js for the model and
6
+ * CONTRACT.md for the routes.
7
+ */
8
+
9
+ import { GOVERNED_NOTE, THREAD_TARGET_TYPES, buildQuery, compactBody, governedArgs, validateBody, validateEnum } from './shared.js';
10
+
11
+ const DISCUSSIONS = '/api/v1/work-discussions';
12
+ const CHANGES = '/api/v1/work-thread-changes';
13
+
14
+ function requireTarget(args) {
15
+ validateEnum(args.targetType, THREAD_TARGET_TYPES, 'targetType');
16
+ if (!String(args.targetId ?? '').trim()) throw new Error('targetId is required');
17
+ }
18
+
19
+ export function registerWorkDiscussionTools(
20
+ server,
21
+ { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope = () => undefined }
22
+ ) {
23
+ server.tool(
24
+ 'list_discussion',
25
+ 'Read the discussion under one goal, idea, decision or indicator: a flat list, oldest first, each post with inReplyTo (the thread), anchorText (the passage of the body it is about, null = the whole thing), resolvedAt and its author. Resolved threads are hidden unless includeResolved. Read this BEFORE arguing a point or taking a position — the objection you are about to raise has often been raised.',
26
+ {
27
+ targetType: z.enum(THREAD_TARGET_TYPES).describe('What the discussion hangs off.'),
28
+ targetId: z.string().describe('The goal, idea, decision or indicator id.'),
29
+ includeResolved: z.boolean().optional().describe('Also return resolved threads. Default false.'),
30
+ },
31
+ async (args) => {
32
+ requireTarget(args);
33
+ const query = buildQuery({
34
+ targetType: args.targetType,
35
+ targetId: args.targetId,
36
+ includeResolved: args.includeResolved ? 'true' : undefined,
37
+ });
38
+ const data = await api('GET', `${DISCUSSIONS}${query}`);
39
+ const items = data?.data || [];
40
+ return ok({ count: items.length, items });
41
+ }
42
+ );
43
+
44
+ server.tool(
45
+ 'list_thread_changes',
46
+ 'Read the field-change log for one goal, idea, decision or indicator: every PATCH and state move as a row with field, oldValue, newValue, who changed it, when, and the reason — newest first. Never rewritten. This is "blame" and "diff" for the thread nouns; use it to answer "what did this say before" and "who changed the target date".',
47
+ {
48
+ targetType: z.enum(THREAD_TARGET_TYPES).describe('What the log is for.'),
49
+ targetId: z.string().describe('The goal, idea, decision or indicator id.'),
50
+ },
51
+ async (args) => {
52
+ requireTarget(args);
53
+ const query = buildQuery({ targetType: args.targetType, targetId: args.targetId });
54
+ const data = await api('GET', `${CHANGES}${query}`);
55
+ const items = data?.data || [];
56
+ return ok({ count: items.length, items });
57
+ }
58
+ );
59
+
60
+ server.tool(
61
+ 'post_discussion',
62
+ `Post to the discussion under a goal, idea, decision or indicator. Reply to a post with inReplyTo; anchor the comment to a passage of the target's body with anchorText (quote it exactly, as a Google Docs comment is pinned to a selection) or omit it to comment on the whole thing. Posts are appended, never edited by a tool; each call adds one row, so do not retry with a fresh idempotencyKey. Put the argument here and the verdict in take_decision_position.${GOVERNED_NOTE}`,
63
+ {
64
+ targetType: z.enum(THREAD_TARGET_TYPES).describe('What the post hangs off.'),
65
+ targetId: z.string().describe('The goal, idea, decision or indicator id.'),
66
+ body: z.string().describe('Markdown. Say the thing; cite the evidence.'),
67
+ inReplyTo: z.string().optional().describe('Post id this replies to, from list_discussion. Omit to start a thread.'),
68
+ anchorText: z.string().optional().describe('The exact passage of the target\'s body this is about. Omit for the whole thing.'),
69
+ ...governedArgs(z),
70
+ },
71
+ async (args) => {
72
+ requireTarget(args);
73
+ const text = validateBody(args.body);
74
+ if (!text.trim()) throw new Error('body is required and cannot be blank');
75
+ const body = compactBody({
76
+ targetType: args.targetType,
77
+ targetId: args.targetId,
78
+ body: text,
79
+ inReplyTo: args.inReplyTo,
80
+ anchorText: args.anchorText,
81
+ });
82
+ const preview = validateApiBridgeRequest({
83
+ method: 'POST',
84
+ path: DISCUSSIONS,
85
+ dryRun: args.dryRun,
86
+ approved: args.approved,
87
+ reason: args.reason,
88
+ idempotencyKey: args.idempotencyKey,
89
+ grantedScope: getGrantedScope(),
90
+ });
91
+ if (preview?.dryRun) return ok({ ...preview, wouldCreate: body });
92
+ const data = await api('POST', DISCUSSIONS, { body, headers: buildMutationHeaders(args) });
93
+ return ok({ posted: true, post: data?.data });
94
+ }
95
+ );
96
+
97
+ server.tool(
98
+ 'resolve_discussion',
99
+ `Resolve a discussion thread (resolved: true) or reopen it (resolved: false). Root posts only — replies inherit. Resolving hides the thread from list_discussion by default; nothing is deleted and the posts stay readable with includeResolved. Any member may resolve; resolve when the point is settled or absorbed into the body, not to silence it.${GOVERNED_NOTE}`,
100
+ {
101
+ postId: z.string().describe('The ROOT post id of the thread, from list_discussion.'),
102
+ resolved: z.boolean().describe('true to resolve, false to reopen.'),
103
+ ...governedArgs(z),
104
+ },
105
+ async (args) => {
106
+ if (typeof args.resolved !== 'boolean') throw new Error('resolved must be true or false');
107
+ const method = args.resolved ? 'POST' : 'DELETE';
108
+ const path = `${DISCUSSIONS}/${encodeURIComponent(args.postId)}/resolve`;
109
+ const preview = validateApiBridgeRequest({
110
+ method,
111
+ path,
112
+ dryRun: args.dryRun,
113
+ approved: args.approved,
114
+ reason: args.reason,
115
+ idempotencyKey: args.idempotencyKey,
116
+ grantedScope: getGrantedScope(),
117
+ });
118
+ if (preview?.dryRun) return ok({ ...preview, wouldChange: { postId: args.postId, resolved: args.resolved } });
119
+ const data = await api(method, path, { headers: buildMutationHeaders(args) });
120
+ return ok({ updated: true, postId: args.postId, resolved: args.resolved, post: data?.data });
121
+ }
122
+ );
123
+ }
124
+
125
+ export const WORK_DISCUSSION_TOOL_NAMES = ['list_discussion', 'list_thread_changes', 'post_discussion', 'resolve_discussion'];
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Goal tools: a goal, the strategies under it (the bets), and the edges that
3
+ * say which initiatives and ideas realize them. See work-thread-tools.js for
4
+ * the model and CONTRACT.md for the routes.
5
+ */
6
+
7
+ import {
8
+ AUDIENCES,
9
+ GOAL_LEVELS,
10
+ GOVERNED_NOTE,
11
+ audienceQuery,
12
+ buildQuery,
13
+ compactBody,
14
+ governedArgs,
15
+ pickExactlyOne,
16
+ summarizeThread,
17
+ validateBody,
18
+ validateConfidence,
19
+ validateDate,
20
+ validateGoalLevel,
21
+ validateReason,
22
+ validateTitle,
23
+ } from './shared.js';
24
+
25
+ const BASE = '/api/v1/work-goals';
26
+
27
+ export function registerWorkGoalTools(
28
+ server,
29
+ { z, api, ok, validateApiBridgeRequest, buildMutationHeaders, getGrantedScope = () => undefined }
30
+ ) {
31
+ server.tool(
32
+ 'list_goals',
33
+ 'List the goal THREADS, compact: each goal with its strategies nested, each strategy with its indicators (latest reading plus derived status: no_reading | achieved | on_track | off_track | unknown_pace), progress as COUNTS over the cards its scopes reach, and how many decisions shaped it. Nothing here is a board: no columns, no stored progress. Filter by audience (seller/manager/leader are who Adrata serves; builder is us building it; company is the whole business) and by level. Start here; get_goal_thread needs an id from this list.',
34
+ {
35
+ audience: z
36
+ .array(z.enum(AUDIENCES))
37
+ .optional()
38
+ .describe('Narrow to these audiences. Omit for all — the API never hides by audience; this is a lens.'),
39
+ level: z.enum(GOAL_LEVELS).optional().describe('goal (top) or strategy (a bet under a goal). Omit for both.'),
40
+ includeArchived: z.boolean().optional().describe('Include archived goals. Default false.'),
41
+ },
42
+ async (args) => {
43
+ const query = buildQuery({
44
+ audience: audienceQuery(args.audience),
45
+ level: args.level,
46
+ includeArchived: args.includeArchived ? 'true' : undefined,
47
+ });
48
+ const data = await api('GET', `${BASE}${query}`);
49
+ const items = data?.data || [];
50
+ return ok({ count: items.length, items });
51
+ }
52
+ );
53
+
54
+ server.tool(
55
+ 'get_goal_thread',
56
+ 'Read one goal as a THREAD: the goal, its strategies, the initiatives/epics linked to it with their progress counts, the ideas that serve it with their state, the decisions that shaped it with a support/concern/object tally, its indicators with the last 12 readings, and the discussion count. `summary` is the compact form to read first; `thread` is the whole thing. Use this to answer "what are we doing about X and is it working".',
57
+ {
58
+ goalId: z.string().describe('Goal or strategy id from list_goals.'),
59
+ },
60
+ async (args) => {
61
+ const data = await api('GET', `${BASE}/${encodeURIComponent(args.goalId)}`);
62
+ const thread = data?.data ?? null;
63
+ if (!thread) return ok({ thread: null, summary: null });
64
+ return ok({ summary: summarizeThread(thread), thread });
65
+ }
66
+ );
67
+
68
+ server.tool(
69
+ 'create_goal',
70
+ `Create a goal, or a STRATEGY under a goal (level: strategy + parentGoalId). A strategy is a bet: give its hypothesis (what we expect), its falsifier (what observation would prove it wrong) and a confidence 0-1, so it can be scored when it resolves. Goals are Starfield-owned and born here; the app is the writer. Check list_goals first — two goals with one intent is a tree nobody trusts. Progress is never set: it is derived from the cards its linked scopes reach.${GOVERNED_NOTE}`,
71
+ {
72
+ level: z.enum(GOAL_LEVELS).describe('goal = the outcome; strategy = one of the ways to reach a goal (needs parentGoalId).'),
73
+ title: z.string().describe('The outcome as a statement, at most 160 characters.'),
74
+ body: z.string().optional().describe('Markdown. Why this goal, what "achieved" looks like.'),
75
+ audience: z.enum(AUDIENCES).describe('Who this is for. company for the whole business; builder for us building Adrata/Starfield.'),
76
+ parentGoalId: z.string().optional().describe('REQUIRED for a strategy: the goal it serves. Must be absent for a goal.'),
77
+ hypothesis: z.string().optional().describe('Strategy: what we expect to happen if we do this.'),
78
+ falsifier: z.string().optional().describe('Strategy: the observation that would prove the bet wrong.'),
79
+ confidence: z.number().min(0).max(1).optional().describe('Strategy: stated probability the bet pays off, 0-1.'),
80
+ targetOn: z.string().optional().describe('Target day, YYYY-MM-DD.'),
81
+ revenueOutcomeId: z.string().optional().describe('The revenue outcome this goal rolls up to, when one exists.'),
82
+ ...governedArgs(z),
83
+ },
84
+ async (args) => {
85
+ validateGoalLevel(args.level, args.parentGoalId);
86
+ const body = compactBody({
87
+ level: args.level,
88
+ title: validateTitle(args.title),
89
+ body: args.body === undefined ? undefined : validateBody(args.body),
90
+ audience: args.audience,
91
+ parentGoalId: args.parentGoalId,
92
+ hypothesis: args.hypothesis,
93
+ falsifier: args.falsifier,
94
+ confidence: validateConfidence(args.confidence),
95
+ targetOn: validateDate(args.targetOn, 'targetOn'),
96
+ revenueOutcomeId: args.revenueOutcomeId,
97
+ });
98
+ const preview = validateApiBridgeRequest({
99
+ method: 'POST',
100
+ path: BASE,
101
+ dryRun: args.dryRun,
102
+ approved: args.approved,
103
+ reason: args.reason,
104
+ idempotencyKey: args.idempotencyKey,
105
+ grantedScope: getGrantedScope(),
106
+ });
107
+ if (preview?.dryRun) return ok({ ...preview, wouldCreate: body });
108
+ const data = await api('POST', BASE, { body, headers: buildMutationHeaders(args) });
109
+ return ok({ created: true, goal: data?.data });
110
+ }
111
+ );
112
+
113
+ server.tool(
114
+ 'link_goal',
115
+ `Say that an initiative/epic (scopeId) REALIZES this goal or strategy, or that an idea (ideaId) SERVES it. Exactly one of scopeId | ideaId. The scope and the idea do not move or change; this is an edge with a reason, and the goal's progress is derived from the cards the linked scopes reach. Linking a pair that is already linked replaces the reason (an upsert). The reason is recorded on the link and doubles as the audit reason.${GOVERNED_NOTE}`,
116
+ {
117
+ goalId: z.string().describe('Goal or strategy id from list_goals.'),
118
+ scopeId: z.string().optional().describe('Initiative or epic id from list_work_scopes. Exactly one of scopeId | ideaId.'),
119
+ ideaId: z.string().optional().describe('Idea id from list_ideas. Exactly one of scopeId | ideaId.'),
120
+ ...governedArgs(z),
121
+ reason: z.string().describe('Why this scope realizes / this idea serves the goal, in a sentence. Recorded on the link; also the audit reason.'),
122
+ },
123
+ async (args) => {
124
+ const { key, value } = pickExactlyOne({ scopeId: args.scopeId, ideaId: args.ideaId });
125
+ const reason = validateReason(args.reason);
126
+ const segment = key === 'scopeId' ? 'scopes' : 'ideas';
127
+ const path = `${BASE}/${encodeURIComponent(args.goalId)}/${segment}`;
128
+ const body = { [key]: value, reason };
129
+ const preview = validateApiBridgeRequest({
130
+ method: 'POST',
131
+ path,
132
+ dryRun: args.dryRun,
133
+ approved: args.approved,
134
+ reason,
135
+ idempotencyKey: args.idempotencyKey,
136
+ grantedScope: getGrantedScope(),
137
+ });
138
+ if (preview?.dryRun) return ok({ ...preview, wouldLink: { goalId: args.goalId, ...body } });
139
+ const data = await api('POST', path, { body, headers: buildMutationHeaders({ ...args, reason }) });
140
+ return ok({ linked: true, goalId: args.goalId, [key]: value, goal: data?.data });
141
+ }
142
+ );
143
+ }
144
+
145
+ export const WORK_GOAL_TOOL_NAMES = ['list_goals', 'get_goal_thread', 'create_goal', 'link_goal'];