@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.
@@ -0,0 +1,63 @@
1
+ // FileStore abstracts where a bundle's files live. Local filesystem now;
2
+ // a GitHub Git Data API implementation later lets the hosted App operate
3
+ // without ever cloning a repo.
4
+
5
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+
8
+ export interface FileStore {
9
+ /** Relative paths (posix separators) of every .md file under the root, sorted. */
10
+ list(): Promise<string[]>;
11
+ read(path: string): Promise<string>;
12
+ write(path: string, content: string): Promise<void>;
13
+ }
14
+
15
+ export class LocalFileStore implements FileStore {
16
+ constructor(readonly root: string) {}
17
+
18
+ async list(): Promise<string[]> {
19
+ const out: string[] = [];
20
+ const walk = async (rel: string): Promise<void> => {
21
+ const entries = await readdir(join(this.root, rel), {
22
+ withFileTypes: true,
23
+ });
24
+ for (const entry of entries) {
25
+ if (entry.name.startsWith(".")) continue;
26
+ const relPath = rel === "" ? entry.name : `${rel}/${entry.name}`;
27
+ if (entry.isDirectory()) await walk(relPath);
28
+ else if (entry.name.endsWith(".md")) out.push(relPath);
29
+ }
30
+ };
31
+ await walk("");
32
+ return out.sort();
33
+ }
34
+
35
+ read(path: string): Promise<string> {
36
+ return readFile(join(this.root, path), "utf8");
37
+ }
38
+
39
+ async write(path: string, content: string): Promise<void> {
40
+ const abs = join(this.root, path);
41
+ await mkdir(dirname(abs), { recursive: true });
42
+ await writeFile(abs, content, "utf8");
43
+ }
44
+ }
45
+
46
+ /** Test double and future in-process cache seed. */
47
+ export class InMemoryFileStore implements FileStore {
48
+ constructor(readonly files = new Map<string, string>()) {}
49
+
50
+ async list(): Promise<string[]> {
51
+ return [...this.files.keys()].filter((p) => p.endsWith(".md")).sort();
52
+ }
53
+
54
+ async read(path: string): Promise<string> {
55
+ const content = this.files.get(path);
56
+ if (content === undefined) throw new Error(`not found: ${path}`);
57
+ return content;
58
+ }
59
+
60
+ async write(path: string, content: string): Promise<void> {
61
+ this.files.set(path, content);
62
+ }
63
+ }
@@ -0,0 +1,288 @@
1
+ // Optional local-Git coordination for sequential work-item creation. The core
2
+ // create path accepts this boundary explicitly so in-memory, filesystem-only,
3
+ // and future hosted stores keep deterministic max+1 behavior without needing
4
+ // a system Git binary.
5
+
6
+ import { execFile } from "node:child_process";
7
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
8
+ import { hostname } from "node:os";
9
+ import { isAbsolute, join, resolve } from "node:path";
10
+ import { promisify } from "node:util";
11
+ import { parseConfig } from "./config";
12
+ import { LocalFileStore } from "./filestore";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ export interface WorkItemIdCoordinator {
17
+ allocate<T>(
18
+ project: string,
19
+ create: (knownIds: ReadonlySet<string>) => Promise<T>,
20
+ ): Promise<T>;
21
+ }
22
+
23
+ export interface GitWorktreeIdCoordinatorOptions {
24
+ lockTimeoutMs?: number;
25
+ staleLockMs?: number;
26
+ retryDelayMs?: number;
27
+ }
28
+
29
+ const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
30
+ const DEFAULT_STALE_LOCK_MS = 5 * 60_000;
31
+ const DEFAULT_RETRY_DELAY_MS = 25;
32
+ const LOCK_DIRECTORY = "docket/id-allocation.lock";
33
+
34
+ const sleep = (ms: number): Promise<void> =>
35
+ new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
36
+
37
+ const errorCode = (error: unknown): string | undefined =>
38
+ typeof error === "object" && error !== null && "code" in error
39
+ ? String((error as { code?: unknown }).code)
40
+ : undefined;
41
+
42
+ async function git(
43
+ repoRoot: string,
44
+ args: string[],
45
+ ): Promise<{ stdout: string; stderr: string }> {
46
+ return execFileAsync("git", ["-C", repoRoot, ...args], {
47
+ encoding: "utf8",
48
+ maxBuffer: 16 * 1024 * 1024,
49
+ });
50
+ }
51
+
52
+ async function gitCommonDirectory(repoRoot: string): Promise<string | null> {
53
+ try {
54
+ const { stdout } = await git(repoRoot, ["rev-parse", "--git-common-dir"]);
55
+ const value = stdout.trim();
56
+ if (!value) throw new Error("git returned an empty common directory");
57
+ return isAbsolute(value) ? value : resolve(repoRoot, value);
58
+ } catch (error) {
59
+ const message = error instanceof Error ? error.message : String(error);
60
+ if (/not a git repository/i.test(message)) return null;
61
+ throw new Error(`cannot resolve Git common directory: ${message}`);
62
+ }
63
+ }
64
+
65
+ async function linkedWorktrees(repoRoot: string): Promise<string[]> {
66
+ const { stdout } = await git(repoRoot, [
67
+ "worktree",
68
+ "list",
69
+ "--porcelain",
70
+ "-z",
71
+ ]);
72
+ const paths = stdout
73
+ .split("\0")
74
+ .filter((line) => line.startsWith("worktree "))
75
+ .map((line) => line.slice("worktree ".length));
76
+ return [...new Set(paths)];
77
+ }
78
+
79
+ function readableWorkItemId(source: string): string | null {
80
+ const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
81
+ if (!frontmatter?.[1]) return null;
82
+ const id = frontmatter[1].match(/^id:\s*['"]?([^\s'"#]+)['"]?\s*(?:#.*)?$/m);
83
+ return id?.[1] ?? null;
84
+ }
85
+
86
+ async function idsInWorktree(
87
+ worktree: string,
88
+ project: string,
89
+ ): Promise<string[]> {
90
+ let configSource: string;
91
+ try {
92
+ configSource = await readFile(join(worktree, "docket.yaml"), "utf8");
93
+ } catch (error) {
94
+ if (errorCode(error) === "ENOENT") {
95
+ try {
96
+ await stat(worktree);
97
+ } catch (worktreeError) {
98
+ if (errorCode(worktreeError) === "ENOENT") {
99
+ throw new Error(
100
+ `linked worktree disappeared during ID allocation: ${worktree}; prune or restore it, then retry`,
101
+ );
102
+ }
103
+ throw worktreeError;
104
+ }
105
+ // A live worktree from before Docket adoption does not participate in
106
+ // this repository's work-item namespace.
107
+ return [];
108
+ }
109
+ throw error;
110
+ }
111
+
112
+ const config = parseConfig(configSource);
113
+ const store = new LocalFileStore(resolve(worktree, config.bundle));
114
+ let paths: string[];
115
+ try {
116
+ paths = await store.list();
117
+ } catch (error) {
118
+ if (errorCode(error) === "ENOENT") {
119
+ throw new Error(
120
+ `cannot scan configured bundle ${config.bundle} in linked worktree ${worktree}; restore it or remove the stale worktree`,
121
+ );
122
+ }
123
+ throw error;
124
+ }
125
+
126
+ const ids: string[] = [];
127
+ for (const path of paths) {
128
+ if (!/^work\/(?:tasks|epics)\/.+\.md$/.test(path)) continue;
129
+ const source = await store.read(path).catch((error: unknown) => {
130
+ throw new Error(
131
+ `cannot read linked work item ${join(worktree, config.bundle, path)}: ${error instanceof Error ? error.message : String(error)}`,
132
+ );
133
+ });
134
+ const id = readableWorkItemId(source);
135
+ if (!id) {
136
+ throw new Error(
137
+ `cannot allocate an ID while linked work item ${join(worktree, config.bundle, path)} has no readable frontmatter id`,
138
+ );
139
+ }
140
+ if (id.startsWith(`${project}-`)) ids.push(id);
141
+ }
142
+ return ids;
143
+ }
144
+
145
+ async function repositoryIds(
146
+ repoRoot: string,
147
+ project: string,
148
+ ): Promise<Set<string>> {
149
+ const ids = new Set<string>();
150
+ for (const worktree of await linkedWorktrees(repoRoot)) {
151
+ for (const id of await idsInWorktree(worktree, project)) ids.add(id);
152
+ }
153
+ return ids;
154
+ }
155
+
156
+ interface LockOwner {
157
+ pid?: number;
158
+ host?: string;
159
+ startedAt?: string;
160
+ }
161
+
162
+ async function readOwner(lockDirectory: string): Promise<LockOwner | null> {
163
+ try {
164
+ return JSON.parse(
165
+ await readFile(join(lockDirectory, "owner.json"), "utf8"),
166
+ ) as LockOwner;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ function processIsAlive(pid: number): boolean {
173
+ try {
174
+ process.kill(pid, 0);
175
+ return true;
176
+ } catch (error) {
177
+ return errorCode(error) === "EPERM";
178
+ }
179
+ }
180
+
181
+ async function lockIsStale(
182
+ lockDirectory: string,
183
+ staleLockMs: number,
184
+ ): Promise<boolean> {
185
+ const owner = await readOwner(lockDirectory);
186
+ if (
187
+ owner?.host === hostname() &&
188
+ typeof owner.pid === "number" &&
189
+ Number.isInteger(owner.pid)
190
+ ) {
191
+ return !processIsAlive(owner.pid);
192
+ }
193
+ const metadata = await stat(lockDirectory);
194
+ return Date.now() - metadata.mtimeMs >= staleLockMs;
195
+ }
196
+
197
+ async function acquireLock(
198
+ commonDirectory: string,
199
+ options: Required<GitWorktreeIdCoordinatorOptions>,
200
+ ): Promise<string> {
201
+ const parent = join(commonDirectory, "docket");
202
+ const lockDirectory = join(commonDirectory, LOCK_DIRECTORY);
203
+ await mkdir(parent, { recursive: true });
204
+ const started = Date.now();
205
+
206
+ for (;;) {
207
+ try {
208
+ await mkdir(lockDirectory);
209
+ await writeFile(
210
+ join(lockDirectory, "owner.json"),
211
+ `${JSON.stringify({
212
+ pid: process.pid,
213
+ host: hostname(),
214
+ startedAt: new Date().toISOString(),
215
+ })}\n`,
216
+ "utf8",
217
+ );
218
+ return lockDirectory;
219
+ } catch (error) {
220
+ if (errorCode(error) !== "EEXIST") throw error;
221
+
222
+ if (
223
+ await lockIsStale(lockDirectory, options.staleLockMs).catch(() => true)
224
+ ) {
225
+ const staleDirectory = `${lockDirectory}.stale-${process.pid}-${Date.now()}`;
226
+ try {
227
+ await rename(lockDirectory, staleDirectory);
228
+ await rm(staleDirectory, { recursive: true, force: true });
229
+ continue;
230
+ } catch (recoveryError) {
231
+ if (
232
+ errorCode(recoveryError) === "ENOENT" ||
233
+ errorCode(recoveryError) === "EEXIST"
234
+ ) {
235
+ continue;
236
+ }
237
+ throw new Error(
238
+ `cannot recover stale Docket ID-allocation lock ${lockDirectory}: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`,
239
+ );
240
+ }
241
+ }
242
+
243
+ if (Date.now() - started >= options.lockTimeoutMs) {
244
+ const owner = await readOwner(lockDirectory);
245
+ throw new Error(
246
+ `timed out after ${options.lockTimeoutMs}ms waiting for Docket ID-allocation lock ${lockDirectory}${owner ? ` (owner ${owner.host ?? "unknown"}:${owner.pid ?? "unknown"}, started ${owner.startedAt ?? "unknown"})` : ""}`,
247
+ );
248
+ }
249
+ await sleep(options.retryDelayMs);
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Same-repository coordinator for LocalFileStore creation. The lock lives in
256
+ * Git's common directory, so every linked worktree uses one allocation
257
+ * boundary. The scan reads each live worktree's own docket.yaml and working
258
+ * files, which includes staged, unstaged, and untracked concepts.
259
+ */
260
+ export class GitWorktreeIdCoordinator implements WorkItemIdCoordinator {
261
+ private readonly options: Required<GitWorktreeIdCoordinatorOptions>;
262
+
263
+ constructor(
264
+ private readonly repoRoot: string,
265
+ options: GitWorktreeIdCoordinatorOptions = {},
266
+ ) {
267
+ this.options = {
268
+ lockTimeoutMs: options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS,
269
+ staleLockMs: options.staleLockMs ?? DEFAULT_STALE_LOCK_MS,
270
+ retryDelayMs: options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
271
+ };
272
+ }
273
+
274
+ async allocate<T>(
275
+ project: string,
276
+ create: (knownIds: ReadonlySet<string>) => Promise<T>,
277
+ ): Promise<T> {
278
+ const commonDirectory = await gitCommonDirectory(this.repoRoot);
279
+ if (!commonDirectory) return create(new Set());
280
+
281
+ const lockDirectory = await acquireLock(commonDirectory, this.options);
282
+ try {
283
+ return await create(await repositoryIds(this.repoRoot, project));
284
+ } finally {
285
+ await rm(lockDirectory, { recursive: true, force: true });
286
+ }
287
+ }
288
+ }
package/src/index.ts ADDED
@@ -0,0 +1,199 @@
1
+ // @gitdocket/core — the engine. Every surface (CLI, web, MCP, App) is a thin
2
+ // client over this library; there is exactly one write path.
3
+
4
+ export {
5
+ type Bundle,
6
+ findRepoRoot,
7
+ loadBundle,
8
+ loadRepo,
9
+ readyWorkItems,
10
+ } from "./bundle";
11
+ export {
12
+ CONFIG_FILENAME,
13
+ DEFAULT_BUNDLE,
14
+ type DocketConfig,
15
+ parseConfig,
16
+ } from "./config";
17
+ export {
18
+ ENGINE_SEMANTICS,
19
+ READY_QUEUE_DESCRIPTION,
20
+ } from "./engine-semantics";
21
+ export { type FileStore, InMemoryFileStore, LocalFileStore } from "./filestore";
22
+ export {
23
+ GitWorktreeIdCoordinator,
24
+ type GitWorktreeIdCoordinatorOptions,
25
+ type WorkItemIdCoordinator,
26
+ } from "./id-allocation";
27
+ export { applyIndex, INDEX_MARKER, renderIndex } from "./indexmd";
28
+ export {
29
+ ALLOW_RULES,
30
+ composeFreshnessBaseline,
31
+ composeHook,
32
+ defaultConfigYaml,
33
+ deriveProjectKey,
34
+ ensureGitignore,
35
+ type InitAction,
36
+ type InitResult,
37
+ mergeClaudeSettings,
38
+ mergeCodexConfig,
39
+ mergeMcpJson,
40
+ needsFrontmatter,
41
+ proposeType,
42
+ scaffoldFiles,
43
+ upgradeCodexConfig,
44
+ upgradeHookBlock,
45
+ } from "./init";
46
+ export {
47
+ AGENT_INTENT_DISAMBIGUATION,
48
+ AGENT_INTENT_IDS,
49
+ AGENT_INTENTS,
50
+ type AgentIntentContract,
51
+ type AgentIntentId,
52
+ agentIntent,
53
+ DIRECT_WORK_INTENT,
54
+ DIRECT_WORK_INTENT_ID,
55
+ DOCKET_INTENT_DISAMBIGUATION,
56
+ DOCKET_INTENT_IDS,
57
+ DOCKET_INTENTS,
58
+ type DocketIntentContract,
59
+ type DocketIntentId,
60
+ docketIntent,
61
+ type IntentEntrypoint,
62
+ type IntentMode,
63
+ PICKUP_AUTHORITY_EVIDENCE,
64
+ } from "./intents";
65
+ export {
66
+ type FreshnessWatermark,
67
+ findFreshnessWatermark,
68
+ type LintOptions,
69
+ lintBundle,
70
+ resolveLink,
71
+ } from "./lint";
72
+ export {
73
+ appendLog,
74
+ type CreateInput,
75
+ createWorkItem,
76
+ nextId,
77
+ setEpic,
78
+ setPriority,
79
+ setRank,
80
+ setStatus,
81
+ slugify,
82
+ } from "./ops";
83
+ export {
84
+ buildContextPacket,
85
+ type CommitRef,
86
+ type ContextPacket,
87
+ type PacketDep,
88
+ type PacketLink,
89
+ } from "./packet";
90
+ export {
91
+ type Concept,
92
+ type Decision,
93
+ type Diagnostic,
94
+ type GenericConcept,
95
+ isReserved,
96
+ type Link,
97
+ parseConcept,
98
+ type WorkItem,
99
+ } from "./parse";
100
+ export {
101
+ type IntentDiscoveryDiagnostic,
102
+ type IntentDiscoveryDiagnosticCode,
103
+ PROMPT_ROUTING_FIXTURES,
104
+ type PromptRoutingDiagnostic,
105
+ type PromptRoutingDiagnosticCode,
106
+ type PromptRoutingFixture,
107
+ type PromptRoutingTrait,
108
+ validateIntentDiscoveryDescriptions,
109
+ validatePromptRoutingFixtures,
110
+ } from "./prompt-routing";
111
+ export {
112
+ buildSchemas,
113
+ type DecisionFrontmatter,
114
+ type GenericFrontmatter,
115
+ type Schemas,
116
+ type WorkItemFrontmatter,
117
+ } from "./schema";
118
+ export { type SearchHit, searchBundle } from "./search";
119
+ export {
120
+ formatOrigin,
121
+ type Origin,
122
+ parseOrigin,
123
+ recoverOrigin,
124
+ type ShippedHistory,
125
+ shippedHistory,
126
+ shippedWorkflow,
127
+ } from "./shipped";
128
+ export {
129
+ type LegacyStateOfPlayNote,
130
+ parseStateOfPlay,
131
+ presentStateOfPlay,
132
+ REENTRY_CONTEXT_FORMAT,
133
+ REENTRY_CONTEXT_V1_FORMAT,
134
+ type ReentryAssessment,
135
+ type ReentryContextNote,
136
+ type ReentryV1ContextNote,
137
+ STATE_OF_PLAY_PATH,
138
+ STATE_OF_PLAY_REVIEW_MAX_DAYS,
139
+ STATE_OF_PLAY_STALE_COMMITS,
140
+ type StateOfPlayNote,
141
+ type StateOfPlayParseResult,
142
+ type StateOfPlayPresentationOptions,
143
+ type StateOfPlayReview,
144
+ type StateOfPlayReviewReason,
145
+ type StateOfPlayView,
146
+ } from "./state-of-play";
147
+ export {
148
+ byManualOrder,
149
+ canTransition,
150
+ DECISION_STATES,
151
+ type DecisionStatus,
152
+ isPriority,
153
+ isReady,
154
+ isStatus,
155
+ isTerminalStatus,
156
+ PRIORITIES,
157
+ type Priority,
158
+ STATES,
159
+ type Status,
160
+ TERMINAL_STATES,
161
+ TRANSITIONS,
162
+ WORK_ITEM_TYPES,
163
+ type WorkItemType,
164
+ } from "./states";
165
+ export {
166
+ type Merge3,
167
+ markerVersion,
168
+ type UpgradeAction,
169
+ type UpgradeResult,
170
+ upgradeAdapter,
171
+ upgradeWorkflowFile,
172
+ } from "./upgrade";
173
+ export {
174
+ resolveVerifyMarkers,
175
+ scanVerifyMarkers,
176
+ VERIFY_TOKEN,
177
+ type VerifyMarker,
178
+ type VerifySource,
179
+ type VerifyStatusRow,
180
+ verifyStatus,
181
+ } from "./verify";
182
+ export { DOCKET_VERSION } from "./version";
183
+ export {
184
+ ADAPTER_MARKER,
185
+ composeManagedSection,
186
+ DOCKET_WORKFLOWS,
187
+ hasAdapterMarker,
188
+ hasDocketSection,
189
+ renderAgentSkillStub,
190
+ renderClaudeSkillStub,
191
+ renderDocketSection,
192
+ renderWorkflow,
193
+ validateWorkflowSemantics,
194
+ WORKFLOWS_DIR,
195
+ type WorkflowDef,
196
+ type WorkflowSemantic,
197
+ type WorkflowSemanticDiagnostic,
198
+ workflowPath,
199
+ } from "./workflows";
package/src/indexmd.ts ADDED
@@ -0,0 +1,146 @@
1
+ // Generated index.md: derived content is never hand-maintained.
2
+ // Everything above the marker is human preamble and survives regeneration;
3
+ // everything below is machine-owned and rendered deterministically from the
4
+ // bundle (lockfile pattern — committed, and CI fails on drift).
5
+
6
+ import type { Bundle } from "./bundle";
7
+ import type { GenericConcept, WorkItem } from "./parse";
8
+ import { isTerminalStatus } from "./states";
9
+
10
+ export const INDEX_MARKER = "<!-- docket:generated -->";
11
+
12
+ const idNum = (id: string): number => Number(id.match(/(\d+)$/)?.[1] ?? 0);
13
+ const byId = (a: { fm: { id: string } }, b: { fm: { id: string } }): number =>
14
+ idNum(a.fm.id) - idNum(b.fm.id);
15
+
16
+ const link = (text: string, path: string): string => `[${text}](/${path})`;
17
+
18
+ const taskLine = (t: WorkItem, ready: ReadonlySet<string>): string => {
19
+ const text = link(`${t.fm.id} — ${t.fm.title ?? t.fm.id}`, t.path);
20
+ if (t.fm.status === "done") return `- ✅ ${text}`;
21
+ if (t.fm.status === "closed") return `- ⏹️ ${text} *(closed)*`;
22
+ if (t.fm.status === "in-progress" || t.fm.status === "in-review")
23
+ return `- 🔄 ${text}`;
24
+ if (t.fm.status === "blocked") return `- 🚫 ${text}`;
25
+ return ready.has(t.fm.id) ? `- ${text} *(ready)*` : `- ${text}`;
26
+ };
27
+
28
+ const tsOf = (t: WorkItem): string =>
29
+ typeof t.fm.timestamp === "string" ? t.fm.timestamp : "";
30
+
31
+ /**
32
+ * Liveness order: what's moving, then what could move, then the
33
+ * queue, then terminal history newest-first (timestamp is the transition time; ISO
34
+ * strings compare chronologically). File-derived and deterministic: no
35
+ * wall-clock input, so regeneration without a bundle change never diffs.
36
+ */
37
+ export function byLiveness(
38
+ ready: ReadonlySet<string>,
39
+ ): (a: WorkItem, b: WorkItem) => number {
40
+ const rank = (t: WorkItem): number => {
41
+ if (t.fm.status === "in-progress" || t.fm.status === "in-review") return 0;
42
+ if (ready.has(t.fm.id)) return 1;
43
+ if (isTerminalStatus(t.fm.status)) return 3;
44
+ return 2; // todo (unready) + blocked
45
+ };
46
+ return (a, b) => {
47
+ const byRank = rank(a) - rank(b);
48
+ if (byRank !== 0) return byRank;
49
+ if (rank(a) === 3) {
50
+ const byClose = tsOf(b).localeCompare(tsOf(a));
51
+ if (byClose !== 0) return byClose;
52
+ }
53
+ return byId(a, b);
54
+ };
55
+ }
56
+
57
+ /** The machine-owned body: generic concepts by directory, decisions, work by epic. */
58
+ export function renderIndex(bundle: Bundle): string {
59
+ const sections: string[] = [];
60
+
61
+ const groups = new Map<string, GenericConcept[]>();
62
+ for (const c of bundle.concepts) {
63
+ if (c.kind !== "generic") continue;
64
+ const dir = c.path.includes("/") ? (c.path.split("/")[0] ?? "") : "";
65
+ const group = groups.get(dir) ?? [];
66
+ group.push(c);
67
+ groups.set(dir, group);
68
+ }
69
+ for (const [dir, items] of [...groups.entries()].sort()) {
70
+ const name = dir ? dir[0]?.toUpperCase() + dir.slice(1) : "Concepts";
71
+ const lines = items
72
+ .sort((a, z) => a.path.localeCompare(z.path))
73
+ .map(
74
+ (c) =>
75
+ `- ${link(c.fm.title ?? c.path, c.path)}${c.fm.description ? ` — ${c.fm.description}` : ""}`,
76
+ );
77
+ sections.push(`## ${name}\n\n${lines.join("\n")}`);
78
+ }
79
+
80
+ if (bundle.decisions.length > 0) {
81
+ const lines = [...bundle.decisions].sort(byId).map((d) => {
82
+ const text = link(`${d.fm.id} — ${d.fm.title ?? d.fm.id}`, d.path);
83
+ return d.fm.status === "superseded"
84
+ ? `- ~~${text}~~ *(superseded)*`
85
+ : `- ${text}`;
86
+ });
87
+ sections.push(`## Decisions\n\n${lines.join("\n")}`);
88
+ }
89
+
90
+ const ready = new Set(bundle.readyIds());
91
+ const epics = bundle.workItems.filter((w) => w.fm.type === "Epic").sort(byId);
92
+ const tasks = bundle.workItems.filter((w) => w.fm.type === "Task").sort(byId);
93
+ if (epics.length > 0 || tasks.length > 0) {
94
+ const work: string[] = ["## Work"];
95
+ const claimed = new Set<string>();
96
+ const order = byLiveness(ready);
97
+ for (const epic of epics) {
98
+ const own = tasks.filter((t) => t.fm.epic?.includes(`/${epic.fm.id}-`));
99
+ for (const t of own) claimed.add(t.fm.id);
100
+ // Fully-done epics collapse to their rollup: the history lives
101
+ // in git and on the epic page; the index is orientation, not archive.
102
+ const finished =
103
+ epic.fm.status === "done" &&
104
+ own.length > 0 &&
105
+ own.every((t) => t.fm.status === "done");
106
+ const head = `### ${link(epic.fm.title ?? epic.fm.id, epic.path)} *(${epic.fm.status})*`;
107
+ if (finished) {
108
+ work.push(`${head}\n\n✅ all ${own.length} tasks done`);
109
+ continue;
110
+ }
111
+ work.push(head);
112
+ if (own.length > 0)
113
+ work.push(
114
+ own
115
+ .sort(order)
116
+ .map((t) => taskLine(t, ready))
117
+ .join("\n"),
118
+ );
119
+ }
120
+ const orphans = tasks.filter((t) => !claimed.has(t.fm.id));
121
+ if (orphans.length > 0) {
122
+ work.push("### No epic");
123
+ work.push(
124
+ orphans
125
+ .sort(order)
126
+ .map((t) => taskLine(t, ready))
127
+ .join("\n"),
128
+ );
129
+ }
130
+ sections.push(work.join("\n\n"));
131
+ }
132
+
133
+ return sections.join("\n\n");
134
+ }
135
+
136
+ /** Splice the generated body below the marker, preserving the human preamble above it. */
137
+ export function applyIndex(current: string, body: string): string {
138
+ const at = current.indexOf(INDEX_MARKER);
139
+ const preamble =
140
+ at >= 0
141
+ ? current.slice(0, at)
142
+ : current.trim()
143
+ ? `${current.trimEnd()}\n\n`
144
+ : "# Index\n\n";
145
+ return `${preamble}${INDEX_MARKER}\n\n${body}\n`;
146
+ }