@aligndottech/cli 0.38.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/commands/connect.d.ts +11 -13
  2. package/dist/commands/connect.d.ts.map +1 -1
  3. package/dist/commands/connect.js +0 -21
  4. package/dist/commands/connect.js.map +1 -1
  5. package/dist/commands/import/confluence.js +2 -2
  6. package/dist/commands/import/confluence.js.map +1 -1
  7. package/dist/commands/import/docs.js +1 -1
  8. package/dist/commands/import/docs.js.map +1 -1
  9. package/dist/commands/import/git.js +1 -1
  10. package/dist/commands/import/git.js.map +1 -1
  11. package/dist/commands/import/github.js +1 -1
  12. package/dist/commands/import/github.js.map +1 -1
  13. package/dist/commands/import/gitlab.js +1 -1
  14. package/dist/commands/import/gitlab.js.map +1 -1
  15. package/dist/commands/import/jira.js +2 -2
  16. package/dist/commands/import/jira.js.map +1 -1
  17. package/dist/commands/import/linear.js +1 -1
  18. package/dist/commands/import/linear.js.map +1 -1
  19. package/dist/commands/import/notion.js +1 -1
  20. package/dist/commands/import/notion.js.map +1 -1
  21. package/dist/commands/import/sessions.d.ts +1 -1
  22. package/dist/commands/import/sessions.js +1 -1
  23. package/dist/commands/import/sessions.js.map +1 -1
  24. package/dist/commands/import/slack.js +1 -1
  25. package/dist/commands/import/slack.js.map +1 -1
  26. package/dist/commands/import/teams.js +1 -1
  27. package/dist/commands/import/teams.js.map +1 -1
  28. package/dist/commands/import/zoom.js +1 -1
  29. package/dist/commands/import/zoom.js.map +1 -1
  30. package/dist/commands/import.d.ts +24 -3
  31. package/dist/commands/import.d.ts.map +1 -1
  32. package/dist/commands/import.js +38 -21
  33. package/dist/commands/import.js.map +1 -1
  34. package/dist/commands/mcp.d.ts +81 -3
  35. package/dist/commands/mcp.d.ts.map +1 -1
  36. package/dist/commands/mcp.js +235 -17
  37. package/dist/commands/mcp.js.map +1 -1
  38. package/dist/commands/ratify.js +1 -1
  39. package/dist/commands/ratify.js.map +1 -1
  40. package/dist/commands/registry.d.ts.map +1 -1
  41. package/dist/commands/registry.js +11 -1
  42. package/dist/commands/registry.js.map +1 -1
  43. package/dist/commands/search.js +1 -1
  44. package/dist/commands/search.js.map +1 -1
  45. package/dist/commands/setup.js +1 -1
  46. package/dist/commands/setup.js.map +1 -1
  47. package/dist/commands/why.js +3 -3
  48. package/dist/commands/why.js.map +1 -1
  49. package/dist/lib/capture-sources.d.ts +1 -1
  50. package/dist/lib/capture-sources.js +1 -1
  51. package/dist/lib/command-opts.d.ts +1 -1
  52. package/dist/lib/command-opts.js +1 -1
  53. package/dist/lib/commands-doc.d.ts.map +1 -1
  54. package/dist/lib/commands-doc.js +4 -1
  55. package/dist/lib/commands-doc.js.map +1 -1
  56. package/dist/lib/decision-refs.d.ts +1 -1
  57. package/dist/lib/decision-refs.js +1 -1
  58. package/dist/lib/errors.d.ts +1 -1
  59. package/dist/lib/errors.js +2 -2
  60. package/dist/lib/errors.js.map +1 -1
  61. package/dist/lib/fetchers/git.d.ts +1 -1
  62. package/dist/lib/fetchers/git.js +1 -1
  63. package/dist/lib/gateway-client.d.ts +70 -1
  64. package/dist/lib/gateway-client.d.ts.map +1 -1
  65. package/dist/lib/gateway-client.js +121 -8
  66. package/dist/lib/gateway-client.js.map +1 -1
  67. package/dist/lib/git.js +1 -1
  68. package/dist/lib/git.js.map +1 -1
  69. package/dist/lib/import-defaults.d.ts +1 -1
  70. package/dist/lib/import-defaults.js +1 -1
  71. package/dist/lib/local-db.js +1 -1
  72. package/dist/lib/local-gateway-client.d.ts +1 -1
  73. package/dist/lib/local-gateway-client.d.ts.map +1 -1
  74. package/dist/lib/local-gateway-client.js +9 -1
  75. package/dist/lib/local-gateway-client.js.map +1 -1
  76. package/dist/lib/mcp-timeline-tools.d.ts +128 -0
  77. package/dist/lib/mcp-timeline-tools.d.ts.map +1 -0
  78. package/dist/lib/mcp-timeline-tools.js +504 -0
  79. package/dist/lib/mcp-timeline-tools.js.map +1 -0
  80. package/dist/lib/personal-oauth.d.ts +1 -1
  81. package/dist/lib/personal-oauth.js +4 -4
  82. package/dist/lib/personal-oauth.js.map +1 -1
  83. package/dist/lib/resolve-env.d.ts +1 -1
  84. package/dist/lib/resolve-env.js +1 -1
  85. package/dist/lib/sessions/import-summary.d.ts +2 -2
  86. package/dist/lib/sessions/import-summary.js +1 -1
  87. package/dist/lib/sessions/registry.d.ts +1 -1
  88. package/dist/lib/sessions/registry.js +1 -1
  89. package/dist/lib/usage-telemetry.d.ts +2 -2
  90. package/dist/lib/usage-telemetry.js +2 -2
  91. package/package.json +1 -1
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Shape one `GET /snapshots/:id` row into the rationale answer.
3
+ *
4
+ * Tolerant of a row with no `decision_json` at all, which is the LOCAL case: local mode serves
5
+ * this tool for real (the local client implements getDecision) and its rows carry no such
6
+ * column. Thinner, not broken - the summary becomes the rationale, which is the same
7
+ * last-resort fallback the hosted handler uses.
8
+ */
9
+ export declare function shapeDecisionRationale(row: Record<string, unknown>, decisionId: string): Record<string, unknown>;
10
+ /** Names this server publishes for the trio. Imported by mcp.ts, so there is one writer. */
11
+ export declare const TOPIC_TIMELINE_TOOL = "align_get_topic_timeline";
12
+ export declare const DECISION_RATIONALE_TOOL = "align_get_decision_rationale";
13
+ export declare const DECISION_TIMELINE_TOOL = "align_get_decision_timeline";
14
+ /**
15
+ * The one phrase the tool description and the per-result message both use to gate the
16
+ * full-timeline render. Shared so the two surfaces cannot drift: they are two writers of one
17
+ * fact with no reader that sees both, and each surface's test pins only its own copy.
18
+ */
19
+ export declare const STORY_GATE = "asked for the story";
20
+ /**
21
+ * One row of the gateway's topic timeline. Every field optional but `id`/`title`/`created_at`,
22
+ * because the gateway and this client ship separately and an older one sends fewer.
23
+ */
24
+ export interface TimelineRow {
25
+ id: string;
26
+ title: string;
27
+ summary?: string;
28
+ platform?: string;
29
+ status?: string;
30
+ created_at: string;
31
+ /** ALI-622: the source's decision moment. created_at is Align's import day. */
32
+ decided_at?: string | null;
33
+ /** ALI-876: when the artifact was raised in its source. */
34
+ source_created_at?: string | null;
35
+ /**
36
+ * ALI-876: which of the three clocks the gateway resolved for this row. Computed THERE, by
37
+ * the same resolver that ordered the list, so this client never re-derives it - a second
38
+ * implementation of the precedence could disagree with the ordering it is rendering, which
39
+ * is the failure a label is supposed to prevent.
40
+ */
41
+ date_basis?: 'decided' | 'created' | 'recorded';
42
+ source_url?: string;
43
+ matched_by?: string[];
44
+ }
45
+ export interface TopicTimelineResult {
46
+ topic?: string;
47
+ count?: number;
48
+ superseded_count?: number;
49
+ platforms?: string[];
50
+ spans_platforms?: boolean;
51
+ retrieval?: {
52
+ lexical?: boolean;
53
+ semantic?: boolean;
54
+ };
55
+ activity?: Array<{
56
+ month: string;
57
+ count: number;
58
+ }>;
59
+ disagreements?: Array<{
60
+ relation?: string;
61
+ from?: {
62
+ id?: string;
63
+ };
64
+ to?: {
65
+ id?: string;
66
+ };
67
+ }>;
68
+ why?: Array<{
69
+ id?: string;
70
+ rationale?: string;
71
+ risks?: string[];
72
+ }>;
73
+ still_open?: Array<{
74
+ id?: string;
75
+ questions?: string[];
76
+ }>;
77
+ chain_ids?: string[];
78
+ background_platforms?: string[];
79
+ decisions?: TimelineRow[];
80
+ }
81
+ /**
82
+ * Whether the gateway sent a field, and whether it had anything in it.
83
+ *
84
+ * `absent` and `empty` are DIFFERENT CLAIMS and collapsing them loses the honest one: an empty
85
+ * array from a gateway that looked asserts "there are none", which is stronger than "we could
86
+ * not look". The first port reduced both to a boolean, so `still_open: []` took the
87
+ * unavailable path, whose wording asks for "the single sharpest unanswered question" - an
88
+ * invitation to invent one (Copilot, #296 inline at :378). `disagreements` already had the
89
+ * three-way; this gives `why` and `still_open` the same treatment.
90
+ */
91
+ export type FieldState = 'absent' | 'empty' | 'present';
92
+ interface MessageOpts {
93
+ count: number;
94
+ semanticRan: boolean;
95
+ topic: string;
96
+ backgroundCount: number;
97
+ whyState: FieldState;
98
+ /** undefined = the gateway never sent the field; 0 = it looked and found none. */
99
+ disagreementCount: number | undefined;
100
+ stillOpenState: FieldState;
101
+ /**
102
+ * Chain-scoped, computed over the NARRATED rows. The gateway's `count`, `platforms` and
103
+ * `superseded_count` cover everything it RETRIEVED, and the compact summary line narrates
104
+ * the CHAIN - interpolating the retrieval-wide numbers there would overstate the chain by
105
+ * exactly the background count, citing decisions the agent has no rows for. One right
106
+ * answer per value, so it is computed rather than left for the model to re-derive.
107
+ */
108
+ chainCount: number;
109
+ chainPlatforms: string[];
110
+ chainSupersededCount: number;
111
+ /**
112
+ * ALI-1010, and deliberately NOT folded into chainSupersededCount. A corrected decision was
113
+ * never true; a superseded one was true and was overtaken. Counting them together is the
114
+ * manufactured history the corrections clause exists to prevent.
115
+ */
116
+ chainCorrectedCount: number;
117
+ }
118
+ export declare function topicTimelineMessage(opts: MessageOpts): string;
119
+ /**
120
+ * Shape one gateway topic-timeline response for an agent: project the rows, split the chain
121
+ * from the background, and attach the rendering contract.
122
+ *
123
+ * Pure, and exported, so every clause of the contract is assertable without standing up an
124
+ * MCP server or a gateway.
125
+ */
126
+ export declare function shapeTopicTimeline(result: TopicTimelineResult, requestedTopic: string): Record<string, unknown>;
127
+ export {};
128
+ //# sourceMappingURL=mcp-timeline-tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-timeline-tools.d.ts","sourceRoot":"","sources":["../../src/lib/mcp-timeline-tools.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,504 @@
1
+ import { citationFor, navigableSourceUrl, repositoryOf } from './decision-links.js';
2
+ /**
3
+ * ALI-1070 slice 1: the "timeline trio", ported from the hosted connector so a customer's
4
+ * agent can reach it through `align mcp`.
5
+ *
6
+ * `align mcp` is the server `align mcp --setup` wires into every customer's agent. It
7
+ * published 8 tools against the hosted endpoint's 24, and the three missing ones ported here
8
+ * are the measured lever on AlignBench's weakest split (`reversed`).
9
+ *
10
+ * THE ENDPOINT IS NOT THE PRODUCT. THE MESSAGE IS.
11
+ *
12
+ * `POST /decisions/topic-timeline` already returns the whole story structure - the chain, the
13
+ * conflicts, the reasons, the open questions. What the hosted connector's topicTimeline.ts
14
+ * adds on top is (a) a projection and (b) a RENDERING CONTRACT in the response message, and
15
+ * the contract is where the measured behaviour lives. A customer handed the rows without the
16
+ * contract gets the data and not the ability to tell a supersession from a correction, which
17
+ * is exactly the split this ticket exists to move. So this module is mostly the message.
18
+ *
19
+ * The contract rides on the response rather than on the server instructions because the
20
+ * message is the one surface guaranteed to be in the calling agent's context at the moment it
21
+ * writes the answer: instructions are truncated around 2KB and some clients never surface
22
+ * them at all.
23
+ *
24
+ * Ported faithfully from connectors/mcp-align/src/tools/topicTimeline.ts with three
25
+ * deliberate differences, each because this server is not that server:
26
+ *
27
+ * 1. TOOL NAMES ARE RENDERED, NOT HARDCODED. The hosted message says "get_decision_rationale";
28
+ * this server's name for that tool is `align_get_decision_rationale`. Porting the text
29
+ * verbatim would instruct a customer's agent to call a tool that is not on this server -
30
+ * the same defect align-stack derives ALIGN_MCP_INSTRUCTIONS_READ_ONLY to prevent, and
31
+ * pinned here by mcp-timeline-trio.test.ts ("names no align_ tool it does not register").
32
+ * 2. NO decision_url. That one genuinely needs the hosted connector's FRONTEND_URL. The
33
+ * contract's link clause is conditional ("with decision_url when present, else
34
+ * source_url") so it stays TRUE here, and the closing full-timeline rule was corrected in
35
+ * the ALI-1070 follow-up to stop demanding it unconditionally.
36
+ *
37
+ * `cite` and `repository` ARE emitted. The first port dropped them claiming they needed
38
+ * FRONTEND_URL too, which was simply false: both derive from `source_url` alone through
39
+ * one regex, so they cost about twenty lines and no configuration. Correcting the claim is
40
+ * the point - a stated reason that is wrong outlives the omission it justified.
41
+ * 3. The result is a plain object. This server's createCallToolHandler serialises it and
42
+ * OMIT_RESULT_KEYS strips the heavy fields, so there is no toolText/framed wrapper. That
43
+ * stripping is also why the rationale tool needs shapeDecisionRationale below rather than
44
+ * returning its gateway row raw.
45
+ */
46
+ /**
47
+ * ALI-1070 follow-up, F7 (Copilot, #296 suppressed at mcp.ts:222): project the reasoning OUT
48
+ * of `decision_json` before the serializer strips it.
49
+ *
50
+ * The first port was `return client.getDecision(id)` raw. `serializeMcpResult`'s
51
+ * OMIT_RESULT_KEYS removes `decision_json`, and EVERY field this tool's description promises -
52
+ * rationale, goals, risks, context - lives inside it. Measured against a realistic snapshot
53
+ * row before the fix: the agent received id, title, summary, status, platform, created_at and
54
+ * nothing else. The tool answered with metadata and dropped the rationale it advertises.
55
+ *
56
+ * Worse, the topic-timeline contract's own fallback names this tool ("call
57
+ * align_get_decision_rationale for the decisions whose reasoning the answer needs"), so the
58
+ * one escape hatch the story renderer offers returned no reasoning.
59
+ *
60
+ * Projected rather than un-omitted: `decision_json` is unbounded (ALI-498 measured 43,327
61
+ * characters of it on one 12-row prod component), so the fix is to lift the named fields out,
62
+ * never to let the blob through.
63
+ *
64
+ * Ported from the hosted connector's decisionRationale.ts, including the `.ai` sub-object.
65
+ *
66
+ * ALI-484 is about `alternatives_considered` and `positions_considered` specifically:
67
+ * `rationale` is a 1-2 sentence gloss on the option CHOSEN, while the reasoning an agent needs
68
+ * is the option REJECTED and why, which the scan writes into `suggested_decision_json` and the
69
+ * approve path copies verbatim into `decision_json.ai`. Those two fields therefore read the
70
+ * top level and fall back to `.ai`.
71
+ *
72
+ * `rationale` keeps the HOSTED precedence - top level first, then `.ai`, then the summary.
73
+ * Copilot read the paragraph above as a claim that `.ai.rationale` should win (#298 inline at
74
+ * :134); it is not, and the fix is this wording rather than a re-ranking. A port that silently
75
+ * re-ranks its source stops being verifiable against it, and there is no evidence which order
76
+ * answers better. The both-present case is now pinned by a test so the choice is deliberate.
77
+ */
78
+ /** A stored decision is free-form JSON, so a field asked for as a list can arrive as anything. */
79
+ function asArray(value) {
80
+ return Array.isArray(value) ? value : [];
81
+ }
82
+ /** ALI-582: the Jira keys and implementing PRs the gateway already sends in this response. */
83
+ const MAX_ARTIFACTS_PER_DECISION = 20;
84
+ function mentionedArtifacts(refs) {
85
+ if (!Array.isArray(refs))
86
+ return {};
87
+ const all = refs
88
+ .map((r) => {
89
+ if (!r || typeof r !== 'object')
90
+ return undefined;
91
+ const ref = r;
92
+ const id = ref['external_id'];
93
+ if (typeof id !== 'string' || id.length === 0)
94
+ return undefined;
95
+ return {
96
+ ...(typeof ref['connector'] === 'string' ? { connector: ref['connector'] } : {}),
97
+ external_id: id,
98
+ ...(typeof ref['external_url'] === 'string' ? { external_url: ref['external_url'] } : {}),
99
+ ...(typeof ref['reference_type'] === 'string'
100
+ ? { reference_type: ref['reference_type'] }
101
+ : {}),
102
+ };
103
+ })
104
+ .filter((a) => a !== undefined);
105
+ // Absent rather than [] when there are none: an empty list reads as "we looked and this
106
+ // decision mentions nothing", which is a claim this projection cannot make.
107
+ if (all.length === 0)
108
+ return {};
109
+ return {
110
+ mentioned_artifacts: all.slice(0, MAX_ARTIFACTS_PER_DECISION),
111
+ ...(all.length > MAX_ARTIFACTS_PER_DECISION ? { mentioned_artifacts_truncated: true } : {}),
112
+ };
113
+ }
114
+ /**
115
+ * Shape one `GET /snapshots/:id` row into the rationale answer.
116
+ *
117
+ * Tolerant of a row with no `decision_json` at all, which is the LOCAL case: local mode serves
118
+ * this tool for real (the local client implements getDecision) and its rows carry no such
119
+ * column. Thinner, not broken - the summary becomes the rationale, which is the same
120
+ * last-resort fallback the hosted handler uses.
121
+ */
122
+ export function shapeDecisionRationale(row, decisionId) {
123
+ const dj = row['decision_json'] ?? {};
124
+ const aiSub = dj['ai'] ?? {};
125
+ const summary = typeof row['summary'] === 'string' ? row['summary'] : '';
126
+ return {
127
+ decision_id: decisionId,
128
+ title: row['title'],
129
+ summary: row['summary'],
130
+ status: row['status'],
131
+ platform: row['platform'],
132
+ created_at: row['created_at'],
133
+ rationale: dj['rationale'] ??
134
+ aiSub['rationale'] ??
135
+ summary,
136
+ goals: dj['goals'] ?? [],
137
+ risks: dj['risks'] ?? aiSub['risks'] ?? [],
138
+ context: dj['context'] ?? '',
139
+ alternatives_considered: asArray(dj['alternatives_considered'] ?? aiSub['alternatives_considered']),
140
+ positions_considered: asArray(dj['positions_considered'] ?? aiSub['positions_considered']),
141
+ ...mentionedArtifacts(row['external_references']),
142
+ };
143
+ }
144
+ /** Names this server publishes for the trio. Imported by mcp.ts, so there is one writer. */
145
+ export const TOPIC_TIMELINE_TOOL = 'align_get_topic_timeline';
146
+ export const DECISION_RATIONALE_TOOL = 'align_get_decision_rationale';
147
+ export const DECISION_TIMELINE_TOOL = 'align_get_decision_timeline';
148
+ /**
149
+ * The one phrase the tool description and the per-result message both use to gate the
150
+ * full-timeline render. Shared so the two surfaces cannot drift: they are two writers of one
151
+ * fact with no reader that sees both, and each surface's test pins only its own copy.
152
+ */
153
+ export const STORY_GATE = 'asked for the story';
154
+ /**
155
+ * 280, matching the hosted connector's `format.ts` (ALI-297/ALI-498). The first port wrote 500
156
+ * and did not declare it as a difference; on a 50-row timeline that is up to ~11 kB of extra
157
+ * agent context per call, in a module whose stated purpose is bounded output.
158
+ */
159
+ const MAX_SUMMARY_CHARS = 280;
160
+ function truncate(text, max = MAX_SUMMARY_CHARS) {
161
+ if (text.length <= max)
162
+ return text;
163
+ // max - 3, not max - 1: the hosted original appends a one-character ellipsis and this repo
164
+ // stays ASCII, so the marker costs three. The first port kept the hosted arithmetic with the
165
+ // longer marker and returned max + 2 - inside a cap whose whole job is to bound context.
166
+ return `${text.slice(0, max - 3).trimEnd()}...`;
167
+ }
168
+ /**
169
+ * The source-url helpers come from decision-links.ts, which already owns all four of them
170
+ * (Copilot, #298 inline at :185 - it named the synthetic list; the follow-up had in fact
171
+ * duplicated FOUR).
172
+ *
173
+ * That module's own docblock says it is already a second writer of the gateway's synthetic
174
+ * namespace list and asks to be kept in sync, so a third copy in this repo is exactly the
175
+ * drift it warns about: the gateway mints a new `align://` namespace, one copy learns it, and
176
+ * the other silently re-exposes a minted identity as a link.
177
+ *
178
+ * Reuse is also a capability gain, not just deduplication: the canonical `citationFor` cites
179
+ * Linear and Jira tickets by their key ("ALI-346", "PROJ-12"), which the deleted local copy
180
+ * could not do at all. `navigableSourceUrl` is the shared refusal to hand back an `align://`
181
+ * identity, and `sourceFields` below is the topic-timeline presentation built on it.
182
+ */
183
+ function sourceFields(sourceUrl) {
184
+ if (typeof sourceUrl !== 'string' || sourceUrl.length === 0)
185
+ return {};
186
+ const navigable = navigableSourceUrl(sourceUrl);
187
+ if (navigable)
188
+ return { source_url: navigable };
189
+ // A minted identity is not "where this was decided", so it is never emitted as source_url.
190
+ // The agent is told the origin is unverified instead - an `align://` string handed over as
191
+ // an origin gets either fetched or cited, and both are wrong.
192
+ return { source_unverified: true };
193
+ }
194
+ /**
195
+ * The projection is an ALLOWLIST, not a delete.
196
+ *
197
+ * ALI-498: one 12-row prod component carried 43,327 characters of `decision_json`. A delete
198
+ * list only removes the heavy fields somebody thought of; an allowlist cannot leak a field
199
+ * the gateway adds later. OMIT_RESULT_KEYS in mcp.ts is the belt to this braces.
200
+ */
201
+ function present(d) {
202
+ return {
203
+ id: d.id,
204
+ title: d.title,
205
+ ...(d.summary ? { summary: truncate(d.summary) } : {}),
206
+ ...(d.platform ? { platform: d.platform } : {}),
207
+ ...(d.status ? { status: d.status } : {}),
208
+ created_at: d.created_at,
209
+ // Key omitted when absent: a missing source date must not render as one.
210
+ ...(d.decided_at ? { decided_at: d.decided_at } : {}),
211
+ ...(d.source_created_at ? { source_created_at: d.source_created_at } : {}),
212
+ ...(d.date_basis ? { date_basis: d.date_basis } : {}),
213
+ ...sourceFields(d.source_url),
214
+ ...(repositoryOf(d.source_url) ? { repository: repositoryOf(d.source_url) } : {}),
215
+ ...(citationFor(d.source_url) ? { cite: citationFor(d.source_url) } : {}),
216
+ ...(d.matched_by ? { matched_by: d.matched_by } : {}),
217
+ };
218
+ }
219
+ export function topicTimelineMessage(opts) {
220
+ const { count, semanticRan, topic, backgroundCount, whyState, disagreementCount, stillOpenState, chainCount, chainPlatforms, chainSupersededCount, chainCorrectedCount, } = opts;
221
+ /**
222
+ * F11 (Copilot, #296 suppressed at :170): the early return used to bypass the PARTIAL notice
223
+ * entirely. "No decisions found" plus a silently lexical-only search is the worst pairing in
224
+ * this whole message - the agent concludes the graph has nothing on the topic, when the half
225
+ * of retrieval that finds differently-worded decisions never ran. Declared before the branch
226
+ * so both paths use one writer of the sentence.
227
+ */
228
+ const partial = semanticRan
229
+ ? ''
230
+ : ' PARTIAL: semantic retrieval was unavailable, so this is lexical matches only and may be missing related decisions that use different wording.';
231
+ if (count === 0) {
232
+ return `No decisions found about "${topic}". Try a broader phrase, or search to see what the graph does cover.${partial}`;
233
+ }
234
+ /**
235
+ * F3 (Copilot, #296 inline at :175): CHAIN-scoped, not retrieval-wide.
236
+ *
237
+ * This used the gateway's `superseded_count`, which covers everything RETRIEVED, while
238
+ * calling it "the history explaining why the current answer is current". A superseded
239
+ * BACKGROUND match is not that history and is not even listed for the agent to inspect, so
240
+ * the sentence named rows nobody can see. The summary line further down was already
241
+ * chain-scoped; these two now agree. `supersededCount` is deliberately no longer read here -
242
+ * it stays in the payload, where it correctly describes the retrieved set.
243
+ */
244
+ const history = chainSupersededCount > 0
245
+ ? ` ${chainSupersededCount} of them are superseded or archived - that is the history explaining why the current answer is current.`
246
+ : '';
247
+ /**
248
+ * ALI-1010. The one clause that stops a correction being told as a reversal, and the single
249
+ * clause most responsible for the `reversed` split this port is measured on.
250
+ *
251
+ * Conditional, like `why` and the Contested slot: on a chain with no corrected decision this
252
+ * would send the agent looking for rows that are not there. And it has to NAME the wrong
253
+ * rendering, because everything else in this message pushes the other way - the compact
254
+ * template asks for "the biggest reversal" and the full-timeline rules say "name which
255
+ * decision replaced them". A model handed those and a status it has never seen will reach
256
+ * for the supersession vocabulary it already has.
257
+ *
258
+ * The status is the only signal the agent gets: `superseded` and `corrected` are both
259
+ * not-active and look alike from the row alone, which is exactly why the difference is
260
+ * spelled out here rather than left to be inferred.
261
+ */
262
+ const corrections = chainCorrectedCount > 0
263
+ ? ` ${chainCorrectedCount} decision(s) in this chain have status \`corrected\`: the record was NEVER TRUE and has been corrected, which is NOT a reversal and NOT a supersession - nothing changed in the world, the graph simply held something wrong. Say "corrected" or "was never true"; never "replaced", "reversed", "superseded" or "changed their mind" about one of these, and never count it as part of the history explaining why the current answer is current. A corrected decision is excluded from the superseded count above for this reason.`
264
+ : '';
265
+ /**
266
+ * ALI-590, the other load-bearing addition. Without it an agent reads every row it is
267
+ * handed, and on a mature graph most rows are adjacent-but-unrelated - a webhook-security
268
+ * topic pulls legal, DPA and pricing tickets over the 0.25 similarity floor. Naming the
269
+ * count and forbidding the enumeration is what turns 50 rows into a story.
270
+ */
271
+ const background = backgroundCount > 0
272
+ ? ` ${backgroundCount} further decision(s) matched this topic loosely and are NOT listed - they are summarised in \`background\`. Say the number in one clause if it is useful ("about a dozen adjacent decisions"); do not ask for them and do not imply the chain is everything.`
273
+ : '';
274
+ /**
275
+ * The `why` clause replaces an explicit instruction to go and call the rationale tool, which
276
+ * produced one follow-up call per interesting decision (five on an observed prod session).
277
+ * The reasons for the arguing decisions are now IN this payload, so the instruction is to
278
+ * use them, and to fall back to the tool only for a decision this payload does not cover.
279
+ *
280
+ * CONDITIONAL on `why` actually being present, because the key is omitted when the gateway
281
+ * sent nothing (an older gateway, or a degraded rationale lookup). Promising a field that is
282
+ * absent is worse than not mentioning it: the agent either hunts for it or decides the
283
+ * reasons are unavailable and does not fall back.
284
+ */
285
+ const why = whyState === 'present'
286
+ ? ` \`why\` carries each arguing decision's reason and logged risks - use it directly and do NOT call ${DECISION_RATIONALE_TOOL} for a decision that appears there; call it only for one that does not appear there${
287
+ // With real questions in the payload the risks synthesis is retired for this
288
+ // response: two stated sources for one section is how the model picks the wrong one
289
+ // (ALI-627).
290
+ stillOpenState === 'present' ? '' : ', and draw the closing open-questions line from its risks'}.`
291
+ : whyState === 'empty'
292
+ ? // The gateway looked across this chain and nothing is recorded. Sending the agent to
293
+ // the rationale tool per decision would find the same nothing, one call at a time,
294
+ // so the instruction is to SAY it rather than to go hunting.
295
+ ` The gateway looked for recorded reasoning on this chain and found none - say the reasons are not recorded rather than calling ${DECISION_RATIONALE_TOOL} for each decision.`
296
+ : ` This payload carries no \`why\`, so call ${DECISION_RATIONALE_TOOL} for the decisions whose reasoning the answer needs${
297
+ // Only point at `disagreements` when the field is here AND non-empty. Pointing at
298
+ // one absent field from inside the fallback for another is the same defect nested
299
+ // one level down, and pointing at an empty list is a hunt with no quarry.
300
+ (disagreementCount ?? 0) > 0 ? ' - prefer the ones named in `disagreements`' : ''}.`;
301
+ /**
302
+ * Conditional for the same reason as the `why` clause: an instruction naming a field that is
303
+ * not in the payload sends the agent hunting, and on an older gateway neither key exists.
304
+ * Every clause here must be true of THIS response, not of the newest gateway.
305
+ */
306
+ const disagreements = disagreementCount === undefined
307
+ ? ' This payload does not report which decisions conflict, so say the conflict relationships were unavailable rather than implying there are none.'
308
+ : disagreementCount === 0
309
+ ? ' The gateway looked for conflicts touching this chain and found none - say so in one clause rather than staying silent.'
310
+ : ' Fill the **Contested** slot naming the ids in `disagreements` and say a later decision argues with them - in both the compact answer and the full timeline, because a hidden conflict is the one thing compaction must never buy.';
311
+ /**
312
+ * A worked TEMPLATE, not adjectives about clarity - in TWO tiers.
313
+ *
314
+ * A live prod run produced a correct answer as seven dense paragraphs: unreadable on a
315
+ * shared screen. "Render this as a story, not a flat list" is an adjective - it names what
316
+ * to avoid and says nothing about shape. A line template fixes ordering, length and where
317
+ * the status goes; "be concise" fixes nothing. The word cap is a number for the same reason:
318
+ * it is checkable, and a model asked to "keep it brief" writes a sentence per entry and
319
+ * rebuilds the wall.
320
+ */
321
+ // The Contested slot exists only when there is a conflict to put in it: a slot over an empty
322
+ // or absent list sends the model hunting (the same conditionality rule as `why` above).
323
+ const contestedSlot = (disagreementCount ?? 0) > 0
324
+ ? '\n\n**Contested:** {which decision argues with which, one line}'
325
+ : '';
326
+ // The offer names only what THIS payload carries: risks ride in `why`, questions in
327
+ // `still_open`, and promising an absent one makes the follow-up a hunt or an invention.
328
+ // Offers only what THIS payload actually carries. An `empty` state is a field that arrived
329
+ // with nothing in it, so offering "the open risks" off the back of it would be a promise the
330
+ // follow-up cannot keep.
331
+ const offerItems = `the full chain${whyState === 'present' ? ', the open risks' : ''}${stillOpenState === 'present' ? ', the open questions' : ''}`;
332
+ const render = ` Answer COMPACTLY by default, never as paragraphs - the user asked a question, not for a history. Link every decision you name (dated row, Now, Contested, supersedes) with decision_url when present, else source_url; do not invent links. Use exactly this shape, with a blank line between sections:` +
333
+ `\n\n**Now:** {current position in one sentence} - {title} ({status})${contestedSlot}` +
334
+ `\n\n{up to three pivotal dated chain steps - the first position, the biggest reversal, the newest - one line each, in the full timeline's line shape}` +
335
+ `\n\n${chainCount} decision(s) across ${chainPlatforms.join(', ') || 'unknown tool(s)'} got it here ({oldest date} - {newest date}, ${chainSupersededCount} superseded or archived).` +
336
+ // C6 (Copilot, #298 suppressed at :523). The stillOpenState fix reached only the
337
+ // full-timeline section; this compact slot is the DEFAULT path and asked for a question
338
+ // unconditionally, so `still_open: []` still invited the invention the empty state exists
339
+ // to prevent - the same defect one level up, on the more-travelled route.
340
+ //
341
+ // An interpolation rather than a bare parenthesised ternary: a conditional dropped into a
342
+ // chain of literals makes the whole concatenation literal-plus-expression, which is what
343
+ // eslint's prefer-template objects to. Identical output.
344
+ `\n\n${stillOpenState === 'empty'
345
+ ? '{one line saying the chain leaves no open question}'
346
+ : '{the single sharpest open question, one line}'}` +
347
+ `\n\n{one-line offer of the rest - ${offerItems} - inviting the user to ask for the story}` +
348
+ `\n\nRender the FULL VERTICAL TIMELINE instead ONLY when the user ${STORY_GATE}, the history, or how it got here - or as the follow-up after the compact answer. That shape, also with a blank line between sections:` +
349
+ '\n\nOpen with the same **Now:** line, then:' +
350
+ '\n\n**How it got here**' +
351
+ '\n`DD Mon` `{platform}` **{short title}** - {why, max 15 words} - {cite or id} - {status}' +
352
+ '\n`DD Mon` `{platform}` **{short title}** - ... (one line per decision, oldest first)' +
353
+ '\n\n`{platform}` is the tool the decision was made in - teams, slack, jira, github, linear, confluence. NEVER omit the platform: a Teams meeting contradicting a GitHub pull request is the thing no single tool can show, and it is invisible if every line looks alike. The cite implies it for some rows and not others, so print it explicitly on all of them.' +
354
+ '\n\nThe gutter is the date and NEVER an id: print the row\'s date, not a ticket key, and never the same identifier twice on one line. `date_basis` says which clock it is - `decided` (`decided_at`, the source decided it), `created` (`source_created_at`: RAISED only, never agreed), `recorded` (`created_at`, Align\'s ingest minute, not a source date). Suffix anything not `decided` right after the date - `(raised)` or `(seen)` - or a column of dates all reads as decisions. Where several share a date, normal for an imported backlog, repeat the date. If MOST are `recorded`, add ONE closing note that the graph lacks their source dates.' +
355
+ // C3 (Copilot, #298 inline at :446). Gated on `why`: with no entries there are no risks
356
+ // to line up, so an unconditional section invites exactly the fabrication the empty state
357
+ // exists to prevent. The empty case says so rather than going silent, because silence
358
+ // reads as "there are none" too and only one of those is a measurement.
359
+ `${whyState === 'present'
360
+ ? '\n\n**Open risks** - one short line each, naming the decision it came from.'
361
+ : whyState === 'empty'
362
+ ? '\n\n**Open risks** - no risks were recorded against this chain; say so in one clause and do not infer any.'
363
+ : ''}`;
364
+ /**
365
+ * Two sources, one section, stated conditionally (ALI-627). With `still_open` present the
366
+ * section closes on the decisions' own stated questions; without it, the synthesis from
367
+ * risks remains - a promise scoped to THIS response, like the `why` clause above.
368
+ */
369
+ // Both trailing sections are tier-labelled: without a label they read as unconditional
370
+ // rules of the compact default too, which quietly rebuilds the wall.
371
+ const stillOpenSection = stillOpenState === 'present'
372
+ ? '\n\n**Still open** (the full timeline closes on this) - one line per question in `still_open`, naming the decision it came from; end with the single sharpest.'
373
+ : stillOpenState === 'empty'
374
+ ? // The chain was asked and states none. Asking for "the single sharpest unanswered
375
+ // question" here is what made the model invent one.
376
+ '\n\n**Still open** (the full timeline closes on this) - this chain states no open questions; say so in one clause rather than naming one.'
377
+ : '\n\n**Still open** (the full timeline closes on this) - the single sharpest unanswered question, one line.';
378
+ const renderRules =
379
+ // F4 (Copilot, #296 at :300) then C4 (#298 at :532). The original closed "Cite
380
+ // decision_url and source_url as different links", unconditionally, in a port that never
381
+ // emits decision_url. The first replacement then demanded `source_url` just as
382
+ // unconditionally - and `present()` deliberately omits THAT for a synthetic identity and
383
+ // for a row with no source at all, so it recreated the same defect one field along. The
384
+ // rule is conditional on both sides now, and it names what to do when neither arrived,
385
+ // because "link it" with nothing to link is what produces an invented URL.
386
+ '\n\nRules for the FULL timeline: ONE LINE PER DECISION, never a paragraph. Max 15 words of explanation per line. Mark superseded entries and name which decision replaced them. Link a decision when its row carries a source_url, which is where it was decided; a row marked `source_unverified` has no link anyone can open, so name it without one and never substitute a link you did not receive.';
387
+ return `${count} decision(s) about "${topic}".${history}${corrections}${partial}${background}${render}${stillOpenSection}${renderRules}${disagreements}${why}`;
388
+ }
389
+ /**
390
+ * Shape one gateway topic-timeline response for an agent: project the rows, split the chain
391
+ * from the background, and attach the rendering contract.
392
+ *
393
+ * Pure, and exported, so every clause of the contract is assertable without standing up an
394
+ * MCP server or a gateway.
395
+ */
396
+ export function shapeTopicTimeline(result, requestedTopic) {
397
+ const rows = Array.isArray(result?.decisions) ? result.decisions : [];
398
+ const semanticRan = result?.retrieval?.semantic !== false;
399
+ /**
400
+ * The chain is what gets narrated; everything else is counted (ALI-590).
401
+ *
402
+ * `chain_ids` is a NEWER gateway field, and this client and the gateway deploy separately -
403
+ * so an older gateway (or one whose edge lookup degraded) sends no ids and every decision
404
+ * stays in the chain, which is the pre-ALI-590 behaviour. Falling back to "narrate
405
+ * everything" is the right direction to fail: a shorter answer that silently dropped
406
+ * history is the one nobody could detect.
407
+ */
408
+ const chainIds = Array.isArray(result.chain_ids) ? new Set(result.chain_ids) : undefined;
409
+ const chain = chainIds ? rows.filter((d) => chainIds.has(d.id)) : rows;
410
+ const background = chainIds ? rows.filter((d) => !chainIds.has(d.id)) : [];
411
+ const backgroundPlatforms = Array.isArray(result.background_platforms)
412
+ ? result.background_platforms
413
+ : [...new Set(background.map((d) => d.platform).filter((p) => Boolean(p)))].sort();
414
+ /**
415
+ * Both shape fields are scoped to the CHAIN, not to the retrieved set.
416
+ *
417
+ * The gateway computes them over everything it retrieved, which is right for a payload that
418
+ * returns everything it retrieved. This tool returns only the chain, so forwarding them
419
+ * unfiltered would name a decision that is not in `decisions` - enumerating background
420
+ * material through the back door and citing titles the agent cannot link.
421
+ */
422
+ const inChain = chainIds
423
+ ? (id) => typeof id === 'string' && chainIds.has(id)
424
+ : () => true;
425
+ /**
426
+ * `undefined` when the gateway did not send the field, `[]` only when it sent an empty one.
427
+ *
428
+ * Those are different answers and collapsing them loses the honest one: an empty array from
429
+ * a gateway that never reports disagreements asserts "we looked and there are none", which
430
+ * is the stronger claim. A gateway that DID look and found nothing keeps its empty array -
431
+ * that is a real result, and it is how the absence gets reported.
432
+ */
433
+ const disagreements = Array.isArray(result.disagreements)
434
+ ? result.disagreements.filter((d) => inChain(d?.from?.id) && inChain(d?.to?.id))
435
+ : undefined;
436
+ const why = (Array.isArray(result.why) ? result.why : []).filter((w) => inChain(w?.id));
437
+ // Chain-scoped like `why`: a question naming a background decision would cite a title the
438
+ // agent has no row for.
439
+ const stillOpen = (Array.isArray(result.still_open) ? result.still_open : []).filter((s) => inChain(s?.id));
440
+ /**
441
+ * Presence and non-emptiness, kept apart (Copilot, #296 inline at :378).
442
+ *
443
+ * `absent` is "the gateway did not send the field" - an older gateway, or a degraded lookup.
444
+ * `empty` is "it sent one with nothing in it", which is a real observation and the stronger
445
+ * claim. Note a field that ARRIVED and was then filtered down to nothing by the chain scope
446
+ * is `empty` and not `absent`: the gateway did look, and what it found does not apply to the
447
+ * rows the agent is being shown. Either way the honest answer is "none here", never "we
448
+ * could not look".
449
+ */
450
+ const stateOf = (raw, filtered) => !Array.isArray(raw) ? 'absent' : filtered.length > 0 ? 'present' : 'empty';
451
+ const whyState = stateOf(result.why, why);
452
+ const stillOpenState = stateOf(result.still_open, stillOpen);
453
+ return {
454
+ topic: result.topic ?? requestedTopic,
455
+ count: result.count ?? rows.length,
456
+ superseded_count: result.superseded_count ?? 0,
457
+ platforms: result.platforms ?? [],
458
+ spans_platforms: Boolean(result.spans_platforms),
459
+ retrieval: result.retrieval ?? { lexical: true, semantic: semanticRan },
460
+ ...(Array.isArray(result.activity) ? { activity: result.activity } : {}),
461
+ ...(disagreements ? { disagreements } : {}),
462
+ // Forwarded verbatim: the gateway already clipped every string and never sends
463
+ // decision_json, so there is nothing left for this projection to bound.
464
+ // Emitted whenever the gateway SENT the field, empty included - the same rule
465
+ // `disagreements` above already follows. A key that is present and empty is how the
466
+ // absence gets reported honestly.
467
+ ...(whyState !== 'absent' ? { why } : {}),
468
+ ...(stillOpenState !== 'absent' ? { still_open: stillOpen } : {}),
469
+ decisions: chain.map(present),
470
+ /**
471
+ * Counted, never enumerated. The whole defect this fixes is an agent reading 50 rows
472
+ * aloud, so handing the tail back in any listable form would reintroduce it - the count
473
+ * and the platforms are enough to say "there is more here" honestly.
474
+ */
475
+ ...(background.length > 0
476
+ ? {
477
+ background: {
478
+ count: background.length,
479
+ platforms: backgroundPlatforms,
480
+ note: 'Matched the topic loosely - not part of the decision chain. Not listed on purpose.',
481
+ },
482
+ }
483
+ : {}),
484
+ message: topicTimelineMessage({
485
+ count: result.count ?? rows.length,
486
+ semanticRan,
487
+ topic: result.topic ?? requestedTopic,
488
+ backgroundCount: background.length,
489
+ whyState,
490
+ disagreementCount: disagreements === undefined ? undefined : disagreements.length,
491
+ stillOpenState,
492
+ chainCount: chain.length,
493
+ chainPlatforms: [
494
+ ...new Set(chain.map((d) => d.platform).filter((p) => Boolean(p))),
495
+ ],
496
+ chainSupersededCount: chain.filter((d) => d.status === 'superseded' || d.status === 'archived')
497
+ .length,
498
+ // Counted separately, never added to the line above: the gateway's RETIRED set draws the
499
+ // same boundary, and the two must agree or the message contradicts the payload.
500
+ chainCorrectedCount: chain.filter((d) => d.status === 'corrected').length,
501
+ }),
502
+ };
503
+ }
504
+ //# sourceMappingURL=mcp-timeline-tools.js.map