@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
package/src/core.ts CHANGED
@@ -61,12 +61,7 @@ export type {
61
61
  Result as ContractResult,
62
62
  } from "./core/task-contract";
63
63
  export { TaskStore, runtimeVersion } from "./core/task-store";
64
- export type {
65
- MetadataLock,
66
- ProcessEvidence,
67
- RecoveryInput,
68
- TaskIndexEntry,
69
- } from "./core/task-store";
64
+ export type { TaskIndexEntry } from "./core/task-store";
70
65
  export { compactTaskContext, reconcileResume } from "./core/task-context";
71
66
  export type {
72
67
  CompactTaskContext,
@@ -6,10 +6,24 @@ import { canonicalJson } from "../core/task-contract";
6
6
  import { isObservedCheck, type Freshness } from "../core/task-evaluation";
7
7
  import { worktreeSignal } from "../git/rev";
8
8
  import { WorkitCore, type OperationContext } from "../core/task-engine";
9
- import { fileSignature, racySignature, TaskStore, type TaskIndexEntry } from "../core/task-store";
9
+ import {
10
+ fileSignature,
11
+ MIGRATION_PENDING,
12
+ racySignature,
13
+ TaskStore,
14
+ type TaskIndexEntry,
15
+ } from "../core/task-store";
10
16
  import { capabilitiesFor, type HostDescriptor } from "./descriptor";
11
17
  import type { HookInput } from "./protocol";
12
18
 
19
+ /**
20
+ * Shown on every per-turn path while the checkout's 2.x store waits for the
21
+ * CLI to migrate it (hooks never migrate), instead of silently no context.
22
+ */
23
+ export const MIGRATION_PENDING_NOTE = "workit migration pending — run `workit task status`";
24
+ const migrationPending = (result: { ok: boolean; code?: string; error?: string }): boolean =>
25
+ !result.ok && result.code === "needs_input" && (result.error ?? "").startsWith(MIGRATION_PENDING);
26
+
13
27
  /** A native host session, e.g. `{ host: "opencode", handle: sessionID }`. */
14
28
  export type SessionHandle = { host: string; handle: string };
15
29
 
@@ -50,6 +64,34 @@ export const currentTaskEntry = (
50
64
  return active.length === 1 ? active[0] : null;
51
65
  };
52
66
 
67
+ /**
68
+ * The open task bound to this checkout's branch (its implicit task, D3),
69
+ * unless a host conversation other than this one is bound to it: someone
70
+ * else's conversation is only ever offered, never injected. Sessions of the
71
+ * CLI (`workit_cli`, which creates implicit tasks) are tooling, not
72
+ * conversations.
73
+ */
74
+ const implicitTaskEntry = (
75
+ store: TaskStore,
76
+ entries: TaskIndexEntry[],
77
+ session: SessionHandle,
78
+ ): TaskIndexEntry | null => {
79
+ const open = entries.filter(
80
+ (entry) =>
81
+ entry.key !== null &&
82
+ entry.status !== "closed" &&
83
+ !entry.sessions.some(
84
+ (item) =>
85
+ item.host !== "workit_cli" &&
86
+ !(item.host === session.host && item.handle === session.handle),
87
+ ),
88
+ );
89
+ if (open.length === 0) return null;
90
+ const key = store.currentKey();
91
+ if (!key.ok) return null;
92
+ return open.filter((entry) => entry.key === key.data.key).toSorted(newestFirst)[0] ?? null;
93
+ };
94
+
53
95
  /**
54
96
  * History offer for open tasks not bound to `session` (and not `excludeTaskId`,
55
97
  * the task already shown), built from the task index. Task text is quoted and
@@ -109,7 +151,9 @@ const recentSignal = (root: string): string | null => {
109
151
  const cache = new Map<string, CachedContext>();
110
152
 
111
153
  /**
112
- * Compact context for `session`'s current task, for per-turn host injection.
154
+ * Compact context for `session`'s current task (the task bound to the
155
+ * session, else the implicit task of the checkout's branch), for per-turn
156
+ * host injection.
113
157
  *
114
158
  * Reads the task index (no full-record parse for unrelated tasks), never
115
159
  * captures a candidate, and reuses the previous result while the task and
@@ -123,8 +167,10 @@ export function sessionCompactContext(
123
167
  selection: HostDescriptor["context"]["task"] = "session-bound",
124
168
  ): string | null {
125
169
  const listed = store.listTaskIndex();
126
- if (!listed.ok) return null;
127
- const entry = currentTaskEntry(listed.data, session, selection);
170
+ if (!listed.ok) return migrationPending(listed) ? MIGRATION_PENDING_NOTE : null;
171
+ const entry =
172
+ currentTaskEntry(listed.data, session, selection) ??
173
+ implicitTaskEntry(store, listed.data, session);
128
174
  return entry ? entryCompactContext(store, entry, session, context) : null;
129
175
  }
130
176
 
@@ -208,8 +254,13 @@ export const sessionContextText = (
208
254
  try {
209
255
  const store = new TaskStore(input.cwd);
210
256
  const listed = store.listTaskIndex();
257
+ // Hooks never migrate (hot path, possibly a live 2.x writer): say how.
258
+ if (migrationPending(listed))
259
+ return `<workit-contract>\n${invariantBootstrap()}\n${MIGRATION_PENDING_NOTE}${options.addendum ? `\n${options.addendum}` : ""}\n</workit-contract>`;
211
260
  if (!listed.ok) throw new Error(listed.error);
212
- const entry = currentTaskEntry(listed.data, session, descriptor.context.task);
261
+ const entry =
262
+ currentTaskEntry(listed.data, session, descriptor.context.task) ??
263
+ implicitTaskEntry(store, listed.data, session);
213
264
  const text = entry
214
265
  ? entryCompactContext(store, entry, session, hookOperationContext(input, descriptor))
215
266
  : null;
@@ -230,6 +281,7 @@ export const turnContextText = (input: HookInput, descriptor: HostDescriptor): s
230
281
  hookOperationContext(input, descriptor),
231
282
  descriptor.context.task,
232
283
  );
284
+ if (text === MIGRATION_PENDING_NOTE) return text;
233
285
  return text ? `<workit-task-context>${text}</workit-task-context>` : null;
234
286
  } catch {
235
287
  return null;
@@ -17,14 +17,35 @@ const offered = new Set<string>();
17
17
  * `workit:implementer` is ours: a bare or other-plugin `implementer` is not. */
18
18
  const CLAUDE_WORKTREE_IMPLEMENTER = "workit:implementer";
19
19
 
20
+ /** The plugin's read-only judges: each records verdicts as its own session. */
21
+ const CLAUDE_JUDGES: ReadonlySet<string> = new Set(["workit:verifier", "workit:reviewer"]);
22
+
23
+ /** `<lead session>:<agent id>`, safe for the ledger's session grammar. */
24
+ const subagentSession = (session: string | null, agentId: string): string =>
25
+ [session, agentId]
26
+ .filter((part): part is string => Boolean(part))
27
+ .join(":")
28
+ .replace(/[^A-Za-z0-9_.:@/+-]/g, "-")
29
+ .slice(0, 128);
30
+
20
31
  const subagentStartText = (
21
32
  host: HookInput["host"],
33
+ session: string | null,
22
34
  descriptor: HostDescriptor,
23
35
  event: Extract<HookInput["event"], { kind: "subagent.start" }>,
24
- ): string =>
25
- host === "claude_code" && event.agentType === CLAUDE_WORKTREE_IMPLEMENTER
26
- ? `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) working in its own git worktree: it may edit and commit there, within its brief's scope. Before the first commit, switch to a policy-compliant branch (\`git switch -c <type>/<slug>\`, e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never push, open a PR, or merge unless the brief asks for it.`
27
- : `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided; writer delegation is unavailable.`;
36
+ ): string => {
37
+ if (host === "claude_code" && event.agentType === CLAUDE_WORKTREE_IMPLEMENTER)
38
+ return `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) working in its own git worktree: it may edit and commit there, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
39
+ const readOnly = `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided; writer delegation is unavailable.`;
40
+ if (
41
+ host === "claude_code" &&
42
+ event.agentType &&
43
+ CLAUDE_JUDGES.has(event.agentType) &&
44
+ event.agentId
45
+ )
46
+ return `${readOnly} Its own Workit session is ${subagentSession(session, event.agentId)}: record verdicts with \`workit ledger verdict <result> --session ${subagentSession(session, event.agentId)} ...\` so the ledger tells it apart from the author.`;
47
+ return readOnly;
48
+ };
28
49
 
29
50
  export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
30
51
  const { descriptor } = deps;
@@ -50,7 +71,10 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
50
71
  case "shell.pre":
51
72
  return usable(descriptor.shellPolicy.deny) ? shellPolicy(input.cwd, event.command) : NONE;
52
73
  case "subagent.start":
53
- return { kind: "context", text: subagentStartText(input.host, descriptor, event) };
74
+ return {
75
+ kind: "context",
76
+ text: subagentStartText(input.host, input.session.id ?? null, descriptor, event),
77
+ };
54
78
  case "compact.pre":
55
79
  return {
56
80
  kind: "notice",
@@ -294,5 +294,5 @@ export const codexAdapter: HostAdapter = {
294
294
  render,
295
295
  // Without a session id there is no actor to bind a writer to.
296
296
  addendum: (input) =>
297
- `<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run the workit CLI for task mutations: node_modules/.bin/workit <family> <action> --json --confirm${input.session.id ? `; bind the writer to this session with node_modules/.bin/workit writer acquire --task <id> --revision <rev> --actor ${input.session.id} --confirm` : ""}. Binding decisions and external actions need a human.</workit-codex-mutations>`,
297
+ `<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json --confirm${input.session.id ? `; bind the writer to this session with node_modules/.bin/workit writer acquire --task <id> --revision <rev> --actor ${input.session.id} --confirm` : ""}. A verifier or reviewer records workit ledger verdict under a session the lead assigns (WORKIT_SESSION_ID=<lead>-v<n>), never the author's. Merge and release need a workspace grant; a question answer is not host permission.</workit-codex-mutations>`,
298
298
  };
package/src/ledger.ts CHANGED
@@ -40,6 +40,7 @@ import { createHash, randomBytes } from "node:crypto";
40
40
  import fs from "node:fs";
41
41
  import path from "node:path";
42
42
  import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
43
+ import { resolveStore } from "./store/paths";
43
44
 
44
45
  export const LEDGER_VERSION = 1;
45
46
  /** The largest row (including its newline) that one append may carry. */
@@ -254,29 +255,22 @@ export function diffHash(cwd: string, base: string, head: string): string | null
254
255
 
255
256
  export type StoreRoot = { root: string; shared: boolean };
256
257
 
257
- const NOT_A_REPO = /not a git repository \(or any (of the parent directories|parent up to)/u;
258
-
259
258
  /**
260
259
  * Where workit state lives for `cwd`: `<git common dir>/workit` (shared by all
261
260
  * worktrees of the repo), or `<cwd>/.workit` only when git reports that `cwd`
262
- * is not inside a repository. A broken repository, a missing git binary or a
263
- * refused (dubious-ownership) repository is `unavailable`.
261
+ * is not inside a repository (or git is not installed). A broken repository
262
+ * or a refused (dubious-ownership) repository is `unavailable`. The task
263
+ * store resolves the same way (store/paths.ts).
264
264
  */
265
265
  export function storeRoot(cwd: string): LedgerResult<StoreRoot> {
266
- const run = gitRun(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
267
- const common = run.stdout.trim();
268
- if (run.ok && common)
269
- return {
270
- ok: true,
271
- value: { root: path.join(path.resolve(cwd, common), "workit"), shared: true },
272
- };
273
- if (!run.ok && NOT_A_REPO.test(run.stderr))
274
- return { ok: true, value: { root: path.join(path.resolve(cwd), ".workit"), shared: false } };
275
- return err(
276
- "unavailable",
277
- `cannot locate the workit store: git rev-parse failed (${run.stderr.trim().split("\n")[0] || "no output"})`,
278
- "fix the repository (git status should work here) or run from a non-git directory",
279
- );
266
+ const location = resolveStore(path.resolve(cwd));
267
+ if (location instanceof Error)
268
+ return err(
269
+ "unavailable",
270
+ location.message.replace(/; fix the repository.*$/u, ""),
271
+ "fix the repository (git status should work here) or run from a non-git directory",
272
+ );
273
+ return { ok: true, value: { root: location.dir, shared: location.shared } };
280
274
  }
281
275
 
282
276
  export function ledgerPath(cwd: string): LedgerResult<string> {
@@ -1077,6 +1071,12 @@ export function recordVerdict(
1077
1071
  surface?: string | null;
1078
1072
  self?: boolean;
1079
1073
  evidenceRefs?: string[];
1074
+ /**
1075
+ * A role id derived from another session (`--as`): the session it was
1076
+ * derived from. `null` means it was derived from no session, which makes
1077
+ * the verdict self. An author's derived id is refused like the author.
1078
+ */
1079
+ derivedFrom?: string | null;
1080
1080
  },
1081
1081
  ): LedgerResult<VerdictRow> {
1082
1082
  if (!(VERDICT_RESULTS as readonly string[]).includes(input.result))
@@ -1111,19 +1111,22 @@ export function recordVerdict(
1111
1111
  );
1112
1112
  const session = context.actor.session;
1113
1113
  const selfReason: SelfReason | null =
1114
- input.self === true ? "flag" : session ? null : "no_session";
1114
+ input.self === true ? "flag" : session && input.derivedFrom !== null ? null : "no_session";
1115
1115
  const self = selfReason !== null;
1116
- if (
1117
- !self &&
1118
- session &&
1119
- authorSessions(context.cwd, ledger.value.rows, branch, { base: key.base, head: key.head }).has(
1120
- session,
1121
- )
1122
- )
1116
+ const authors = authorSessions(context.cwd, ledger.value.rows, branch, {
1117
+ base: key.base,
1118
+ head: key.head,
1119
+ });
1120
+ const authoring = [session, input.derivedFrom].find(
1121
+ (candidate): candidate is string => typeof candidate === "string" && authors.has(candidate),
1122
+ );
1123
+ if (!self && authoring)
1123
1124
  return err(
1124
1125
  "blocked",
1125
- `author_verdict: session ${session} authored ${branch}; a verdict must come from a different session`,
1126
- 'have a non-author session record the verdict, or pass --self (a self verdict is never accepted by merge:"verified")',
1126
+ `author_verdict: session ${authoring} authored ${branch}; a verdict must come from a different session`,
1127
+ input.derivedFrom === undefined
1128
+ ? 'have a non-author session record the verdict, or pass --self (a self verdict is never accepted by merge:"verified")'
1129
+ : "run the verifier as a separate session: the lead spawns it with its own WORKIT_SESSION_ID (Claude Code: subagents get one from the SubagentStart hook)",
1127
1130
  );
1128
1131
  const link = checkSupersede(context, "verdict", self);
1129
1132
  if (!link.ok) return link;
@@ -0,0 +1,217 @@
1
+ // The append-only event log of one task: `events.jsonl`, one JSON event per
2
+ // line, plus `snapshot.json`, a rebuildable cache of the reduced state
3
+ // (design §4.1).
4
+ //
5
+ // - A writer holds the task lock, appends one complete line with O_APPEND in
6
+ // a single write, and fsyncs. A crash can therefore only leave a torn last
7
+ // line (no trailing newline); readers ignore it and the next locked append
8
+ // truncates it away first. Any other unreadable line is real damage.
9
+ // - Readers are lenient (D17): unknown event keys and unknown event types are
10
+ // ignored, unless the event says `critical: true`, in which case a reader
11
+ // that does not know the type fails closed with an upgrade message.
12
+ // - The snapshot names the event it was taken after (seq, id, byte range). A
13
+ // snapshot that does not match the log is ignored and the log is replayed.
14
+ import { randomUUID } from "node:crypto";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ export const EVENT_VERSION = 1;
19
+
20
+ export type EventActor = { host: string; session: string | null; agentId: string | null };
21
+
22
+ export type StoreEvent = {
23
+ v: number;
24
+ seq: number;
25
+ at: string;
26
+ id: string;
27
+ task: string;
28
+ actor: EventActor | null;
29
+ type: string;
30
+ data: Record<string, unknown>;
31
+ critical?: boolean;
32
+ };
33
+
34
+ export type LogRead = {
35
+ events: StoreEvent[];
36
+ /** Byte offset just past the last complete event line. */
37
+ end: number;
38
+ /** Byte offset where the last complete event line starts. */
39
+ lastStart: number;
40
+ /** File size; larger than `end` when a torn line trails the log. */
41
+ size: number;
42
+ };
43
+
44
+ export class LogDamage extends Error {
45
+ constructor(
46
+ message: string,
47
+ readonly upgrade: boolean = false,
48
+ ) {
49
+ super(message);
50
+ }
51
+ }
52
+
53
+ const isObject = (value: unknown): value is Record<string, unknown> =>
54
+ typeof value === "object" && value !== null && !Array.isArray(value);
55
+
56
+ const parseEvent = (line: string, file: string, offset: number): StoreEvent => {
57
+ let value: unknown;
58
+ try {
59
+ value = JSON.parse(line);
60
+ } catch {
61
+ throw new LogDamage(`${file}: unreadable event at byte ${offset}`);
62
+ }
63
+ if (!isObject(value) || typeof value.v !== "number")
64
+ throw new LogDamage(`${file}: malformed event at byte ${offset}`);
65
+ if (value.v > EVENT_VERSION)
66
+ throw new LogDamage(
67
+ `${file}: event format v${value.v} was written by a newer Workit; upgrade Workit to read this task`,
68
+ true,
69
+ );
70
+ if (
71
+ typeof value.seq !== "number" ||
72
+ !Number.isInteger(value.seq) ||
73
+ typeof value.type !== "string" ||
74
+ typeof value.id !== "string" ||
75
+ typeof value.task !== "string" ||
76
+ !isObject(value.data)
77
+ )
78
+ throw new LogDamage(`${file}: malformed event at byte ${offset}`);
79
+ return value as StoreEvent;
80
+ };
81
+
82
+ /**
83
+ * Read the events of `file` starting at byte `from` (a line start). A torn
84
+ * trailing line is excluded; `expectSeq` is the seq the first event must have.
85
+ * Returns null when the file does not exist.
86
+ */
87
+ export function readLog(file: string, from = 0, expectSeq: number | null = null): LogRead | null {
88
+ let fd: number;
89
+ try {
90
+ fd = fs.openSync(file, "r");
91
+ } catch (error) {
92
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
93
+ throw error;
94
+ }
95
+ let bytes: Buffer;
96
+ let size: number;
97
+ try {
98
+ size = fs.fstatSync(fd).size;
99
+ if (from > size) throw new LogDamage(`${file}: shorter than its snapshot`);
100
+ bytes = Buffer.alloc(size - from);
101
+ let read = 0;
102
+ while (read < bytes.length) {
103
+ const got = fs.readSync(fd, bytes, read, bytes.length - read, from + read);
104
+ if (got === 0) break;
105
+ read += got;
106
+ }
107
+ bytes = bytes.subarray(0, read);
108
+ size = from + read;
109
+ } finally {
110
+ fs.closeSync(fd);
111
+ }
112
+ const events: StoreEvent[] = [];
113
+ let start = 0;
114
+ let lastStart = from;
115
+ let seq = expectSeq;
116
+ for (;;) {
117
+ const newline = bytes.indexOf(0x0a, start);
118
+ if (newline < 0) break;
119
+ const line = bytes.subarray(start, newline).toString("utf8");
120
+ if (line.trim() !== "") {
121
+ const event = parseEvent(line, file, from + start);
122
+ if (seq !== null && event.seq !== seq)
123
+ throw new LogDamage(`${file}: event seq ${event.seq} where ${seq} was expected`);
124
+ seq = event.seq + 1;
125
+ events.push(event);
126
+ lastStart = from + start;
127
+ }
128
+ start = newline + 1;
129
+ }
130
+ return { events, end: from + start, lastStart, size };
131
+ }
132
+
133
+ /** Durably write `dir`'s entries (no-op where directories cannot be fsynced). */
134
+ export const fsyncDirectory = (dir: string): void => {
135
+ try {
136
+ const fd = fs.openSync(dir, "r");
137
+ try {
138
+ fs.fsyncSync(fd);
139
+ } finally {
140
+ fs.closeSync(fd);
141
+ }
142
+ } catch (error) {
143
+ if (process.platform !== "win32") throw error;
144
+ }
145
+ };
146
+
147
+ /**
148
+ * Append one event line. `validEnd` is where the last complete line ends: a
149
+ * torn tail beyond it is truncated first. Returns the byte offset the line
150
+ * starts at.
151
+ */
152
+ export function appendEvent(file: string, event: StoreEvent, validEnd: number): number {
153
+ const line = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
154
+ const created = !fs.existsSync(file);
155
+ // Cut a torn tail first, through a writable handle: an append-only handle
156
+ // cannot truncate on Windows.
157
+ if (!created && fs.statSync(file).size !== validEnd) {
158
+ const cut = fs.openSync(file, "r+");
159
+ try {
160
+ fs.ftruncateSync(cut, validEnd);
161
+ fs.fsyncSync(cut);
162
+ } finally {
163
+ fs.closeSync(cut);
164
+ }
165
+ }
166
+ const fd = fs.openSync(file, "a", 0o600);
167
+ try {
168
+ let written = 0;
169
+ while (written < line.length) written += fs.writeSync(fd, line, written, line.length - written);
170
+ fs.fsyncSync(fd);
171
+ } finally {
172
+ fs.closeSync(fd);
173
+ }
174
+ if (created) fsyncDirectory(path.dirname(file));
175
+ return validEnd;
176
+ }
177
+
178
+ /** Replace `file` with `bytes` atomically (temp file + rename). */
179
+ export function replaceFile(file: string, bytes: string, durable: boolean): void {
180
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
181
+ try {
182
+ const fd = fs.openSync(temporary, "wx", 0o600);
183
+ try {
184
+ fs.writeSync(fd, bytes);
185
+ if (durable) fs.fsyncSync(fd);
186
+ } finally {
187
+ fs.closeSync(fd);
188
+ }
189
+ renameWithRetry(temporary, file);
190
+ if (durable) fsyncDirectory(path.dirname(file));
191
+ } catch (error) {
192
+ try {
193
+ fs.unlinkSync(temporary);
194
+ } catch {}
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ const TRANSIENT_WINDOWS_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
200
+ /** Windows briefly refuses to replace a file another process is reading. */
201
+ export const isTransientWindowsError = (error: unknown): boolean => {
202
+ if (process.platform !== "win32") return false;
203
+ const code = (error as { code?: unknown } | null)?.code;
204
+ return typeof code === "string" && TRANSIENT_WINDOWS_CODES.has(code);
205
+ };
206
+ export const retryTransient = <T>(run: () => T): T => {
207
+ if (process.platform !== "win32") return run();
208
+ for (let attempt = 0; ; attempt += 1) {
209
+ try {
210
+ return run();
211
+ } catch (error) {
212
+ if (attempt >= 20 || !isTransientWindowsError(error)) throw error;
213
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5 * (attempt + 1));
214
+ }
215
+ }
216
+ };
217
+ const renameWithRetry = (from: string, to: string) => retryTransient(() => fs.renameSync(from, to));
@@ -0,0 +1,29 @@
1
+ // One-time notes from the store to the user. A host installs a reporter on
2
+ // the global `Symbol.for("workit.migrationReporter")` slot (the CLI router
3
+ // does; it must not import workit-core on its cold path). Without one, the
4
+ // note is silent: hooks and in-process hosts own their output channels.
5
+
6
+ export type MigrationReport = {
7
+ /** The 2.x store that was migrated. */
8
+ from: string;
9
+ /** The store it now lives in. */
10
+ to: string;
11
+ /** Where the 2.x files were backed up. */
12
+ backup: string;
13
+ tasks: number;
14
+ skipped: number;
15
+ workspace: boolean;
16
+ };
17
+
18
+ const SLOT = Symbol.for("workit.migrationReporter");
19
+
20
+ export const reportMigration = (report: MigrationReport): void => {
21
+ try {
22
+ const reporter = (globalThis as Record<symbol, unknown>)[SLOT];
23
+ if (typeof reporter === "function") reporter(report);
24
+ } catch {}
25
+ };
26
+
27
+ /** Whether a host installed a reporter (it can tell the user about a migration). */
28
+ export const migrationReporterInstalled = (): boolean =>
29
+ typeof (globalThis as Record<symbol, unknown>)[SLOT] === "function";
@@ -0,0 +1,135 @@
1
+ // Structural JSON diff for the task event log (design §4.1). One mutation
2
+ // becomes the smallest set of operations that turns the previous record into
3
+ // the next one, so the log grows with the change, never with the record:
4
+ //
5
+ // set replace the value at `path`
6
+ // del remove the key at `path`
7
+ // push append `values` to the array at `path`
8
+ // cut truncate the array at `path` to `length`
9
+ //
10
+ // Arrays are compared element by element, so appending an entry (evidence,
11
+ // findings, decisions) is one `push`, and editing one entry in place is a
12
+ // diff inside that entry. Paths are arrays of keys and indices.
13
+
14
+ export type PatchPath = (string | number)[];
15
+ export type PatchOp =
16
+ | { op: "set"; path: PatchPath; value: unknown }
17
+ | { op: "del"; path: PatchPath }
18
+ | { op: "push"; path: PatchPath; values: unknown[] }
19
+ | { op: "cut"; path: PatchPath; length: number };
20
+
21
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
22
+ typeof value === "object" && value !== null && !Array.isArray(value);
23
+
24
+ const equal = (left: unknown, right: unknown): boolean => {
25
+ if (left === right) return true;
26
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null)
27
+ return false;
28
+ if (Array.isArray(left) !== Array.isArray(right)) return false;
29
+ if (Array.isArray(left)) {
30
+ const other = right as unknown[];
31
+ return left.length === other.length && left.every((item, index) => equal(item, other[index]));
32
+ }
33
+ const leftKeys = Object.keys(left).filter(
34
+ (key) => (left as Record<string, unknown>)[key] !== undefined,
35
+ );
36
+ const rightKeys = Object.keys(right).filter(
37
+ (key) => (right as Record<string, unknown>)[key] !== undefined,
38
+ );
39
+ return (
40
+ leftKeys.length === rightKeys.length &&
41
+ leftKeys.every((key) =>
42
+ equal((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]),
43
+ )
44
+ );
45
+ };
46
+
47
+ /** Operations that turn `before` into `after` (JSON values only). */
48
+ export function diff(before: unknown, after: unknown, path: PatchPath = []): PatchOp[] {
49
+ if (equal(before, after)) return [];
50
+ if (isPlainObject(before) && isPlainObject(after)) {
51
+ const ops: PatchOp[] = [];
52
+ for (const key of Object.keys(before))
53
+ if (before[key] !== undefined && after[key] === undefined)
54
+ ops.push({ op: "del", path: [...path, key] });
55
+ for (const key of Object.keys(after)) {
56
+ if (after[key] === undefined) continue;
57
+ if (before[key] === undefined)
58
+ ops.push({ op: "set", path: [...path, key], value: after[key] });
59
+ else ops.push(...diff(before[key], after[key], [...path, key]));
60
+ }
61
+ return ops;
62
+ }
63
+ if (Array.isArray(before) && Array.isArray(after)) {
64
+ const ops: PatchOp[] = [];
65
+ if (after.length < before.length) ops.push({ op: "cut", path, length: after.length });
66
+ const shared = Math.min(before.length, after.length);
67
+ // A rewrite of most of the array is cheaper as one value.
68
+ let changed = 0;
69
+ for (let index = 0; index < shared; index += 1)
70
+ if (!equal(before[index], after[index])) changed += 1;
71
+ if (shared > 0 && changed > shared / 2 && changed > 4)
72
+ return [{ op: "set", path, value: after }];
73
+ for (let index = 0; index < shared; index += 1)
74
+ ops.push(...diff(before[index], after[index], [...path, index]));
75
+ if (after.length > before.length)
76
+ ops.push({ op: "push", path, values: after.slice(before.length) });
77
+ return ops;
78
+ }
79
+ return [{ op: "set", path, value: after }];
80
+ }
81
+
82
+ const clone = <T>(value: T): T => structuredClone(value);
83
+
84
+ /** Apply `ops` to `target` in place; throws on a path that does not exist. */
85
+ export function apply(target: unknown, ops: readonly PatchOp[]): unknown {
86
+ let root = target;
87
+ for (const op of ops) {
88
+ if (op.path.length === 0) {
89
+ if (op.op === "set") root = clone(op.value);
90
+ else if (op.op === "push" && Array.isArray(root)) root.push(...clone(op.values));
91
+ else if (op.op === "cut" && Array.isArray(root)) root.length = op.length;
92
+ else throw new Error(`patch: ${op.op} at the root is invalid`);
93
+ continue;
94
+ }
95
+ let parent: unknown = root;
96
+ for (const key of op.path.slice(0, -1)) {
97
+ if (typeof parent !== "object" || parent === null)
98
+ throw new Error(`patch: missing path ${op.path.join(".")}`);
99
+ parent = (parent as Record<string | number, unknown>)[key];
100
+ }
101
+ if (typeof parent !== "object" || parent === null)
102
+ throw new Error(`patch: missing path ${op.path.join(".")}`);
103
+ const key = op.path.at(-1)!;
104
+ const container = parent as Record<string | number, unknown>;
105
+ if (op.op === "set") container[key] = clone(op.value);
106
+ else if (op.op === "del") delete container[key];
107
+ else {
108
+ const array = container[key];
109
+ if (!Array.isArray(array)) throw new Error(`patch: ${op.path.join(".")} is not an array`);
110
+ if (op.op === "push") array.push(...clone(op.values));
111
+ else array.length = op.length;
112
+ }
113
+ }
114
+ return root;
115
+ }
116
+
117
+ const isPath = (value: unknown): value is PatchPath =>
118
+ Array.isArray(value) &&
119
+ value.every(
120
+ (key) =>
121
+ typeof key === "string" || (typeof key === "number" && Number.isInteger(key) && key >= 0),
122
+ );
123
+
124
+ /** Structural check of untrusted ops (from disk). */
125
+ export const isPatch = (value: unknown): value is PatchOp[] =>
126
+ Array.isArray(value) &&
127
+ value.every((op) => {
128
+ if (!isPlainObject(op) || !isPath(op.path)) return false;
129
+ if (op.op === "set") return "value" in op;
130
+ if (op.op === "del") return op.path.length > 0;
131
+ if (op.op === "push") return Array.isArray(op.values);
132
+ if (op.op === "cut")
133
+ return typeof op.length === "number" && Number.isInteger(op.length) && op.length >= 0;
134
+ return false;
135
+ });