@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/cache.ts
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
// SQLite cache: disposable, derived, gitignored — files stay the
|
|
2
|
+
// source of truth. bun:sqlite makes this module Bun-only, so it ships as the
|
|
3
|
+
// `@gitdocket/core/cache` subpath and the main entry stays runtime-portable.
|
|
4
|
+
// Callers with a repo (the CLI) pass in git-derived activity rows.
|
|
5
|
+
|
|
6
|
+
import type { Database } from "bun:sqlite";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { Glob } from "bun";
|
|
12
|
+
import type { Bundle } from "./bundle";
|
|
13
|
+
import type { DocketConfig } from "./config";
|
|
14
|
+
import { resolveLink } from "./lint";
|
|
15
|
+
import {
|
|
16
|
+
resolveVerifyMarkers,
|
|
17
|
+
scanVerifyMarkers,
|
|
18
|
+
type VerifyMarker,
|
|
19
|
+
} from "./verify";
|
|
20
|
+
|
|
21
|
+
export interface ActivityRow {
|
|
22
|
+
taskId: string;
|
|
23
|
+
sha: string;
|
|
24
|
+
date: string;
|
|
25
|
+
subject: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GitCheckpoint {
|
|
29
|
+
revision: string;
|
|
30
|
+
time: string;
|
|
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
|
+
|
|
77
|
+
/** Current Git revision and commit time, or undefined outside usable history. */
|
|
78
|
+
export function gitCheckpoint(cwd: string): GitCheckpoint | undefined {
|
|
79
|
+
try {
|
|
80
|
+
const [revision, time] = execFileSync(
|
|
81
|
+
"git",
|
|
82
|
+
["show", "-s", "--format=%H%x1f%cI", "HEAD"],
|
|
83
|
+
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
84
|
+
)
|
|
85
|
+
.trim()
|
|
86
|
+
.split("\x1f");
|
|
87
|
+
return revision && time ? { revision, time } : undefined;
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
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
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Scan configured repo files for verification markers. This lives beside the
|
|
360
|
+
* cache because both `docket index` and `docket serve` populate the same
|
|
361
|
+
* disposable verification rows from it. No config means a fully dormant
|
|
362
|
+
* feature, and nothing here invokes a test runner.
|
|
363
|
+
*/
|
|
364
|
+
export async function scanRepoMarkers(
|
|
365
|
+
root: string,
|
|
366
|
+
config: DocketConfig,
|
|
367
|
+
bundle: Bundle,
|
|
368
|
+
): Promise<VerifyMarker[]> {
|
|
369
|
+
if (!config.verify) return [];
|
|
370
|
+
const seen = new Set<string>();
|
|
371
|
+
const markers: VerifyMarker[] = [];
|
|
372
|
+
for (const pattern of config.verify.tests) {
|
|
373
|
+
for await (const path of new Glob(pattern).scan({ cwd: root })) {
|
|
374
|
+
const posix = path.replaceAll("\\", "/");
|
|
375
|
+
if (posix.includes("node_modules/") || seen.has(posix)) continue;
|
|
376
|
+
seen.add(posix);
|
|
377
|
+
const content = await readFile(join(root, path), "utf8").catch(() => "");
|
|
378
|
+
markers.push(...scanVerifyMarkers(posix, content));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
markers.sort((a, z) => a.source.localeCompare(z.source) || a.line - z.line);
|
|
382
|
+
return resolveVerifyMarkers(
|
|
383
|
+
markers,
|
|
384
|
+
new Set(bundle.concepts.map((concept) => concept.path)),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Every commit carrying a Task trailer, one row per (task, commit). Empty if git can't answer. */
|
|
389
|
+
export function scanActivity(
|
|
390
|
+
cwd: string,
|
|
391
|
+
trailerKey: string,
|
|
392
|
+
byId: Bundle["byId"],
|
|
393
|
+
): ActivityRow[] {
|
|
394
|
+
try {
|
|
395
|
+
return scanHeadActivity(cwd, trailerKey, byId);
|
|
396
|
+
} catch {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Count distinct Task-trailered commits after a state-of-play watermark.
|
|
403
|
+
* Undefined means Git could not resolve the watermark/repository; callers
|
|
404
|
+
* render that uncertainty instead of pretending the note is current.
|
|
405
|
+
*/
|
|
406
|
+
export function taskLinkedCommitsSince(
|
|
407
|
+
cwd: string,
|
|
408
|
+
trailerKey: string,
|
|
409
|
+
sha: string,
|
|
410
|
+
): number | undefined {
|
|
411
|
+
if (!/^[0-9a-f]{7,40}$/i.test(sha)) return undefined;
|
|
412
|
+
try {
|
|
413
|
+
const records = execFileSync(
|
|
414
|
+
"git",
|
|
415
|
+
[
|
|
416
|
+
"log",
|
|
417
|
+
`${sha}..HEAD`,
|
|
418
|
+
`--format=%x1e%H%x1f%(trailers:key=${trailerKey},valueonly)`,
|
|
419
|
+
],
|
|
420
|
+
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
421
|
+
).split("\x1e");
|
|
422
|
+
return records.slice(1).filter((record) => {
|
|
423
|
+
const [, trailers = ""] = record.split("\x1f");
|
|
424
|
+
return trailers.trim().length > 0;
|
|
425
|
+
}).length;
|
|
426
|
+
} catch {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const SCHEMA = `
|
|
432
|
+
DROP VIEW IF EXISTS backlinks;
|
|
433
|
+
DROP VIEW IF EXISTS board;
|
|
434
|
+
DROP VIEW IF EXISTS epic_rollup;
|
|
435
|
+
DROP TABLE IF EXISTS concepts;
|
|
436
|
+
DROP TABLE IF EXISTS links;
|
|
437
|
+
DROP TABLE IF EXISTS activity;
|
|
438
|
+
DROP TABLE IF EXISTS verifications;
|
|
439
|
+
DROP TABLE IF EXISTS verification_results;
|
|
440
|
+
CREATE TABLE concepts (
|
|
441
|
+
path TEXT PRIMARY KEY,
|
|
442
|
+
id TEXT,
|
|
443
|
+
type TEXT NOT NULL,
|
|
444
|
+
title TEXT,
|
|
445
|
+
status TEXT,
|
|
446
|
+
priority TEXT,
|
|
447
|
+
rank REAL,
|
|
448
|
+
epic TEXT,
|
|
449
|
+
timestamp TEXT
|
|
450
|
+
);
|
|
451
|
+
CREATE TABLE links (from_path TEXT NOT NULL, target TEXT NOT NULL, to_path TEXT);
|
|
452
|
+
CREATE TABLE activity (task_id TEXT NOT NULL, sha TEXT NOT NULL, date TEXT NOT NULL, subject TEXT NOT NULL);
|
|
453
|
+
-- Verification linkage: resolved docket:verifies markers.
|
|
454
|
+
-- kind is 'test' today; 'case' arrives with the eval profile (Phase B).
|
|
455
|
+
CREATE TABLE verifications (
|
|
456
|
+
concept_path TEXT NOT NULL,
|
|
457
|
+
kind TEXT NOT NULL,
|
|
458
|
+
source_path TEXT NOT NULL,
|
|
459
|
+
line INTEGER,
|
|
460
|
+
anchor TEXT
|
|
461
|
+
);
|
|
462
|
+
-- Populated by verify ingest; empty until then. Ephemeral by design:
|
|
463
|
+
-- a cache of CI's last word, dropped on rebuild like every other table.
|
|
464
|
+
CREATE TABLE verification_results (
|
|
465
|
+
source_path TEXT PRIMARY KEY,
|
|
466
|
+
status TEXT NOT NULL,
|
|
467
|
+
ran_at TEXT,
|
|
468
|
+
detail TEXT
|
|
469
|
+
);
|
|
470
|
+
CREATE VIEW backlinks AS
|
|
471
|
+
SELECT to_path AS path, from_path FROM links WHERE to_path IS NOT NULL;
|
|
472
|
+
-- Terminal history sorts by transition time (setStatus bumps timestamp),
|
|
473
|
+
-- newest first; active columns lead with manual rank — unranked
|
|
474
|
+
-- tasks trail in the default priority-then-id order.
|
|
475
|
+
CREATE VIEW board AS
|
|
476
|
+
SELECT status, priority, rank, id, title, path, timestamp FROM concepts
|
|
477
|
+
WHERE type = 'Task'
|
|
478
|
+
ORDER BY status,
|
|
479
|
+
CASE WHEN status IN ('done', 'closed') THEN timestamp END DESC,
|
|
480
|
+
rank IS NULL, rank,
|
|
481
|
+
priority, id;
|
|
482
|
+
-- last_activity: freshest timestamp across the epic and its tasks (setStatus
|
|
483
|
+
-- bumps timestamps, so this tracks the latest status transition anywhere in
|
|
484
|
+
-- the epic). Empty string when nothing is stamped, so DESC sorts it last.
|
|
485
|
+
CREATE VIEW epic_rollup AS
|
|
486
|
+
SELECT e.id AS epic_id, e.title AS epic_title,
|
|
487
|
+
COUNT(t.path) AS total,
|
|
488
|
+
COALESCE(SUM(t.status = 'done'), 0) AS done,
|
|
489
|
+
COALESCE(SUM(t.status = 'closed'), 0) AS closed,
|
|
490
|
+
max(COALESCE(MAX(t.timestamp), ''), COALESCE(e.timestamp, '')) AS last_activity
|
|
491
|
+
FROM concepts e
|
|
492
|
+
LEFT JOIN concepts t ON t.type = 'Task' AND t.epic LIKE '%/' || e.id || '-%'
|
|
493
|
+
WHERE e.type = 'Epic'
|
|
494
|
+
GROUP BY e.path;
|
|
495
|
+
`;
|
|
496
|
+
|
|
497
|
+
/** Rebuild the cache from scratch — it is derived and disposable, never migrated. */
|
|
498
|
+
export function buildCache(
|
|
499
|
+
db: Database,
|
|
500
|
+
bundle: Bundle,
|
|
501
|
+
activity: ActivityRow[] = [],
|
|
502
|
+
verifications: VerifyMarker[] = [],
|
|
503
|
+
): void {
|
|
504
|
+
db.exec(SCHEMA);
|
|
505
|
+
const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
506
|
+
const paths = new Set(bundle.concepts.map((c) => c.path));
|
|
507
|
+
|
|
508
|
+
const insertConcept = db.prepare(
|
|
509
|
+
"INSERT INTO concepts (path, id, type, title, status, priority, rank, epic, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
510
|
+
);
|
|
511
|
+
const insertLink = db.prepare(
|
|
512
|
+
"INSERT INTO links (from_path, target, to_path) VALUES (?, ?, ?)",
|
|
513
|
+
);
|
|
514
|
+
const insertActivity = db.prepare(
|
|
515
|
+
"INSERT INTO activity (task_id, sha, date, subject) VALUES (?, ?, ?, ?)",
|
|
516
|
+
);
|
|
517
|
+
const insertVerification = db.prepare(
|
|
518
|
+
"INSERT INTO verifications (concept_path, kind, source_path, line, anchor) VALUES (?, ?, ?, ?, ?)",
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
db.transaction(() => {
|
|
522
|
+
for (const c of bundle.concepts) {
|
|
523
|
+
insertConcept.run(
|
|
524
|
+
c.path,
|
|
525
|
+
str(c.fm.id),
|
|
526
|
+
c.fm.type,
|
|
527
|
+
c.fm.title ?? null,
|
|
528
|
+
str(c.fm.status),
|
|
529
|
+
str(c.fm.priority),
|
|
530
|
+
typeof c.fm.rank === "number" ? c.fm.rank : null,
|
|
531
|
+
str(c.fm.epic),
|
|
532
|
+
str(c.fm.timestamp),
|
|
533
|
+
);
|
|
534
|
+
for (const l of c.links) {
|
|
535
|
+
if (!l.internal) continue;
|
|
536
|
+
const resolved = resolveLink(c.path, l.target);
|
|
537
|
+
insertLink.run(
|
|
538
|
+
c.path,
|
|
539
|
+
l.target,
|
|
540
|
+
resolved && paths.has(resolved) ? resolved : null,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
for (const a of activity)
|
|
545
|
+
insertActivity.run(a.taskId, a.sha, a.date, a.subject);
|
|
546
|
+
for (const v of verifications) {
|
|
547
|
+
if (!v.spec) continue; // unresolved markers are lint's problem, not rows
|
|
548
|
+
insertVerification.run(
|
|
549
|
+
v.spec,
|
|
550
|
+
"test",
|
|
551
|
+
v.source,
|
|
552
|
+
v.line,
|
|
553
|
+
v.anchor ?? null,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
})();
|
|
557
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// docket.yaml — parsed with explicit defaults rather than schema magic so a
|
|
2
|
+
// missing or partial config always yields a fully-populated DocketConfig.
|
|
3
|
+
// Unknown keys are preserved (OKF-style tolerance applies to config too).
|
|
4
|
+
|
|
5
|
+
import { parse as parseYaml } from "yaml";
|
|
6
|
+
import { STATES } from "./states";
|
|
7
|
+
|
|
8
|
+
export interface DocketConfig {
|
|
9
|
+
project: string;
|
|
10
|
+
bundle: string;
|
|
11
|
+
ids: { scheme: string; decision_prefix: string };
|
|
12
|
+
workflow: { states: readonly string[] };
|
|
13
|
+
git: { trailer: string; branch_prefix: string };
|
|
14
|
+
/** Verification linkage. null = key absent = feature fully dormant. */
|
|
15
|
+
verify: { tests: string[] } | null;
|
|
16
|
+
extra: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const CONFIG_FILENAME = "docket.yaml";
|
|
20
|
+
|
|
21
|
+
/** Default bundle root — a Docket-signaling name that doesn't collide
|
|
22
|
+
* with a repo's existing docs/ folder. Repos with an explicit `bundle:` keep it. */
|
|
23
|
+
export const DEFAULT_BUNDLE = "docket/";
|
|
24
|
+
|
|
25
|
+
export function parseConfig(source?: string): DocketConfig {
|
|
26
|
+
const raw: Record<string, unknown> =
|
|
27
|
+
source &&
|
|
28
|
+
typeof parseYaml(source) === "object" &&
|
|
29
|
+
parseYaml(source) !== null
|
|
30
|
+
? (parseYaml(source) as Record<string, unknown>)
|
|
31
|
+
: {};
|
|
32
|
+
|
|
33
|
+
const section = (key: string): Record<string, unknown> => {
|
|
34
|
+
const value = raw[key];
|
|
35
|
+
return typeof value === "object" && value !== null
|
|
36
|
+
? (value as Record<string, unknown>)
|
|
37
|
+
: {};
|
|
38
|
+
};
|
|
39
|
+
const str = (value: unknown, fallback: string): string =>
|
|
40
|
+
typeof value === "string" && value.length > 0 ? value : fallback;
|
|
41
|
+
|
|
42
|
+
const ids = section("ids");
|
|
43
|
+
const workflow = section("workflow");
|
|
44
|
+
const git = section("git");
|
|
45
|
+
const configuredStates = Array.isArray(workflow.states)
|
|
46
|
+
? workflow.states.filter((s): s is string => typeof s === "string")
|
|
47
|
+
: [...STATES];
|
|
48
|
+
// Canonical states are engine semantics, not optional feature flags. Append
|
|
49
|
+
// newly introduced states for older adopting configs so upgraded clients can
|
|
50
|
+
// expose them without rewriting the user's docket.yaml.
|
|
51
|
+
const states = [
|
|
52
|
+
...configuredStates,
|
|
53
|
+
...STATES.filter((state) => !configuredStates.includes(state)),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// verify: the whole feature's on/off switch is this key's presence.
|
|
57
|
+
const verifySection = section("verify");
|
|
58
|
+
const verify =
|
|
59
|
+
"verify" in raw
|
|
60
|
+
? {
|
|
61
|
+
tests: Array.isArray(verifySection.tests)
|
|
62
|
+
? verifySection.tests.filter(
|
|
63
|
+
(g): g is string => typeof g === "string",
|
|
64
|
+
)
|
|
65
|
+
: [],
|
|
66
|
+
}
|
|
67
|
+
: null;
|
|
68
|
+
|
|
69
|
+
const known = new Set([
|
|
70
|
+
"project",
|
|
71
|
+
"bundle",
|
|
72
|
+
"ids",
|
|
73
|
+
"workflow",
|
|
74
|
+
"git",
|
|
75
|
+
"verify",
|
|
76
|
+
]);
|
|
77
|
+
const extra = Object.fromEntries(
|
|
78
|
+
Object.entries(raw).filter(([k]) => !known.has(k)),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
project: str(raw.project, "DKT"),
|
|
83
|
+
bundle: str(raw.bundle, DEFAULT_BUNDLE),
|
|
84
|
+
ids: {
|
|
85
|
+
scheme: str(ids.scheme, "sequential"),
|
|
86
|
+
decision_prefix: str(ids.decision_prefix, "DEC"),
|
|
87
|
+
},
|
|
88
|
+
workflow: { states },
|
|
89
|
+
git: {
|
|
90
|
+
trailer: str(git.trailer, "Task"),
|
|
91
|
+
branch_prefix: str(git.branch_prefix, "task/"),
|
|
92
|
+
},
|
|
93
|
+
verify,
|
|
94
|
+
extra,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical agent-facing descriptions of engine-owned task semantics.
|
|
3
|
+
*
|
|
4
|
+
* Execution remains in states.ts, bundle.ts, and ops.ts. Shipped workflows
|
|
5
|
+
* and command adapters interpolate these claims instead of restating them,
|
|
6
|
+
* while workflow guard tests exercise the executable behavior behind them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const ENGINE_SEMANTICS = {
|
|
10
|
+
readiness:
|
|
11
|
+
"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.",
|
|
12
|
+
readyOrdering:
|
|
13
|
+
"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.",
|
|
14
|
+
transitions:
|
|
15
|
+
"Stored 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.",
|
|
16
|
+
mutationOwnership: {
|
|
17
|
+
pickup:
|
|
18
|
+
"The 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.",
|
|
19
|
+
grooming:
|
|
20
|
+
"The engine owns ready/list derivation and task mutation mechanics; the groom workflow owns audit judgment, proposed changes, and the authorization boundary.",
|
|
21
|
+
close:
|
|
22
|
+
"The 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.",
|
|
23
|
+
},
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export const READY_QUEUE_DESCRIPTION = `${ENGINE_SEMANTICS.readiness} ${ENGINE_SEMANTICS.readyOrdering}`;
|