@nonbot/mcp 0.2.4 → 0.3.1

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/README.md CHANGED
@@ -100,9 +100,9 @@ Ask your agent to "list my non.bot portfolios", "show what rules apply before I
100
100
  ## Tools
101
101
 
102
102
  <!-- tool-table:start — generated by `npm run gen:tools`; tests/readme-tool-table.test.ts fails if this drifts from the code -->
103
- ### Portfolio (27)
103
+ ### Portfolio (37)
104
104
 
105
- The SAFe portfolio canvas: epics, features, stories, snapshots, personas, and the Run launch surface.
105
+ The SAFe portfolio canvas: epics, features, stories, snapshots, releases, personas, and the Run launch surface.
106
106
 
107
107
  | Tool | What it does | Kind | PAT scope |
108
108
  |---|---|---|---|
@@ -127,12 +127,22 @@ The SAFe portfolio canvas: epics, features, stories, snapshots, personas, and th
127
127
  | `portfolio_snapshot` | Save a labeled version snapshot of a portfolio’s CURRENT graph — a time-travel restore point. | write | `portfolio:write` |
128
128
  | `portfolio_move_subtree` | Move one item AND every descendant (by parentItemId) out of a portfolio into another one — an existing portfolio (target_portfolio_id) or a brand-new one (new_… | write | `portfolio:write` |
129
129
  | `portfolio_comment_post` | Post a comment on one portfolio item (optionally as a reply via parent_comment_id). | write | `portfolio:write` |
130
+ | `portfolio_preview` | Draw the portfolio as an SVG schematic — every card at its stored Map (default) or Timeline position and size, with its level, state and title (done cards pale… | read | `portfolio:read` |
130
131
  | `portfolio_create` | Create a new, empty portfolio owned by the caller and return its id + version. | write | `portfolio:write` |
131
132
  | `portfolio_add_items` | Add up to 200 items of any level (objective, key-result, epic, feature, story, enabler, milestone) in one versioned write. | write | `portfolio:write` |
132
133
  | `portfolio_patch_item` | Change fields of ONE portfolio item without rewriting the graph (PATCH /api/portfolio/graph/node): `set` writes allowlisted PortfolioItemData fields (title, de… | write | `portfolio:write` |
133
134
  | `portfolio_layout` | Re-position every item so the Timeline reads cleanly: x from each item's start date (the canvas reads dates off x), y top-down — objectives, key results, then… | write | `portfolio:write` |
135
+ | `portfolio_style` | Give a portfolio one consistent look in ONE versioned write: rules, applied in order, each matching cards by levels / states / ids / hasImage and setting how t… | write | `portfolio:write` |
134
136
  | `portfolio_set_meta` | Graph-level settings that travel with the portfolio (the `__graph_meta__` record): `context_text` (the portfolio's context notes — what a newcomer reads first)… | write | `portfolio:write` |
135
137
  | `portfolio_asset_upload` | Store one image (PNG / JPEG / WebP / GIF, ≤ 2.2 MB — downscale first; the app keeps images at 1024 px JPEG) in your private asset store and get back its URL, f… | write | `portfolio:write` |
138
+ | `portfolio_completed_work` | The done items of a portfolio in a date window, computed from the graph: each row names its done date and the clock it came from (doneAt — stamped when filed,… | read | `portfolio:read` |
139
+ | `portfolio_rebuild_done_work` | For every done card with no finish clock (no doneAt, no stateChangedAt), gather POINTERS — not verdicts — to when it finished: commits in the local git repo at… | read | `portfolio:read` (+ `radbugs:read` for radBUGS evidence) |
140
+ | `portfolio_release_list` | The releases of a portfolio, oldest first: id, name, from–to window, draft / approvedAt / open, the chain (previous / next release by the OUT wire), member cou… | read | `portfolio:read` |
141
+ | `portfolio_propose_releases` | Write DRAFT releases for done work that is not filed yet: one per calendar week (Mon–Sun, by each card's done date) and — unless objective_releases is false —… | write | `portfolio:write` |
142
+ | `portfolio_release_create` | Add one release (a Release node) covering from–to, chained after the release whose window ends latest before it starts (and before the one that starts next), p… | write | `portfolio:write` |
143
+ | `portfolio_release_file` | File cards into a release, each WITH its subtree (children by parent; from an objective, its key results' linked epics). | write | `portfolio:write` |
144
+ | `portfolio_release_unfile` | Take cards out of their release: item_ids (exactly those cards — no subtree), or release_id for every member of one release. | write | `portfolio:write` |
145
+ | `portfolio_release_approve` | Turn a DRAFT release into a release: files exactly the cards it proposes (still-done ones; each keeps its parent, edges and position, gets releaseId and doneAt… | write | `portfolio:write` |
136
146
 
137
147
  ### IP node (11)
138
148
 
@@ -209,12 +219,13 @@ The supervisor surface: what the daemon and the fleet are doing, plus the three
209
219
  | `run_prompt_answer` | Answer a mid-run prompt on the operator's behalf — the daemon types the answer into the live pane. | destructive | `runs:respond` (explicit; a wildcard token does not satisfy it) |
210
220
  | `run_stop_daemon` | The panic button — queue a remote shutdown for the operator's daemon (CLI ≥0.9.12). | destructive | `runs:control` |
211
221
 
212
- ### radBUGS (1)
222
+ ### radBUGS (2)
213
223
 
214
- File a bug straight into the radBUGS inbox, stamped as filed from MCP.
224
+ File a bug straight into the radBUGS inbox, stamped as filed from MCP; the site owner can list them.
215
225
 
216
226
  | Tool | What it does | Kind | PAT scope |
217
227
  |---|---|---|---|
228
+ | `radbug_list` | List radBUGS from the non.bot inbox, newest first (open ones lead): id (ti_…), status (open \| ai_flagged \| done \| wontfix), priority, area, page URL, descripti… | read | `radbugs:read` (site owner only) |
218
229
  | `radbug_file` | File a bug report into the non.bot radBUGS inbox, stamped as filed from MCP. | write | `radbugs:write` |
219
230
  <!-- tool-table:end -->
220
231
 
@@ -245,8 +256,10 @@ Safety rails on writes: `portfolio_update` pre-fetches the live version, backs t
245
256
  - `core/http.ts` — token-bound fetch wrapper
246
257
  - `core/validate.ts` — pre-flight graph validation
247
258
  - `core/server.ts` — `registerAll(server, ctx)` (the transport-agnostic heart) + `createNonbotMcpServer(ctx)`
248
- - `core/toolsets/{portfolio,whygraph,run,monitor,ip,radbugs}.ts`, `core/resources.ts`, `core/prompts.ts`
249
- - `src/stdio.ts` — the `nonbot-mcp` bin: env/CLI-auth token + filesystem backup hook
259
+ - `core/toolsets/{portfolio,build,release,ip,whygraph,run,monitor,radbugs}.ts`, `core/resources.ts`, `core/prompts.ts`
260
+ - `core/releases.ts` — the Release node's membership + chain rules, restated from the app (`apps/web/src/modules/portfolio/lib/releases.ts`, `src/modules/release/lib/releaseChain.ts`) and parity-tested against it; `core/completedWork.ts` — done work, draft proposals and evidence matching
261
+ - `core/layout.ts` — the Timeline (packed), Map and Full Stack layouts (`portfolio_layout` / `portfolio_add_items`; Full Stack's five rows restate the app's `FULL_STACK_TEMPLATE` and set y only on the Timeline, where x is the date); `core/satellites.ts` — inner-loop satellite sizes and rosters, restated from the app (`lib/innerLoopLayout.ts`, `theme/portfolio-theme.ts`) and parity-tested against it, for the Full Stack epic columns and the preview's satellite bands
262
+ - `src/stdio.ts` — the `nonbot-mcp` bin: env/CLI-auth token + filesystem backup hook + the `git log` hook (`src/gitLog.ts`: execFile with an argv array, absolute `repo_path` only) used by `portfolio_rebuild_done_work`
250
263
  - `scripts/gen-tool-table.mjs` — prints the tool tables above from the live definitions (`--portfolio-mcp` does the same for the legacy server)
251
264
 
252
265
  ```bash
@@ -0,0 +1,127 @@
1
+ import { type Edge, type GraphNode } from './releases.js';
2
+ export type DateSource = 'doneAt' | 'stateChangedAt' | 'updatedAt' | 'none';
3
+ export declare const isoDay: (ms: number) => string;
4
+ export declare const isoTime: (ms: number) => string;
5
+ export declare function doneClock(n: GraphNode): {
6
+ at: number | null;
7
+ source: DateSource;
8
+ };
9
+ export declare function windowMs(from?: string, to?: string): {
10
+ lo: number;
11
+ hi: number;
12
+ };
13
+ export declare function rollupParent(n: GraphNode, byId: ReadonlyMap<string, GraphNode>, edges: ReadonlyArray<Edge>): GraphNode | null;
14
+ export interface CompletedRow {
15
+ id: string;
16
+ title: string;
17
+ level: string;
18
+ done_at: string | null;
19
+ date_source: DateSource;
20
+ release?: {
21
+ id: string;
22
+ name: string;
23
+ };
24
+ path: string[];
25
+ }
26
+ export interface RollupNode {
27
+ id: string;
28
+ title: string;
29
+ level: string;
30
+ state: string;
31
+ counted: boolean;
32
+ done_at?: string | null;
33
+ date_source?: DateSource;
34
+ partially_done?: true;
35
+ why_not_counted?: string;
36
+ children: RollupNode[];
37
+ }
38
+ export declare function completedWork(nodes: ReadonlyArray<GraphNode>, edges: ReadonlyArray<Edge>, opts?: {
39
+ from?: string;
40
+ to?: string;
41
+ includeFiled?: boolean;
42
+ }): {
43
+ counts: Record<string, number>;
44
+ date_sources: Record<DateSource, number>;
45
+ rows: CompletedRow[];
46
+ rollup: RollupNode[];
47
+ partially_done_parents: {
48
+ id: string;
49
+ title: string;
50
+ level: string;
51
+ state: string;
52
+ done_children: number;
53
+ }[];
54
+ undated: {
55
+ id: string;
56
+ title: string;
57
+ level: string;
58
+ }[];
59
+ filed_skipped: number;
60
+ };
61
+ export declare function weekName(from: string): string;
62
+ export interface DraftProposal {
63
+ kind: 'week' | 'objective';
64
+ name: string;
65
+ from: string;
66
+ to: string;
67
+ proposed: string[];
68
+ objective_id?: string;
69
+ }
70
+ export declare function proposeReleases(nodes: ReadonlyArray<GraphNode>, edges: ReadonlyArray<Edge>, opts?: {
71
+ from?: string;
72
+ to?: string;
73
+ objectiveReleases?: boolean;
74
+ }): {
75
+ drafts: DraftProposal[];
76
+ undated: {
77
+ id: string;
78
+ title: string;
79
+ level: string;
80
+ }[];
81
+ already_proposed: string[];
82
+ filed_skipped: number;
83
+ };
84
+ export interface Commit {
85
+ hash: string;
86
+ date: string;
87
+ subject: string;
88
+ body?: string;
89
+ }
90
+ export interface Radbug {
91
+ id: string;
92
+ status?: string;
93
+ description?: string;
94
+ rawMessage?: string | null;
95
+ pageUrl?: string | null;
96
+ createdAt?: unknown;
97
+ updatedAt?: unknown;
98
+ }
99
+ export interface Comment {
100
+ id?: unknown;
101
+ content?: unknown;
102
+ createdAt?: unknown;
103
+ userName?: unknown;
104
+ }
105
+ export interface Evidence {
106
+ kind: 'commit' | 'radbug' | 'comment';
107
+ ref: string;
108
+ date: string | null;
109
+ why: string;
110
+ text: string;
111
+ }
112
+ export declare function toMs(v: unknown): number | null;
113
+ export declare const radbugTokens: (s: string) => string[];
114
+ export declare function cites(text: string, id: string): boolean;
115
+ export declare function matchEvidence(card: GraphNode, inputs: {
116
+ commits?: Commit[];
117
+ radbugs?: Radbug[];
118
+ comments?: Comment[];
119
+ }): {
120
+ evidence: Evidence[];
121
+ proposal: {
122
+ done_at: string | null;
123
+ done_at_ms: number | null;
124
+ basis: 'commit' | 'radbug' | 'comment' | 'unknown';
125
+ };
126
+ };
127
+ export declare function clocklessDone(nodes: ReadonlyArray<GraphNode>): GraphNode[];
@@ -0,0 +1,287 @@
1
+ import { isDone, isFiled, releaseIdOf, releaseModulesFromGraphNodes, subtreeOf, weekWindow } from './releases.js';
2
+ const ROLLUP_ORDER = ['objective', 'key-result', 'epic', 'feature', 'story', 'enabler', 'milestone'];
3
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null);
4
+ const title = (n) => String(n.data?.title ?? n.data?.label ?? '').trim();
5
+ const level = (n) => String(n.data?.hierarchyLevel ?? 'item');
6
+ const pad = (n) => String(n).padStart(2, '0');
7
+ export const isoDay = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; };
8
+ export const isoTime = (ms) => new Date(ms).toISOString();
9
+ export function doneClock(n) {
10
+ const d = n.data ?? {};
11
+ if (num(d.doneAt))
12
+ return { at: d.doneAt, source: 'doneAt' };
13
+ if (num(d.stateChangedAt))
14
+ return { at: d.stateChangedAt, source: 'stateChangedAt' };
15
+ if (num(d.updatedAt))
16
+ return { at: d.updatedAt, source: 'updatedAt' };
17
+ return { at: null, source: 'none' };
18
+ }
19
+ export function windowMs(from, to) {
20
+ const day = (s) => new Date(Number(s.slice(0, 4)), Number(s.slice(5, 7)) - 1, Number(s.slice(8, 10)));
21
+ const lo = from ? day(from).getTime() : -Infinity;
22
+ let hi = Infinity;
23
+ if (to) {
24
+ const d = day(to);
25
+ d.setDate(d.getDate() + 1);
26
+ hi = d.getTime() - 1;
27
+ }
28
+ return { lo, hi };
29
+ }
30
+ const items = (nodes) => nodes.filter(n => n.type === 'portfolio-item' && typeof n.id === 'string');
31
+ export function rollupParent(n, byId, edges) {
32
+ if (level(n) === 'epic') {
33
+ const krs = [
34
+ ...edges.filter(e => (e.dependencyType ?? e.type) === 'okr-link' && e.target === n.id).map(e => e.source),
35
+ ...[...byId.values()].filter(k => level(k) === 'key-result' && Array.isArray(k.data?.linkedEpicIds) && k.data.linkedEpicIds.includes(n.id)).map(k => k.id),
36
+ ];
37
+ for (const kr of krs) {
38
+ const obj = byId.get(String(byId.get(kr)?.data?.parentItemId ?? ''));
39
+ if (obj && level(obj) === 'objective')
40
+ return obj;
41
+ }
42
+ }
43
+ const p = n.data?.parentItemId;
44
+ const parent = typeof p === 'string' ? byId.get(p) : undefined;
45
+ return parent && parent.id !== n.id ? parent : null;
46
+ }
47
+ export function completedWork(nodes, edges, opts = {}) {
48
+ const releases = releaseModulesFromGraphNodes(nodes);
49
+ const all = items(nodes);
50
+ const byId = new Map(all.map(n => [n.id, n]));
51
+ const { lo, hi } = windowMs(opts.from, opts.to);
52
+ const windowed = opts.from !== undefined || opts.to !== undefined;
53
+ const counted = new Map();
54
+ const undated = [];
55
+ let filedSkipped = 0;
56
+ for (const n of all) {
57
+ if (!isDone(n))
58
+ continue;
59
+ if (!opts.includeFiled && isFiled(n, releases)) {
60
+ filedSkipped++;
61
+ continue;
62
+ }
63
+ const c = doneClock(n);
64
+ if (c.at === null) {
65
+ if (windowed) {
66
+ undated.push({ id: n.id, title: title(n), level: level(n) });
67
+ continue;
68
+ }
69
+ }
70
+ else if (c.at < lo || c.at > hi)
71
+ continue;
72
+ counted.set(n.id, c);
73
+ }
74
+ const chainOf = (n) => {
75
+ const out = [];
76
+ const seen = new Set([n.id]);
77
+ for (let p = rollupParent(n, byId, edges); p && !seen.has(p.id); p = rollupParent(p, byId, edges)) {
78
+ out.unshift(p);
79
+ seen.add(p.id);
80
+ }
81
+ return out;
82
+ };
83
+ const releaseName = (n) => {
84
+ const rid = releaseIdOf(n);
85
+ const r = rid ? releases.get(rid) : undefined;
86
+ return r ? { id: r.id, name: String(r.data.name ?? '') } : undefined;
87
+ };
88
+ const rows = [];
89
+ const tree = new Map();
90
+ const roots = [];
91
+ const nodeFor = (n) => {
92
+ let t = tree.get(n.id);
93
+ if (t)
94
+ return t;
95
+ const c = counted.get(n.id);
96
+ t = { id: n.id, title: title(n), level: level(n), state: String(n.data?.state ?? ''), counted: !!c, children: [] };
97
+ if (c) {
98
+ t.done_at = c.at === null ? null : isoTime(c.at);
99
+ t.date_source = c.source;
100
+ }
101
+ else if (!isDone(n))
102
+ t.partially_done = true;
103
+ else
104
+ t.why_not_counted = !opts.includeFiled && isFiled(n, releases) ? 'filed' : 'outside the window';
105
+ tree.set(n.id, t);
106
+ return t;
107
+ };
108
+ const partial = new Map();
109
+ for (const [id, c] of counted) {
110
+ const n = byId.get(id);
111
+ const chain = chainOf(n);
112
+ const rel = releaseName(n);
113
+ rows.push({ id, title: title(n), level: level(n), done_at: c.at === null ? null : isoTime(c.at), date_source: c.source, ...(rel ? { release: rel } : {}), path: chain.map(title) });
114
+ let parent = null;
115
+ for (const a of [...chain, n]) {
116
+ const t = nodeFor(a);
117
+ if (parent) {
118
+ if (!parent.children.includes(t))
119
+ parent.children.push(t);
120
+ }
121
+ else if (!roots.includes(t))
122
+ roots.push(t);
123
+ parent = t;
124
+ }
125
+ for (const a of chain)
126
+ if (!isDone(a)) {
127
+ const p = partial.get(a.id) ?? { id: a.id, title: title(a), level: level(a), state: String(a.data?.state ?? ''), done_children: 0 };
128
+ p.done_children++;
129
+ partial.set(a.id, p);
130
+ }
131
+ }
132
+ const childIds = new Set();
133
+ for (const t of tree.values())
134
+ for (const c of t.children)
135
+ childIds.add(c.id);
136
+ const finalRoots = roots.filter(r => !childIds.has(r.id));
137
+ const rank = (t) => { const i = ROLLUP_ORDER.indexOf(t.level); return i < 0 ? 99 : i; };
138
+ const sortRec = (list) => { list.sort((a, b) => rank(a) - rank(b) || String(a.done_at ?? '').localeCompare(String(b.done_at ?? '')) || a.title.localeCompare(b.title)); for (const t of list)
139
+ sortRec(t.children); };
140
+ sortRec(finalRoots);
141
+ rows.sort((a, b) => String(a.done_at ?? '').localeCompare(String(b.done_at ?? '')) || a.title.localeCompare(b.title));
142
+ const counts = {};
143
+ for (const r of rows)
144
+ counts[r.level] = (counts[r.level] ?? 0) + 1;
145
+ const bySource = { doneAt: 0, stateChangedAt: 0, updatedAt: 0, none: 0 };
146
+ for (const r of rows)
147
+ bySource[r.date_source]++;
148
+ return { counts, date_sources: bySource, rows, rollup: finalRoots, partially_done_parents: [...partial.values()], undated, filed_skipped: filedSkipped };
149
+ }
150
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
151
+ export function weekName(from) {
152
+ return `Week of ${MONTHS[Number(from.slice(5, 7)) - 1]} ${Number(from.slice(8, 10))}, ${from.slice(0, 4)}`;
153
+ }
154
+ export function proposeReleases(nodes, edges, opts = {}) {
155
+ const releases = releaseModulesFromGraphNodes(nodes);
156
+ const all = items(nodes);
157
+ const { lo, hi } = windowMs(opts.from, opts.to);
158
+ const inDraft = new Set();
159
+ for (const r of releases.values())
160
+ if (r.data.draft === true && Array.isArray(r.data.proposed))
161
+ for (const id of r.data.proposed)
162
+ if (typeof id === 'string')
163
+ inDraft.add(id);
164
+ const clock = new Map();
165
+ const undated = [];
166
+ const alreadyProposed = [];
167
+ let filed = 0;
168
+ for (const n of all) {
169
+ if (!isDone(n))
170
+ continue;
171
+ if (isFiled(n, releases)) {
172
+ filed++;
173
+ continue;
174
+ }
175
+ if (inDraft.has(n.id)) {
176
+ alreadyProposed.push(n.id);
177
+ continue;
178
+ }
179
+ const c = doneClock(n);
180
+ if (c.at === null) {
181
+ undated.push({ id: n.id, title: title(n), level: level(n) });
182
+ continue;
183
+ }
184
+ if (c.at < lo || c.at > hi)
185
+ continue;
186
+ clock.set(n.id, c.at);
187
+ }
188
+ const drafts = [];
189
+ const claimed = new Set();
190
+ if (opts.objectiveReleases !== false) {
191
+ const objectives = all.filter(n => level(n) === 'objective' && clock.has(n.id)).sort((a, b) => clock.get(a.id) - clock.get(b.id));
192
+ for (const o of objectives) {
193
+ const members = [...subtreeOf([o.id], all, edges)].filter(id => clock.has(id) && !claimed.has(id));
194
+ if (!members.length)
195
+ continue;
196
+ for (const id of members)
197
+ claimed.add(id);
198
+ const times = members.map(id => clock.get(id));
199
+ drafts.push({ kind: 'objective', objective_id: o.id, name: title(o) || 'Objective', from: isoDay(Math.min(...times)), to: isoDay(Math.max(...times)), proposed: sortByClock(members, clock) });
200
+ }
201
+ }
202
+ const weeks = new Map();
203
+ for (const [id, at] of clock) {
204
+ if (claimed.has(id))
205
+ continue;
206
+ const w = weekWindow(new Date(at));
207
+ const g = weeks.get(w.from) ?? weeks.set(w.from, { to: w.to, ids: [] }).get(w.from);
208
+ g.ids.push(id);
209
+ }
210
+ for (const [from, g] of weeks)
211
+ drafts.push({ kind: 'week', name: weekName(from), from, to: g.to, proposed: sortByClock(g.ids, clock) });
212
+ drafts.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.name.localeCompare(b.name));
213
+ return { drafts, undated, already_proposed: alreadyProposed, filed_skipped: filed };
214
+ }
215
+ const sortByClock = (ids, clock) => [...ids].sort((a, b) => clock.get(a) - clock.get(b) || a.localeCompare(b));
216
+ export function toMs(v) {
217
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
218
+ return v;
219
+ if (typeof v === 'string' && v) {
220
+ const t = Date.parse(v);
221
+ return Number.isFinite(t) ? t : null;
222
+ }
223
+ return null;
224
+ }
225
+ const TI_RE = /\bti_[0-9a-f]{8,16}\b/g;
226
+ export const radbugTokens = (s) => [...new Set(s.match(TI_RE) ?? [])];
227
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
228
+ export function cites(text, id) {
229
+ if (id.length < 6)
230
+ return false;
231
+ return new RegExp(`(^|[^A-Za-z0-9_-])${escapeRe(id)}($|[^A-Za-z0-9_-])`).test(text);
232
+ }
233
+ const clip = (s, n) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
234
+ export function matchEvidence(card, inputs) {
235
+ const comments = inputs.comments ?? [];
236
+ const own = JSON.stringify(card.data ?? {}) + ' ' + comments.map(c => String(c.content ?? '')).join(' ');
237
+ const tokens = radbugTokens(own);
238
+ const closed = (inputs.radbugs ?? []).filter(b => b.status === 'done');
239
+ const tied = new Map();
240
+ for (const b of closed) {
241
+ if (tokens.some(t => b.id.startsWith(t)))
242
+ tied.set(b.id, 'the card or its comments cite it');
243
+ else if (cites(`${b.description ?? ''} ${b.rawMessage ?? ''} ${b.pageUrl ?? ''}`, card.id))
244
+ tied.set(b.id, 'it mentions the card id');
245
+ }
246
+ for (const t of tokens)
247
+ if (![...tied.keys()].some(id => id.startsWith(t)))
248
+ tied.set(t, 'the card or its comments cite it');
249
+ const evidence = [];
250
+ for (const b of closed) {
251
+ const why = tied.get(b.id);
252
+ if (!why)
253
+ continue;
254
+ const at = toMs(b.updatedAt) ?? toMs(b.createdAt);
255
+ evidence.push({ kind: 'radbug', ref: b.id, date: at === null ? null : isoTime(at), why: `closed radBUG — ${why}`, text: clip(b.description, 160) });
256
+ }
257
+ const tiedIds = [...tied.keys()];
258
+ for (const c of inputs.commits ?? []) {
259
+ const msg = `${c.subject}\n${c.body ?? ''}`;
260
+ let why = cites(msg, card.id) ? 'cites the card id' : '';
261
+ if (!why) {
262
+ const hit = radbugTokens(msg).find(t => tiedIds.some(id => id.startsWith(t) || t.startsWith(id)));
263
+ if (hit)
264
+ why = `cites radBUG ${hit}`;
265
+ }
266
+ if (why)
267
+ evidence.push({ kind: 'commit', ref: c.hash.slice(0, 12), date: toMs(c.date) === null ? null : isoTime(toMs(c.date)), why, text: clip(c.subject, 160) });
268
+ }
269
+ for (const c of comments) {
270
+ const at = toMs(c.createdAt);
271
+ evidence.push({ kind: 'comment', ref: String(c.id ?? ''), date: at === null ? null : isoTime(at), why: `comment${c.userName ? ` by ${clip(c.userName, 40)}` : ''}`, text: clip(c.content, 160) });
272
+ }
273
+ const latest = (kinds) => {
274
+ let best = null;
275
+ for (const e of evidence)
276
+ if (kinds.includes(e.kind) && e.date && (!best || e.date > best.date))
277
+ best = e;
278
+ return best;
279
+ };
280
+ const strong = latest(['commit', 'radbug']);
281
+ const pick = strong ?? latest(['comment']);
282
+ const ms = pick?.date ? Date.parse(pick.date) : null;
283
+ return { evidence, proposal: { done_at: pick?.date ?? null, done_at_ms: ms, basis: pick ? pick.kind : 'unknown' } };
284
+ }
285
+ export function clocklessDone(nodes) {
286
+ return items(nodes).filter(n => isDone(n) && !num(n.data?.doneAt) && !num(n.data?.stateChangedAt));
287
+ }
@@ -5,5 +5,13 @@ export interface ApiContext {
5
5
  readonly: boolean;
6
6
  backup?: (id: string, json: unknown) => Promise<void>;
7
7
  readLocalImage?: (path: string) => Promise<string>;
8
+ gitLog?: (repoPath: string, since: string) => Promise<GitCommit[]>;
8
9
  fetchImpl?: typeof fetch;
9
10
  }
11
+ export interface GitCommit {
12
+ hash: string;
13
+ date: string;
14
+ subject: string;
15
+ body?: string;
16
+ }
17
+ export declare function checkRepoPath(p: unknown): string | null;
@@ -32,3 +32,14 @@ export function resolveApiBase(raw0) {
32
32
  }
33
33
  return raw;
34
34
  }
35
+ export function checkRepoPath(p) {
36
+ if (typeof p !== 'string' || !p || p.length > 1024)
37
+ return 'repo_path must be a non-empty absolute path (≤ 1024 chars)';
38
+ if (/[\0\r\n]/.test(p))
39
+ return 'repo_path contains a control character';
40
+ if (!(p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p)))
41
+ return 'repo_path must be absolute';
42
+ if (p.split(/[\\/]+/).some(seg => seg === '..'))
43
+ return 'repo_path must not contain a ".." segment';
44
+ return null;
45
+ }
@@ -10,9 +10,17 @@ export interface LayoutNode {
10
10
  };
11
11
  data?: Record<string, any>;
12
12
  }
13
+ export interface LayoutEdge {
14
+ source: string;
15
+ target: string;
16
+ dependencyType?: string;
17
+ }
13
18
  export interface LayoutOptions {
14
19
  now?: Date;
15
20
  heights?: Record<string, number>;
21
+ group?: 'objective';
22
+ edges?: LayoutEdge[];
23
+ objectiveOrder?: string[];
16
24
  }
17
25
  export interface LayoutResult {
18
26
  positions: Map<string, {
@@ -36,3 +44,80 @@ export declare const isItem: (n: LayoutNode) => boolean;
36
44
  export declare const isIpNode: (n: LayoutNode) => boolean;
37
45
  export declare function layoutTimeline(nodes: LayoutNode[], opts?: LayoutOptions): LayoutResult;
38
46
  export declare function applyLayout<T extends LayoutNode>(nodes: T[], res: LayoutResult): T[];
47
+ export declare const MAP_WIDTH: Record<Level, number>;
48
+ export declare const MAP_GRID_COLS = 3;
49
+ export declare const MAP_GRID_MAX_COLS = 6;
50
+ export interface MapLayoutOptions {
51
+ edges?: LayoutEdge[];
52
+ objectiveOrder?: string[];
53
+ heights?: Record<string, number>;
54
+ sizes?: Record<string, {
55
+ width: number;
56
+ height: number;
57
+ }>;
58
+ pack?: 'stack' | 'grid';
59
+ aspect?: number;
60
+ headerHeight?: number;
61
+ columnHeight?: number;
62
+ }
63
+ export interface MapBand {
64
+ kind: 'objective' | 'loose' | 'rest' | 'modules';
65
+ objectiveId: string | null;
66
+ x: number;
67
+ y: number;
68
+ width: number;
69
+ height: number;
70
+ header: boolean;
71
+ items: number;
72
+ done: number;
73
+ epics: number;
74
+ }
75
+ export declare function layoutMap(nodes: LayoutNode[], opts?: MapLayoutOptions): Map<string, {
76
+ x: number;
77
+ y: number;
78
+ }>;
79
+ export declare function layoutMapDetailed(nodes: LayoutNode[], opts?: MapLayoutOptions): {
80
+ positions: Map<string, {
81
+ x: number;
82
+ y: number;
83
+ }>;
84
+ bands: MapBand[];
85
+ };
86
+ export declare function applyMapLayout<T extends LayoutNode>(nodes: T[], positions: Map<string, {
87
+ x: number;
88
+ y: number;
89
+ }>): T[];
90
+ export declare const FS_ROW: Record<Level, number>;
91
+ export declare const FS_EPIC_STAGGER = 20;
92
+ export declare const FS_LEAF_PITCH = 240;
93
+ export declare const FS_MILESTONE_LIFT_GAP = 20;
94
+ export declare const FS_FEATURE_MIN_WIDTH = 600;
95
+ export declare const FS_GROUP_GAP = 240;
96
+ export interface FullStackOptions {
97
+ edges?: LayoutEdge[];
98
+ objectiveOrder?: string[];
99
+ sizes?: Record<string, {
100
+ width: number;
101
+ height: number;
102
+ }>;
103
+ }
104
+ export declare function layoutFullStack(nodes: LayoutNode[], opts?: FullStackOptions): Map<string, {
105
+ x: number;
106
+ y: number;
107
+ }>;
108
+ export declare function applyFullStackTimeline<T extends LayoutNode>(nodes: T[], positions: Map<string, {
109
+ x: number;
110
+ y: number;
111
+ }>): T[];
112
+ export declare function timelineRowOverlaps(nodes: LayoutNode[]): number;
113
+ export declare function mapBoxes(n: LayoutNode & {
114
+ freePosition?: {
115
+ x: number;
116
+ y: number;
117
+ };
118
+ }): Array<{
119
+ x: number;
120
+ y: number;
121
+ width: number;
122
+ height: number;
123
+ }>;