@gitdocket/core 0.0.0 → 0.1.0
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 +268 -0
- package/src/config.ts +96 -0
- package/src/engine-semantics.ts +26 -0
- package/src/filestore.ts +63 -0
- package/src/index.ts +194 -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 +300 -0
- package/src/orientation.ts +76 -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 +40 -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/ops.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
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 { resolveLink } from "./lint";
|
|
11
|
+
import {
|
|
12
|
+
canTransition,
|
|
13
|
+
isPriority,
|
|
14
|
+
isStatus,
|
|
15
|
+
type Priority,
|
|
16
|
+
type Status,
|
|
17
|
+
type WorkItemType,
|
|
18
|
+
} from "./states";
|
|
19
|
+
|
|
20
|
+
export interface CreateInput {
|
|
21
|
+
title: string;
|
|
22
|
+
type?: WorkItemType;
|
|
23
|
+
description?: string;
|
|
24
|
+
epic?: string;
|
|
25
|
+
dependsOn?: string[];
|
|
26
|
+
priority?: Priority;
|
|
27
|
+
rank?: number;
|
|
28
|
+
assignee?: string;
|
|
29
|
+
tags?: string[];
|
|
30
|
+
slug?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function slugify(title: string): string {
|
|
34
|
+
return (
|
|
35
|
+
title
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
38
|
+
.replace(/^-+|-+$/g, "")
|
|
39
|
+
.slice(0, 40)
|
|
40
|
+
.replace(/-+$/g, "") || "item"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Next work-item number: max over ids matching `<project>-<n>`, plus one. */
|
|
45
|
+
export function nextId(bundle: Bundle): string {
|
|
46
|
+
const pattern = new RegExp(`^${bundle.config.project}-(\\d+)$`);
|
|
47
|
+
let max = 0;
|
|
48
|
+
for (const item of bundle.workItems) {
|
|
49
|
+
const match = item.fm.id.match(pattern);
|
|
50
|
+
if (match?.[1]) max = Math.max(max, Number(match[1]));
|
|
51
|
+
}
|
|
52
|
+
return `${bundle.config.project}-${max + 1}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const yamlLine = (key: string, value: unknown): string =>
|
|
56
|
+
stringifyYaml({ [key]: value }).trimEnd();
|
|
57
|
+
|
|
58
|
+
export async function createWorkItem(
|
|
59
|
+
store: FileStore,
|
|
60
|
+
config: DocketConfig,
|
|
61
|
+
input: CreateInput,
|
|
62
|
+
): Promise<{ id: string; path: string }> {
|
|
63
|
+
const bundle = await loadBundle(store, config);
|
|
64
|
+
const id = nextId(bundle);
|
|
65
|
+
if (bundle.byId(id))
|
|
66
|
+
throw new Error(`id collision on ${id} — bundle has duplicate ids?`);
|
|
67
|
+
|
|
68
|
+
const type = input.type ?? "Task";
|
|
69
|
+
const slug = input.slug ?? slugify(input.title);
|
|
70
|
+
const dir = type === "Epic" ? "work/epics" : "work/tasks";
|
|
71
|
+
const path = `${dir}/${id}-${slug}.md`;
|
|
72
|
+
|
|
73
|
+
const lines = [
|
|
74
|
+
yamlLine("type", type),
|
|
75
|
+
yamlLine("title", input.title),
|
|
76
|
+
...(input.description ? [yamlLine("description", input.description)] : []),
|
|
77
|
+
yamlLine("id", id),
|
|
78
|
+
yamlLine("status", "todo"),
|
|
79
|
+
...(input.epic ? [yamlLine("epic", input.epic)] : []),
|
|
80
|
+
...(input.dependsOn?.length
|
|
81
|
+
? [`depends_on: [${input.dependsOn.join(", ")}]`]
|
|
82
|
+
: []),
|
|
83
|
+
yamlLine("priority", input.priority ?? "p2"),
|
|
84
|
+
...(input.rank !== undefined ? [yamlLine("rank", input.rank)] : []),
|
|
85
|
+
...(input.assignee ? [yamlLine("assignee", input.assignee)] : []),
|
|
86
|
+
...(input.tags?.length ? [`tags: [${input.tags.join(", ")}]`] : []),
|
|
87
|
+
yamlLine("timestamp", new Date().toISOString().replace(/\.\d{3}Z$/, "Z")),
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const context = input.epic
|
|
91
|
+
? `See [epic](${input.epic}).`
|
|
92
|
+
: "(links to specs/docs here)";
|
|
93
|
+
const body = `# Context\n\n${context}\n\n# Acceptance Criteria\n\n- [ ] …\n`;
|
|
94
|
+
|
|
95
|
+
await store.write(path, `---\n${lines.join("\n")}\n---\n\n${body}`);
|
|
96
|
+
return { id, path };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function splitFrontmatter(source: string): { fm: string; rest: string } {
|
|
100
|
+
const match = source.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
101
|
+
if (!match) throw new Error("file has no frontmatter block");
|
|
102
|
+
return { fm: match[0], rest: source.slice(match[0].length) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function setStatus(
|
|
106
|
+
store: FileStore,
|
|
107
|
+
config: DocketConfig,
|
|
108
|
+
id: string,
|
|
109
|
+
to: string,
|
|
110
|
+
opts: { note?: string } = {},
|
|
111
|
+
): Promise<{ id: string; path: string; from: Status; to: Status }> {
|
|
112
|
+
if (!isStatus(to)) throw new Error(`unknown status "${to}"`);
|
|
113
|
+
if (to === "closed" && !opts.note?.trim()) {
|
|
114
|
+
throw new Error("closing without completion requires a disposition note");
|
|
115
|
+
}
|
|
116
|
+
const bundle = await loadBundle(store, config);
|
|
117
|
+
const item = bundle.byId(id);
|
|
118
|
+
if (item?.kind !== "work") throw new Error(`no work item with id ${id}`);
|
|
119
|
+
|
|
120
|
+
const from = item.fm.status;
|
|
121
|
+
if (from === to) throw new Error(`${item.fm.id} is already ${to}`);
|
|
122
|
+
if (!canTransition(from, to)) {
|
|
123
|
+
throw new Error(`invalid transition ${from} → ${to} for ${item.fm.id}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const source = await store.read(item.path);
|
|
127
|
+
const { fm, rest } = splitFrontmatter(source);
|
|
128
|
+
let updated = fm.replace(/^status:.*$/m, `status: ${to}`);
|
|
129
|
+
const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
130
|
+
if (/^timestamp:.*$/m.test(updated)) {
|
|
131
|
+
updated = updated.replace(/^timestamp:.*$/m, `timestamp: ${stamp}`);
|
|
132
|
+
}
|
|
133
|
+
await store.write(item.path, updated + rest);
|
|
134
|
+
|
|
135
|
+
if (opts.note?.trim())
|
|
136
|
+
await appendLog(store, config, item.fm.id, opts.note.trim());
|
|
137
|
+
return { id: item.fm.id, path: item.path, from, to };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Replace (or remove, line = null) a frontmatter field in place; when the
|
|
141
|
+
// field is absent, insert after the first matching anchor so the result keeps
|
|
142
|
+
// the field order createWorkItem writes. Anchored fields are single-line in
|
|
143
|
+
// CLI-shape files; block-style lists are skipped as anchors on purpose.
|
|
144
|
+
function upsertFmLine(
|
|
145
|
+
fm: string,
|
|
146
|
+
key: string,
|
|
147
|
+
line: string | null,
|
|
148
|
+
anchors: readonly RegExp[],
|
|
149
|
+
): string {
|
|
150
|
+
const existing = new RegExp(`^${key}:.*$`, "m");
|
|
151
|
+
if (existing.test(fm)) {
|
|
152
|
+
if (line !== null) return fm.replace(existing, line);
|
|
153
|
+
return fm.replace(new RegExp(`^${key}:.*\\n`, "m"), "");
|
|
154
|
+
}
|
|
155
|
+
if (line === null) return fm;
|
|
156
|
+
for (const anchor of anchors) {
|
|
157
|
+
const match = fm.match(anchor);
|
|
158
|
+
if (match?.index !== undefined) {
|
|
159
|
+
const at = match.index + match[0].length;
|
|
160
|
+
return `${fm.slice(0, at)}\n${line}${fm.slice(at)}`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
throw new Error(`no anchor line to place ${key}: after`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function setPriority(
|
|
167
|
+
store: FileStore,
|
|
168
|
+
config: DocketConfig,
|
|
169
|
+
id: string,
|
|
170
|
+
to: string,
|
|
171
|
+
): Promise<{ id: string; path: string; from: Priority; to: Priority }> {
|
|
172
|
+
if (!isPriority(to)) throw new Error(`unknown priority "${to}"`);
|
|
173
|
+
const bundle = await loadBundle(store, config);
|
|
174
|
+
const item = bundle.byId(id);
|
|
175
|
+
if (item?.kind !== "work") throw new Error(`no work item with id ${id}`);
|
|
176
|
+
|
|
177
|
+
const from = item.fm.priority ?? "p2";
|
|
178
|
+
if (from === to) throw new Error(`${item.fm.id} is already ${to}`);
|
|
179
|
+
|
|
180
|
+
const source = await store.read(item.path);
|
|
181
|
+
const { fm, rest } = splitFrontmatter(source);
|
|
182
|
+
// No timestamp bump: timestamp marks status transitions (the epic lists
|
|
183
|
+
// order on it); a priority tweak shouldn't reshuffle those.
|
|
184
|
+
const updated = upsertFmLine(fm, "priority", yamlLine("priority", to), [
|
|
185
|
+
/^depends_on: \[.*$/m,
|
|
186
|
+
/^epic:.*$/m,
|
|
187
|
+
/^status:.*$/m,
|
|
188
|
+
]);
|
|
189
|
+
await store.write(item.path, updated + rest);
|
|
190
|
+
return { id: item.fm.id, path: item.path, from, to };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Rank is the manual within-lane order: one global number per task,
|
|
194
|
+
// lower first, decimals allowed so an insert between neighbors takes the
|
|
195
|
+
// midpoint and touches only the moved task's file. Unranked sorts last.
|
|
196
|
+
export async function setRank(
|
|
197
|
+
store: FileStore,
|
|
198
|
+
config: DocketConfig,
|
|
199
|
+
id: string,
|
|
200
|
+
to: number | null,
|
|
201
|
+
): Promise<{
|
|
202
|
+
id: string;
|
|
203
|
+
path: string;
|
|
204
|
+
from: number | null;
|
|
205
|
+
to: number | null;
|
|
206
|
+
}> {
|
|
207
|
+
if (to !== null && !Number.isFinite(to))
|
|
208
|
+
throw new Error(`rank must be a finite number, got ${to}`);
|
|
209
|
+
const bundle = await loadBundle(store, config);
|
|
210
|
+
const item = bundle.byId(id);
|
|
211
|
+
if (item?.kind !== "work") throw new Error(`no work item with id ${id}`);
|
|
212
|
+
if (item.fm.type === "Epic")
|
|
213
|
+
throw new Error(`${item.fm.id} is an epic — rank orders tasks`);
|
|
214
|
+
|
|
215
|
+
const from = item.fm.rank ?? null;
|
|
216
|
+
if (from === to)
|
|
217
|
+
throw new Error(`${item.fm.id} rank is already ${to ?? "unset"}`);
|
|
218
|
+
|
|
219
|
+
const source = await store.read(item.path);
|
|
220
|
+
const { fm, rest } = splitFrontmatter(source);
|
|
221
|
+
// No timestamp bump — same reasoning as priority: reordering a lane
|
|
222
|
+
// shouldn't reshuffle the activity-ordered lists.
|
|
223
|
+
const updated = upsertFmLine(
|
|
224
|
+
fm,
|
|
225
|
+
"rank",
|
|
226
|
+
to === null ? null : yamlLine("rank", to),
|
|
227
|
+
[/^priority:.*$/m, /^depends_on: \[.*$/m, /^epic:.*$/m, /^status:.*$/m],
|
|
228
|
+
);
|
|
229
|
+
await store.write(item.path, updated + rest);
|
|
230
|
+
return { id: item.fm.id, path: item.path, from, to };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function setEpic(
|
|
234
|
+
store: FileStore,
|
|
235
|
+
config: DocketConfig,
|
|
236
|
+
id: string,
|
|
237
|
+
to: string | null,
|
|
238
|
+
): Promise<{
|
|
239
|
+
id: string;
|
|
240
|
+
path: string;
|
|
241
|
+
from: string | null;
|
|
242
|
+
to: string | null;
|
|
243
|
+
}> {
|
|
244
|
+
const bundle = await loadBundle(store, config);
|
|
245
|
+
const item = bundle.byId(id);
|
|
246
|
+
if (item?.kind !== "work") throw new Error(`no work item with id ${id}`);
|
|
247
|
+
if (item.fm.type === "Epic")
|
|
248
|
+
throw new Error(`${item.fm.id} is an epic — epics don't nest`);
|
|
249
|
+
|
|
250
|
+
let link: string | null = null;
|
|
251
|
+
if (to) {
|
|
252
|
+
link = to.startsWith("/") ? to : `/${to}`;
|
|
253
|
+
const resolved = resolveLink(item.path, link);
|
|
254
|
+
const target = resolved
|
|
255
|
+
? bundle.concepts.find((c) => c.path === resolved)
|
|
256
|
+
: undefined;
|
|
257
|
+
if (target?.kind !== "work" || target.fm.type !== "Epic")
|
|
258
|
+
throw new Error(`no epic at ${link}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const from = typeof item.fm.epic === "string" ? item.fm.epic : null;
|
|
262
|
+
if (from === link)
|
|
263
|
+
throw new Error(`${item.fm.id} epic is already ${link ?? "unset"}`);
|
|
264
|
+
|
|
265
|
+
const source = await store.read(item.path);
|
|
266
|
+
const { fm, rest } = splitFrontmatter(source);
|
|
267
|
+
const updated = upsertFmLine(
|
|
268
|
+
fm,
|
|
269
|
+
"epic",
|
|
270
|
+
link === null ? null : yamlLine("epic", link),
|
|
271
|
+
[/^status:.*$/m],
|
|
272
|
+
);
|
|
273
|
+
await store.write(item.path, updated + rest);
|
|
274
|
+
return { id: item.fm.id, path: item.path, from, to: link };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Insert a dated entry directly under `# Log` (newest first), creating the section if needed. */
|
|
278
|
+
export async function appendLog(
|
|
279
|
+
store: FileStore,
|
|
280
|
+
config: DocketConfig,
|
|
281
|
+
id: string,
|
|
282
|
+
entry: string,
|
|
283
|
+
): Promise<{ path: string }> {
|
|
284
|
+
const bundle = await loadBundle(store, config);
|
|
285
|
+
const item = bundle.byId(id);
|
|
286
|
+
if (!item) throw new Error(`no item with id ${id}`);
|
|
287
|
+
|
|
288
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
289
|
+
const line = `**${date}** — ${entry}`;
|
|
290
|
+
const source = await store.read(item.path);
|
|
291
|
+
|
|
292
|
+
// Consume the blank lines after the heading and re-emit them around the new
|
|
293
|
+
// entry, so consecutive entries stay separated by exactly one blank line.
|
|
294
|
+
const updated = /^# Log\s*$/m.test(source)
|
|
295
|
+
? `${source.replace(/^# Log[ \t]*\n*/m, `# Log\n\n${line}\n\n`).trimEnd()}\n`
|
|
296
|
+
: `${source.trimEnd()}\n\n# Log\n\n${line}\n`;
|
|
297
|
+
|
|
298
|
+
await store.write(item.path, updated);
|
|
299
|
+
return { path: item.path };
|
|
300
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
gitCheckpoint,
|
|
11
|
+
scanActivity,
|
|
12
|
+
taskLinkedCommitsSince,
|
|
13
|
+
} from "./cache";
|
|
14
|
+
import type { DocketConfig } from "./config";
|
|
15
|
+
import type { FileStore } from "./filestore";
|
|
16
|
+
import { deriveOverview, type OverviewModel } from "./overview";
|
|
17
|
+
import {
|
|
18
|
+
parseStateOfPlay,
|
|
19
|
+
presentStateOfPlay,
|
|
20
|
+
REENTRY_CONTEXT_FORMAT,
|
|
21
|
+
REENTRY_CONTEXT_V1_FORMAT,
|
|
22
|
+
STATE_OF_PLAY_PATH,
|
|
23
|
+
type StateOfPlayView,
|
|
24
|
+
} from "./state-of-play";
|
|
25
|
+
|
|
26
|
+
export type RepositoryOverview = OverviewModel & {
|
|
27
|
+
narrative?: StateOfPlayView;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export interface RepositoryOverviewInput {
|
|
31
|
+
bundle: Bundle;
|
|
32
|
+
config: DocketConfig;
|
|
33
|
+
store: FileStore;
|
|
34
|
+
/** Omit outside a Git-backed repository; the derived task selection remains valid. */
|
|
35
|
+
root?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function deriveRepositoryOverview({
|
|
39
|
+
bundle,
|
|
40
|
+
config,
|
|
41
|
+
store,
|
|
42
|
+
root,
|
|
43
|
+
}: RepositoryOverviewInput): Promise<RepositoryOverview> {
|
|
44
|
+
const db = new Database(":memory:");
|
|
45
|
+
try {
|
|
46
|
+
buildCache(
|
|
47
|
+
db,
|
|
48
|
+
bundle,
|
|
49
|
+
root ? scanActivity(root, config.git.trailer, bundle.byId) : [],
|
|
50
|
+
);
|
|
51
|
+
const source = await store.read(STATE_OF_PLAY_PATH).catch(() => undefined);
|
|
52
|
+
const note = source ? parseStateOfPlay(source).note : undefined;
|
|
53
|
+
const checkpoint = root ? gitCheckpoint(root) : undefined;
|
|
54
|
+
const model = deriveOverview(bundle, db, {
|
|
55
|
+
checkpoint,
|
|
56
|
+
historyAvailable: Boolean(checkpoint),
|
|
57
|
+
decisionLinks:
|
|
58
|
+
note?.format === REENTRY_CONTEXT_FORMAT
|
|
59
|
+
? note.decisionLinks
|
|
60
|
+
: note?.format === REENTRY_CONTEXT_V1_FORMAT
|
|
61
|
+
? note.assessment.decisionLinks
|
|
62
|
+
: undefined,
|
|
63
|
+
});
|
|
64
|
+
const narrative = note
|
|
65
|
+
? presentStateOfPlay(
|
|
66
|
+
note,
|
|
67
|
+
root
|
|
68
|
+
? taskLinkedCommitsSince(root, config.git.trailer, note.asOf)
|
|
69
|
+
: undefined,
|
|
70
|
+
)
|
|
71
|
+
: undefined;
|
|
72
|
+
return narrative ? { narrative, ...model } : model;
|
|
73
|
+
} finally {
|
|
74
|
+
db.close();
|
|
75
|
+
}
|
|
76
|
+
}
|