@gitdocket/core 0.0.0 → 0.1.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/package.json +27 -6
- package/src/bundle.ts +127 -0
- package/src/cache.ts +557 -0
- package/src/config.ts +96 -0
- package/src/engine-semantics.ts +26 -0
- package/src/filestore.ts +63 -0
- package/src/id-allocation.ts +288 -0
- package/src/index.ts +199 -0
- package/src/indexmd.ts +146 -0
- package/src/init.ts +338 -0
- package/src/intents.ts +262 -0
- package/src/lint.ts +240 -0
- package/src/ops.ts +320 -0
- package/src/orientation.ts +88 -0
- package/src/overview.ts +593 -0
- package/src/packet.ts +119 -0
- package/src/parse.ts +200 -0
- package/src/prompt-routing.ts +651 -0
- package/src/schema.ts +56 -0
- package/src/search.ts +147 -0
- package/src/shipped-history.json +53 -0
- package/src/shipped.ts +96 -0
- package/src/state-of-play.ts +370 -0
- package/src/states.ts +85 -0
- package/src/upgrade.ts +177 -0
- package/src/verify.ts +122 -0
- package/src/version.ts +6 -0
- package/src/workflows.ts +481 -0
- package/README.md +0 -5
package/src/overview.ts
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
// Shared Home/CLI overview derivation. This module is Bun-only
|
|
2
|
+
// because it reads the disposable sqlite cache; the main core entry remains
|
|
3
|
+
// runtime-portable.
|
|
4
|
+
|
|
5
|
+
import type { Database } from "bun:sqlite";
|
|
6
|
+
import { type Bundle, readyWorkItems } from "./bundle";
|
|
7
|
+
import { resolveLink } from "./lint";
|
|
8
|
+
import type { Decision, WorkItem } from "./parse";
|
|
9
|
+
import {
|
|
10
|
+
byManualOrder,
|
|
11
|
+
type DecisionStatus,
|
|
12
|
+
isTerminalStatus,
|
|
13
|
+
type Priority,
|
|
14
|
+
type Status,
|
|
15
|
+
} from "./states";
|
|
16
|
+
|
|
17
|
+
/** A fortnight keeps recently-moving work visible without turning Home into history. */
|
|
18
|
+
export const OVERVIEW_ACTIVITY_WINDOW_DAYS = 14;
|
|
19
|
+
/** Two next picks keep each workstream scannable; the full queue stays in Tasks. */
|
|
20
|
+
export const OVERVIEW_NEXT_LIMIT = 2;
|
|
21
|
+
/** Idle workstreams only enter through ready work when they are near the global front. */
|
|
22
|
+
export const OVERVIEW_READY_HEAD_LIMIT = 10;
|
|
23
|
+
/** Outcome-shaped execution groups remain brief; complete inventories stay elsewhere. */
|
|
24
|
+
export const OVERVIEW_EXECUTION_GROUP_LIMIT = 5;
|
|
25
|
+
/** Summaries stay scannable; concept links retain the complete authored text. */
|
|
26
|
+
export const OVERVIEW_SUMMARY_MAX_LENGTH = 360;
|
|
27
|
+
|
|
28
|
+
export interface OverviewTask {
|
|
29
|
+
path: string;
|
|
30
|
+
id: string;
|
|
31
|
+
title: string | null;
|
|
32
|
+
status: Status;
|
|
33
|
+
priority: Priority;
|
|
34
|
+
rank: number | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface OverviewEpic {
|
|
38
|
+
path: string;
|
|
39
|
+
id: string;
|
|
40
|
+
title: string | null;
|
|
41
|
+
status: Status;
|
|
42
|
+
priority: Priority;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface OverviewProgress {
|
|
46
|
+
done: number;
|
|
47
|
+
total: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True when child-task completion and the epic's own lifecycle disagree.
|
|
52
|
+
* This is a prompt for human reconciliation, never authority to close an epic.
|
|
53
|
+
*/
|
|
54
|
+
export const epicNeedsCleanup = (
|
|
55
|
+
status: string | null,
|
|
56
|
+
progress: OverviewProgress,
|
|
57
|
+
): boolean =>
|
|
58
|
+
!isTerminalStatus(status ?? "") &&
|
|
59
|
+
progress.total > 0 &&
|
|
60
|
+
progress.done === progress.total;
|
|
61
|
+
|
|
62
|
+
export interface OverviewGroup {
|
|
63
|
+
now: OverviewTask[];
|
|
64
|
+
next: OverviewTask[];
|
|
65
|
+
/** Full ready count before the display cap, for a truthful “N more” link. */
|
|
66
|
+
nextTotal: number;
|
|
67
|
+
blockedOnly: boolean;
|
|
68
|
+
lastActivity: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface OverviewWorkstream extends OverviewGroup {
|
|
72
|
+
epic: OverviewEpic;
|
|
73
|
+
progress: OverviewProgress;
|
|
74
|
+
needsCleanup: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface OverviewWorkstreams {
|
|
78
|
+
/** Admitted streams with work in progress, ready work, or blocked open work. */
|
|
79
|
+
current: OverviewWorkstream[];
|
|
80
|
+
/** Streams admitted only by the recent-activity window. */
|
|
81
|
+
recentOnly: OverviewWorkstream[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface OverviewModel {
|
|
85
|
+
upNext: OverviewTask | null;
|
|
86
|
+
workstreams: OverviewWorkstreams;
|
|
87
|
+
loose: OverviewGroup | null;
|
|
88
|
+
execution: OverviewExecutionSummary;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface OverviewCheckpoint {
|
|
92
|
+
/** Git revision served to the reader, null when Git history is unavailable. */
|
|
93
|
+
revision: string | null;
|
|
94
|
+
/** Revision time, or the newest canonical task/decision movement as fallback. */
|
|
95
|
+
time: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type OverviewDeltaMode = "shared-recent" | "history-unavailable";
|
|
99
|
+
|
|
100
|
+
export interface OverviewDeltaScope {
|
|
101
|
+
mode: OverviewDeltaMode;
|
|
102
|
+
after: string | null;
|
|
103
|
+
requested: null;
|
|
104
|
+
fallback: "first-visit" | "history-unavailable";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface OverviewConceptRef {
|
|
108
|
+
path: string;
|
|
109
|
+
id: string;
|
|
110
|
+
title: string | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface OverviewExecutionItem extends OverviewConceptRef {
|
|
114
|
+
status: Status;
|
|
115
|
+
/** Authored outcome/description when available, otherwise the concept title. */
|
|
116
|
+
summary: string;
|
|
117
|
+
occurredAt: string | null;
|
|
118
|
+
supportingConcepts: OverviewConceptRef[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type OverviewAttentionReason = "blocked" | "stale" | "needs-cleanup";
|
|
122
|
+
|
|
123
|
+
export interface OverviewAttentionItem extends OverviewExecutionItem {
|
|
124
|
+
reason: OverviewAttentionReason;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type OverviewChangeKind =
|
|
128
|
+
| "queued"
|
|
129
|
+
| "started"
|
|
130
|
+
| "in-review"
|
|
131
|
+
| "blocked"
|
|
132
|
+
| "completed"
|
|
133
|
+
| "closed"
|
|
134
|
+
| "needs-cleanup";
|
|
135
|
+
|
|
136
|
+
export interface OverviewMaterialChange extends OverviewExecutionItem {
|
|
137
|
+
change: OverviewChangeKind;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface OverviewDecision {
|
|
141
|
+
path: string;
|
|
142
|
+
id: string;
|
|
143
|
+
title: string | null;
|
|
144
|
+
status: DecisionStatus;
|
|
145
|
+
choice: string;
|
|
146
|
+
rationale: string | null;
|
|
147
|
+
consequence: string | null;
|
|
148
|
+
occurredAt: string | null;
|
|
149
|
+
curated: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface OverviewExecutionSummary {
|
|
153
|
+
checkpoint: OverviewCheckpoint;
|
|
154
|
+
scope: OverviewDeltaScope;
|
|
155
|
+
shipped: OverviewExecutionItem[];
|
|
156
|
+
inFlight: OverviewExecutionItem[];
|
|
157
|
+
upNext: OverviewExecutionItem[];
|
|
158
|
+
needsAttention: OverviewAttentionItem[];
|
|
159
|
+
changes: OverviewMaterialChange[];
|
|
160
|
+
decisions: OverviewDecision[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface OverviewOptions {
|
|
164
|
+
/** Injectable wall clock for deterministic activity-window tests. */
|
|
165
|
+
now?: Date;
|
|
166
|
+
/** Current repository checkpoint supplied by the Git-aware caller. */
|
|
167
|
+
checkpoint?: Partial<OverviewCheckpoint>;
|
|
168
|
+
/** Whether the caller could inspect Git, distinct from a valid empty history. */
|
|
169
|
+
historyAvailable?: boolean;
|
|
170
|
+
/** Explicit decision links curated by the authored product checkpoint. */
|
|
171
|
+
decisionLinks?: string[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface ActivityDateRow {
|
|
175
|
+
taskId: string;
|
|
176
|
+
sha: string;
|
|
177
|
+
date: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const taskRef = (task: WorkItem): OverviewTask => ({
|
|
181
|
+
path: task.path,
|
|
182
|
+
id: task.fm.id,
|
|
183
|
+
title: task.fm.title ?? null,
|
|
184
|
+
status: task.fm.status,
|
|
185
|
+
priority: task.fm.priority,
|
|
186
|
+
rank: task.fm.rank ?? null,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const epicRef = (epic: WorkItem): OverviewEpic => ({
|
|
190
|
+
path: epic.path,
|
|
191
|
+
id: epic.fm.id,
|
|
192
|
+
title: epic.fm.title ?? null,
|
|
193
|
+
status: epic.fm.status,
|
|
194
|
+
priority: epic.fm.priority,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const newest = (dates: Iterable<string | undefined>): string | null => {
|
|
198
|
+
let result = "";
|
|
199
|
+
for (const date of dates) {
|
|
200
|
+
if (date && date > result) result = date;
|
|
201
|
+
}
|
|
202
|
+
return result || null;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const transitionDate = (item: WorkItem): string | undefined =>
|
|
206
|
+
typeof item.fm.timestamp === "string" ? item.fm.timestamp : undefined;
|
|
207
|
+
|
|
208
|
+
const conceptTimestamp = (item: WorkItem | Decision): string | undefined =>
|
|
209
|
+
typeof item.fm.timestamp === "string" ? item.fm.timestamp : undefined;
|
|
210
|
+
|
|
211
|
+
const plainSummary = (markdown: string | undefined): string | undefined => {
|
|
212
|
+
const paragraph = markdown
|
|
213
|
+
?.split(/\n\s*\n/)
|
|
214
|
+
.map((part) => part.trim())
|
|
215
|
+
.find((part) => part.length > 0);
|
|
216
|
+
if (!paragraph) return undefined;
|
|
217
|
+
const text = paragraph
|
|
218
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
219
|
+
.replace(/[*`>#]/g, "")
|
|
220
|
+
.replace(/^[-+]\s+/gm, "")
|
|
221
|
+
.replace(/\s+/g, " ")
|
|
222
|
+
.trim();
|
|
223
|
+
if (text.length <= OVERVIEW_SUMMARY_MAX_LENGTH) return text;
|
|
224
|
+
const clipped = text.slice(0, OVERVIEW_SUMMARY_MAX_LENGTH - 1);
|
|
225
|
+
const boundary = clipped.lastIndexOf(" ");
|
|
226
|
+
return `${clipped.slice(0, boundary > 240 ? boundary : clipped.length).trim()}…`;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const movementDate = (
|
|
230
|
+
item: WorkItem,
|
|
231
|
+
activityByTask: ReadonlyMap<string, ActivityDateRow[]>,
|
|
232
|
+
): string | null =>
|
|
233
|
+
newest([
|
|
234
|
+
transitionDate(item),
|
|
235
|
+
...(activityByTask.get(item.fm.id) ?? []).map((row) => row.date),
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
const inScope = (date: string | null, after: number): boolean =>
|
|
239
|
+
date !== null &&
|
|
240
|
+
Number.isFinite(Date.parse(date)) &&
|
|
241
|
+
Date.parse(date) > after;
|
|
242
|
+
|
|
243
|
+
const ref = (item: WorkItem | Decision): OverviewConceptRef => ({
|
|
244
|
+
path: item.path,
|
|
245
|
+
id: item.fm.id,
|
|
246
|
+
title: item.fm.title ?? null,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
function deriveDeltaScope(
|
|
250
|
+
cutoff: number,
|
|
251
|
+
hasCanonicalHistory: boolean,
|
|
252
|
+
): OverviewDeltaScope {
|
|
253
|
+
return hasCanonicalHistory
|
|
254
|
+
? {
|
|
255
|
+
mode: "shared-recent",
|
|
256
|
+
after: new Date(cutoff).toISOString(),
|
|
257
|
+
requested: null,
|
|
258
|
+
fallback: "first-visit",
|
|
259
|
+
}
|
|
260
|
+
: {
|
|
261
|
+
mode: "history-unavailable",
|
|
262
|
+
after: null,
|
|
263
|
+
requested: null,
|
|
264
|
+
fallback: "history-unavailable",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Derive the complete overview model once. Callers render this value as-is;
|
|
270
|
+
* selection and ordering do not belong in the CLI, API, or browser.
|
|
271
|
+
*/
|
|
272
|
+
export function deriveOverview(
|
|
273
|
+
bundle: Bundle,
|
|
274
|
+
db: Database,
|
|
275
|
+
options: OverviewOptions = {},
|
|
276
|
+
): OverviewModel {
|
|
277
|
+
const now = options.now ?? new Date();
|
|
278
|
+
const cutoff =
|
|
279
|
+
now.getTime() - OVERVIEW_ACTIVITY_WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
|
280
|
+
|
|
281
|
+
const tasks = bundle.workItems.filter((item) => item.fm.type === "Task");
|
|
282
|
+
const epics = bundle.workItems.filter((item) => item.fm.type === "Epic");
|
|
283
|
+
const epicByPath = new Map(epics.map((epic) => [epic.path, epic]));
|
|
284
|
+
|
|
285
|
+
const epicPathFor = (task: WorkItem): string | undefined => {
|
|
286
|
+
if (typeof task.fm.epic !== "string") return undefined;
|
|
287
|
+
const path = resolveLink(task.path, task.fm.epic);
|
|
288
|
+
return path && epicByPath.has(path) ? path : undefined;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const tasksByEpic = new Map<string, WorkItem[]>();
|
|
292
|
+
const looseTasks: WorkItem[] = [];
|
|
293
|
+
for (const task of tasks) {
|
|
294
|
+
const path = epicPathFor(task);
|
|
295
|
+
if (!path) {
|
|
296
|
+
looseTasks.push(task);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const children = tasksByEpic.get(path) ?? [];
|
|
300
|
+
children.push(task);
|
|
301
|
+
tasksByEpic.set(path, children);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const activityRows = db
|
|
305
|
+
.query("SELECT task_id AS taskId, sha, date FROM activity")
|
|
306
|
+
.all() as ActivityDateRow[];
|
|
307
|
+
const activityByTask = new Map<string, ActivityDateRow[]>();
|
|
308
|
+
for (const row of activityRows) {
|
|
309
|
+
const rows = activityByTask.get(row.taskId) ?? [];
|
|
310
|
+
rows.push(row);
|
|
311
|
+
activityByTask.set(row.taskId, rows);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const ready = readyWorkItems(bundle);
|
|
315
|
+
const readyPosition = new Map(
|
|
316
|
+
ready.map((task, index) => [task.fm.id, index] as const),
|
|
317
|
+
);
|
|
318
|
+
const nearHead = new Set(
|
|
319
|
+
ready.slice(0, OVERVIEW_READY_HEAD_LIMIT).map((task) => task.fm.id),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const groupFor = (members: WorkItem[]): OverviewGroup => {
|
|
323
|
+
const nowTasks = members
|
|
324
|
+
.filter((task) => task.fm.status === "in-progress")
|
|
325
|
+
.sort((a, z) => byManualOrder(a.fm, z.fm));
|
|
326
|
+
const readyMembers = ready.filter((task) => members.includes(task));
|
|
327
|
+
const nextTasks = readyMembers.slice(0, OVERVIEW_NEXT_LIMIT);
|
|
328
|
+
const dates = members.flatMap((task) => [
|
|
329
|
+
transitionDate(task),
|
|
330
|
+
...(activityByTask.get(task.fm.id) ?? []).map((row) => row.date),
|
|
331
|
+
]);
|
|
332
|
+
return {
|
|
333
|
+
now: nowTasks.map(taskRef),
|
|
334
|
+
next: nextTasks.map(taskRef),
|
|
335
|
+
nextTotal: readyMembers.length,
|
|
336
|
+
blockedOnly:
|
|
337
|
+
nowTasks.length === 0 &&
|
|
338
|
+
nextTasks.length === 0 &&
|
|
339
|
+
members.some((task) => task.fm.status === "blocked"),
|
|
340
|
+
lastActivity: newest(dates),
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const workstreams = epics.flatMap((epic): OverviewWorkstream[] => {
|
|
345
|
+
const members = tasksByEpic.get(epic.path) ?? [];
|
|
346
|
+
const group = groupFor(members);
|
|
347
|
+
const progress = {
|
|
348
|
+
done: members.filter((task) => task.fm.status === "done").length,
|
|
349
|
+
total: members.length,
|
|
350
|
+
};
|
|
351
|
+
const hasRecentCommit = members.some((task) =>
|
|
352
|
+
(activityByTask.get(task.fm.id) ?? []).some(
|
|
353
|
+
(row) => Date.parse(row.date) >= cutoff,
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
const hasNearHeadReady = members.some((task) => nearHead.has(task.fm.id));
|
|
357
|
+
if (group.now.length === 0 && !hasRecentCommit && !hasNearHeadReady)
|
|
358
|
+
return [];
|
|
359
|
+
return [
|
|
360
|
+
{
|
|
361
|
+
epic: epicRef(epic),
|
|
362
|
+
progress,
|
|
363
|
+
needsCleanup: epicNeedsCleanup(epic.fm.status, progress),
|
|
364
|
+
...group,
|
|
365
|
+
lastActivity: newest([
|
|
366
|
+
transitionDate(epic),
|
|
367
|
+
group.lastActivity ?? undefined,
|
|
368
|
+
]),
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
workstreams.sort((a, z) => {
|
|
374
|
+
const activity = (z.lastActivity ?? "").localeCompare(a.lastActivity ?? "");
|
|
375
|
+
if (activity !== 0) return activity;
|
|
376
|
+
const nextPosition = (stream: OverviewWorkstream): number =>
|
|
377
|
+
Math.min(
|
|
378
|
+
...stream.next.map(
|
|
379
|
+
(task) => readyPosition.get(task.id) ?? Number.POSITIVE_INFINITY,
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
const readyOrder = nextPosition(a) - nextPosition(z);
|
|
383
|
+
return Number.isNaN(readyOrder) || readyOrder === 0
|
|
384
|
+
? a.epic.id.localeCompare(z.epic.id)
|
|
385
|
+
: readyOrder;
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Admission above stays governed by the established activity and ready-head
|
|
389
|
+
// thresholds. Classification happens once, after ordering, so every surface
|
|
390
|
+
// receives the same mutually-exclusive current/recent-only groups.
|
|
391
|
+
const isCurrent = (stream: OverviewWorkstream): boolean =>
|
|
392
|
+
stream.now.length > 0 || stream.nextTotal > 0 || stream.blockedOnly;
|
|
393
|
+
const current = workstreams.filter(isCurrent);
|
|
394
|
+
const recentOnly = workstreams.filter((stream) => !isCurrent(stream));
|
|
395
|
+
|
|
396
|
+
const looseGroup = groupFor(looseTasks);
|
|
397
|
+
const looseHasRecentCommit = looseTasks.some((task) =>
|
|
398
|
+
(activityByTask.get(task.fm.id) ?? []).some(
|
|
399
|
+
(row) => Date.parse(row.date) >= cutoff,
|
|
400
|
+
),
|
|
401
|
+
);
|
|
402
|
+
const loose =
|
|
403
|
+
looseGroup.now.length > 0 ||
|
|
404
|
+
looseGroup.next.length > 0 ||
|
|
405
|
+
looseHasRecentCommit
|
|
406
|
+
? looseGroup
|
|
407
|
+
: null;
|
|
408
|
+
|
|
409
|
+
const movementDates = [
|
|
410
|
+
...tasks.map((task) => movementDate(task, activityByTask)),
|
|
411
|
+
...epics.map((item) => conceptTimestamp(item) ?? null),
|
|
412
|
+
...bundle.decisions.map((item) => conceptTimestamp(item) ?? null),
|
|
413
|
+
].filter((date): date is string => Boolean(date));
|
|
414
|
+
const checkpoint: OverviewCheckpoint = {
|
|
415
|
+
revision: options.checkpoint?.revision ?? null,
|
|
416
|
+
time: newest([options.checkpoint?.time ?? undefined, ...movementDates]),
|
|
417
|
+
};
|
|
418
|
+
const hasCanonicalHistory =
|
|
419
|
+
movementDates.length > 0 || options.historyAvailable === true;
|
|
420
|
+
const scope = deriveDeltaScope(cutoff, hasCanonicalHistory);
|
|
421
|
+
const scopeAfter = scope.after ? Date.parse(scope.after) : Number.NaN;
|
|
422
|
+
|
|
423
|
+
const taskEpic = (task: WorkItem): WorkItem | undefined => {
|
|
424
|
+
const path = epicPathFor(task);
|
|
425
|
+
return path ? epicByPath.get(path) : undefined;
|
|
426
|
+
};
|
|
427
|
+
const executionItem = (item: WorkItem): OverviewExecutionItem => {
|
|
428
|
+
const parent = item.fm.type === "Task" ? taskEpic(item) : undefined;
|
|
429
|
+
const authored =
|
|
430
|
+
item.fm.type === "Task" && item.fm.status === "done"
|
|
431
|
+
? plainSummary(item.outcome)
|
|
432
|
+
: undefined;
|
|
433
|
+
const summary =
|
|
434
|
+
authored ??
|
|
435
|
+
plainSummary(
|
|
436
|
+
typeof item.fm.description === "string"
|
|
437
|
+
? item.fm.description
|
|
438
|
+
: undefined,
|
|
439
|
+
) ??
|
|
440
|
+
item.fm.title ??
|
|
441
|
+
item.fm.id;
|
|
442
|
+
return {
|
|
443
|
+
...ref(item),
|
|
444
|
+
status: item.fm.status,
|
|
445
|
+
summary,
|
|
446
|
+
occurredAt:
|
|
447
|
+
item.fm.type === "Task"
|
|
448
|
+
? movementDate(item, activityByTask)
|
|
449
|
+
: (conceptTimestamp(item) ?? null),
|
|
450
|
+
supportingConcepts: parent ? [ref(parent)] : [],
|
|
451
|
+
};
|
|
452
|
+
};
|
|
453
|
+
const newestFirst = <T extends { occurredAt: string | null; id: string }>(
|
|
454
|
+
items: T[],
|
|
455
|
+
): T[] =>
|
|
456
|
+
items.sort(
|
|
457
|
+
(a, z) =>
|
|
458
|
+
(z.occurredAt ?? "").localeCompare(a.occurredAt ?? "") ||
|
|
459
|
+
a.id.localeCompare(z.id, undefined, { numeric: true }),
|
|
460
|
+
);
|
|
461
|
+
const bounded = <T>(items: T[]): T[] =>
|
|
462
|
+
items.slice(0, OVERVIEW_EXECUTION_GROUP_LIMIT);
|
|
463
|
+
const movedInScope = (item: WorkItem): boolean =>
|
|
464
|
+
Number.isFinite(scopeAfter) &&
|
|
465
|
+
inScope(
|
|
466
|
+
item.fm.type === "Task"
|
|
467
|
+
? movementDate(item, activityByTask)
|
|
468
|
+
: (conceptTimestamp(item) ?? null),
|
|
469
|
+
scopeAfter,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const shippedCandidates = tasks.filter((task) => task.fm.status === "done");
|
|
473
|
+
const shipped = bounded(
|
|
474
|
+
newestFirst(
|
|
475
|
+
(Number.isFinite(scopeAfter)
|
|
476
|
+
? shippedCandidates.filter(movedInScope)
|
|
477
|
+
: shippedCandidates
|
|
478
|
+
).map(executionItem),
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
const inFlight = bounded(
|
|
483
|
+
newestFirst(
|
|
484
|
+
tasks
|
|
485
|
+
.filter(
|
|
486
|
+
(task) =>
|
|
487
|
+
task.fm.status === "in-progress" || task.fm.status === "in-review",
|
|
488
|
+
)
|
|
489
|
+
.map(executionItem),
|
|
490
|
+
),
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
const executionUpNext = bounded(ready.map(executionItem));
|
|
494
|
+
const cleanupEpics = epics.filter((epic) => {
|
|
495
|
+
const members = tasksByEpic.get(epic.path) ?? [];
|
|
496
|
+
return epicNeedsCleanup(epic.fm.status, {
|
|
497
|
+
done: members.filter((task) => task.fm.status === "done").length,
|
|
498
|
+
total: members.length,
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
const needsAttention: OverviewAttentionItem[] = [
|
|
502
|
+
...tasks
|
|
503
|
+
.filter((task) => task.fm.status === "blocked")
|
|
504
|
+
.map((task) => ({ ...executionItem(task), reason: "blocked" as const })),
|
|
505
|
+
...tasks
|
|
506
|
+
.filter(
|
|
507
|
+
(task) =>
|
|
508
|
+
(task.fm.status === "in-progress" ||
|
|
509
|
+
task.fm.status === "in-review") &&
|
|
510
|
+
!inScope(movementDate(task, activityByTask), cutoff),
|
|
511
|
+
)
|
|
512
|
+
.map((task) => ({ ...executionItem(task), reason: "stale" as const })),
|
|
513
|
+
...cleanupEpics.map((epic) => ({
|
|
514
|
+
...executionItem(epic),
|
|
515
|
+
reason: "needs-cleanup" as const,
|
|
516
|
+
})),
|
|
517
|
+
];
|
|
518
|
+
|
|
519
|
+
const changeFor = (item: WorkItem): OverviewChangeKind => {
|
|
520
|
+
if (item.fm.type === "Epic") return "needs-cleanup";
|
|
521
|
+
if (item.fm.status === "done") return "completed";
|
|
522
|
+
if (item.fm.status === "closed") return "closed";
|
|
523
|
+
if (item.fm.status === "in-progress") return "started";
|
|
524
|
+
if (item.fm.status === "in-review") return "in-review";
|
|
525
|
+
if (item.fm.status === "blocked") return "blocked";
|
|
526
|
+
return "queued";
|
|
527
|
+
};
|
|
528
|
+
const changes = Number.isFinite(scopeAfter)
|
|
529
|
+
? bounded(
|
|
530
|
+
newestFirst(
|
|
531
|
+
[
|
|
532
|
+
...tasks.filter(movedInScope),
|
|
533
|
+
...cleanupEpics.filter(movedInScope),
|
|
534
|
+
].map((item) => ({
|
|
535
|
+
...executionItem(item),
|
|
536
|
+
change: changeFor(item),
|
|
537
|
+
})),
|
|
538
|
+
),
|
|
539
|
+
)
|
|
540
|
+
: [];
|
|
541
|
+
|
|
542
|
+
const curatedPaths = new Set(
|
|
543
|
+
(options.decisionLinks ?? []).map((path) => path.replace(/^\//, "")),
|
|
544
|
+
);
|
|
545
|
+
const decisions = bounded(
|
|
546
|
+
bundle.decisions
|
|
547
|
+
.filter((decision) => {
|
|
548
|
+
const curated = curatedPaths.has(decision.path);
|
|
549
|
+
return (
|
|
550
|
+
curated ||
|
|
551
|
+
(Number.isFinite(scopeAfter) &&
|
|
552
|
+
inScope(conceptTimestamp(decision) ?? null, scopeAfter))
|
|
553
|
+
);
|
|
554
|
+
})
|
|
555
|
+
.map(
|
|
556
|
+
(decision): OverviewDecision => ({
|
|
557
|
+
...ref(decision),
|
|
558
|
+
status: decision.fm.status,
|
|
559
|
+
choice:
|
|
560
|
+
plainSummary(decision.decision) ??
|
|
561
|
+
plainSummary(decision.fm.description) ??
|
|
562
|
+
decision.fm.title ??
|
|
563
|
+
decision.fm.id,
|
|
564
|
+
rationale: plainSummary(decision.context) ?? null,
|
|
565
|
+
consequence: plainSummary(decision.consequences) ?? null,
|
|
566
|
+
occurredAt: conceptTimestamp(decision) ?? null,
|
|
567
|
+
curated: curatedPaths.has(decision.path),
|
|
568
|
+
}),
|
|
569
|
+
)
|
|
570
|
+
.sort(
|
|
571
|
+
(a, z) =>
|
|
572
|
+
Number(z.curated) - Number(a.curated) ||
|
|
573
|
+
(z.occurredAt ?? "").localeCompare(a.occurredAt ?? "") ||
|
|
574
|
+
a.id.localeCompare(z.id, undefined, { numeric: true }),
|
|
575
|
+
),
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
upNext: ready[0] ? taskRef(ready[0]) : null,
|
|
580
|
+
workstreams: { current, recentOnly },
|
|
581
|
+
loose,
|
|
582
|
+
execution: {
|
|
583
|
+
checkpoint,
|
|
584
|
+
scope,
|
|
585
|
+
shipped,
|
|
586
|
+
inFlight,
|
|
587
|
+
upNext: executionUpNext,
|
|
588
|
+
needsAttention: bounded(newestFirst(needsAttention)),
|
|
589
|
+
changes,
|
|
590
|
+
decisions,
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
}
|
package/src/packet.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The context packet: the compact, deterministic subset of the graph
|
|
2
|
+
// a fresh session needs to begin work on a task — frontmatter + body, the
|
|
3
|
+
// epic, dependency statuses, one-hop linked concepts, and the task's commit
|
|
4
|
+
// trail. Everything derives from the bundle plus git activity the caller
|
|
5
|
+
// supplies; no new state.
|
|
6
|
+
|
|
7
|
+
import type { Bundle } from "./bundle";
|
|
8
|
+
import type { FileStore } from "./filestore";
|
|
9
|
+
import { resolveLink } from "./lint";
|
|
10
|
+
import type { WorkItemFrontmatter } from "./schema";
|
|
11
|
+
|
|
12
|
+
/** A commit carrying the task's trailer; the caller derives these from git. */
|
|
13
|
+
export interface CommitRef {
|
|
14
|
+
sha: string;
|
|
15
|
+
date: string;
|
|
16
|
+
subject: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PacketDep {
|
|
20
|
+
id: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
/** Undefined when the id resolves to nothing — lint flags that separately. */
|
|
23
|
+
status?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One-hop linked concept: enough to decide whether to open it, not its body. */
|
|
27
|
+
export interface PacketLink {
|
|
28
|
+
path: string;
|
|
29
|
+
type: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
status?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ContextPacket {
|
|
36
|
+
/** Canonical host-neutral title intent for the current agent session. */
|
|
37
|
+
suggestedSessionTitle: string;
|
|
38
|
+
task: { path: string; fm: WorkItemFrontmatter; body: string };
|
|
39
|
+
epic?: { path: string; id?: string; title?: string; status?: string };
|
|
40
|
+
deps: PacketDep[];
|
|
41
|
+
linked: PacketLink[];
|
|
42
|
+
commits: CommitRef[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the packet for a work item. `commits` is the task's trailer-matched
|
|
47
|
+
* history, newest first — callers with a repo get it from `scanActivity`
|
|
48
|
+
* (`@gitdocket/core/cache`); repo-less callers pass `[]`.
|
|
49
|
+
*/
|
|
50
|
+
export async function buildContextPacket(
|
|
51
|
+
store: FileStore,
|
|
52
|
+
bundle: Bundle,
|
|
53
|
+
id: string,
|
|
54
|
+
commits: CommitRef[] = [],
|
|
55
|
+
): Promise<ContextPacket> {
|
|
56
|
+
const item = bundle.byId(id);
|
|
57
|
+
if (item?.kind !== "work") throw new Error(`no work item with id ${id}`);
|
|
58
|
+
|
|
59
|
+
const source = await store.read(item.path);
|
|
60
|
+
const body = source.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
|
|
61
|
+
|
|
62
|
+
const conceptAt = (path: string | undefined) =>
|
|
63
|
+
path ? bundle.concepts.find((c) => c.path === path) : undefined;
|
|
64
|
+
const str = (v: unknown): string | undefined =>
|
|
65
|
+
typeof v === "string" ? v : undefined;
|
|
66
|
+
|
|
67
|
+
const epicPath =
|
|
68
|
+
typeof item.fm.epic === "string"
|
|
69
|
+
? resolveLink(item.path, item.fm.epic)
|
|
70
|
+
: undefined;
|
|
71
|
+
const epicConcept = conceptAt(epicPath);
|
|
72
|
+
const epic =
|
|
73
|
+
epicPath && epicConcept
|
|
74
|
+
? {
|
|
75
|
+
path: epicPath,
|
|
76
|
+
id: str(epicConcept.fm.id),
|
|
77
|
+
title: epicConcept.fm.title,
|
|
78
|
+
status: str(epicConcept.fm.status),
|
|
79
|
+
}
|
|
80
|
+
: undefined;
|
|
81
|
+
|
|
82
|
+
const deps: PacketDep[] = item.fm.depends_on.map((depId) => {
|
|
83
|
+
const dep = bundle.byId(depId);
|
|
84
|
+
return { id: depId, title: dep?.fm.title, status: dep?.fm.status };
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// One-hop targets from the body, minus what the packet already carries
|
|
88
|
+
// as structure (the task itself, its epic, its dependencies).
|
|
89
|
+
const skip = new Set([item.path, epicPath]);
|
|
90
|
+
for (const dep of item.fm.depends_on) {
|
|
91
|
+
const target = bundle.byId(dep);
|
|
92
|
+
if (target) skip.add(target.path);
|
|
93
|
+
}
|
|
94
|
+
const linked: PacketLink[] = [];
|
|
95
|
+
for (const link of item.links) {
|
|
96
|
+
if (!link.internal) continue;
|
|
97
|
+
const resolved = resolveLink(item.path, link.target);
|
|
98
|
+
if (!resolved || skip.has(resolved)) continue;
|
|
99
|
+
const concept = conceptAt(resolved);
|
|
100
|
+
if (!concept) continue;
|
|
101
|
+
skip.add(resolved); // dedupe repeat links
|
|
102
|
+
linked.push({
|
|
103
|
+
path: resolved,
|
|
104
|
+
type: concept.fm.type,
|
|
105
|
+
title: concept.fm.title,
|
|
106
|
+
description: concept.fm.description,
|
|
107
|
+
status: str(concept.fm.status),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
suggestedSessionTitle: `${item.fm.id} — ${item.fm.title ?? ""}`,
|
|
113
|
+
task: { path: item.path, fm: item.fm, body },
|
|
114
|
+
epic,
|
|
115
|
+
deps,
|
|
116
|
+
linked,
|
|
117
|
+
commits,
|
|
118
|
+
};
|
|
119
|
+
}
|