@brainervirus/workit-core 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/workit-bdd/SKILL.md +35 -29
  4. package/skills/workit-continue/SKILL.md +53 -0
  5. package/skills/workit-debug/SKILL.md +41 -36
  6. package/skills/workit-deslop/SKILL.md +35 -25
  7. package/skills/workit-fanout/SKILL.md +62 -0
  8. package/skills/workit-fanout/references/brief.md +56 -0
  9. package/skills/workit-implement/SKILL.md +48 -44
  10. package/skills/workit-review/SKILL.md +42 -51
  11. package/skills/workit-review/references/impact.md +24 -0
  12. package/skills/workit-shape/SKILL.md +71 -0
  13. package/skills/workit-shape/references/diagrams.md +17 -0
  14. package/skills/workit-shape/references/knowledge.md +58 -0
  15. package/skills/workit-shape/references/mockups.md +15 -0
  16. package/skills/workit-shape/references/slicing.md +42 -0
  17. package/skills/workit-ship/SKILL.md +52 -0
  18. package/skills/workit-test-audit/SKILL.md +10 -2
  19. package/skills/workit-verify-app/SKILL.md +63 -0
  20. package/skills/workit-verify-app/references/template.md +49 -0
  21. package/src/core/external-action-effects.ts +2 -2
  22. package/src/core/methods.ts +68 -95
  23. package/src/core/skill-manifests.ts +49 -25
  24. package/src/core/store-lock.ts +13 -2
  25. package/src/core/task-contract.ts +17 -18
  26. package/src/core/task-engine.ts +108 -58
  27. package/src/core/task-store.ts +1857 -1259
  28. package/src/core.ts +1 -6
  29. package/src/hooks/context.ts +57 -5
  30. package/src/hooks/handle.ts +29 -5
  31. package/src/hooks/hosts/codex.ts +1 -1
  32. package/src/ledger.ts +31 -28
  33. package/src/store/event-log.ts +217 -0
  34. package/src/store/notes.ts +29 -0
  35. package/src/store/patch.ts +135 -0
  36. package/src/store/paths.ts +368 -0
  37. package/src/store/reduce.ts +203 -0
  38. package/templates/plan-template.md +17 -18
  39. package/templates/spec-template.md +4 -3
  40. package/skills/workit-babysit/SKILL.md +0 -37
  41. package/skills/workit-behavioral-tdd/SKILL.md +0 -56
  42. package/skills/workit-blast-radius/SKILL.md +0 -26
  43. package/skills/workit-challenge/SKILL.md +0 -47
  44. package/skills/workit-diagram/SKILL.md +0 -27
  45. package/skills/workit-green-run/SKILL.md +0 -24
  46. package/skills/workit-handoff/SKILL.md +0 -37
  47. package/skills/workit-mockup/SKILL.md +0 -23
  48. package/skills/workit-plan/SKILL.md +0 -45
  49. package/skills/workit-steer/SKILL.md +0 -39
  50. package/templates/execution-contract.md +0 -40
@@ -0,0 +1,368 @@
1
+ // Where the task event store lives and which task a checkout's work belongs
2
+ // to (design §4.1, D3, D13).
3
+ //
4
+ // - The store directory is `<git common dir>/workit` in a git repository, so
5
+ // every worktree of the repo shares it and it survives worktree removal and
6
+ // `git clean`; outside git it is `<root>/.workit`.
7
+ // - The implicit task key is the branch name when HEAD is attached,
8
+ // `detached-<hash of the worktree>` on a detached HEAD, and
9
+ // `dir-<hash of the root>` outside git.
10
+ //
11
+ // Per-turn hooks resolve this on every event, so the common layouts (a `.git`
12
+ // directory, or a worktree's `.git` file with its `commondir`) are read from
13
+ // the filesystem. Anything else, or any GIT_* discovery override in the
14
+ // environment, asks git itself.
15
+ import { spawnSync } from "node:child_process";
16
+ import { createHash } from "node:crypto";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ export type StoreLocation = {
21
+ /** The store directory. */
22
+ dir: string;
23
+ /** True in a git repository (shared by its worktrees). */
24
+ shared: boolean;
25
+ /** The worktree top level (null outside git, or in a bare repository). */
26
+ top: string | null;
27
+ /** The worktree's own git dir, when read from the filesystem. */
28
+ git?: { gitDir: string; top: string | null; reftable: boolean };
29
+ };
30
+
31
+ export type TaskKey = {
32
+ key: string;
33
+ kind: "branch" | "detached" | "dir";
34
+ /** The branch name when HEAD is attached. */
35
+ branch: string | null;
36
+ /** When a task was bound to this key (stored with the binding). */
37
+ boundAt?: string;
38
+ };
39
+
40
+ const NOT_A_REPO = /not a git repository \(or any (of the parent directories|parent up to)/u;
41
+
42
+ type GitRun = { ok: boolean; stdout: string; stderr: string; missing: boolean };
43
+ const gitRun = (cwd: string, args: string[]): GitRun => {
44
+ const run = spawnSync("git", args, {
45
+ cwd,
46
+ encoding: "utf8",
47
+ timeout: 20_000,
48
+ killSignal: "SIGKILL",
49
+ windowsHide: true,
50
+ env: {
51
+ ...process.env,
52
+ GIT_TERMINAL_PROMPT: "0",
53
+ GIT_OPTIONAL_LOCKS: "0",
54
+ LC_ALL: "C",
55
+ LANGUAGE: "C",
56
+ },
57
+ });
58
+ const error: NodeJS.ErrnoException | undefined = run.error;
59
+ return {
60
+ ok: run.status === 0,
61
+ stdout: run.stdout ?? "",
62
+ stderr: error ? String(error.message) : (run.stderr ?? ""),
63
+ missing: error?.code === "ENOENT",
64
+ };
65
+ };
66
+
67
+ const shortHash = (value: string): string =>
68
+ createHash("sha256").update(value).digest("hex").slice(0, 12);
69
+
70
+ /** The canonical path (the native resolver also expands Windows 8.3 short names). */
71
+ const real = (file: string): string => {
72
+ try {
73
+ return fs.realpathSync.native(file);
74
+ } catch {
75
+ return file;
76
+ }
77
+ };
78
+
79
+ /** Environment that changes how git discovers a repository. */
80
+ const DISCOVERY_ENV = [
81
+ "GIT_DIR",
82
+ "GIT_COMMON_DIR",
83
+ "GIT_WORK_TREE",
84
+ "GIT_CEILING_DIRECTORIES",
85
+ "GIT_DISCOVERY_ACROSS_FILESYSTEM",
86
+ ];
87
+
88
+ type FastGit =
89
+ | { kind: "none" }
90
+ | { kind: "repo"; common: string; gitDir: string; top: string | null; reftable: boolean };
91
+
92
+ /** A directory that is itself a git dir (a bare repository, or inside `.git`). */
93
+ const isGitDir = (dir: string): boolean => {
94
+ try {
95
+ return (
96
+ fs.statSync(path.join(dir, "HEAD")).isFile() &&
97
+ fs.statSync(path.join(dir, "objects")).isDirectory() &&
98
+ fs.statSync(path.join(dir, "refs")).isDirectory()
99
+ );
100
+ } catch {
101
+ return false;
102
+ }
103
+ };
104
+
105
+ const reftableRefs = (common: string): boolean => {
106
+ try {
107
+ return /^\s*refstorage\s*=\s*reftable\s*$/imu.test(
108
+ fs.readFileSync(path.join(common, "config"), "utf8"),
109
+ );
110
+ } catch {
111
+ return false;
112
+ }
113
+ };
114
+
115
+ /**
116
+ * Find the repository the way git does for the plain layouts, without
117
+ * spawning git: from the canonical path (symlinks resolved first, as git
118
+ * does), walk up to the first `.git` or git dir (stopping at a filesystem
119
+ * boundary, like git), then follow a `.git` file and `commondir`. Null when
120
+ * the layout is anything else, so the caller asks git.
121
+ */
122
+ function fastGit(root: string): FastGit | null {
123
+ if (DISCOVERY_ENV.some((name) => process.env[name])) return null;
124
+ let dir = real(root);
125
+ let device: number | null = null;
126
+ for (;;) {
127
+ let stat: fs.Stats;
128
+ try {
129
+ stat = fs.statSync(dir);
130
+ } catch {
131
+ return null;
132
+ }
133
+ if (device !== null && stat.dev !== device) return { kind: "none" };
134
+ device = stat.dev;
135
+ const dotGit = path.join(dir, ".git");
136
+ let entry: fs.Stats | null = null;
137
+ try {
138
+ entry = fs.statSync(dotGit);
139
+ } catch {}
140
+ let gitDir: string | null = null;
141
+ let top: string | null = null;
142
+ if (entry) {
143
+ top = dir;
144
+ if (entry.isDirectory()) gitDir = dotGit;
145
+ else if (entry.isFile()) {
146
+ let text = "";
147
+ try {
148
+ text = fs.readFileSync(dotGit, "utf8");
149
+ } catch {
150
+ return null;
151
+ }
152
+ const match = /^gitdir: (.+)$/mu.exec(text);
153
+ if (!match) return null;
154
+ gitDir = path.resolve(dir, match[1].trim());
155
+ } else return null;
156
+ if (!fs.existsSync(path.join(gitDir, "HEAD"))) return null;
157
+ } else if (isGitDir(dir)) gitDir = dir;
158
+ if (gitDir) {
159
+ let common = gitDir;
160
+ try {
161
+ common = path.resolve(
162
+ gitDir,
163
+ fs.readFileSync(path.join(gitDir, "commondir"), "utf8").trim(),
164
+ );
165
+ } catch {}
166
+ if (!fs.existsSync(path.join(common, "objects"))) return null;
167
+ return {
168
+ kind: "repo",
169
+ common: real(common),
170
+ gitDir: real(gitDir),
171
+ top: top === null ? null : real(top),
172
+ reftable: reftableRefs(common),
173
+ };
174
+ }
175
+ const parent = path.dirname(dir);
176
+ if (parent === dir) return { kind: "none" };
177
+ dir = parent;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The store directory for a checkout root, or an error message when git
183
+ * cannot answer for a directory that is (or may be) inside a repository.
184
+ * `top` is the worktree's top level (null for a bare repository or a git
185
+ * dir); a checkout is identified by it, so every subdirectory of a worktree
186
+ * is the same checkout.
187
+ */
188
+ export function resolveStore(root: string): StoreLocation | Error {
189
+ root = path.resolve(root);
190
+ // A root that is not (yet) a directory has no repository: keep the 2.x
191
+ // per-directory layout, so reads and writes fail or create as before.
192
+ let directory = false;
193
+ try {
194
+ directory = fs.statSync(root).isDirectory();
195
+ } catch {}
196
+ if (!directory) return { dir: path.join(root, ".workit"), shared: false, top: null };
197
+ const fast = fastGit(root);
198
+ if (fast?.kind === "none") return { dir: path.join(root, ".workit"), shared: false, top: null };
199
+ if (fast?.kind === "repo")
200
+ return {
201
+ dir: path.join(fast.common, "workit"),
202
+ shared: true,
203
+ top: fast.top,
204
+ git: { gitDir: fast.gitDir, top: fast.top, reftable: fast.reftable },
205
+ };
206
+ const run = gitRun(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
207
+ const common = run.stdout.trim();
208
+ if (run.ok && common) {
209
+ const top = gitRun(root, ["rev-parse", "--path-format=absolute", "--show-toplevel"]);
210
+ return {
211
+ dir: path.join(real(path.resolve(root, common)), "workit"),
212
+ shared: true,
213
+ top: top.ok && top.stdout.trim() ? real(top.stdout.trim()) : null,
214
+ };
215
+ }
216
+ if (run.missing || (!run.ok && NOT_A_REPO.test(run.stderr)))
217
+ return { dir: path.join(root, ".workit"), shared: false, top: null };
218
+ return new Error(
219
+ `cannot locate the workit store: git rev-parse failed (${run.stderr.trim().split("\n")[0] || "no output"}); fix the repository (git status should work here)`,
220
+ );
221
+ }
222
+
223
+ /** The branch a rebase in progress will update (HEAD is detached meanwhile). */
224
+ const rebasingBranch = (gitDir: string): string | null => {
225
+ for (const state of ["rebase-merge", "rebase-apply"]) {
226
+ try {
227
+ const ref = fs.readFileSync(path.join(gitDir, state, "head-name"), "utf8").trim();
228
+ const match = /^refs\/heads\/(.+)$/u.exec(ref);
229
+ if (match) return match[1];
230
+ } catch {}
231
+ }
232
+ return null;
233
+ };
234
+
235
+ /** The implicit task key for `root` (see the module comment). */
236
+ export function resolveTaskKey(root: string, location: StoreLocation): TaskKey {
237
+ if (!location.shared) return { key: `dir-${shortHash(root)}`, kind: "dir", branch: null };
238
+ const branchKey = (name: string): TaskKey => ({ key: name, kind: "branch", branch: name });
239
+ // reftable keeps refs out of the filesystem; HEAD is a `refs/heads/.invalid` stub.
240
+ if (location.git && !location.git.reftable) {
241
+ let head = "";
242
+ try {
243
+ head = fs.readFileSync(path.join(location.git.gitDir, "HEAD"), "utf8").trim();
244
+ } catch {}
245
+ const ref = /^ref: refs\/heads\/(.+)$/u.exec(head);
246
+ if (ref && ref[1] !== ".invalid") return branchKey(ref[1]);
247
+ if (/^[0-9a-f]{40,64}$/u.test(head)) {
248
+ const rebasing = rebasingBranch(location.git.gitDir);
249
+ if (rebasing) return branchKey(rebasing);
250
+ return {
251
+ key: `detached-${shortHash(location.git.top ?? location.git.gitDir)}`,
252
+ kind: "detached",
253
+ branch: null,
254
+ };
255
+ }
256
+ }
257
+ const branch = gitRun(root, ["symbolic-ref", "-q", "--short", "HEAD"]);
258
+ const name = branch.ok ? branch.stdout.trim() : "";
259
+ if (name && name !== ".invalid") return branchKey(name);
260
+ const gitDir = gitRun(root, ["rev-parse", "--path-format=absolute", "--git-dir"]);
261
+ if (gitDir.ok && gitDir.stdout.trim()) {
262
+ const rebasing = rebasingBranch(gitDir.stdout.trim());
263
+ if (rebasing) return branchKey(rebasing);
264
+ }
265
+ const top = gitRun(root, ["rev-parse", "--path-format=absolute", "--show-toplevel"]);
266
+ const worktree = top.ok && top.stdout.trim() ? top.stdout.trim() : root;
267
+ return { key: `detached-${shortHash(real(worktree))}`, kind: "detached", branch: null };
268
+ }
269
+
270
+ /**
271
+ * The checkout a path belongs to: its worktree's top level in a git
272
+ * repository (so every subdirectory is the same checkout), else the path.
273
+ */
274
+ export function checkoutRootOf(root: string, location?: StoreLocation | Error): string {
275
+ const resolved = path.resolve(root);
276
+ let canonical = resolved;
277
+ try {
278
+ canonical = fs.realpathSync(resolved);
279
+ } catch {
280
+ return resolved;
281
+ }
282
+ const found = location ?? resolveStore(canonical);
283
+ if (found instanceof Error || !found.shared || !found.top) return canonical;
284
+ // Keep the caller's spelling (e.g. a Windows short name) when it already is the top.
285
+ if (real(canonical) === found.top) return canonical;
286
+ try {
287
+ return fs.realpathSync(found.top);
288
+ } catch {
289
+ return canonical;
290
+ }
291
+ }
292
+
293
+ export type BranchRename = { from: string; to: string; /** Unix seconds. */ at: number };
294
+
295
+ /**
296
+ * Branch renames recorded in the reflogs ("Branch: renamed refs/heads/a to
297
+ * refs/heads/b"): every branch log in the common dir, plus this worktree's
298
+ * HEAD log. Oldest first.
299
+ */
300
+ export function branchRenames(location: StoreLocation): BranchRename[] {
301
+ if (!location.shared) return [];
302
+ const files: string[] = [];
303
+ const walk = (dir: string) => {
304
+ let entries: fs.Dirent[];
305
+ try {
306
+ entries = fs.readdirSync(dir, { withFileTypes: true });
307
+ } catch {
308
+ return;
309
+ }
310
+ for (const entry of entries)
311
+ if (entry.isDirectory()) walk(path.join(dir, entry.name));
312
+ else files.push(path.join(dir, entry.name));
313
+ };
314
+ walk(path.join(path.dirname(location.dir), "logs", "refs", "heads"));
315
+ if (location.git) files.push(path.join(location.git.gitDir, "logs", "HEAD"));
316
+ const seen = new Set<string>();
317
+ const renames: BranchRename[] = [];
318
+ for (const file of files) {
319
+ let text = "";
320
+ try {
321
+ text = fs.readFileSync(file, "utf8");
322
+ } catch {
323
+ continue;
324
+ }
325
+ for (const match of text.matchAll(
326
+ / (\d+) [+-]\d{4}\tBranch: renamed refs\/heads\/(\S+) to refs\/heads\/(\S+)/gu,
327
+ )) {
328
+ const rename = { from: match[2], to: match[3], at: Number(match[1]) };
329
+ const id = `${rename.at}\0${rename.from}\0${rename.to}`;
330
+ if (seen.has(id)) continue;
331
+ seen.add(id);
332
+ renames.push(rename);
333
+ }
334
+ }
335
+ return renames.toSorted((left, right) => left.at - right.at);
336
+ }
337
+
338
+ /**
339
+ * The key a task bound to `key` at `boundAt` (ISO time) follows to: a branch
340
+ * renamed after the task was bound takes its task along, so a new branch
341
+ * created later under the old name does not inherit it.
342
+ */
343
+ export function followRenames(key: string, boundAt: string, renames: BranchRename[]): string {
344
+ let current = key;
345
+ let since = Math.floor(Date.parse(boundAt) / 1000);
346
+ for (const rename of renames)
347
+ if (rename.from === current && rename.at >= since) {
348
+ current = rename.to;
349
+ since = rename.at;
350
+ }
351
+ return current;
352
+ }
353
+
354
+ /**
355
+ * A filesystem-safe directory name for one checkout's records, from its
356
+ * canonical path, so every spelling of the same directory (symlinks, Windows
357
+ * short names, drive-letter case) maps to one checkout.
358
+ */
359
+ export const checkoutSlug = (root: string): string => {
360
+ let canonical = real(root);
361
+ if (process.platform === "win32") canonical = canonical.toLowerCase();
362
+ const base =
363
+ path
364
+ .basename(canonical)
365
+ .replace(/[^A-Za-z0-9._-]/g, "_")
366
+ .slice(0, 40) || "root";
367
+ return `${base}-${shortHash(canonical)}`;
368
+ };
@@ -0,0 +1,203 @@
1
+ // Pure reduction of a task's events into its state (design §4.1), plus the
2
+ // content-addressed blob encoding for stored candidates.
3
+ //
4
+ // Every event may carry, in `data`:
5
+ // record the whole task record (opening events and compaction checkpoints)
6
+ // ops a structural patch of the record (see patch.ts)
7
+ // key the implicit-task key the task is bound to (null unbinds)
8
+ // legacy where a migrated task came from
9
+ // The reducer applies whatever of these an event carries, so an event type
10
+ // this runtime does not know still replays correctly when it uses them. An
11
+ // unknown event that carries none of them is ignored, unless it is marked
12
+ // `critical`, in which case the reader fails closed.
13
+ import { createHash } from "node:crypto";
14
+ import { LogDamage, type StoreEvent } from "./event-log";
15
+ import { apply, isPatch, type PatchOp } from "./patch";
16
+ import type { TaskKey } from "./paths";
17
+
18
+ export type LegacyOrigin = { path: string; digest: string };
19
+
20
+ export type ReducedTask = {
21
+ record: Record<string, unknown>;
22
+ key: TaskKey | null;
23
+ legacy: LegacyOrigin | null;
24
+ };
25
+
26
+ /** Event types that open (or restart, after compaction) a task's log. */
27
+ export const OPENING_TYPES: ReadonlySet<string> = new Set([
28
+ "task.opened",
29
+ "task.imported",
30
+ "migrated.from_v2",
31
+ "task.checkpoint",
32
+ ]);
33
+
34
+ /** Event types this runtime writes and reads. */
35
+ const KNOWN_TYPES: ReadonlySet<string> = new Set([
36
+ ...OPENING_TYPES,
37
+ "task.bound",
38
+ "task.noted",
39
+ "task.revised",
40
+ "task.judged",
41
+ "task.paused",
42
+ "task.resumed",
43
+ "task.closed",
44
+ "task.updated",
45
+ "check.ran",
46
+ "evidence.recorded",
47
+ "finding.recorded",
48
+ "finding.updated",
49
+ "decision.recorded",
50
+ "decision.updated",
51
+ "worker.updated",
52
+ ]);
53
+
54
+ const isObject = (value: unknown): value is Record<string, unknown> =>
55
+ typeof value === "object" && value !== null && !Array.isArray(value);
56
+
57
+ const isKey = (value: unknown): value is TaskKey =>
58
+ isObject(value) &&
59
+ typeof value.key === "string" &&
60
+ (value.kind === "branch" || value.kind === "detached" || value.kind === "dir") &&
61
+ (value.branch === null || typeof value.branch === "string");
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // blobs: stored candidates are written once, by content digest
65
+
66
+ export type BlobStore = {
67
+ read(digest: string): unknown;
68
+ write(digest: string, value: unknown): void;
69
+ };
70
+
71
+ const BLOB_REF = "$blob";
72
+ const blobRef = (value: unknown): string | null =>
73
+ isObject(value) &&
74
+ Object.keys(value).length === 1 &&
75
+ typeof value[BLOB_REF] === "string" &&
76
+ /^[0-9a-f]{64}$/.test(value[BLOB_REF])
77
+ ? value[BLOB_REF]
78
+ : null;
79
+
80
+ export const blobDigest = (value: unknown): string =>
81
+ createHash("sha256").update(JSON.stringify(value)).digest("hex");
82
+
83
+ const toBlob = (value: unknown, blobs: BlobStore): unknown => {
84
+ if (!isObject(value)) return value;
85
+ const digest = blobDigest(value);
86
+ blobs.write(digest, value);
87
+ return { [BLOB_REF]: digest };
88
+ };
89
+
90
+ /** Replace candidate bodies in a record with blob references. */
91
+ export const encodeRecord = (
92
+ record: Record<string, unknown>,
93
+ blobs: BlobStore,
94
+ ): Record<string, unknown> =>
95
+ Array.isArray(record.candidates)
96
+ ? { ...record, candidates: record.candidates.map((item) => toBlob(item, blobs)) }
97
+ : record;
98
+
99
+ /** Replace candidate bodies inside patch ops with blob references. */
100
+ export const encodeOps = (ops: PatchOp[], blobs: BlobStore): PatchOp[] =>
101
+ ops.map((op) => {
102
+ if (op.path[0] !== "candidates") return op;
103
+ if (op.op === "push" && op.path.length === 1)
104
+ return { ...op, values: op.values.map((item) => toBlob(item, blobs)) };
105
+ if (op.op === "set" && op.path.length === 1 && Array.isArray(op.value))
106
+ return { ...op, value: op.value.map((item) => toBlob(item, blobs)) };
107
+ if (op.op === "set" && op.path.length === 2) return { ...op, value: toBlob(op.value, blobs) };
108
+ return op;
109
+ });
110
+
111
+ /** Resolve blob references anywhere in `value`. */
112
+ const decode = (value: unknown, blobs: BlobStore): unknown => {
113
+ const digest = blobRef(value);
114
+ if (digest) return blobs.read(digest);
115
+ if (Array.isArray(value)) return value.map((item) => decode(item, blobs));
116
+ if (isObject(value)) {
117
+ const out: Record<string, unknown> = {};
118
+ for (const [key, item] of Object.entries(value)) out[key] = decode(item, blobs);
119
+ return out;
120
+ }
121
+ return value;
122
+ };
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // reduction
126
+
127
+ /** Apply one event to `state` (null before the opening event). */
128
+ export function reduce(
129
+ state: ReducedTask | null,
130
+ event: StoreEvent,
131
+ blobs: BlobStore,
132
+ ): ReducedTask | null {
133
+ const data = event.data;
134
+ const known = KNOWN_TYPES.has(event.type);
135
+ const carries = "record" in data || "ops" in data || "key" in data;
136
+ if (!known && !carries) {
137
+ if (event.critical === true)
138
+ throw new LogDamage(
139
+ `event ${event.type} was written by a newer Workit and must be understood; upgrade Workit to read this task`,
140
+ true,
141
+ );
142
+ return state;
143
+ }
144
+ let next = state;
145
+ if ("record" in data) {
146
+ if (!isObject(data.record))
147
+ throw new LogDamage(`event ${event.seq} carries a malformed record`);
148
+ next = {
149
+ record: decode(data.record, blobs) as Record<string, unknown>,
150
+ key: null,
151
+ legacy: null,
152
+ };
153
+ } else if (next === null) {
154
+ throw new LogDamage(`the log does not start with an opening event (found ${event.type})`);
155
+ } else next = { ...next };
156
+ if ("ops" in data) {
157
+ if (!isPatch(data.ops)) throw new LogDamage(`event ${event.seq} carries a malformed patch`);
158
+ try {
159
+ next.record = apply(
160
+ next.record,
161
+ decode(data.ops, blobs) as PatchOp[],
162
+ ) as ReducedTask["record"];
163
+ } catch (error) {
164
+ throw new LogDamage(`event ${event.seq} does not apply: ${String(error)}`);
165
+ }
166
+ }
167
+ if ("key" in data) next.key = isKey(data.key) ? data.key : null;
168
+ if ("legacy" in data && isObject(data.legacy))
169
+ next.legacy = {
170
+ path: String(data.legacy.path ?? ""),
171
+ digest: String(data.legacy.digest ?? ""),
172
+ };
173
+ return next;
174
+ }
175
+
176
+ /** A readable event type for a mutation (informational; replay does not use it). */
177
+ export function eventType(before: Record<string, unknown>, after: Record<string, unknown>): string {
178
+ const count = (value: unknown) => (Array.isArray(value) ? value.length : 0);
179
+ if (before.status !== after.status)
180
+ return after.status === "closed"
181
+ ? "task.closed"
182
+ : after.status === "paused"
183
+ ? "task.paused"
184
+ : "task.resumed";
185
+ if (count(after.evidence) > count(before.evidence)) {
186
+ const last = (after.evidence as Array<{ data?: { observation?: unknown } }>).at(-1);
187
+ return last?.data?.observation ? "check.ran" : "evidence.recorded";
188
+ }
189
+ if (count(after.findings) > count(before.findings)) return "finding.recorded";
190
+ if (count(after.decisions) > count(before.decisions)) return "decision.recorded";
191
+ if (JSON.stringify(before.findings) !== JSON.stringify(after.findings)) return "finding.updated";
192
+ if (JSON.stringify(before.decisions) !== JSON.stringify(after.decisions))
193
+ return "decision.updated";
194
+ if (JSON.stringify(before.workers) !== JSON.stringify(after.workers)) return "worker.updated";
195
+ if (JSON.stringify(before.intent) !== JSON.stringify(after.intent)) return "task.revised";
196
+ if (
197
+ JSON.stringify(before.assessments) !== JSON.stringify(after.assessments) ||
198
+ JSON.stringify(before.policy) !== JSON.stringify(after.policy)
199
+ )
200
+ return "task.judged";
201
+ if (JSON.stringify(before.progress) !== JSON.stringify(after.progress)) return "task.noted";
202
+ return "task.updated";
203
+ }
@@ -1,27 +1,26 @@
1
- # <Feature> Implementation Plan
1
+ # <Feature> plan
2
2
 
3
- > **For agentic workers:** Load `workit-implement` when policy selects implementation. Use bounded `workit_worker` delegation when the host supports it; otherwise execute inline within writer scope. Steps use checkbox (`- [ ]`) syntax for tracking.
3
+ Spec: `docs/<slug>/spec.md` · Goal: <one sentence>
4
4
 
5
- **Spec:** `docs/<slug>/spec.md`
6
- **Branch:** `feature/<slug>`
5
+ A plan records decisions, not code: per slice the branch, what it touches, its
6
+ acceptance, how it is verified, and what it depends on (workit-shape
7
+ `references/slicing.md`). Independent slices fan out (workit-fanout);
8
+ dependent ones stack (`workit stack plan <bottom> … <top>`).
7
9
 
8
- **Goal:** <one sentence>
10
+ ## Global constraints
9
11
 
10
- ## Global Constraints
11
-
12
- - Each task lands exactly one contiguous non-empty commit range (`base..head`): fix rounds append commits to that range and never rewrite/amend an active review range; record the real base..head shas in task progress.
13
- - The final task closes the lead Workit task with `workit_task` `action: "close"` (CLI: `workit task close --payload … [--confirm]`) once requirements are satisfied and repository verification passes — never finish while the task is still `active` or `paused`.
14
- - <project-wide requirements, one line each>
12
+ - <project-wide requirement, one line each>
15
13
 
16
14
  ---
17
15
 
18
- ### Task N: <Component>
19
-
20
- - [ ] **Step 1: <action>**
16
+ ### S1 <type>/<slug> (base: main)
21
17
 
22
- <!-- per-task criteria: how this task is verified -->
23
- **Criteria:** <verifiable check>
18
+ Touches: <areas, not line numbers>
19
+ Acceptance:
20
+ - Given <state>, When <action>, Then <observable result>
21
+ Verify: `workit check test`; verify-<app> "<feature>"
22
+ Decisions: <ledger refs or one-line rulings>
24
23
 
25
- | Status | Task |
26
- | --- | --- |
27
- | pending | N: <Component> |
24
+ | Slice | Branch | Depends on | Status |
25
+ | --- | --- | --- | --- |
26
+ | S1 | <type>/<slug> | — | pending |
@@ -19,14 +19,15 @@ no durable agreement: `Spec: none (<reason>)`, no file.)
19
19
 
20
20
  ## Architecture
21
21
 
22
- <!-- May be `N/A (<reason>)` when the change has no flows or architecture. -->
23
- <!-- Otherwise REQUIRED: render a mermaid diagram (workit-diagram skill). -->
22
+ <!-- Optional: only when a diagram argues a decision (workit-shape
23
+ references/diagrams.md); otherwise delete this section. -->
24
24
  ```mermaid
25
25
  flowchart TD
26
26
  A[Start] --> B[Step]
27
27
  ```
28
28
 
29
- <!-- REQUIRED if this spec touches UI: render an ASCII wireframe (workit-mockup skill). -->
29
+ <!-- Optional: a UI choice still open gets an ASCII sketch (workit-shape
30
+ references/mockups.md). -->
30
31
  ```text
31
32
  ┌──────────────┐
32
33
  │ Header │
@@ -1,37 +0,0 @@
1
- ---
2
- name: workit-babysit
3
- description: Use when the user asks to babysit a PR or explicitly opts in to PR follow-up
4
- ---
5
-
6
- # Babysit a PR to PR-ready
7
-
8
- PR creation does not start babysitting. `babysit:true` opts into this skill;
9
- omission means no follow-up. A PR URL by itself is not an instruction to drive
10
- it. When the user asks to babysit a URL from a route Workit did not enforce,
11
- help without claiming the Workit route was enforced. Never mutate PR topology
12
- (no rebase strategy changes, no force-push).
13
-
14
-
15
- ## Method
16
-
17
- 1. Default to drive mode for an explicit babysit request: resolve conflicts,
18
- address review threads, and get checks green. Watch reports status only;
19
- threads-only addresses review threads. Ask about mode only when the choice
20
- materially changes the work.
21
- 2. Work toward PR-ready in order: conflicts → review threads → CI. Keep a brief
22
- checkpoint when continuity needs it; task progress is optional.
23
- 3. Classify CI before retry: flake (rerun once) vs stale base (verify with
24
- `git merge-base --is-ancestor` before updating) vs real failure (fix).
25
- 4. Triage bot findings skeptically: reproduce or quote code before acting;
26
- invalid bots get a reasoned dismissal, never silent ignore.
27
- 5. Batch fixes into one push wave; re-verify green after every push.
28
- 6. Stop at PR-ready. A PR creation or babysit request does not authorize merge
29
- or release. Continue to merge or release only when the user explicitly sets
30
- that delivery endpoint and host authority allows the action.
31
-
32
- ## Completion
33
-
34
- Report PR-ready status with evidence for fixes, or a brief blocker report. If
35
- the user explicitly authorized merge, honor the configured strategy only after
36
- checks and required approval. After a squash merge, re-record evidence against
37
- the new base commit before closing tracked work.