@gitdocket/core 0.1.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 +1 -1
- package/src/cache.ts +312 -23
- package/src/id-allocation.ts +288 -0
- package/src/index.ts +5 -0
- package/src/ops.ts +53 -33
- package/src/orientation.ts +17 -5
- package/src/shipped-history.json +13 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/cache.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Database } from "bun:sqlite";
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
8
9
|
import { readFile } from "node:fs/promises";
|
|
9
|
-
import { join } from "node:path";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
10
11
|
import { Glob } from "bun";
|
|
11
12
|
import type { Bundle } from "./bundle";
|
|
12
13
|
import type { DocketConfig } from "./config";
|
|
@@ -29,6 +30,50 @@ export interface GitCheckpoint {
|
|
|
29
30
|
time: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
export interface GitWorktreeEvidence {
|
|
34
|
+
path: string;
|
|
35
|
+
head: string;
|
|
36
|
+
ref: string | null;
|
|
37
|
+
activeTaskId: string | null;
|
|
38
|
+
dirty: boolean | null;
|
|
39
|
+
mergedIntoCurrentHead: boolean | null;
|
|
40
|
+
current: boolean;
|
|
41
|
+
available: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GitActivityObservation extends ActivityRow {
|
|
45
|
+
/** Unmerged observations are evidence only; current-checkout state stays canonical. */
|
|
46
|
+
mergedIntoCurrentHead: false;
|
|
47
|
+
refs: string[];
|
|
48
|
+
worktrees: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface GitEvidence {
|
|
52
|
+
status: "available" | "history-unavailable";
|
|
53
|
+
checkpoint: GitCheckpoint | null;
|
|
54
|
+
/** Canonical activity reachable from the calling checkout's HEAD. */
|
|
55
|
+
activity: ActivityRow[];
|
|
56
|
+
/** Bounded task-linked commits reachable from local tips but not current HEAD. */
|
|
57
|
+
unmergedActivity: GitActivityObservation[];
|
|
58
|
+
/** Bounded linked-checkout inventory, including checkout-local active markers. */
|
|
59
|
+
worktrees: GitWorktreeEvidence[];
|
|
60
|
+
truncated: boolean;
|
|
61
|
+
reason?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface GitEvidenceOptions {
|
|
65
|
+
commitLimit?: number;
|
|
66
|
+
refLimit?: number;
|
|
67
|
+
worktreeLimit?: number;
|
|
68
|
+
/** Test seam: pinned SHA inventory must survive refs/worktrees disappearing. */
|
|
69
|
+
afterInventory?: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const GIT_EVIDENCE_COMMIT_LIMIT = 50;
|
|
73
|
+
export const GIT_EVIDENCE_REF_LIMIT = 128;
|
|
74
|
+
export const GIT_EVIDENCE_WORKTREE_LIMIT = 64;
|
|
75
|
+
const GIT_EVIDENCE_SCAN_LIMIT_PER_TIP = 500;
|
|
76
|
+
|
|
32
77
|
/** Current Git revision and commit time, or undefined outside usable history. */
|
|
33
78
|
export function gitCheckpoint(cwd: string): GitCheckpoint | undefined {
|
|
34
79
|
try {
|
|
@@ -45,6 +90,271 @@ export function gitCheckpoint(cwd: string): GitCheckpoint | undefined {
|
|
|
45
90
|
}
|
|
46
91
|
}
|
|
47
92
|
|
|
93
|
+
const gitOutput = (cwd: string, args: string[]): string =>
|
|
94
|
+
execFileSync("git", args, {
|
|
95
|
+
cwd,
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
98
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function parseActivityRecords(
|
|
102
|
+
output: string,
|
|
103
|
+
byId: Bundle["byId"],
|
|
104
|
+
): ActivityRow[] {
|
|
105
|
+
return output
|
|
106
|
+
.split("\x1e")
|
|
107
|
+
.slice(1)
|
|
108
|
+
.flatMap((record) => {
|
|
109
|
+
const [sha, date, subject, trailers] = record.split("\x1f");
|
|
110
|
+
if (!sha || !date) return [];
|
|
111
|
+
return (trailers ?? "")
|
|
112
|
+
.split("\n")
|
|
113
|
+
.map((trailer) => trailer.trim())
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.map((id) => ({
|
|
116
|
+
taskId: byId(id)?.fm.id ?? id,
|
|
117
|
+
sha,
|
|
118
|
+
date,
|
|
119
|
+
subject: (subject ?? "").trim(),
|
|
120
|
+
}));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function scanHeadActivity(
|
|
125
|
+
cwd: string,
|
|
126
|
+
trailerKey: string,
|
|
127
|
+
byId: Bundle["byId"],
|
|
128
|
+
): ActivityRow[] {
|
|
129
|
+
return parseActivityRecords(
|
|
130
|
+
gitOutput(cwd, [
|
|
131
|
+
"log",
|
|
132
|
+
"HEAD",
|
|
133
|
+
`--format=%x1e%H%x1f%cI%x1f%s%x1f%(trailers:key=${trailerKey},valueonly)`,
|
|
134
|
+
]),
|
|
135
|
+
byId,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface TipSources {
|
|
140
|
+
refs: Set<string>;
|
|
141
|
+
worktrees: Set<string>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function mergedInto(cwd: string, commit: string, head: string): boolean | null {
|
|
145
|
+
const result = Bun.spawnSync(
|
|
146
|
+
["git", "merge-base", "--is-ancestor", commit, head],
|
|
147
|
+
{ cwd, stdout: "ignore", stderr: "ignore" },
|
|
148
|
+
);
|
|
149
|
+
if (result.exitCode === 0) return true;
|
|
150
|
+
if (result.exitCode === 1) return false;
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function worktreeEvidence(
|
|
155
|
+
cwd: string,
|
|
156
|
+
currentHead: string,
|
|
157
|
+
): GitWorktreeEvidence[] {
|
|
158
|
+
const output = gitOutput(cwd, ["worktree", "list", "--porcelain", "-z"]);
|
|
159
|
+
return output
|
|
160
|
+
.split("\0\0")
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
.flatMap((record): GitWorktreeEvidence[] => {
|
|
163
|
+
const fields = record.split("\0");
|
|
164
|
+
const path = fields
|
|
165
|
+
.find((field) => field.startsWith("worktree "))
|
|
166
|
+
?.slice("worktree ".length);
|
|
167
|
+
const head = fields
|
|
168
|
+
.find((field) => field.startsWith("HEAD "))
|
|
169
|
+
?.slice("HEAD ".length);
|
|
170
|
+
if (!path || !head) return [];
|
|
171
|
+
const ref =
|
|
172
|
+
fields
|
|
173
|
+
.find((field) => field.startsWith("branch "))
|
|
174
|
+
?.slice("branch ".length) ?? null;
|
|
175
|
+
let activeTaskId: string | null = null;
|
|
176
|
+
let dirty: boolean | null = null;
|
|
177
|
+
let available = true;
|
|
178
|
+
try {
|
|
179
|
+
activeTaskId =
|
|
180
|
+
readFileSync(join(path, ".docket", "active-task"), "utf8").trim() ||
|
|
181
|
+
null;
|
|
182
|
+
} catch {
|
|
183
|
+
// A missing marker is the normal idle state; availability is checked
|
|
184
|
+
// independently through Git status below.
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
dirty =
|
|
188
|
+
gitOutput(path, [
|
|
189
|
+
"status",
|
|
190
|
+
"--porcelain=v1",
|
|
191
|
+
"--untracked-files=normal",
|
|
192
|
+
]).trim().length > 0;
|
|
193
|
+
} catch {
|
|
194
|
+
available = false;
|
|
195
|
+
}
|
|
196
|
+
return [
|
|
197
|
+
{
|
|
198
|
+
path,
|
|
199
|
+
head,
|
|
200
|
+
ref,
|
|
201
|
+
activeTaskId,
|
|
202
|
+
dirty,
|
|
203
|
+
mergedIntoCurrentHead: mergedInto(cwd, head, currentHead),
|
|
204
|
+
current: resolve(path) === resolve(cwd),
|
|
205
|
+
available,
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function refTips(cwd: string): { ref: string; head: string }[] {
|
|
212
|
+
return gitOutput(cwd, [
|
|
213
|
+
"for-each-ref",
|
|
214
|
+
"--format=%(refname)%09%(objectname)",
|
|
215
|
+
"refs/heads",
|
|
216
|
+
"refs/remotes",
|
|
217
|
+
])
|
|
218
|
+
.split("\n")
|
|
219
|
+
.filter(Boolean)
|
|
220
|
+
.flatMap((line) => {
|
|
221
|
+
const [ref, head] = line.split("\t");
|
|
222
|
+
return ref && head && /^[0-9a-f]{40}$/i.test(head) ? [{ ref, head }] : [];
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read-only, local Git evidence. Current-checkout bundle data remains the only
|
|
228
|
+
* source for identity, status, readiness, rollups, and authored narrative;
|
|
229
|
+
* these pinned observations never hydrate concepts from another ref.
|
|
230
|
+
*/
|
|
231
|
+
export function scanGitEvidence(
|
|
232
|
+
cwd: string,
|
|
233
|
+
trailerKey: string,
|
|
234
|
+
byId: Bundle["byId"],
|
|
235
|
+
options: GitEvidenceOptions = {},
|
|
236
|
+
): GitEvidence {
|
|
237
|
+
const checkpoint = gitCheckpoint(cwd);
|
|
238
|
+
if (!checkpoint) {
|
|
239
|
+
return {
|
|
240
|
+
status: "history-unavailable",
|
|
241
|
+
checkpoint: null,
|
|
242
|
+
activity: [],
|
|
243
|
+
unmergedActivity: [],
|
|
244
|
+
worktrees: [],
|
|
245
|
+
truncated: false,
|
|
246
|
+
reason: "Git history is unavailable from this checkout",
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const commitLimit = options.commitLimit ?? GIT_EVIDENCE_COMMIT_LIMIT;
|
|
252
|
+
const refLimit = options.refLimit ?? GIT_EVIDENCE_REF_LIMIT;
|
|
253
|
+
const worktreeLimit = options.worktreeLimit ?? GIT_EVIDENCE_WORKTREE_LIMIT;
|
|
254
|
+
const allRefs = refTips(cwd).sort((a, z) => a.ref.localeCompare(z.ref));
|
|
255
|
+
const allWorktrees = worktreeEvidence(cwd, checkpoint.revision).sort(
|
|
256
|
+
(a, z) => a.path.localeCompare(z.path),
|
|
257
|
+
);
|
|
258
|
+
const refs = allRefs.slice(0, refLimit);
|
|
259
|
+
const worktrees = allWorktrees.slice(0, worktreeLimit);
|
|
260
|
+
let truncated =
|
|
261
|
+
refs.length < allRefs.length || worktrees.length < allWorktrees.length;
|
|
262
|
+
|
|
263
|
+
const tips = new Map<string, TipSources>();
|
|
264
|
+
const sourcesFor = (head: string): TipSources => {
|
|
265
|
+
const existing = tips.get(head) ?? {
|
|
266
|
+
refs: new Set<string>(),
|
|
267
|
+
worktrees: new Set<string>(),
|
|
268
|
+
};
|
|
269
|
+
tips.set(head, existing);
|
|
270
|
+
return existing;
|
|
271
|
+
};
|
|
272
|
+
for (const { ref, head } of refs) sourcesFor(head).refs.add(ref);
|
|
273
|
+
for (const worktree of worktrees) {
|
|
274
|
+
const sources = sourcesFor(worktree.head);
|
|
275
|
+
sources.worktrees.add(worktree.path);
|
|
276
|
+
if (worktree.ref) sources.refs.add(worktree.ref);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Refs may disappear after this point; every subsequent read uses the
|
|
280
|
+
// immutable object ID captured above.
|
|
281
|
+
options.afterInventory?.();
|
|
282
|
+
|
|
283
|
+
const observations = new Map<
|
|
284
|
+
string,
|
|
285
|
+
GitActivityObservation & {
|
|
286
|
+
refSet: Set<string>;
|
|
287
|
+
worktreeSet: Set<string>;
|
|
288
|
+
}
|
|
289
|
+
>();
|
|
290
|
+
for (const [tip, sources] of tips) {
|
|
291
|
+
if (tip === checkpoint.revision) continue;
|
|
292
|
+
const rows = parseActivityRecords(
|
|
293
|
+
gitOutput(cwd, [
|
|
294
|
+
"log",
|
|
295
|
+
tip,
|
|
296
|
+
"--not",
|
|
297
|
+
checkpoint.revision,
|
|
298
|
+
`--max-count=${GIT_EVIDENCE_SCAN_LIMIT_PER_TIP + 1}`,
|
|
299
|
+
`--format=%x1e%H%x1f%cI%x1f%s%x1f%(trailers:key=${trailerKey},valueonly)`,
|
|
300
|
+
]),
|
|
301
|
+
byId,
|
|
302
|
+
);
|
|
303
|
+
if (rows.length > GIT_EVIDENCE_SCAN_LIMIT_PER_TIP) truncated = true;
|
|
304
|
+
for (const row of rows.slice(0, GIT_EVIDENCE_SCAN_LIMIT_PER_TIP)) {
|
|
305
|
+
const key = `${row.sha}\x1f${row.taskId}`;
|
|
306
|
+
const observation = observations.get(key) ?? {
|
|
307
|
+
...row,
|
|
308
|
+
mergedIntoCurrentHead: false as const,
|
|
309
|
+
refs: [],
|
|
310
|
+
worktrees: [],
|
|
311
|
+
refSet: new Set<string>(),
|
|
312
|
+
worktreeSet: new Set<string>(),
|
|
313
|
+
};
|
|
314
|
+
for (const ref of sources.refs) observation.refSet.add(ref);
|
|
315
|
+
for (const path of sources.worktrees) observation.worktreeSet.add(path);
|
|
316
|
+
observations.set(key, observation);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const unmergedActivity = [...observations.values()]
|
|
321
|
+
.sort(
|
|
322
|
+
(a, z) =>
|
|
323
|
+
z.date.localeCompare(a.date) ||
|
|
324
|
+
a.sha.localeCompare(z.sha) ||
|
|
325
|
+
a.taskId.localeCompare(z.taskId),
|
|
326
|
+
)
|
|
327
|
+
.map(({ refSet, worktreeSet, ...observation }) => ({
|
|
328
|
+
...observation,
|
|
329
|
+
refs: [...refSet].sort(),
|
|
330
|
+
worktrees: [...worktreeSet].sort(),
|
|
331
|
+
}));
|
|
332
|
+
if (unmergedActivity.length > commitLimit) truncated = true;
|
|
333
|
+
|
|
334
|
+
const allActivity = scanHeadActivity(cwd, trailerKey, byId);
|
|
335
|
+
if (allActivity.length > commitLimit) truncated = true;
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
status: "available",
|
|
339
|
+
checkpoint,
|
|
340
|
+
activity: allActivity.slice(0, commitLimit),
|
|
341
|
+
unmergedActivity: unmergedActivity.slice(0, commitLimit),
|
|
342
|
+
worktrees,
|
|
343
|
+
truncated,
|
|
344
|
+
};
|
|
345
|
+
} catch (error) {
|
|
346
|
+
return {
|
|
347
|
+
status: "history-unavailable",
|
|
348
|
+
checkpoint,
|
|
349
|
+
activity: [],
|
|
350
|
+
unmergedActivity: [],
|
|
351
|
+
worktrees: [],
|
|
352
|
+
truncated: false,
|
|
353
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
48
358
|
/**
|
|
49
359
|
* Scan configured repo files for verification markers. This lives beside the
|
|
50
360
|
* cache because both `docket index` and `docket serve` populate the same
|
|
@@ -82,28 +392,7 @@ export function scanActivity(
|
|
|
82
392
|
byId: Bundle["byId"],
|
|
83
393
|
): ActivityRow[] {
|
|
84
394
|
try {
|
|
85
|
-
|
|
86
|
-
"git",
|
|
87
|
-
[
|
|
88
|
-
"log",
|
|
89
|
-
`--format=%x1e%H%x1f%cI%x1f%s%x1f%(trailers:key=${trailerKey},valueonly)`,
|
|
90
|
-
],
|
|
91
|
-
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
92
|
-
).split("\x1e");
|
|
93
|
-
return records.slice(1).flatMap((record) => {
|
|
94
|
-
const [sha, date, subject, trailers] = record.split("\x1f");
|
|
95
|
-
if (!sha || !date) return [];
|
|
96
|
-
return (trailers ?? "")
|
|
97
|
-
.split("\n")
|
|
98
|
-
.map((t) => t.trim())
|
|
99
|
-
.filter(Boolean)
|
|
100
|
-
.map((id) => ({
|
|
101
|
-
taskId: byId(id)?.fm.id ?? id, // aliases resolve to the canonical id
|
|
102
|
-
sha,
|
|
103
|
-
date,
|
|
104
|
-
subject: (subject ?? "").trim(),
|
|
105
|
-
}));
|
|
106
|
-
});
|
|
395
|
+
return scanHeadActivity(cwd, trailerKey, byId);
|
|
107
396
|
} catch {
|
|
108
397
|
return [];
|
|
109
398
|
}
|
|
@@ -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
CHANGED
|
@@ -19,6 +19,11 @@ export {
|
|
|
19
19
|
READY_QUEUE_DESCRIPTION,
|
|
20
20
|
} from "./engine-semantics";
|
|
21
21
|
export { type FileStore, InMemoryFileStore, LocalFileStore } from "./filestore";
|
|
22
|
+
export {
|
|
23
|
+
GitWorktreeIdCoordinator,
|
|
24
|
+
type GitWorktreeIdCoordinatorOptions,
|
|
25
|
+
type WorkItemIdCoordinator,
|
|
26
|
+
} from "./id-allocation";
|
|
22
27
|
export { applyIndex, INDEX_MARKER, renderIndex } from "./indexmd";
|
|
23
28
|
export {
|
|
24
29
|
ALLOW_RULES,
|
package/src/ops.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { stringify as stringifyYaml } from "yaml";
|
|
|
7
7
|
import { type Bundle, loadBundle } from "./bundle";
|
|
8
8
|
import type { DocketConfig } from "./config";
|
|
9
9
|
import type { FileStore } from "./filestore";
|
|
10
|
+
import type { WorkItemIdCoordinator } from "./id-allocation";
|
|
10
11
|
import { resolveLink } from "./lint";
|
|
11
12
|
import {
|
|
12
13
|
canTransition,
|
|
@@ -42,11 +43,17 @@ export function slugify(title: string): string {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/** Next work-item number: max over ids matching `<project>-<n>`, plus one. */
|
|
45
|
-
export function nextId(
|
|
46
|
+
export function nextId(
|
|
47
|
+
bundle: Bundle,
|
|
48
|
+
knownIds: ReadonlySet<string> = new Set(),
|
|
49
|
+
): string {
|
|
46
50
|
const pattern = new RegExp(`^${bundle.config.project}-(\\d+)$`);
|
|
47
51
|
let max = 0;
|
|
48
|
-
for (const
|
|
49
|
-
|
|
52
|
+
for (const id of [
|
|
53
|
+
...bundle.workItems.map((item) => item.fm.id),
|
|
54
|
+
...knownIds,
|
|
55
|
+
]) {
|
|
56
|
+
const match = id.match(pattern);
|
|
50
57
|
if (match?.[1]) max = Math.max(max, Number(match[1]));
|
|
51
58
|
}
|
|
52
59
|
return `${bundle.config.project}-${max + 1}`;
|
|
@@ -59,41 +66,54 @@ export async function createWorkItem(
|
|
|
59
66
|
store: FileStore,
|
|
60
67
|
config: DocketConfig,
|
|
61
68
|
input: CreateInput,
|
|
69
|
+
coordinator?: WorkItemIdCoordinator,
|
|
62
70
|
): Promise<{ id: string; path: string }> {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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`;
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
+
];
|
|
72
104
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
];
|
|
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`;
|
|
89
109
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const body = `# Context\n\n${context}\n\n# Acceptance Criteria\n\n- [ ] …\n`;
|
|
110
|
+
await store.write(path, `---\n${lines.join("\n")}\n---\n\n${body}`);
|
|
111
|
+
return { id, path };
|
|
112
|
+
};
|
|
94
113
|
|
|
95
|
-
|
|
96
|
-
|
|
114
|
+
return coordinator
|
|
115
|
+
? coordinator.allocate(config.project, create)
|
|
116
|
+
: create(new Set());
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
function splitFrontmatter(source: string): { fm: string; rest: string } {
|
package/src/orientation.ts
CHANGED
|
@@ -7,8 +7,9 @@ import { Database } from "bun:sqlite";
|
|
|
7
7
|
import type { Bundle } from "./bundle";
|
|
8
8
|
import {
|
|
9
9
|
buildCache,
|
|
10
|
-
|
|
10
|
+
type GitEvidence,
|
|
11
11
|
scanActivity,
|
|
12
|
+
scanGitEvidence,
|
|
12
13
|
taskLinkedCommitsSince,
|
|
13
14
|
} from "./cache";
|
|
14
15
|
import type { DocketConfig } from "./config";
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
|
|
26
27
|
export type RepositoryOverview = OverviewModel & {
|
|
27
28
|
narrative?: StateOfPlayView;
|
|
29
|
+
git: GitEvidence;
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
export interface RepositoryOverviewInput {
|
|
@@ -43,6 +45,17 @@ export async function deriveRepositoryOverview({
|
|
|
43
45
|
}: RepositoryOverviewInput): Promise<RepositoryOverview> {
|
|
44
46
|
const db = new Database(":memory:");
|
|
45
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
|
+
};
|
|
46
59
|
buildCache(
|
|
47
60
|
db,
|
|
48
61
|
bundle,
|
|
@@ -50,10 +63,9 @@ export async function deriveRepositoryOverview({
|
|
|
50
63
|
);
|
|
51
64
|
const source = await store.read(STATE_OF_PLAY_PATH).catch(() => undefined);
|
|
52
65
|
const note = source ? parseStateOfPlay(source).note : undefined;
|
|
53
|
-
const checkpoint = root ? gitCheckpoint(root) : undefined;
|
|
54
66
|
const model = deriveOverview(bundle, db, {
|
|
55
|
-
checkpoint,
|
|
56
|
-
historyAvailable:
|
|
67
|
+
checkpoint: git.checkpoint ?? undefined,
|
|
68
|
+
historyAvailable: git.status === "available",
|
|
57
69
|
decisionLinks:
|
|
58
70
|
note?.format === REENTRY_CONTEXT_FORMAT
|
|
59
71
|
? note.decisionLinks
|
|
@@ -69,7 +81,7 @@ export async function deriveRepositoryOverview({
|
|
|
69
81
|
: undefined,
|
|
70
82
|
)
|
|
71
83
|
: undefined;
|
|
72
|
-
return narrative ? { narrative, ...model } : model;
|
|
84
|
+
return narrative ? { narrative, ...model, git } : { ...model, git };
|
|
73
85
|
} finally {
|
|
74
86
|
db.close();
|
|
75
87
|
}
|
package/src/shipped-history.json
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"bodies": {
|
|
5
|
+
"docket-pickup": "Use this workflow only for authorized tracked Docket work. Pickup authority requires positive evidence: a Docket ID, an unambiguous reference to an existing tracked item, or an explicit request to select the next Docket or backlog item. Generic implementation language does not select pickup. A concrete direct request proceeds in the user's stated scope without creating, starting, or adopting Docket work; do not invoke this workflow for it.\n\n1. **Resolve the target and command**: a Docket ID authorizes `docket task start <ID> --json`. Resolve an unambiguous tracked-item reference to its ID, then use the same named command. Only explicit next-Docket-task or backlog-selection language authorizes bare `docket task start --json`. If an apparent tracked reference remains ambiguous, perform only focused resolution or ask for clarification; never omit the ID, substitute the top ready item, or mutate `.docket/active-task`.\n2. **Start through the engine**: run only the command authorized in step 1. If the command fails, stop; do not rename the session or begin tracked work.\n3. **Use the returned title intent**: read `suggestedSessionTitle` from the successful structured result. Do not rebuild it from prompt text or separately queried task fields.\n4. **Best-effort rename**: ask the current harness's native adapter to name the calling session with that exact value. If the host has no current-session naming capability, the capability is unavailable, or the rename fails, continue silently without retrying or treating pickup as failed.\n5. **Hand off context**: use the returned task, epic, dependency, linked-concept, and commit fields as the context packet, then begin the requested tracked work.\n\nStored status changes go through the engine's canonical transition table; invalid transitions are rejected, `done` and `closed` are terminal, and moving to `closed` requires a disposition note. Only `done` satisfies dependencies or counts as completion.\n\nThe engine owns task selection, the state-machine-checked status transition, active-task state, title derivation, and the context packet; the pickup workflow owns only their sequence, and a native adapter owns only its bounded rename binding.",
|
|
6
|
+
"docket-epic": "Supervise the named epic until its acceptance criteria support explicit closure or one concrete blocker prevents safe progress. Docket files and task-linked Git history are the durable source of truth. Native worker, wait, follow-up, notification, and isolated-checkout capabilities are optional accelerators; they never change readiness or completion semantics.\n\n## 1. Establish the authoritative graph\n\n1. Confirm the user named an epic and authorized running it, not merely reviewing it. Read the epic file, verify that it is an Epic with an ID and title, then run `docket task list --epic <EPIC-ID> --all --json` and `docket ready --json`.\n2. Derive one manager title from those authoritative epic fields, exactly `Epic <ID> — <title>`, and retain it for the entire supervision run. Ask the current harness's native adapter to apply it to the calling manager session. Unsupported, unavailable, or failed rename capability is a silent no-op; it never blocks supervision.\n3. Record the manager baseline: current Git commit and branch, working-tree state, epic status and acceptance criteria, every child status and dependency, already-linked task commits, and the stopping condition. Preserve unrelated user changes; do not hide, overwrite, or move them into a worker checkout.\n4. Use the engine's ready result as authoritative. Ready is derived, never written: a task is ready only when its stored status is `todo` and every dependency resolves to `done`; an unknown dependency blocks it. The ready queue puts ranked tasks first by ascending `rank`; rank ties and the unranked tail use priority (`p0` through `p3`), then ascending task ID as the stable fallback. Filter that result to the named epic; never dispatch from a remembered or hand-derived ready list.\n5. If no child is ready but unfinished children remain, inspect their dependency and blocked-state evidence. Continue only when Docket state identifies a resolvable in-scope next action; otherwise prepare the blocker receipt in section 6.\n\n## 2. Preflight isolation and likely write overlap\n\nBefore creating any worker, inspect each ready child's context, acceptance criteria, linked concepts, and likely implementation/test/generated-document surfaces. Parallel writing is allowed only when every selected child is dependency-independent, likely write sets are materially distinct, each worker has a separate checkout at the exact accepted manager ref, and the manager can integrate and verify results one at a time. Treat shared workflow templates, generated adapters, dependency manifests, schemas, migrations, indexes, and central registries as likely overlap unless evidence shows otherwise.\n\nIf any condition is unknown or false—or if the host lacks a verified worker, wait/follow-up, notification, or isolated-checkout binding—use the mandatory serial fallback: run exactly one child at a time in the calling session or one isolated worker, integrate it fully, refresh Docket state, and only then choose the next child. Never run concurrent writers in one checkout. A shared `.docket/active-task` is single-checkout state, not a coordination mechanism.\n\n## 3. Dispatch one bounded child contract\n\nFor each selected child, provide the exact task ID, accepted baseline commit, isolated checkout or serial location, permitted scope, acceptance criteria, relevant linked concepts, expected verification, and these constraints:\n\n- follow [the pickup workflow](/workflows/docket-pickup.md) before implementation and [the close workflow](/workflows/docket-close.md) only after the task is actually complete;\n- change only the named child and required reconciliation surfaces; do not start siblings, close the epic, or invent orchestration infrastructure;\n- preserve unrelated changes, use task-linked commits, clear the checkout's active-task marker after close, and return commit hashes, verification results, interventions, and exact blockers;\n- do not claim integration or readiness changes from the worker checkout—the manager re-establishes those facts after accepting the result.\n\nWhen no native worker binding is available, execute this same contract serially in the calling session. The contract, not process count, defines supervision.\n\nAn isolated child session follows pickup normally and keeps its own `<ID> — <title>` task name; never apply the manager title to that child. In the serial fallback, child pickup can temporarily rename the shared calling session, so immediately after every successful child pickup reapply the retained `Epic <ID> — <title>` manager title before implementation continues. A failed or unsupported restoration remains a silent no-op and does not change task state or the child contract.\n\n## 4. Inspect and integrate one result at a time\n\n1. Treat a worker report as a lead, not authority. Inspect its checkout or ref, diff, task file, checked or explicitly waived criteria, Outcome, Log, commit trailers, verification output, and clean active-task state.\n2. Reject or return incomplete, out-of-scope, unverified, or ambiguously based work. Keep the branch/worktree/ref recoverable and state the required correction. Never mark the child done merely because the worker said it finished.\n3. Integrate one accepted commit series into the manager checkout. Resolve only understood in-scope conflicts; otherwise stop integration, preserve both refs and the conflict evidence, and produce a blocker receipt. Do not integrate a second result against unresolved or unverified state.\n4. Run the verification proportionate to the accepted diff, regenerate derived state with `docket index`, then rerun `docket task list --epic <EPIC-ID> --all --json` and `docket ready --json`. Re-read the epic and Git history. Select further work only from this refreshed state.\n5. At every accepted boundary, durable task files plus integrated Git commits must be sufficient for a replacement manager to resume. Native task IDs and wait cursors are useful transient handles, never the recovery source of truth.\n\n## 5. Review and close the epic explicitly\n\nAll children being done is necessary evidence, not epic completion. When no unfinished child remains, review every epic acceptance criterion against integrated task Outcomes, diffs, tests, decisions, and reconciled docs. Run final repository verification. If any criterion lacks evidence, create or identify the smallest in-scope follow-on child and continue; do not check or waive a criterion silently.\n\nWhen every criterion is satisfied or explicitly waived with a reason, apply the close workflow to the epic itself: write its Outcome with commit evidence, reconcile affected concepts, close through the engine, regenerate the index, update the log, and commit with the epic's task trailer. Verify the integrated epic status rather than inferring it from the close command's prose.\n\n## 6. Return one consolidated receipt\n\nReturn only after verified epic closure or a concrete blocker. Before returning, ask the native adapter to reapply the retained manager title once so the calling session ends on the epic rather than incidental child work; unsupported or failed rename remains a silent no-op. A completion receipt names the epic, integrated child and epic commits, verification performed, serial-versus-parallel choice and why, interventions or conflicts, and any deliberately deferred follow-up. A blocker receipt names the exact failing child or epic criterion, dependency/decision/error, last accepted manager commit, preserved worker refs or worktrees, current Docket state, checks already attempted, and the single action needed to resume.\n\nDo not create an orchestration database, scheduler, permanent runner, or synthetic epic status. On interruption, restart this workflow from section 1: Docket and Git reveal completed children and the next authoritative ready set; absent native lifecycle state simply selects the serial fallback.",
|
|
7
|
+
"docket-task": "Create a work item conformant with the OKF task profile (bundled at `specs/okf-task-profile.md` when the repo carries it). The request describes the item (\"task: add X to Y, epic phase-1, depends on KEY-8\").\n\n**Prefer the engine**: `docket task create --title \"…\" --epic /work/epics/… --deps KEY-x,KEY-y --priority p1 --description \"…\"` handles ID assignment, file placement, and a conformant template. Then edit the created file to fill in real `# Context` links and `# Acceptance Criteria`, and run `docket index`. The manual steps below are the fallback when the engine is unavailable.\n\n1. **Assign the ID**: work items (tasks AND epics) take the next number in the project sequence under the key from `docket.yaml` — `grep -rh \"^id: KEY-\" <bundle>/`, max + 1. Decisions likewise on their own prefix (default `DEC-`). Verify the result is unused.\n2. **Write the file** at `work/tasks/<ID>-<short-slug>.md` (epics → `work/epics/`, decisions → `decisions/`) with frontmatter: `type`, `title`, `description` (one sentence), `id`, `status: todo`, `epic` (bundle-absolute link — ask or infer; a task without an epic is allowed but noted), `depends_on` (task IDs, omit if none), `priority` (default `p2`), `assignee`, `tags`, `timestamp` (current UTC ISO 8601).\n3. **Body**: `# Context` — link the relevant specs/docs/decisions (bundle-absolute paths); `# Acceptance Criteria` — checkboxes, verifiable, few. Omit `# Log` until there's something to log.\n4. **Regenerate the index** (`docket index`) and add a `log.md` entry when the item is notable.\n5. If work starts now, follow [the pickup workflow](/workflows/docket-pickup.md). It delegates task state and context-packet mechanics to `docket task start <ID> --json`; never set the active task without the status move or vice versa. Pausing later is `docket task stop` (clears the active task, status stays).\n\nNever skip or reuse numbers, never hand-maintain task lists inside epic files, never mark `status` beyond `todo` at creation.",
|
|
8
|
+
"docket-groom": "Run this full backlog-hygiene audit only when the user explicitly asks to groom or audit the backlog, find stale or inconsistent work, or review task hygiene. Ordinary status, orientation, what-is-next, and review requests use `docket overview --json` instead and stop when that structured response is sufficient.\n\nRead every file in `work/` and report, then apply agreed fixes. Start from the engine: `docket ready --json` and `docket task list --json`.\n\nThe engine owns ready/list derivation and task mutation mechanics; the groom workflow owns audit judgment, proposed changes, and the authorization boundary.\n\n1. **Derive ready**: `docket ready` (never compute by hand). Ready is derived, never written: a task is ready only when its stored status is `todo` and every dependency resolves to `done`; an unknown dependency blocks it. The ready queue puts ranked tasks first by ascending `rank`; rank ties and the unranked tail use priority (`p0` through `p3`), then ascending task ID as the stable fallback.\n2. **Flag inconsistencies**:\n - `in-progress` tasks with no commits trailer-matching their ID (`git log --grep \"Task: <ID>\"`) and no Log entry in 7+ days → probably stalled; propose `blocked` or `todo`.\n - `done` tasks with unchecked acceptance criteria or missing `# Outcome`.\n - `closed` tasks without a concrete `# Disposition` and replacement links when applicable.\n - `depends_on` pointing at nonexistent or done-and-superseded IDs; broken bundle links (`docket lint`).\n - Epics without a `spec` link; tasks without an `epic` link.\n - `index.md` out of sync (`docket index` fixes; report if it changes anything).\n3. **Propose, then apply**: present findings compactly; on confirmation (or when running autonomously, for mechanical fixes only) update files via `docket task move`/`docket task log`, regenerate the index, and add a `**YYYY-MM-DD**` line to affected `# Log` sections explaining status changes.\n4. Commit as `chore(docket): groom backlog` (no task trailer — `docket task stop` first).\n\nNever change priorities or close tasks without saying so; grooming narrates every mutation.",
|
|
9
|
+
"docket-close": "Conclude the given task (default: the ID in `.docket/active-task`). A terminal move is the moment the wiki gets paid — don't skip steps.\n\nStored status changes go through the engine's canonical transition table; invalid transitions are rejected, `done` and `closed` are terminal, and moving to `closed` requires a disposition note. Only `done` satisfies dependencies or counts as completion.\n\nThe engine owns the state-machine-checked terminal move and dated Log mutation; the close workflow owns the choice between completion (`done`) and non-completion (`closed`), Outcome or Disposition judgment, documentation review, and derived index/log reconciliation.\n\n1. **Choose the terminal meaning explicitly**. Completion is the backward-compatible default: every acceptance criterion is checked (or explicitly waived in the Outcome with a reason), and the target state is `done`. Use non-completion only when the user explicitly intends to abandon, decline, supersede, or otherwise discontinue the work; leave unmet criteria unchecked, target `closed`, and require a concrete disposition reason. If neither meaning is supported, say so and stop.\n2. **Write the terminal narrative**. For completion, write `# Outcome`: what actually shipped, citing commit hashes found via `git log --grep \"Task: <ID>\" --oneline` plus the task file's history, with anything descoped or discovered. For non-completion, write `# Disposition`: why the work ended, what remains unmet, and any replacement task or decision links; do not claim that work shipped.\n3. **Reconcile the docs** (the LLM-first step): from the task diff and terminal narrative, identify wiki concepts (`specs/`, `reference/`, `decisions/`, plan documents) the conclusion invalidates or extends. Update them now. If a choice foreclosed alternatives, record it as a `type: Decision` concept and link it from the Outcome or Disposition.\n4. **Update state**: for completion, run `docket task close <ID> --note \"…\"`; for non-completion, run `docket task close <ID> --without-completion --note \"<disposition>\"`. Then run `docket index`, add a `log.md` entry that says completed or closed, and check dependency and epic effects. Only `done` unblocks dependents or counts toward epic completion; a terminal epic may be `closed` without all children being done.\n5. **Commit everything together** — task file + reconciled docs + index/log — with the `Task: <ID>` trailer (keep the task active so the hook injects it, or add it manually), then `docket task stop` to clear the active task.\n\nThe commit that concludes a task must contain the doc reconciliation — that's the product's core promise.",
|
|
10
|
+
"docket-standup": "Report project status from files + git. **Mutate nothing.** Pull state from the engine (`docket task list --json`, `docket ready --json`); use git for the activity window.\n\n1. **Window**: since the last standup or the range given (default: 7 days).\n2. **Done**: tasks whose status flipped to `done` in the window — from `git log -p --since=<window> -- <bundle>/work/tasks/` (status line changes) — one line each: ID, title, outcome gist.\n3. **Closed without completion**: tasks whose status flipped to `closed` in the window — one line each: ID, title, and disposition; keep them separate from shipped work.\n4. **In flight**: `in-progress` tasks with their latest Log entry and commit count from `git log --grep \"Task: <ID>\" --since=<window>`. Call out any with zero commits and no Log movement.\n5. **Ready next**: derived ready list (`docket ready`), top 5. Ready is derived, never written: a task is ready only when its stored status is `todo` and every dependency resolves to `done`; an unknown dependency blocks it. The ready queue puts ranked tasks first by ascending `rank`; rank ties and the unranked tail use priority (`p0` through `p3`), then ascending task ID as the stable fallback.\n6. **Blocked**: `blocked` tasks with the blocking reason from their Log.\n7. **Epic pulse**: one line per active epic — fraction of its tasks done, with closed children called out separately (derive by grep, don't trust hand-maintained lists).\n\nOutput: compact markdown suitable for pasting into a chat. Flag (don't fix) any inconsistencies noticed along the way — fixing belongs to [docket-groom](/workflows/docket-groom.md).",
|
|
11
|
+
"docket-state-of-play": "Refresh the optional bundle-root `overview.md` re-entry note. The engine parses, ages, and renders this authored summary but never writes it; live task status, readiness, progress, and activity stay in the derived overview.\n\n1. **Read the evidence**: run `docket overview --json`; read the product spec, current epics and tasks, recent Outcomes, explicit Decision concepts, `log.md`, recent task-linked commits, and the existing `overview.md` when present. Treat the derived overview as execution truth and the product spec/decisions as direction truth.\n2. **Write only the re-entry through-line**: summarize a few recent outcomes rather than commits, then name the one or few current/next epics or frontiers—including work already underway—with enough context to understand the move. Put the canonical resume target first when one exists; multiple real frontiers remain multiple authored links rather than an engine-selected winner. Add Worth knowing only for a decision, constraint, discovery, risk, parked thread, or useful wiki destination that materially helps re-entry. Use concrete nouns and consequences, link claims to bundle evidence, and omit empty material instead of writing filler. The preserved project preamble owns the recognizable full name, concise purpose, and other durable product introduction; do not repeat it here, and do not infer missing identity. Repeat a derived fact only when it explains why something matters, never to copy an inventory.\n3. **Write the linked note**: use the full output of `git rev-parse HEAD` as `as_of` and the current UTC ISO-8601 time as `reviewed_at`. What we've done recently and What's up next are required and non-empty. Worth knowing is optional; omit the heading when it would be empty.\n\n ```markdown\n ---\n format: re-entry/v2\n as_of: <full commit sha>\n reviewed_at: <timestamp>\n ---\n\n # Project re-entry\n\n ## What we've done recently\n\n - <outcome and consequence with a link to evidence>\n\n ## What's up next\n\n - <current or next frontier and why it matters, linked to its epic or task>\n\n ## Worth knowing\n\n - <optional decision, constraint, discovery, risk, or parked thread with a useful link>\n ```\n\n4. **Apply freshness honestly**: five task-linked commits after `as_of` or fourteen days after `reviewed_at` makes the note need review. Renderers keep the visibly dated last-known context readable rather than hiding it or presenting it as fresh. Refresh when the re-entry through-line materially changes, not merely to reset a clock. After a task close that changes the note, stamp the close commit in a separate tracker-only refresh so it starts at zero task-linked commits behind.\n5. **Verify and commit**: run `docket overview` and `docket lint`; confirm the linked sections and freshness are accurate. Commit as `chore(docket): refresh product context` with no Task trailer (`docket task stop` first).\n\nA missing `overview.md` is valid and renders no placeholder. Earlier formats remain readable and unchanged, but renderers label legacy prose and `re-entry/v1` as needing review. Never migrate them automatically; the next meaningful refresh replaces the file with the linked form above.",
|
|
12
|
+
"docket-freshness": "Close-time reconciliation is prospective — it fires only when a task closes, and only for that task's diff. This workflow is the retrospective complement: periodically re-ask \"what does this invalidate?\" across everything that happened since the last sweep.\n\n1. **Find the anchor**: the most recent `**Freshness**` entry in `log.md` holds the watermark sha. If none exists (first run), sweep the full history.\n2. **Collect the range**: `git log <sha>..HEAD --name-only` (keep trailers). Partition the commits:\n - **Trailerless** — the high-risk bucket: nobody ever asked the reconciliation question. Give each the full treatment: from its changed paths, which concepts (`specs/`, `reference/`, `decisions/`, plan documents) does it invalidate or extend?\n - **Trailered** (`Task: KEY-n`) — reconciliation should have happened at close. Spot-check: did closes that plausibly invalidated docs actually touch them?\n3. **Rotate a deep read**: pick the 1–2 concepts in `specs/` and `reference/` with the oldest last-modified commit and verify their content against current reality (code, plan). This catches drift that has no local commit at all — don't skip it just because the commit range is clean.\n4. **Propose, then apply**: present findings compactly (per doc: what's stale, which commit made it so). On confirmation — or autonomously for unambiguous factual fixes only — update the docs.\n5. **Stamp the watermark**: append to today's section of `log.md`:\n\n ```\n - **Freshness** — reviewed through `<short-sha of HEAD>` (<n> commits, <k> trailerless): <one-line findings summary, or \"no drift found\">.\n ```\n\n A \"no drift found\" stamp is a real result — record it; the recorded null finding is what makes the next sweep cheap.\n6. Commit doc fixes and the watermark together as `chore(docket): freshness review` (`docket task stop` first — no task trailer).\n\nNever end a sweep without stamping the watermark, even when nothing changed."
|
|
13
|
+
}
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.1.0",
|
|
4
17
|
"bodies": {
|
package/src/version.ts
CHANGED