@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/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
|
+
}
|