@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.
- package/access/tiers.js +39 -0
- package/api-bridge.js +25 -0
- package/package.json +2 -2
- package/server.js +15 -0
- package/server.json +2 -2
- package/skills/capture-to-idea/SKILL.md +170 -0
- package/skills/log-outreach.md +1 -1
- package/tool-annotations.js +85 -0
- package/tools/threads/shared.js +390 -0
- package/tools/threads/work-decision-tools.js +368 -0
- package/tools/threads/work-discussion-tools.js +125 -0
- package/tools/threads/work-goal-tools.js +145 -0
- package/tools/threads/work-idea-tools.js +172 -0
- package/tools/threads/work-indicator-tools.js +129 -0
- package/tools/threads/work-proposition-tools.js +417 -0
- package/tools/threads/work-thread-tools.js +165 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared vocabulary and pure helpers for the Starfield THREADS tools — Goals,
|
|
3
|
+
* Propositions, Indicators, Decisions, Ideas, the Discussion that runs under
|
|
4
|
+
* all five, the Galaxy documents each can file, and the one graph read.
|
|
5
|
+
*
|
|
6
|
+
* The contract: `spoq/epics/backlog/starfield-ideas/CONTRACT.md`. Every closed
|
|
7
|
+
* vocabulary below mirrors a `CHECK` constraint in the `work_threads`
|
|
8
|
+
* migration byte-for-byte; the Rust side has the same arrays and a test that
|
|
9
|
+
* the migration and the code agree. If a value here is not in the contract,
|
|
10
|
+
* the API will 400 it, so add it to the contract first.
|
|
11
|
+
*
|
|
12
|
+
* Nothing in this module talks to a server. The helpers are exported so the
|
|
13
|
+
* one thing a thread tool can silently get wrong — deriving "is this goal on
|
|
14
|
+
* track" from a reading, or tallying positions — is testable without one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const GOVERNED_NOTE =
|
|
18
|
+
' Governed write: previews by default. A live write requires dryRun:false plus approved:true, a reason, and an idempotencyKey (reuse the SAME key on retry so a flaky connection cannot write twice).';
|
|
19
|
+
|
|
20
|
+
/** Who the noun is for. A lens, never a hiding rule: the API returns every audience unless asked to filter. */
|
|
21
|
+
export const AUDIENCES = ['seller', 'manager', 'leader', 'builder', 'company'];
|
|
22
|
+
|
|
23
|
+
/** Where an idea arrived from. Required on capture, together with `source`. */
|
|
24
|
+
export const VECTORS = [
|
|
25
|
+
'owner',
|
|
26
|
+
'customer',
|
|
27
|
+
'buyer_demand',
|
|
28
|
+
'competitor',
|
|
29
|
+
'keynote',
|
|
30
|
+
'internal_audit',
|
|
31
|
+
'technology',
|
|
32
|
+
'regulation',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const GOAL_LEVELS = ['goal', 'strategy'];
|
|
36
|
+
export const GOAL_STATUSES = ['proposed', 'committed', 'achieved', 'abandoned'];
|
|
37
|
+
export const IDEA_STATES = ['captured', 'explored', 'parked', 'committed', 'realized', 'dropped'];
|
|
38
|
+
export const DECISION_STATUSES = ['proposed', 'discussing', 'decided', 'superseded', 'reversed'];
|
|
39
|
+
export const POSITIONS = ['support', 'concern', 'object'];
|
|
40
|
+
/** The reviewer's verdict on a decided decision's own prediction; stored as the outcome's first token and scored by calibration. */
|
|
41
|
+
export const OUTCOME_VERDICTS = ['held', 'partly', 'did_not_hold'];
|
|
42
|
+
export const INDICATOR_KINDS = ['leading', 'lagging'];
|
|
43
|
+
export const INDICATOR_DIRECTIONS = ['up', 'down'];
|
|
44
|
+
export const DOORS = ['one_way', 'two_way'];
|
|
45
|
+
/** What a decision may point at. Polymorphic by design; the route checks the target exists in the right table. */
|
|
46
|
+
export const DECISION_LINK_TARGET_TYPES = ['goal', 'idea', 'scope', 'work_item', 'indicator'];
|
|
47
|
+
/** How one decision relates to another — the edges of the decision graph. refines = sub-decision. */
|
|
48
|
+
export const DECISION_RELATIONS = ['supersedes', 'amends', 'refines', 'depends_on', 'conflicts_with', 'relates_to'];
|
|
49
|
+
/** What a discussion post, or a field-change row, hangs off. */
|
|
50
|
+
export const THREAD_TARGET_TYPES = ['goal', 'idea', 'decision', 'indicator'];
|
|
51
|
+
/** Derived, never stored (see indicatorStatus). */
|
|
52
|
+
export const INDICATOR_STATUSES = ['no_reading', 'achieved', 'on_track', 'off_track', 'unknown_pace'];
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Propositions — the fifth noun (CONTRACT.md "Propositions: the fifth noun").
|
|
56
|
+
// A claim about the world we act on, with the observation that would show it
|
|
57
|
+
// false. `confidence` on the row is the latest reading's value and is never
|
|
58
|
+
// edited directly; moving to contested or falsified needs a reading with
|
|
59
|
+
// evidence. Each array mirrors a CHECK in `work_propositions` /
|
|
60
|
+
// `work_proposition_links` byte-for-byte.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export const PROPOSITION_STATUSES = ['proposed', 'supported', 'contested', 'falsified', 'superseded'];
|
|
64
|
+
/** What rests on a proposition — the `assumes` edge. */
|
|
65
|
+
export const PROPOSITION_LINK_TARGET_TYPES = ['goal', 'decision', 'idea', 'indicator'];
|
|
66
|
+
/** Statuses the API refuses without a reading that carries evidence; the tool says so before the 400. */
|
|
67
|
+
export const PROPOSITION_EVIDENCE_STATUSES = new Set(['contested', 'falsified']);
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Galaxy documents on every noun (CONTRACT.md "Galaxy, and the ontology as the
|
|
71
|
+
// core of the system"). `work_thread_documents` keyed by (target_type,
|
|
72
|
+
// target_id, document_id), each with a role and a reason.
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export const DOCUMENT_TARGET_TYPES = ['goal', 'proposition', 'decision', 'indicator', 'idea'];
|
|
76
|
+
export const DOCUMENT_ROLES = ['memo', 'outcome', 'evidence', 'source', 'artifact'];
|
|
77
|
+
/** The route family each noun files its documents under: `POST/DELETE /{family}/{id}/documents[/{documentId}]`. */
|
|
78
|
+
export const DOCUMENT_FAMILY_PATHS = {
|
|
79
|
+
goal: '/api/v1/work-goals',
|
|
80
|
+
proposition: '/api/v1/work-propositions',
|
|
81
|
+
decision: '/api/v1/work-decisions',
|
|
82
|
+
indicator: '/api/v1/work-indicators',
|
|
83
|
+
idea: '/api/v1/work-ideas',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// The one graph read, any root (CONTRACT.md "Thread graphs you can generate").
|
|
88
|
+
// `GET /work-threads/graph?root=<kind>:<id>&depth=<n>&format=json|mermaid`.
|
|
89
|
+
// The contract's graph section names six root kinds; the propositions section
|
|
90
|
+
// adds the proposition as a node with `assumes` edges, so it is a root too.
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
export const THREAD_GRAPH_ROOT_KINDS = ['goal', 'strategy', 'decision', 'idea', 'indicator', 'scope', 'proposition'];
|
|
94
|
+
export const THREAD_GRAPH_RELATIONS = [
|
|
95
|
+
'serves', 'measured_by', 'decided_by', 'realizes', 'relates',
|
|
96
|
+
'supersedes', 'amends', 'refines', 'depends_on', 'conflicts_with', 'assumes',
|
|
97
|
+
];
|
|
98
|
+
export const THREAD_GRAPH_FORMATS = ['json', 'mermaid'];
|
|
99
|
+
export const THREAD_GRAPH_DEFAULT_DEPTH = 3;
|
|
100
|
+
export const THREAD_GRAPH_MAX_NODES = 200;
|
|
101
|
+
|
|
102
|
+
/** Transitions the route refuses without a non-empty reason (400 naming the field). */
|
|
103
|
+
export const REASON_REQUIRED_STATES = new Set(['parked', 'dropped', 'superseded', 'reversed', 'abandoned']);
|
|
104
|
+
|
|
105
|
+
/** Mirrors the per-family constants in the Rust modules. */
|
|
106
|
+
export const MAX_TITLE_CHARS = 160;
|
|
107
|
+
export const MAX_REASON_CHARS = 1000;
|
|
108
|
+
export const MAX_BODY_CHARS = 50_000;
|
|
109
|
+
|
|
110
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Argument validators. Each throws a plain Error naming the field, so a bad
|
|
114
|
+
// call fails HERE with a sentence rather than at the API with a 400 the agent
|
|
115
|
+
// has to decode. They validate exactly what the route validates and nothing
|
|
116
|
+
// more: they are a mirror, not a second policy.
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export function validateTitle(title, field = 'title') {
|
|
120
|
+
const text = String(title ?? '').trim();
|
|
121
|
+
if (!text) throw new Error(`${field} is required and cannot be blank`);
|
|
122
|
+
if (text.length > MAX_TITLE_CHARS) {
|
|
123
|
+
throw new Error(`${field} is ${text.length} characters; the limit is ${MAX_TITLE_CHARS}`);
|
|
124
|
+
}
|
|
125
|
+
return text;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function validateBody(body, field = 'body') {
|
|
129
|
+
const text = String(body ?? '');
|
|
130
|
+
if (text.length > MAX_BODY_CHARS) {
|
|
131
|
+
throw new Error(`${field} is ${text.length} characters; the limit is ${MAX_BODY_CHARS}`);
|
|
132
|
+
}
|
|
133
|
+
return text;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A reason recorded ON a row (a link, a transition). Distinct from the audit header, though a tool may pass one string as both. */
|
|
137
|
+
export function validateReason(reason, field = 'reason') {
|
|
138
|
+
const text = String(reason ?? '').trim();
|
|
139
|
+
if (!text) throw new Error(`${field} is required and cannot be blank`);
|
|
140
|
+
if (text.length > MAX_REASON_CHARS) {
|
|
141
|
+
throw new Error(`${field} is ${text.length} characters; the limit is ${MAX_REASON_CHARS}`);
|
|
142
|
+
}
|
|
143
|
+
return text;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function validateConfidence(confidence, field = 'confidence') {
|
|
147
|
+
if (confidence === undefined || confidence === null) return undefined;
|
|
148
|
+
const n = Number(confidence);
|
|
149
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) throw new Error(`${field} must be a number from 0 to 1`);
|
|
150
|
+
return n;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function validateDate(value, field) {
|
|
154
|
+
if (value === undefined || value === null || value === '') return value;
|
|
155
|
+
if (!DATE_RE.test(String(value))) throw new Error(`${field} must be a date, YYYY-MM-DD`);
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function validateEnum(value, allowed, field) {
|
|
160
|
+
if (!allowed.includes(value)) {
|
|
161
|
+
throw new Error(`${field} must be one of ${allowed.join(' | ')}; got ${JSON.stringify(value)}`);
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A goal has no parent; a strategy has exactly one. This is the migration's
|
|
168
|
+
* `strategy_has_parent` CHECK, mirrored so the agent learns it from a sentence
|
|
169
|
+
* rather than a constraint-violation string.
|
|
170
|
+
*/
|
|
171
|
+
export function validateGoalLevel(level, parentGoalId) {
|
|
172
|
+
validateEnum(level, GOAL_LEVELS, 'level');
|
|
173
|
+
const hasParent = Boolean(parentGoalId);
|
|
174
|
+
if (level === 'strategy' && !hasParent) {
|
|
175
|
+
throw new Error('a strategy needs parentGoalId: the goal it is one of the ways to reach');
|
|
176
|
+
}
|
|
177
|
+
if (level === 'goal' && hasParent) {
|
|
178
|
+
throw new Error('a goal has no parentGoalId; to nest under a goal, create a strategy');
|
|
179
|
+
}
|
|
180
|
+
return level;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A link call names exactly one target. `choices` is `{ argName: value }`;
|
|
185
|
+
* the one that is set wins, and zero or two is an error naming the options.
|
|
186
|
+
*/
|
|
187
|
+
export function pickExactlyOne(choices) {
|
|
188
|
+
const set = Object.entries(choices).filter(([, v]) => v !== undefined && v !== null && v !== '');
|
|
189
|
+
const names = Object.keys(choices).join(' | ');
|
|
190
|
+
if (set.length === 0) throw new Error(`pass exactly one of ${names}`);
|
|
191
|
+
if (set.length > 1) {
|
|
192
|
+
throw new Error(`pass exactly one of ${names}, not ${set.map(([k]) => k).join(' and ')}`);
|
|
193
|
+
}
|
|
194
|
+
const [key, value] = set[0];
|
|
195
|
+
return { key, value };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A transition into a terminal or parked state needs a stated reason; every transition here gets one anyway. */
|
|
199
|
+
export function validateStateReason(state, reason) {
|
|
200
|
+
if (REASON_REQUIRED_STATES.has(state) && !String(reason ?? '').trim()) {
|
|
201
|
+
throw new Error(`moving to ${state} requires a reason`);
|
|
202
|
+
}
|
|
203
|
+
return validateReason(reason);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Audience filter for a list: array or comma string in, `a,b` out (or undefined). */
|
|
207
|
+
export function audienceQuery(audience) {
|
|
208
|
+
if (audience === undefined || audience === null) return undefined;
|
|
209
|
+
const list = Array.isArray(audience) ? audience : String(audience).split(',');
|
|
210
|
+
const clean = list.map((a) => String(a).trim()).filter(Boolean);
|
|
211
|
+
for (const a of clean) validateEnum(a, AUDIENCES, 'audience');
|
|
212
|
+
return clean.length ? clean.join(',') : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A graph root is `<kind>:<id>`, kind from THREAD_GRAPH_ROOT_KINDS. Returns
|
|
217
|
+
* `{ kind, id, root }` with `root` re-joined, so a stray space is trimmed
|
|
218
|
+
* before it reaches the query string.
|
|
219
|
+
*/
|
|
220
|
+
export function parseGraphRoot(root) {
|
|
221
|
+
const text = String(root ?? '').trim();
|
|
222
|
+
const at = text.indexOf(':');
|
|
223
|
+
if (!text || at <= 0 || at === text.length - 1) {
|
|
224
|
+
throw new Error(`root must be <kind>:<id>, kind one of ${THREAD_GRAPH_ROOT_KINDS.join(' | ')}; got ${JSON.stringify(root)}`);
|
|
225
|
+
}
|
|
226
|
+
const kind = text.slice(0, at).trim();
|
|
227
|
+
const id = text.slice(at + 1).trim();
|
|
228
|
+
validateEnum(kind, THREAD_GRAPH_ROOT_KINDS, 'root kind');
|
|
229
|
+
if (!id) throw new Error('root must be <kind>:<id>; the id is blank');
|
|
230
|
+
return { kind, id, root: `${kind}:${id}` };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A graph depth is a positive integer; undefined means the API default (3). */
|
|
234
|
+
export function validateDepth(depth, field = 'depth') {
|
|
235
|
+
if (depth === undefined || depth === null || depth === '') return undefined;
|
|
236
|
+
const n = Number(depth);
|
|
237
|
+
if (!Number.isInteger(n) || n < 1) throw new Error(`${field} must be a whole number of 1 or more`);
|
|
238
|
+
return n;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The mermaid text out of whatever shape the API hands back for
|
|
243
|
+
* `format=mermaid`: a bare string, `{ mermaid }`, or `{ data: <either> }`.
|
|
244
|
+
* Returns null when there is none, so the caller can say so rather than
|
|
245
|
+
* pass an object where a document was promised.
|
|
246
|
+
*/
|
|
247
|
+
export function mermaidText(payload) {
|
|
248
|
+
if (typeof payload === 'string') return payload;
|
|
249
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
250
|
+
if (typeof payload.mermaid === 'string') return payload.mermaid;
|
|
251
|
+
if (payload.data !== undefined) return mermaidText(payload.data);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Build `?k=v&k2=v2`, skipping undefined/null/empty so a bare list has a bare path. */
|
|
256
|
+
export function buildQuery(params) {
|
|
257
|
+
const search = new URLSearchParams();
|
|
258
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
259
|
+
if (value === undefined || value === null || value === '') continue;
|
|
260
|
+
search.set(key, String(value));
|
|
261
|
+
}
|
|
262
|
+
const s = search.toString();
|
|
263
|
+
return s ? `?${s}` : '';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Drop undefined keys so a body says only what the caller said. */
|
|
267
|
+
export function compactBody(body) {
|
|
268
|
+
return Object.fromEntries(Object.entries(body).filter(([, v]) => v !== undefined));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Derivations. These mirror `derive.rs`; the API is the source of truth and
|
|
273
|
+
// returns `status` itself. They exist so a tool can recompute from readings it
|
|
274
|
+
// was handed (get_goal_thread returns the last 12) and so the rule is pinned.
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
function meets(value, target, direction) {
|
|
278
|
+
if (target === undefined || target === null) return false;
|
|
279
|
+
return direction === 'down' ? value <= target : value >= target;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* no readings → no_reading; latest meets target in direction → achieved;
|
|
284
|
+
* else latest has `expected`: meets expected → on_track, else off_track;
|
|
285
|
+
* else unknown_pace (a reading with no plan line says nothing about pace).
|
|
286
|
+
*/
|
|
287
|
+
export function indicatorStatus(latest, target, direction = 'up') {
|
|
288
|
+
if (!latest || latest.value === undefined || latest.value === null) return 'no_reading';
|
|
289
|
+
const value = Number(latest.value);
|
|
290
|
+
if (meets(value, target === undefined || target === null ? null : Number(target), direction)) {
|
|
291
|
+
return 'achieved';
|
|
292
|
+
}
|
|
293
|
+
if (latest.expected !== undefined && latest.expected !== null) {
|
|
294
|
+
return meets(value, Number(latest.expected), direction) ? 'on_track' : 'off_track';
|
|
295
|
+
}
|
|
296
|
+
return 'unknown_pace';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** `{ support, concern, object }` from a list of positions (rows or bare strings). Unknown values are ignored, never counted. */
|
|
300
|
+
export function positionsTally(positions) {
|
|
301
|
+
const tally = { support: 0, concern: 0, object: 0 };
|
|
302
|
+
for (const p of Array.isArray(positions) ? positions : []) {
|
|
303
|
+
const value = typeof p === 'string' ? p : p?.position;
|
|
304
|
+
if (value in tally) tally[value] += 1;
|
|
305
|
+
}
|
|
306
|
+
return tally;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The API's decision `positions` field IS the tally object `{support, concern,
|
|
311
|
+
* object}` (Rust `PositionTally`; the who-list is `positionHolders`). Pass it
|
|
312
|
+
* through as-is; only tally a list, which is what an older payload or a test
|
|
313
|
+
* fixture may hand over. Anything else counts as no positions.
|
|
314
|
+
*/
|
|
315
|
+
export function tallyFrom(positions) {
|
|
316
|
+
if (Array.isArray(positions)) return positionsTally(positions);
|
|
317
|
+
if (positions && typeof positions === 'object') return positions;
|
|
318
|
+
return positionsTally([]);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The compact form of a goal thread — what an agent should read before the
|
|
323
|
+
* whole thread. Progress is passed through as the COUNTS the API derived,
|
|
324
|
+
* never turned into a percentage (hierarchy Decision 4).
|
|
325
|
+
*/
|
|
326
|
+
export function summarizeThread(thread) {
|
|
327
|
+
if (!thread) return null;
|
|
328
|
+
const goal = thread.goal ?? thread;
|
|
329
|
+
const strategies = Array.isArray(thread.strategies) ? thread.strategies : [];
|
|
330
|
+
const indicators = Array.isArray(thread.indicators) ? thread.indicators : [];
|
|
331
|
+
const decisions = Array.isArray(thread.decisions) ? thread.decisions : [];
|
|
332
|
+
const scopes = Array.isArray(thread.scopes) ? thread.scopes : [];
|
|
333
|
+
const ideas = Array.isArray(thread.ideas) ? thread.ideas : [];
|
|
334
|
+
const indicatorLine = (ind) => {
|
|
335
|
+
const readings = Array.isArray(ind.readings) ? ind.readings : [];
|
|
336
|
+
const latest = ind.latest ?? readings[0] ?? null;
|
|
337
|
+
return {
|
|
338
|
+
id: ind.id,
|
|
339
|
+
name: ind.name,
|
|
340
|
+
kind: ind.kind,
|
|
341
|
+
latest: latest ? { value: latest.value, expected: latest.expected ?? null, observedAt: latest.observedAt } : null,
|
|
342
|
+
target: ind.targetValue ?? null,
|
|
343
|
+
status: ind.status ?? indicatorStatus(latest, ind.targetValue, ind.direction),
|
|
344
|
+
};
|
|
345
|
+
};
|
|
346
|
+
return {
|
|
347
|
+
goal: {
|
|
348
|
+
id: goal.id,
|
|
349
|
+
title: goal.title,
|
|
350
|
+
level: goal.level,
|
|
351
|
+
status: goal.status,
|
|
352
|
+
audience: goal.audience,
|
|
353
|
+
targetOn: goal.targetOn ?? null,
|
|
354
|
+
},
|
|
355
|
+
strategies: strategies.map((s) => ({
|
|
356
|
+
id: s.id,
|
|
357
|
+
title: s.title,
|
|
358
|
+
status: s.status,
|
|
359
|
+
confidence: s.confidence ?? null,
|
|
360
|
+
indicators: (Array.isArray(s.indicators) ? s.indicators : []).map(indicatorLine),
|
|
361
|
+
progress: s.progress ?? null,
|
|
362
|
+
})),
|
|
363
|
+
indicators: indicators.map(indicatorLine),
|
|
364
|
+
decisions: decisions.map((d) => ({
|
|
365
|
+
id: d.id,
|
|
366
|
+
title: d.title,
|
|
367
|
+
status: d.status,
|
|
368
|
+
tally: d.tally ?? d.positionsTally ?? tallyFrom(d.positions),
|
|
369
|
+
})),
|
|
370
|
+
progress: thread.progress ?? goal.progress ?? null,
|
|
371
|
+
counts: {
|
|
372
|
+
strategies: strategies.length,
|
|
373
|
+
scopes: scopes.length,
|
|
374
|
+
ideas: ideas.length,
|
|
375
|
+
decisions: decisions.length,
|
|
376
|
+
indicators: indicators.length,
|
|
377
|
+
posts: thread.discussionCount ?? thread.postCount ?? 0,
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The four governed-envelope args every write tool declares, in one place. */
|
|
383
|
+
export function governedArgs(z) {
|
|
384
|
+
return {
|
|
385
|
+
dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live write.'),
|
|
386
|
+
approved: z.boolean().optional().describe('Required true for a live write.'),
|
|
387
|
+
reason: z.string().optional().describe('Required for a live write: the audit reason.'),
|
|
388
|
+
idempotencyKey: z.string().optional().describe('Required for a live write. Reuse on retry.'),
|
|
389
|
+
};
|
|
390
|
+
}
|