@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/src/lint.ts ADDED
@@ -0,0 +1,240 @@
1
+ // PM 101 encoded as lint, not UI. Errors are profile conformance
2
+ // per the spec's Conformance section; warnings are practice. Core stays
3
+ // git-free: callers with a repo (the CLI) compute git-derived inputs like
4
+ // the trailerless-commit count and pass them in.
5
+
6
+ import type { Bundle } from "./bundle";
7
+ import type { FileStore } from "./filestore";
8
+ import { type Diagnostic, isReserved } from "./parse";
9
+ import {
10
+ parseStateOfPlay,
11
+ presentStateOfPlay,
12
+ STATE_OF_PLAY_PATH,
13
+ } from "./state-of-play";
14
+ import type { VerifyMarker } from "./verify";
15
+
16
+ export interface LintOptions {
17
+ now?: Date;
18
+ /** Staleness threshold for in-flight statuses and the Freshness watermark. */
19
+ maxAgeDays?: number;
20
+ /** Commits without a Task trailer since the watermark — computed by callers with git. */
21
+ trailerlessCommits?: number;
22
+ /** Task-linked commits after overview.md's as_of watermark, computed by Git-aware callers. */
23
+ stateOfPlayCommitsAgo?: number;
24
+ /**
25
+ * docket:verifies markers scanned by callers with repo access.
26
+ * The epic's only lint rule: an unresolvable target warns; "spec is
27
+ * unverified" deliberately does not — that lives in `verify status`.
28
+ */
29
+ verifyMarkers?: VerifyMarker[];
30
+ }
31
+
32
+ export interface FreshnessWatermark {
33
+ sha: string;
34
+ /** The `## YYYY-MM-DD` section the watermark entry sits under. */
35
+ date?: string;
36
+ }
37
+
38
+ /**
39
+ * Latest `**Freshness** … reviewed through <sha>` entry in log.md
40
+ * (newest-first, so first match wins). Prose may sit between the marker and
41
+ * the sha — init's baseline stamp reads "baseline at adoption; reviewed
42
+ * through `<sha>`".
43
+ */
44
+ export function findFreshnessWatermark(
45
+ logSource: string,
46
+ ): FreshnessWatermark | undefined {
47
+ let date: string | undefined;
48
+ for (const line of logSource.split("\n")) {
49
+ const heading = line.match(/^## (\d{4}-\d{2}-\d{2})/);
50
+ if (heading) {
51
+ date = heading[1];
52
+ continue;
53
+ }
54
+ const mark = line.match(
55
+ /\*\*Freshness\*\*.*reviewed through `?([0-9a-f]{7,40})`?/,
56
+ );
57
+ if (mark?.[1]) return { sha: mark[1], date };
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /** Resolve an internal link against the bundle root; undefined = not checkable (non-md, escapes bundle). */
63
+ export function resolveLink(
64
+ fromPath: string,
65
+ target: string,
66
+ ): string | undefined {
67
+ const clean = target.split("#")[0] ?? "";
68
+ if (!clean.endsWith(".md")) return undefined;
69
+ const parts = clean.startsWith("/")
70
+ ? clean.slice(1).split("/")
71
+ : [...fromPath.split("/").slice(0, -1), ...clean.split("/")];
72
+ const out: string[] = [];
73
+ for (const part of parts) {
74
+ if (part === "" || part === ".") continue;
75
+ if (part === "..") {
76
+ if (out.length === 0) return undefined;
77
+ out.pop();
78
+ } else out.push(part);
79
+ }
80
+ return out.join("/");
81
+ }
82
+
83
+ export async function lintBundle(
84
+ store: FileStore,
85
+ bundle: Bundle,
86
+ opts: LintOptions = {},
87
+ ): Promise<Diagnostic[]> {
88
+ const now = opts.now ?? new Date();
89
+ const maxAgeDays = opts.maxAgeDays ?? 14;
90
+ const out: Diagnostic[] = [...bundle.diagnostics];
91
+ const files = new Set(await store.list());
92
+ const report = (severity: Diagnostic["severity"]) => {
93
+ return (path: string, message: string) =>
94
+ out.push({ path, message, severity });
95
+ };
96
+ const error = report("error");
97
+ const warn = report("warning");
98
+ const ageDays = (iso: string): number =>
99
+ (now.getTime() - new Date(iso).getTime()) / 86_400_000;
100
+
101
+ for (const item of bundle.workItems) {
102
+ // Conformance: every depends_on entry resolves to an existing task id.
103
+ for (const dep of item.fm.depends_on) {
104
+ if (bundle.byId(dep)?.kind !== "work")
105
+ error(item.path, `depends_on ${dep} does not resolve to a work item`);
106
+ }
107
+
108
+ const filename = item.path.split("/").at(-1) ?? item.path;
109
+ if (!filename.startsWith(`${item.fm.id}-`))
110
+ warn(
111
+ item.path,
112
+ `filename does not start with ${item.fm.id}- (slug drift?)`,
113
+ );
114
+
115
+ if (item.fm.type === "Epic" && !item.fm.spec)
116
+ warn(item.path, "epic has no spec link");
117
+
118
+ if (
119
+ item.fm.status === "done" &&
120
+ /^\s*- \[ \]/m.test(await store.read(item.path))
121
+ )
122
+ warn(item.path, "done but has unchecked criteria");
123
+
124
+ if (item.fm.status === "in-progress" || item.fm.status === "in-review") {
125
+ const ts = item.fm.timestamp;
126
+ if (typeof ts === "string" && ageDays(ts) > maxAgeDays)
127
+ warn(
128
+ item.path,
129
+ `${item.fm.status} but untouched for ${Math.floor(ageDays(ts))} days — stale?`,
130
+ );
131
+ }
132
+ }
133
+
134
+ for (const concept of bundle.concepts) {
135
+ for (const link of concept.links) {
136
+ if (!link.internal) continue;
137
+ const resolved = resolveLink(concept.path, link.target);
138
+ if (resolved && !files.has(resolved))
139
+ warn(concept.path, `broken link: ${link.target}`);
140
+ }
141
+ }
142
+
143
+ // overview.md is optional. The linked re-entry note expires on either
144
+ // substantial task-linked movement or review age. Superseded formats stay
145
+ // readable and are accepted non-destructively but ask for a linked refresh.
146
+ if (files.has(STATE_OF_PLAY_PATH)) {
147
+ const parsed = parseStateOfPlay(await store.read(STATE_OF_PLAY_PATH));
148
+ out.push(...parsed.diagnostics);
149
+ if (parsed.note) {
150
+ const view = presentStateOfPlay(parsed.note, opts.stateOfPlayCommitsAgo, {
151
+ now,
152
+ });
153
+ if (view.review.status === "needs-review") {
154
+ const why = [
155
+ ...(view.review.reasons.includes("legacy-format")
156
+ ? ["legacy prose format"]
157
+ : []),
158
+ ...(view.review.reasons.includes("superseded-format")
159
+ ? ["superseded re-entry/v1 format"]
160
+ : []),
161
+ ...(view.review.reasons.includes("evidence-moved")
162
+ ? [`${view.taskCommitsAgo} task-linked commits behind ${view.asOf}`]
163
+ : []),
164
+ ...(view.review.reasons.includes("review-expired")
165
+ ? [`reviewed ${view.review.reviewedDaysAgo} days ago`]
166
+ : []),
167
+ ].join(", ");
168
+ warn(
169
+ STATE_OF_PLAY_PATH,
170
+ `product context needs review (${why}) — run the docket-state-of-play workflow`,
171
+ );
172
+ }
173
+ }
174
+ }
175
+
176
+ // Reserved files (index.md, log.md) aren't concepts, but the hand-maintained
177
+ // index is exactly where links rot — check them with a light regex pass.
178
+ for (const path of files) {
179
+ if (!isReserved(path)) continue;
180
+ const source = await store.read(path);
181
+ for (const match of source.matchAll(/\]\(([^)\s]+)\)/g)) {
182
+ const target = match[1] ?? "";
183
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target)) continue; // external
184
+ const resolved = resolveLink(path, target);
185
+ if (resolved && !files.has(resolved))
186
+ warn(path, `broken link: ${target}`);
187
+ }
188
+ }
189
+
190
+ // Stray merge-conflict markers: an ordinary git merge — or a
191
+ // docket upgrade conflict left unresolved — can land markers in committed
192
+ // files, and upgrade re-runs report up-to-date once origin is bumped, so
193
+ // lint is the recurring nag. Requires all three marker lines at line
194
+ // starts; a fenced code block quoting a complete conflict still trips this
195
+ // (accepted — quote partial markers instead).
196
+ for (const path of files) {
197
+ const source = await store.read(path);
198
+ if (
199
+ /^<{7} /m.test(source) &&
200
+ /^={7}$/m.test(source) &&
201
+ /^>{7} /m.test(source)
202
+ )
203
+ warn(path, "merge-conflict markers present — resolve and remove them");
204
+ }
205
+
206
+ // Verification markers: the target must name an existing concept
207
+ // by bundle-absolute path. Marker sources live outside the bundle, so the
208
+ // diagnostic path is repo-relative.
209
+ for (const m of opts.verifyMarkers ?? []) {
210
+ if (!m.spec || !files.has(m.spec))
211
+ warn(
212
+ m.source,
213
+ `docket:verifies target does not resolve: ${m.target} (line ${m.line})`,
214
+ );
215
+ }
216
+
217
+ // Freshness nag — only for bundles that keep a log.md at all.
218
+ if (files.has("log.md")) {
219
+ const watermark = findFreshnessWatermark(await store.read("log.md"));
220
+ if (!watermark) {
221
+ warn(
222
+ "log.md",
223
+ "no **Freshness** watermark — run the docket-freshness workflow",
224
+ );
225
+ } else {
226
+ if (watermark.date && ageDays(watermark.date) > maxAgeDays)
227
+ warn(
228
+ "log.md",
229
+ `Freshness watermark is ${Math.floor(ageDays(watermark.date))} days old — run the docket-freshness workflow`,
230
+ );
231
+ if ((opts.trailerlessCommits ?? 0) > 0)
232
+ warn(
233
+ "log.md",
234
+ `${opts.trailerlessCommits} trailerless work commit(s) since Freshness watermark ${watermark.sha} (tracker-only chore(docket) commits exempt) — run /docket-freshness`,
235
+ );
236
+ }
237
+ }
238
+
239
+ return out;
240
+ }
package/src/ops.ts ADDED
@@ -0,0 +1,320 @@
1
+ // Write operations. Every surface (CLI, MCP, web, App) calls these — there is
2
+ // exactly one write path. Mutations are targeted line edits inside the
3
+ // frontmatter block, never a full YAML re-serialize, so hand-authored
4
+ // formatting and comments survive.
5
+
6
+ import { stringify as stringifyYaml } from "yaml";
7
+ import { type Bundle, loadBundle } from "./bundle";
8
+ import type { DocketConfig } from "./config";
9
+ import type { FileStore } from "./filestore";
10
+ import type { WorkItemIdCoordinator } from "./id-allocation";
11
+ import { resolveLink } from "./lint";
12
+ import {
13
+ canTransition,
14
+ isPriority,
15
+ isStatus,
16
+ type Priority,
17
+ type Status,
18
+ type WorkItemType,
19
+ } from "./states";
20
+
21
+ export interface CreateInput {
22
+ title: string;
23
+ type?: WorkItemType;
24
+ description?: string;
25
+ epic?: string;
26
+ dependsOn?: string[];
27
+ priority?: Priority;
28
+ rank?: number;
29
+ assignee?: string;
30
+ tags?: string[];
31
+ slug?: string;
32
+ }
33
+
34
+ export function slugify(title: string): string {
35
+ return (
36
+ title
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9]+/g, "-")
39
+ .replace(/^-+|-+$/g, "")
40
+ .slice(0, 40)
41
+ .replace(/-+$/g, "") || "item"
42
+ );
43
+ }
44
+
45
+ /** Next work-item number: max over ids matching `<project>-<n>`, plus one. */
46
+ export function nextId(
47
+ bundle: Bundle,
48
+ knownIds: ReadonlySet<string> = new Set(),
49
+ ): string {
50
+ const pattern = new RegExp(`^${bundle.config.project}-(\\d+)$`);
51
+ let max = 0;
52
+ for (const id of [
53
+ ...bundle.workItems.map((item) => item.fm.id),
54
+ ...knownIds,
55
+ ]) {
56
+ const match = id.match(pattern);
57
+ if (match?.[1]) max = Math.max(max, Number(match[1]));
58
+ }
59
+ return `${bundle.config.project}-${max + 1}`;
60
+ }
61
+
62
+ const yamlLine = (key: string, value: unknown): string =>
63
+ stringifyYaml({ [key]: value }).trimEnd();
64
+
65
+ export async function createWorkItem(
66
+ store: FileStore,
67
+ config: DocketConfig,
68
+ input: CreateInput,
69
+ coordinator?: WorkItemIdCoordinator,
70
+ ): Promise<{ id: string; path: string }> {
71
+ const create = async (
72
+ knownIds: ReadonlySet<string>,
73
+ ): Promise<{ id: string; path: string }> => {
74
+ // Load inside the coordination boundary: another caller may have created
75
+ // an item while this process waited for the shared lock.
76
+ const bundle = await loadBundle(store, config);
77
+ const id = nextId(bundle, knownIds);
78
+ if (bundle.byId(id) || knownIds.has(id))
79
+ throw new Error(`id collision on ${id} — bundle has duplicate ids?`);
80
+
81
+ const type = input.type ?? "Task";
82
+ const slug = input.slug ?? slugify(input.title);
83
+ const dir = type === "Epic" ? "work/epics" : "work/tasks";
84
+ const path = `${dir}/${id}-${slug}.md`;
85
+
86
+ const lines = [
87
+ yamlLine("type", type),
88
+ yamlLine("title", input.title),
89
+ ...(input.description
90
+ ? [yamlLine("description", input.description)]
91
+ : []),
92
+ yamlLine("id", id),
93
+ yamlLine("status", "todo"),
94
+ ...(input.epic ? [yamlLine("epic", input.epic)] : []),
95
+ ...(input.dependsOn?.length
96
+ ? [`depends_on: [${input.dependsOn.join(", ")}]`]
97
+ : []),
98
+ yamlLine("priority", input.priority ?? "p2"),
99
+ ...(input.rank !== undefined ? [yamlLine("rank", input.rank)] : []),
100
+ ...(input.assignee ? [yamlLine("assignee", input.assignee)] : []),
101
+ ...(input.tags?.length ? [`tags: [${input.tags.join(", ")}]`] : []),
102
+ yamlLine("timestamp", new Date().toISOString().replace(/\.\d{3}Z$/, "Z")),
103
+ ];
104
+
105
+ const context = input.epic
106
+ ? `See [epic](${input.epic}).`
107
+ : "(links to specs/docs here)";
108
+ const body = `# Context\n\n${context}\n\n# Acceptance Criteria\n\n- [ ] …\n`;
109
+
110
+ await store.write(path, `---\n${lines.join("\n")}\n---\n\n${body}`);
111
+ return { id, path };
112
+ };
113
+
114
+ return coordinator
115
+ ? coordinator.allocate(config.project, create)
116
+ : create(new Set());
117
+ }
118
+
119
+ function splitFrontmatter(source: string): { fm: string; rest: string } {
120
+ const match = source.match(/^---\n([\s\S]*?)\n---\n?/);
121
+ if (!match) throw new Error("file has no frontmatter block");
122
+ return { fm: match[0], rest: source.slice(match[0].length) };
123
+ }
124
+
125
+ export async function setStatus(
126
+ store: FileStore,
127
+ config: DocketConfig,
128
+ id: string,
129
+ to: string,
130
+ opts: { note?: string } = {},
131
+ ): Promise<{ id: string; path: string; from: Status; to: Status }> {
132
+ if (!isStatus(to)) throw new Error(`unknown status "${to}"`);
133
+ if (to === "closed" && !opts.note?.trim()) {
134
+ throw new Error("closing without completion requires a disposition note");
135
+ }
136
+ const bundle = await loadBundle(store, config);
137
+ const item = bundle.byId(id);
138
+ if (item?.kind !== "work") throw new Error(`no work item with id ${id}`);
139
+
140
+ const from = item.fm.status;
141
+ if (from === to) throw new Error(`${item.fm.id} is already ${to}`);
142
+ if (!canTransition(from, to)) {
143
+ throw new Error(`invalid transition ${from} → ${to} for ${item.fm.id}`);
144
+ }
145
+
146
+ const source = await store.read(item.path);
147
+ const { fm, rest } = splitFrontmatter(source);
148
+ let updated = fm.replace(/^status:.*$/m, `status: ${to}`);
149
+ const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
150
+ if (/^timestamp:.*$/m.test(updated)) {
151
+ updated = updated.replace(/^timestamp:.*$/m, `timestamp: ${stamp}`);
152
+ }
153
+ await store.write(item.path, updated + rest);
154
+
155
+ if (opts.note?.trim())
156
+ await appendLog(store, config, item.fm.id, opts.note.trim());
157
+ return { id: item.fm.id, path: item.path, from, to };
158
+ }
159
+
160
+ // Replace (or remove, line = null) a frontmatter field in place; when the
161
+ // field is absent, insert after the first matching anchor so the result keeps
162
+ // the field order createWorkItem writes. Anchored fields are single-line in
163
+ // CLI-shape files; block-style lists are skipped as anchors on purpose.
164
+ function upsertFmLine(
165
+ fm: string,
166
+ key: string,
167
+ line: string | null,
168
+ anchors: readonly RegExp[],
169
+ ): string {
170
+ const existing = new RegExp(`^${key}:.*$`, "m");
171
+ if (existing.test(fm)) {
172
+ if (line !== null) return fm.replace(existing, line);
173
+ return fm.replace(new RegExp(`^${key}:.*\\n`, "m"), "");
174
+ }
175
+ if (line === null) return fm;
176
+ for (const anchor of anchors) {
177
+ const match = fm.match(anchor);
178
+ if (match?.index !== undefined) {
179
+ const at = match.index + match[0].length;
180
+ return `${fm.slice(0, at)}\n${line}${fm.slice(at)}`;
181
+ }
182
+ }
183
+ throw new Error(`no anchor line to place ${key}: after`);
184
+ }
185
+
186
+ export async function setPriority(
187
+ store: FileStore,
188
+ config: DocketConfig,
189
+ id: string,
190
+ to: string,
191
+ ): Promise<{ id: string; path: string; from: Priority; to: Priority }> {
192
+ if (!isPriority(to)) throw new Error(`unknown priority "${to}"`);
193
+ const bundle = await loadBundle(store, config);
194
+ const item = bundle.byId(id);
195
+ if (item?.kind !== "work") throw new Error(`no work item with id ${id}`);
196
+
197
+ const from = item.fm.priority ?? "p2";
198
+ if (from === to) throw new Error(`${item.fm.id} is already ${to}`);
199
+
200
+ const source = await store.read(item.path);
201
+ const { fm, rest } = splitFrontmatter(source);
202
+ // No timestamp bump: timestamp marks status transitions (the epic lists
203
+ // order on it); a priority tweak shouldn't reshuffle those.
204
+ const updated = upsertFmLine(fm, "priority", yamlLine("priority", to), [
205
+ /^depends_on: \[.*$/m,
206
+ /^epic:.*$/m,
207
+ /^status:.*$/m,
208
+ ]);
209
+ await store.write(item.path, updated + rest);
210
+ return { id: item.fm.id, path: item.path, from, to };
211
+ }
212
+
213
+ // Rank is the manual within-lane order: one global number per task,
214
+ // lower first, decimals allowed so an insert between neighbors takes the
215
+ // midpoint and touches only the moved task's file. Unranked sorts last.
216
+ export async function setRank(
217
+ store: FileStore,
218
+ config: DocketConfig,
219
+ id: string,
220
+ to: number | null,
221
+ ): Promise<{
222
+ id: string;
223
+ path: string;
224
+ from: number | null;
225
+ to: number | null;
226
+ }> {
227
+ if (to !== null && !Number.isFinite(to))
228
+ throw new Error(`rank must be a finite number, got ${to}`);
229
+ const bundle = await loadBundle(store, config);
230
+ const item = bundle.byId(id);
231
+ if (item?.kind !== "work") throw new Error(`no work item with id ${id}`);
232
+ if (item.fm.type === "Epic")
233
+ throw new Error(`${item.fm.id} is an epic — rank orders tasks`);
234
+
235
+ const from = item.fm.rank ?? null;
236
+ if (from === to)
237
+ throw new Error(`${item.fm.id} rank is already ${to ?? "unset"}`);
238
+
239
+ const source = await store.read(item.path);
240
+ const { fm, rest } = splitFrontmatter(source);
241
+ // No timestamp bump — same reasoning as priority: reordering a lane
242
+ // shouldn't reshuffle the activity-ordered lists.
243
+ const updated = upsertFmLine(
244
+ fm,
245
+ "rank",
246
+ to === null ? null : yamlLine("rank", to),
247
+ [/^priority:.*$/m, /^depends_on: \[.*$/m, /^epic:.*$/m, /^status:.*$/m],
248
+ );
249
+ await store.write(item.path, updated + rest);
250
+ return { id: item.fm.id, path: item.path, from, to };
251
+ }
252
+
253
+ export async function setEpic(
254
+ store: FileStore,
255
+ config: DocketConfig,
256
+ id: string,
257
+ to: string | null,
258
+ ): Promise<{
259
+ id: string;
260
+ path: string;
261
+ from: string | null;
262
+ to: string | null;
263
+ }> {
264
+ const bundle = await loadBundle(store, config);
265
+ const item = bundle.byId(id);
266
+ if (item?.kind !== "work") throw new Error(`no work item with id ${id}`);
267
+ if (item.fm.type === "Epic")
268
+ throw new Error(`${item.fm.id} is an epic — epics don't nest`);
269
+
270
+ let link: string | null = null;
271
+ if (to) {
272
+ link = to.startsWith("/") ? to : `/${to}`;
273
+ const resolved = resolveLink(item.path, link);
274
+ const target = resolved
275
+ ? bundle.concepts.find((c) => c.path === resolved)
276
+ : undefined;
277
+ if (target?.kind !== "work" || target.fm.type !== "Epic")
278
+ throw new Error(`no epic at ${link}`);
279
+ }
280
+
281
+ const from = typeof item.fm.epic === "string" ? item.fm.epic : null;
282
+ if (from === link)
283
+ throw new Error(`${item.fm.id} epic is already ${link ?? "unset"}`);
284
+
285
+ const source = await store.read(item.path);
286
+ const { fm, rest } = splitFrontmatter(source);
287
+ const updated = upsertFmLine(
288
+ fm,
289
+ "epic",
290
+ link === null ? null : yamlLine("epic", link),
291
+ [/^status:.*$/m],
292
+ );
293
+ await store.write(item.path, updated + rest);
294
+ return { id: item.fm.id, path: item.path, from, to: link };
295
+ }
296
+
297
+ /** Insert a dated entry directly under `# Log` (newest first), creating the section if needed. */
298
+ export async function appendLog(
299
+ store: FileStore,
300
+ config: DocketConfig,
301
+ id: string,
302
+ entry: string,
303
+ ): Promise<{ path: string }> {
304
+ const bundle = await loadBundle(store, config);
305
+ const item = bundle.byId(id);
306
+ if (!item) throw new Error(`no item with id ${id}`);
307
+
308
+ const date = new Date().toISOString().slice(0, 10);
309
+ const line = `**${date}** — ${entry}`;
310
+ const source = await store.read(item.path);
311
+
312
+ // Consume the blank lines after the heading and re-emit them around the new
313
+ // entry, so consecutive entries stay separated by exactly one blank line.
314
+ const updated = /^# Log\s*$/m.test(source)
315
+ ? `${source.replace(/^# Log[ \t]*\n*/m, `# Log\n\n${line}\n\n`).trimEnd()}\n`
316
+ : `${source.trimEnd()}\n\n# Log\n\n${line}\n`;
317
+
318
+ await store.write(item.path, updated);
319
+ return { path: item.path };
320
+ }
@@ -0,0 +1,88 @@
1
+ // Repository orientation is one bounded, read-only derivation shared by the
2
+ // CLI and MCP. It intentionally reads only the bundle, the optional committed
3
+ // product checkpoint, and task-linked Git activity; it never writes cache or
4
+ // bundle state.
5
+
6
+ import { Database } from "bun:sqlite";
7
+ import type { Bundle } from "./bundle";
8
+ import {
9
+ buildCache,
10
+ type GitEvidence,
11
+ scanActivity,
12
+ scanGitEvidence,
13
+ taskLinkedCommitsSince,
14
+ } from "./cache";
15
+ import type { DocketConfig } from "./config";
16
+ import type { FileStore } from "./filestore";
17
+ import { deriveOverview, type OverviewModel } from "./overview";
18
+ import {
19
+ parseStateOfPlay,
20
+ presentStateOfPlay,
21
+ REENTRY_CONTEXT_FORMAT,
22
+ REENTRY_CONTEXT_V1_FORMAT,
23
+ STATE_OF_PLAY_PATH,
24
+ type StateOfPlayView,
25
+ } from "./state-of-play";
26
+
27
+ export type RepositoryOverview = OverviewModel & {
28
+ narrative?: StateOfPlayView;
29
+ git: GitEvidence;
30
+ };
31
+
32
+ export interface RepositoryOverviewInput {
33
+ bundle: Bundle;
34
+ config: DocketConfig;
35
+ store: FileStore;
36
+ /** Omit outside a Git-backed repository; the derived task selection remains valid. */
37
+ root?: string;
38
+ }
39
+
40
+ export async function deriveRepositoryOverview({
41
+ bundle,
42
+ config,
43
+ store,
44
+ root,
45
+ }: RepositoryOverviewInput): Promise<RepositoryOverview> {
46
+ const db = new Database(":memory:");
47
+ try {
48
+ const git = root
49
+ ? scanGitEvidence(root, config.git.trailer, bundle.byId)
50
+ : {
51
+ status: "history-unavailable" as const,
52
+ checkpoint: null,
53
+ activity: [],
54
+ unmergedActivity: [],
55
+ worktrees: [],
56
+ truncated: false,
57
+ reason: "repository root was not provided",
58
+ };
59
+ buildCache(
60
+ db,
61
+ bundle,
62
+ root ? scanActivity(root, config.git.trailer, bundle.byId) : [],
63
+ );
64
+ const source = await store.read(STATE_OF_PLAY_PATH).catch(() => undefined);
65
+ const note = source ? parseStateOfPlay(source).note : undefined;
66
+ const model = deriveOverview(bundle, db, {
67
+ checkpoint: git.checkpoint ?? undefined,
68
+ historyAvailable: git.status === "available",
69
+ decisionLinks:
70
+ note?.format === REENTRY_CONTEXT_FORMAT
71
+ ? note.decisionLinks
72
+ : note?.format === REENTRY_CONTEXT_V1_FORMAT
73
+ ? note.assessment.decisionLinks
74
+ : undefined,
75
+ });
76
+ const narrative = note
77
+ ? presentStateOfPlay(
78
+ note,
79
+ root
80
+ ? taskLinkedCommitsSince(root, config.git.trailer, note.asOf)
81
+ : undefined,
82
+ )
83
+ : undefined;
84
+ return narrative ? { narrative, ...model, git } : { ...model, git };
85
+ } finally {
86
+ db.close();
87
+ }
88
+ }