@taiga-ai/mcp-server 0.11.0 → 0.12.0-dev.417
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/dist/index.js +912 -53
- package/package.json +1 -1
- package/src/client.ts +26 -9
- package/src/server.integration.test.ts +1134 -16
- package/src/tools/backlogs.ts +131 -16
- package/src/tools/builds.ts +53 -6
- package/src/tools/context.ts +132 -22
- package/src/tools/errors.ts +4 -4
- package/src/tools/index.ts +29 -1
- package/src/tools/instructions.ts +15 -7
- package/src/tools/products.ts +27 -2
- package/src/tools/prompts.ts +5 -2
- package/src/tools/queue.ts +168 -0
- package/src/tools/sections.test.ts +36 -1
- package/src/tools/sections.ts +50 -0
- package/src/tools/summarize.ts +23 -0
- package/src/tools/wait.ts +445 -0
- package/taiga-ai-mcp-server-0.12.0-dev.417.tgz +0 -0
- package/tsconfig.json +4 -0
- package/taiga-ai-mcp-server-0.11.0.tgz +0 -0
|
@@ -47,12 +47,19 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
47
47
|
'every product the key can see, and active: true the ones executing now.',
|
|
48
48
|
'',
|
|
49
49
|
'**Operating the line** (taiga_create_backlog_item / taiga_queue_backlog_item / taiga_move_backlog_item /',
|
|
50
|
-
'taiga_resume_backlog_item / taiga_get_plan / taiga_approve_plan / taiga_reject_plan /
|
|
51
|
-
'taiga_resume_build): create an initiative, put it in the queue the coding agent works,
|
|
52
|
-
'
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
'
|
|
50
|
+
'taiga_resume_backlog_item / taiga_set_auto_build / taiga_get_plan / taiga_approve_plan / taiga_reject_plan /',
|
|
51
|
+
'taiga_stop_build / taiga_resume_build): create an initiative, put it in the queue the coding agent works,',
|
|
52
|
+
'move it on the board, decide whether its plan waits for a review (autoBuild: false) or is built at once, read',
|
|
53
|
+
'and approve or reject its plan, stop or resume a run. Queueing, approving and resuming start paid agent work',
|
|
54
|
+
'that pushes commits and opens pull requests; stopping interrupts it. Tell the person what you are about to',
|
|
55
|
+
'start or stop and wait for their yes before calling these; a client that prompts on destructive tools does',
|
|
56
|
+
'that for you. A refusal (CONFLICT) carries the API reason: say it as it is.',
|
|
57
|
+
'',
|
|
58
|
+
'**Watching the line** (taiga_get_queue / taiga_wait_for_backlog_item / taiga_wait_for_build): the queue',
|
|
59
|
+
'read says what the line is waiting on (a plan to review, a pull request to merge, a halt and what lifts',
|
|
60
|
+
'it). Planning takes minutes and a build up to an hour: wait with the wait tools rather than reading the',
|
|
61
|
+
'same item again and again; each returns when the point is reached, when only a person can move it on,',
|
|
62
|
+
'or at its timeout (call again to keep waiting).',
|
|
56
63
|
'',
|
|
57
64
|
'**Sections.** Policies and instructions are maps of sections. Each section carries its text and two',
|
|
58
65
|
'provenance fields you can read past: `source` says where the text originally came from (template:',
|
|
@@ -64,7 +71,8 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
64
71
|
'',
|
|
65
72
|
'Workflow: start with taiga_get_product_context, naming the product by its git remote URL inside a checkout',
|
|
66
73
|
'(git remote get-url origin), else by slug or ID from taiga_list_products. It returns the product, the',
|
|
67
|
-
'factory instructions, the specification and architecture
|
|
74
|
+
'factory instructions, the specification and architecture as the whole sections that fit (the rest named for',
|
|
75
|
+
'taiga_get_product_document to read by section), every policy as its section ids, the open backlog',
|
|
68
76
|
'items, the repositories and the latest runs in one call, and names any part it could not read. Then read',
|
|
69
77
|
'what you need in full: a policy with taiga_get_document, a backlog item with taiga_get_backlog_item.',
|
|
70
78
|
'Without it, use taiga_list_factories and taiga_list_products, then read the factory instructions,',
|
package/src/tools/products.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from './catalogue.js';
|
|
18
18
|
import { apiFailureCode, apiFailureText, isMissOrRefusal, runTool, toolError, toolResult } from './errors.js';
|
|
19
19
|
import { parseRemote } from './remote.js';
|
|
20
|
-
import { stripProvenance } from './sections.js';
|
|
20
|
+
import { pickSections, stripProvenance } from './sections.js';
|
|
21
21
|
import { page, pickFields } from './summarize.js';
|
|
22
22
|
|
|
23
23
|
/** What a product row needs to say for the model to pick one and read it in full. */
|
|
@@ -424,6 +424,8 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
424
424
|
'Read the plan to learn what to build: specification (requirements), architecture (design), ' +
|
|
425
425
|
'threat_model (security risks), technology_decisions. Read reality, when the product has it, to ' +
|
|
426
426
|
"learn what the code actually does before changing it. Omit origin for the product's own timeline. " +
|
|
427
|
+
'A large document runs to tens of thousands of characters: name the sections you need (the ids ' +
|
|
428
|
+
'taiga_get_product_context lists) and the read carries only those, listing the rest with their sizes. ' +
|
|
427
429
|
'A read that finds nothing says which documents and timelines the product does have.',
|
|
428
430
|
inputSchema: {
|
|
429
431
|
productId,
|
|
@@ -431,6 +433,12 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
431
433
|
.enum(PRODUCT_DOCUMENT_TYPES)
|
|
432
434
|
.describe('Document type -- taiga_list_product_documents shows which ones the product has'),
|
|
433
435
|
origin: originInput,
|
|
436
|
+
sections: z
|
|
437
|
+
.array(z.string().min(1).max(200))
|
|
438
|
+
.min(1)
|
|
439
|
+
.max(50)
|
|
440
|
+
.optional()
|
|
441
|
+
.describe('Only these sections, by id; omit for the whole document'),
|
|
434
442
|
},
|
|
435
443
|
annotations: READ_ONLY,
|
|
436
444
|
},
|
|
@@ -443,7 +451,24 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
443
451
|
const origin = params.origin ?? defaultOriginFor(product, params.type);
|
|
444
452
|
try {
|
|
445
453
|
const result = await client.getProductDocument(params.productId, params.type, origin);
|
|
446
|
-
|
|
454
|
+
const doc = stripProvenance(result.data as Record<string, unknown>);
|
|
455
|
+
if (!params.sections) return toolResult(doc);
|
|
456
|
+
const cut = pickSections(doc, new Set(params.sections));
|
|
457
|
+
if (!cut) {
|
|
458
|
+
return toolError(
|
|
459
|
+
'VALIDATION_FAILED',
|
|
460
|
+
`The ${origin} ${params.type} is not made of sections; read it without sections.`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
const all = Object.keys((doc.data as { sections: Record<string, unknown> }).sections);
|
|
464
|
+
const unknown = params.sections.filter((id) => !all.includes(id));
|
|
465
|
+
if (unknown.length > 0) {
|
|
466
|
+
return toolError(
|
|
467
|
+
'NOT_FOUND',
|
|
468
|
+
`The ${origin} ${params.type} has no section ${unknown.join(', ')}; its sections are ${all.join(', ')}.`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return toolResult({ ...cut.doc, omittedSections: cut.omittedSections });
|
|
447
472
|
} catch (error) {
|
|
448
473
|
if (!(error instanceof TaigaApiError) || error.status !== 404) throw error;
|
|
449
474
|
// A product the key cannot see is a product miss, not a document
|
package/src/tools/prompts.ts
CHANGED
|
@@ -93,7 +93,7 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
|
|
|
93
93
|
'taiga_list_products lists the slugs.'
|
|
94
94
|
);
|
|
95
95
|
if ('refusal' in resolved) refuse(resolved.refusal);
|
|
96
|
-
const result = await productContext(client, resolved.product, {
|
|
96
|
+
const result = await productContext(client, resolved.product, { documents: 'full' });
|
|
97
97
|
const bundle = (result.content[0] as { text?: string } | undefined)?.text ?? '{}';
|
|
98
98
|
return message(
|
|
99
99
|
[
|
|
@@ -263,8 +263,11 @@ export function registerPrompts(server: McpServer, client: TaigaClient) {
|
|
|
263
263
|
|
|
264
264
|
/** A part the key's capabilities do not cover; named in its place rather than fetched. */
|
|
265
265
|
class NotGranted extends Error {
|
|
266
|
-
|
|
266
|
+
readonly capability: string;
|
|
267
|
+
|
|
268
|
+
constructor(capability: string) {
|
|
267
269
|
super(`needs ${capability}`);
|
|
270
|
+
this.capability = capability;
|
|
268
271
|
}
|
|
269
272
|
line(what: string): string {
|
|
270
273
|
return `(${what} need ${this.capability}, which this key lacks.)`;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { HALT_CAUSES, canResumeHalt, haltClearsItself, renderHalt, type Halt, type HaltCause } from '@taiga/types';
|
|
3
|
+
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
4
|
+
import { READ_ONLY } from './annotations.js';
|
|
5
|
+
import { isNetworkFailure, runTool, toolResult } from './errors.js';
|
|
6
|
+
import { productId } from './inputs.js';
|
|
7
|
+
import { criteriaCounts } from './summarize.js';
|
|
8
|
+
|
|
9
|
+
/** How many entries the read names; past this an entry keeps its backlogItemId only. */
|
|
10
|
+
const NAMED_ENTRIES = 25;
|
|
11
|
+
|
|
12
|
+
type Row = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
const MOVE_ASIDE =
|
|
15
|
+
'or move it down (taiga_queue_backlog_item with afterId) or off the line (taiga_move_backlog_item) to let the rest pass.';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What lifts a halt, in the tools that do it. Read from the halt registry in
|
|
19
|
+
* @taiga/types (how each cause clears, and whether a person may lift it), so
|
|
20
|
+
* the advice cannot drift from what the API accepts; plan review is the one
|
|
21
|
+
* cause a tool answers directly.
|
|
22
|
+
*/
|
|
23
|
+
function haltNextStep(cause: string | null): string {
|
|
24
|
+
if (cause === 'awaiting_plan_review') {
|
|
25
|
+
return 'Read the plan with taiga_get_plan, then approve it (taiga_approve_plan) or send it back (taiga_reject_plan).';
|
|
26
|
+
}
|
|
27
|
+
const spec = cause ? (HALT_CAUSES as Record<string, { clearedWhen: string } | undefined>)[cause] : undefined;
|
|
28
|
+
if (!cause || !spec) return `Read the initiative with taiga_get_backlog_item to see why, ${MOVE_ASIDE}`;
|
|
29
|
+
const known = cause as HaltCause;
|
|
30
|
+
if (canResumeHalt(known)) return `taiga_resume_backlog_item lifts it, ${MOVE_ASIDE}`;
|
|
31
|
+
if (haltClearsItself(known)) return `It clears when ${spec.clearedWhen}; ${MOVE_ASIDE}`;
|
|
32
|
+
return `Only taking it off the line clears it (taiga_move_backlog_item): ${spec.clearedWhen}.`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The entry the line waits on, by the queue's two kinds of blocker
|
|
37
|
+
* (@taiga/types): an active entry holds every other one wherever it sits, so
|
|
38
|
+
* it answers first; without one, the first halted entry holds the entries
|
|
39
|
+
* behind it. -1 when neither is there.
|
|
40
|
+
*/
|
|
41
|
+
function blockingIndex<T>(entries: T[], stateOf: (entry: T) => unknown): number {
|
|
42
|
+
const active = entries.findIndex((entry) => stateOf(entry) === 'active');
|
|
43
|
+
return active >= 0 ? active : entries.findIndex((entry) => stateOf(entry) === 'halted');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The one sentence a person needs: what the line is doing, or what it is waiting on. */
|
|
47
|
+
function waitingOn(rows: Row[]): string | null {
|
|
48
|
+
const at = blockingIndex(rows, (row) => row.entryState);
|
|
49
|
+
if (at < 0) {
|
|
50
|
+
return rows.length > 0
|
|
51
|
+
? 'Nothing in the queue is running; the first queued initiative starts when the line is free.'
|
|
52
|
+
: null;
|
|
53
|
+
}
|
|
54
|
+
const head = rows[at]!;
|
|
55
|
+
const name = typeof head.title === 'string' ? `"${head.title}"` : `Initiative ${String(head.backlogItemId)}`;
|
|
56
|
+
if (head.entryState === 'halted') {
|
|
57
|
+
const halt = head.halt as { reason: string; nextStep: string };
|
|
58
|
+
const ahead = at > 0 ? ' Entries ahead of it are not held by it.' : '';
|
|
59
|
+
return `${name} halted the queue, holding the entries behind it: ${halt.reason} ${halt.nextStep}${ahead}`;
|
|
60
|
+
}
|
|
61
|
+
const lifecycle = head.lifecycle as { turn?: unknown; stage?: unknown; cta?: unknown } | undefined;
|
|
62
|
+
const held = 'every other queued initiative waits until its pull request merges.';
|
|
63
|
+
if (!lifecycle) return `${name} is the active entry; ${held}`;
|
|
64
|
+
const pr = typeof head.prUrl === 'string' ? ` (${head.prUrl})` : '';
|
|
65
|
+
// An active entry with a landed plan is past its review gate (a closed
|
|
66
|
+
// gate halts it instead): it waits for the Build slot, not for anyone.
|
|
67
|
+
if (lifecycle.cta === 'start_build') return `${name} has its plan and waits for the Build slot; ${held}`;
|
|
68
|
+
if (lifecycle.turn === 'you') {
|
|
69
|
+
return `${name} is waiting on a person (${String(lifecycle.cta ?? lifecycle.stage)})${pr}; ${held}`;
|
|
70
|
+
}
|
|
71
|
+
return `${name} is ${String(lifecycle.stage)}${pr}; ${held}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The product's queue as a person reads it: each live entry in order with its
|
|
76
|
+
* initiative's title and where that initiative is, a halted entry with why and
|
|
77
|
+
* what lifts it, and one sentence saying what the line is waiting on. The
|
|
78
|
+
* route carries item ids only; each is named by one item read (when the key
|
|
79
|
+
* may read the backlog), and a read the API fails leaves that entry unnamed
|
|
80
|
+
* rather than failing the queue.
|
|
81
|
+
*/
|
|
82
|
+
export async function queueView(
|
|
83
|
+
client: TaigaClient,
|
|
84
|
+
product: string,
|
|
85
|
+
options: { name: 'all' | 'blocking' } = { name: 'all' }
|
|
86
|
+
) {
|
|
87
|
+
const entries = ((await client.listQueue(product)).data as Row[]).filter((entry) => entry.state !== 'settled');
|
|
88
|
+
const canName = client.hasCapability('backlog:read');
|
|
89
|
+
// 'blocking' names only the entry the line waits on: a caller after one
|
|
90
|
+
// position and waitingOn need no more reads than that.
|
|
91
|
+
const blocking = blockingIndex(entries, (entry) => entry.state);
|
|
92
|
+
const toName = !canName
|
|
93
|
+
? []
|
|
94
|
+
: options.name === 'all'
|
|
95
|
+
? entries.slice(0, NAMED_ENTRIES).map((_, index) => index)
|
|
96
|
+
: blocking >= 0
|
|
97
|
+
? [blocking]
|
|
98
|
+
: [];
|
|
99
|
+
const settled = await Promise.allSettled(
|
|
100
|
+
toName.map((index) => client.getBacklogItem(String(entries[index]!.backlogItemId)))
|
|
101
|
+
);
|
|
102
|
+
const reads = new Map(toName.map((index, at) => [index, settled[at]!]));
|
|
103
|
+
let unnamed = options.name === 'all' ? entries.length - toName.length : 0;
|
|
104
|
+
const rows = entries.map((entry, index) => {
|
|
105
|
+
const read = reads.get(index);
|
|
106
|
+
if (read?.status === 'rejected') {
|
|
107
|
+
// Only the API failing leaves an entry unnamed; a bug here surfaces.
|
|
108
|
+
if (!(read.reason instanceof TaigaApiError || isNetworkFailure(read.reason))) throw read.reason;
|
|
109
|
+
unnamed += 1;
|
|
110
|
+
}
|
|
111
|
+
const item = read?.status === 'fulfilled' ? (read.value.data as Row) : null;
|
|
112
|
+
const lifecycle = item?.lifecycle as Row | undefined;
|
|
113
|
+
const latestBuild = lifecycle?.latestBuild as Row | null | undefined;
|
|
114
|
+
const row: Row = { position: index + 1, backlogItemId: entry.backlogItemId, entryState: entry.state };
|
|
115
|
+
if (item) {
|
|
116
|
+
Object.assign(row, {
|
|
117
|
+
title: item.title,
|
|
118
|
+
state: item.state,
|
|
119
|
+
planningStatus: item.planningStatus,
|
|
120
|
+
needsHumanCause: item.needsHumanCause,
|
|
121
|
+
criteria: criteriaCounts(item),
|
|
122
|
+
lifecycle: lifecycle ? { turn: lifecycle.turn, stage: lifecycle.stage, cta: lifecycle.cta } : null,
|
|
123
|
+
...(latestBuild ? { buildId: latestBuild.id, prUrl: latestBuild.prUrl ?? null } : {}),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (entry.state === 'halted') {
|
|
127
|
+
const cause = typeof entry.haltCause === 'string' ? entry.haltCause : null;
|
|
128
|
+
// The registry renders the sentence; the stored prose answers for a
|
|
129
|
+
// cause this build does not know yet.
|
|
130
|
+
const rendered = cause ? renderHalt({ cause, params: entry.haltParams ?? {} } as Halt) : '';
|
|
131
|
+
row.halt = {
|
|
132
|
+
cause,
|
|
133
|
+
reason: rendered || (typeof entry.haltReason === 'string' ? entry.haltReason : 'The queue stopped here.'),
|
|
134
|
+
nextStep: haltNextStep(cause),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return row;
|
|
138
|
+
});
|
|
139
|
+
const note =
|
|
140
|
+
unnamed === 0
|
|
141
|
+
? null
|
|
142
|
+
: !canName
|
|
143
|
+
? 'Entries carry backlogItemId only: naming them needs backlog:read, which this key lacks.'
|
|
144
|
+
: entries.length > NAMED_ENTRIES
|
|
145
|
+
? `Only the first ${NAMED_ENTRIES} entries are named; the rest carry backlogItemId only.`
|
|
146
|
+
: 'An entry whose initiative could not be read carries backlogItemId only; taiga_get_backlog_item reads it.';
|
|
147
|
+
return { entries: rows, waitingOn: waitingOn(rows), ...(note ? { note } : {}) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function registerQueueTools(server: McpServer, client: TaigaClient) {
|
|
151
|
+
if (!client.hasCapability('products:read')) return;
|
|
152
|
+
|
|
153
|
+
server.registerTool(
|
|
154
|
+
'taiga_get_queue',
|
|
155
|
+
{
|
|
156
|
+
title: 'Get the product queue',
|
|
157
|
+
description:
|
|
158
|
+
"Read a product's queue, the line the coding agent works one initiative at a time: each entry in order " +
|
|
159
|
+
'with its title, where it is (planning, building, in review) and whose turn it is, a halted entry with why ' +
|
|
160
|
+
'it stopped and what lifts it, and waitingOn, one sentence saying what the line is waiting for. The ' +
|
|
161
|
+
"line advances only when the active entry's pull request merges, so an entry waiting on a review holds " +
|
|
162
|
+
'everything behind it. Use this when a queued initiative is not moving.',
|
|
163
|
+
inputSchema: { productId },
|
|
164
|
+
annotations: READ_ONLY,
|
|
165
|
+
},
|
|
166
|
+
(params) => runTool(async () => toolResult(await queueView(client, params.productId)))
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
applySectionEdit,
|
|
4
|
+
budgetSections,
|
|
5
|
+
instructionsText,
|
|
6
|
+
pickSections,
|
|
7
|
+
sectionContent,
|
|
8
|
+
sectionsOf,
|
|
9
|
+
stripProvenance,
|
|
10
|
+
} from './sections.js';
|
|
3
11
|
|
|
4
12
|
describe('sectionsOf', () => {
|
|
5
13
|
it('returns the sections map of a section-shaped document', () => {
|
|
@@ -106,3 +114,30 @@ describe('stripProvenance', () => {
|
|
|
106
114
|
expect(stripProvenance(doc)).toEqual(doc);
|
|
107
115
|
});
|
|
108
116
|
});
|
|
117
|
+
|
|
118
|
+
describe('budgetSections and pickSections', () => {
|
|
119
|
+
// Parsed, as the API's JSON arrives: an own "__proto__" key, not a prototype.
|
|
120
|
+
const doc = JSON.parse(
|
|
121
|
+
'{"data":{"sections":{"__proto__":{"content":"aaaa"},"constructor":{"content":"bbbb"},"toString":{"content":"cccc"}}}}'
|
|
122
|
+
) as Record<string, unknown>;
|
|
123
|
+
|
|
124
|
+
it('keeps a section named like an Object.prototype member as an ordinary section', () => {
|
|
125
|
+
const cut = budgetSections(doc, 8)!;
|
|
126
|
+
const kept = (cut.doc.data as { sections: Record<string, unknown> }).sections;
|
|
127
|
+
|
|
128
|
+
expect(Object.keys(kept)).toEqual(['__proto__', 'constructor']);
|
|
129
|
+
expect(Object.getPrototypeOf(kept)).toBe(Object.prototype);
|
|
130
|
+
expect(cut.omittedSections).toEqual([{ id: 'toString', characters: 4 }]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('lists every section not picked, including one named like a prototype member', () => {
|
|
134
|
+
const cut = pickSections(doc, new Set(['toString']))!;
|
|
135
|
+
|
|
136
|
+
expect(cut.omittedSections.map((s) => s.id)).toEqual(['__proto__', 'constructor']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('answers null for a document that is not made of sections', () => {
|
|
140
|
+
expect(budgetSections({ data: { legacy: 'x' } }, 10)).toBeNull();
|
|
141
|
+
expect(pickSections({ data: { legacy: 'x' } }, new Set(['a']))).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
});
|
package/src/tools/sections.ts
CHANGED
|
@@ -75,3 +75,53 @@ export function instructionsText(sections: Sections): string | null {
|
|
|
75
75
|
const text = parts.join('\n\n').trim();
|
|
76
76
|
return text || null;
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
/** A document cut to some of its sections, and the ones left out with their sizes. */
|
|
80
|
+
export interface SectionCut {
|
|
81
|
+
doc: Record<string, unknown>;
|
|
82
|
+
omittedSections: { id: string; characters: number }[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cutTo(doc: Record<string, unknown>, sections: Sections, kept: Sections): SectionCut {
|
|
86
|
+
const omittedSections = Object.keys(sections)
|
|
87
|
+
.filter((id) => !Object.hasOwn(kept, id))
|
|
88
|
+
.map((id) => ({ id, characters: sectionContent(sections, id)?.length ?? 0 }));
|
|
89
|
+
return { doc: { ...doc, data: { ...(doc.data as Record<string, unknown>), sections: kept } }, omittedSections };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The named sections of a section-shaped document, in the order the API
|
|
94
|
+
* returns them (a jsonb column: shorter ids first, then by bytes, not the
|
|
95
|
+
* order a person wrote them);
|
|
96
|
+
* every other section is listed with its size so the reader knows what else
|
|
97
|
+
* there is. Null when the document is not made of sections.
|
|
98
|
+
*/
|
|
99
|
+
export function pickSections(doc: Record<string, unknown>, ids: ReadonlySet<string>): SectionCut | null {
|
|
100
|
+
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
101
|
+
if (!sections) return null;
|
|
102
|
+
return cutTo(doc, sections, Object.fromEntries(Object.entries(sections).filter(([id]) => ids.has(id))));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* As many whole sections as fit `budget` characters of text, the rest listed
|
|
107
|
+
* with their sizes so the reader can ask for them by id. Whole, never cut: a
|
|
108
|
+
* section's text is one argument, and half of it reads as all of it. Not "the
|
|
109
|
+
* opening": the document's own order is not in the data (a jsonb column
|
|
110
|
+
* hands back shorter ids first), so a section that does not fit is skipped
|
|
111
|
+
* and a later, smaller one may still be carried. Null when the document is
|
|
112
|
+
* not made of sections.
|
|
113
|
+
*/
|
|
114
|
+
export function budgetSections(doc: Record<string, unknown>, budget: number): SectionCut | null {
|
|
115
|
+
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
116
|
+
if (!sections) return null;
|
|
117
|
+
// Entries, not assignment: a section id is text, and `kept["__proto__"] = x` would set a prototype.
|
|
118
|
+
const kept: [string, unknown][] = [];
|
|
119
|
+
let used = 0;
|
|
120
|
+
for (const id of Object.keys(sections)) {
|
|
121
|
+
const length = sectionContent(sections, id)?.length ?? 0;
|
|
122
|
+
if (used + length > budget) continue;
|
|
123
|
+
kept.push([id, sections[id]]);
|
|
124
|
+
used += length;
|
|
125
|
+
}
|
|
126
|
+
return cutTo(doc, sections, Object.fromEntries(kept));
|
|
127
|
+
}
|
package/src/tools/summarize.ts
CHANGED
|
@@ -20,3 +20,26 @@ export function pickFields<T extends Record<string, unknown>>(
|
|
|
20
20
|
export function page<T>(data: T[], nextCursor: string | null | undefined): { data: T[]; nextCursor: string | null } {
|
|
21
21
|
return { data, nextCursor: nextCursor ?? null };
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How many of an initiative's acceptance criteria are met: one pair a row can
|
|
26
|
+
* carry, so "done with two unmet" shows in a list without reading the item.
|
|
27
|
+
* Null while it has none (an unplanned initiative).
|
|
28
|
+
*/
|
|
29
|
+
export function criteriaCounts(item: Record<string, unknown>): { met: number; total: number } | null {
|
|
30
|
+
const criteria = item.acceptanceCriteria;
|
|
31
|
+
if (!Array.isArray(criteria) || criteria.length === 0) return null;
|
|
32
|
+
const met = criteria.filter((c) => typeof c === 'object' && c !== null && (c as { met?: unknown }).met === true);
|
|
33
|
+
return { met: met.length, total: criteria.length };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Initiative rows cut to `fields`, each with its criteria counts. A row that
|
|
38
|
+
* does not carry `acceptanceCriteria` at all gets no count rather than a
|
|
39
|
+
* null that would read as "has none".
|
|
40
|
+
*/
|
|
41
|
+
export function itemRows(items: Record<string, unknown>[], fields: readonly string[]): Record<string, unknown>[] {
|
|
42
|
+
return pickFields(items, fields).map((row, index) =>
|
|
43
|
+
'acceptanceCriteria' in items[index]! ? { ...row, criteria: criteriaCounts(items[index]!) } : row
|
|
44
|
+
);
|
|
45
|
+
}
|