@brainervirus/workit-core 2.1.4 → 2.2.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 (47) hide show
  1. package/package.json +3 -2
  2. package/scripts/analyze-release-scope.ts +3 -3
  3. package/scripts/doctor-check.ts +1 -2
  4. package/scripts/sync-release-manifests.ts +4 -4
  5. package/src/core/authority.ts +8 -8
  6. package/src/core/auto-approval.ts +6 -6
  7. package/src/core/branch.ts +4 -4
  8. package/src/core/config-conversion.ts +1 -1
  9. package/src/core/config.ts +3 -3
  10. package/src/core/cutover.ts +6 -6
  11. package/src/core/doctor.ts +49 -3
  12. package/src/core/external-action-effects.ts +15 -15
  13. package/src/core/external-action.ts +16 -19
  14. package/src/core/logger.ts +2 -2
  15. package/src/core/methods.ts +5 -0
  16. package/src/core/policy-resolver.ts +9 -6
  17. package/src/core/registration.ts +3 -5
  18. package/src/core/repo-context.ts +1 -1
  19. package/src/core/setup.ts +2 -2
  20. package/src/core/skill-manifests.ts +1 -1
  21. package/src/core/store-lock.ts +301 -0
  22. package/src/core/support-matrix.ts +1 -1
  23. package/src/core/task-context.ts +3 -3
  24. package/src/core/task-contract.ts +129 -9
  25. package/src/core/task-engine.ts +159 -159
  26. package/src/core/task-evaluation.ts +3 -3
  27. package/src/core/task-store.ts +407 -89
  28. package/src/core/tracker-issues.ts +1 -1
  29. package/src/core/uninstall.ts +3 -3
  30. package/src/core/workers.ts +3 -3
  31. package/src/core/workspaces.ts +4 -6
  32. package/src/core/youtrack-tools.ts +1 -1
  33. package/src/core.ts +1 -1
  34. package/src/git/rev.ts +736 -0
  35. package/src/{core/session-context.ts → hooks/context.ts} +97 -10
  36. package/src/hooks/descriptor.ts +113 -0
  37. package/src/hooks/handle.ts +111 -0
  38. package/src/hooks/hosts/claude-code.ts +281 -0
  39. package/src/hooks/hosts/codex.ts +298 -0
  40. package/src/hooks/hosts/cursor.ts +321 -0
  41. package/src/hooks/hosts/fields.ts +55 -0
  42. package/src/hooks/hosts/opencode.ts +79 -0
  43. package/src/hooks/hosts/pi.ts +80 -0
  44. package/src/hooks/index.ts +43 -0
  45. package/src/hooks/policy.ts +14 -0
  46. package/src/hooks/protocol.ts +89 -0
  47. package/src/hooks/run.ts +52 -0
@@ -76,10 +76,13 @@ const compareCodeUnits = (left: string, right: string): number => {
76
76
  return left.length - right.length;
77
77
  };
78
78
 
79
- const stableUnique = (values: string[]): string[] => [...new Set(values)].sort(compareCodeUnits);
79
+ const stableUnique = (values: string[]): string[] =>
80
+ [...new Set(values)].toSorted(compareCodeUnits);
80
81
 
81
82
  const stableList = <T>(values: T[]): T[] =>
82
- [...values].sort((left, right) => compareCodeUnits(canonicalJson(left), canonicalJson(right)));
83
+ [...values].toSorted((left, right) =>
84
+ compareCodeUnits(canonicalJson(left), canonicalJson(right)),
85
+ );
83
86
 
84
87
  const normalizedScope = (scope: Scope): Scope => ({
85
88
  description: scope.description,
@@ -463,10 +466,10 @@ export function diffPolicy(
463
466
  if (!policySchema.safeParse(previous).success && previous !== null)
464
467
  throw new TypeError("previous policy is invalid");
465
468
  if (!policySchema.safeParse(next).success) throw new TypeError("next policy is invalid");
466
- const oldIds = new Set(previous?.requirements.map((requirement) => requirement.id) ?? []);
467
- const newIds = new Set(next.requirements.map((requirement) => requirement.id));
468
- const added = [...newIds].filter((id) => !oldIds.has(id)).sort(compareCodeUnits);
469
- const retired = [...oldIds].filter((id) => !newIds.has(id)).sort(compareCodeUnits);
469
+ const oldIds = new Set(previous?.requirements.map((item) => item.id) ?? []);
470
+ const newIds = new Set(next.requirements.map((item) => item.id));
471
+ const added = [...newIds].filter((id) => !oldIds.has(id)).toSorted(compareCodeUnits);
472
+ const retired = [...oldIds].filter((id) => !newIds.has(id)).toSorted(compareCodeUnits);
470
473
  if (added.length === 0 && retired.length === 0) return null;
471
474
  const normalizedReason = reason.trim();
472
475
  const changeReason = retired.length
@@ -138,9 +138,7 @@ export function mergeCursorMcp(
138
138
  server: Record<string, unknown>,
139
139
  ): MergeResult<Record<string, unknown>> {
140
140
  const base = isRecord(mcp) ? { ...mcp } : {};
141
- const servers = isRecord(base.mcpServers)
142
- ? { ...(base.mcpServers as Record<string, unknown>) }
143
- : {};
141
+ const servers = isRecord(base.mcpServers) ? { ...base.mcpServers } : {};
144
142
  delete servers["workflow-toolkit"]; // legacy duplicate registration
145
143
  servers[serverName] = server;
146
144
  const changed = JSON.stringify(servers) !== JSON.stringify(base.mcpServers) ? ["mcpServers"] : [];
@@ -157,7 +155,7 @@ export function mergeCursorHooks(
157
155
  sessionStartEntry: Record<string, unknown>,
158
156
  ): MergeResult<Record<string, unknown>> {
159
157
  const base: Record<string, unknown> = isRecord(hooks) ? { ...hooks } : { version: 1 };
160
- const hooksMap = isRecord(base.hooks) ? { ...(base.hooks as Record<string, unknown>) } : {};
158
+ const hooksMap = isRecord(base.hooks) ? { ...base.hooks } : {};
161
159
  const changed: string[] = [];
162
160
  const list = Array.isArray(hooksMap.sessionStart) ? hooksMap.sessionStart : [];
163
161
  const same =
@@ -223,7 +221,7 @@ const canonicalHookEntry = (
223
221
  */
224
222
  export function cursorHookDrift(installed: unknown): string[] {
225
223
  if (!isRecord(installed) || !isRecord(installed.hooks)) return ["hooks file is not a hook map"];
226
- const hooks = installed.hooks as Record<string, unknown>;
224
+ const hooks = installed.hooks;
227
225
  const drift: string[] = [];
228
226
  for (const event of ["preToolUse", "beforeShellExecution"] as const) {
229
227
  const list = hooks[event];
@@ -258,7 +258,7 @@ export const documentationFiles = (cwd: string): string[] => {
258
258
  }
259
259
  };
260
260
  walk(cwd, 1);
261
- return matches.sort().slice(0, 200);
261
+ return matches.toSorted().slice(0, 200);
262
262
  };
263
263
 
264
264
  const readTrimmed = (file: string, maxLines: number): string => {
package/src/core/setup.ts CHANGED
@@ -506,13 +506,13 @@ type ResolvedApply = {
506
506
  // the same options (defaulting both from env) — tests pass `home` explicitly.
507
507
  const resolveSetupPaths = (
508
508
  options: ApplySetupOptions,
509
- configDir: string,
509
+ resolvedConfigDir: string,
510
510
  ): Omit<ResolvedApply, "dev"> => {
511
511
  const env = options.env ?? process.env;
512
512
  const home = options.home ?? env.HOME ?? os.homedir();
513
513
  return {
514
514
  home,
515
- configDir,
515
+ configDir: resolvedConfigDir,
516
516
  cwd: options.cwd ?? process.cwd(),
517
517
  env,
518
518
  opencodeConfig:
@@ -41,7 +41,7 @@ export const skillManifestNames = (root: string): string[] =>
41
41
  existsSync(root)
42
42
  ? readdirSync(root)
43
43
  .filter((name) => existsSync(path.join(root, name, "SKILL.md")))
44
- .sort()
44
+ .toSorted()
45
45
  : [];
46
46
 
47
47
  export const validateSkillManifests = (
@@ -0,0 +1,301 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { hostname } from "node:os";
4
+ import path from "node:path";
5
+ import * as z from "zod";
6
+ import { canonicalJson } from "./task-contract";
7
+
8
+ /**
9
+ * Ownership rules for a checkout's `.workit/metadata.lock`.
10
+ *
11
+ * The lock is a short mutex around one store mutation (or one managed effect).
12
+ * A lock whose owner is gone is reclaimed automatically; a lock whose owner is
13
+ * alive is contention, which callers report as retryable `busy`.
14
+ */
15
+
16
+ export type MetadataLock = {
17
+ pid: number;
18
+ processStart: string | null;
19
+ host: string;
20
+ nonce: string;
21
+ externalAction?: true;
22
+ };
23
+
24
+ const metadataLockSchema = z
25
+ .object({
26
+ pid: z.number().int().nonnegative().safe(),
27
+ processStart: z.string().nullable(),
28
+ host: z.string().min(1),
29
+ nonce: z.string().min(1),
30
+ externalAction: z.literal(true).optional(),
31
+ })
32
+ .strict();
33
+
34
+ /** A lock from another host cannot be checked for liveness; trust it this long. */
35
+ export const FOREIGN_LOCK_TTL_MS = 10 * 60_000;
36
+ /** An empty or unparseable lock is a writer mid-create; after this it is debris. */
37
+ export const UNREADABLE_LOCK_TTL_MS = 30_000;
38
+ /** A reclaim guard lives for microseconds; one older than this was abandoned. */
39
+ export const RECLAIM_GUARD_TTL_MS = 30_000;
40
+ /**
41
+ * Total time a mutation waits for a live holder before returning `busy`. The
42
+ * wait blocks the calling thread, so in-process hosts (OpenCode, MCP, Pi) keep
43
+ * the short default; the CLI raises it for its own process.
44
+ */
45
+ let defaultLockTimeoutMs = 250;
46
+ export const defaultLockTimeout = (): number => defaultLockTimeoutMs;
47
+ export const setDefaultLockTimeout = (ms: number): void => {
48
+ defaultLockTimeoutMs = ms;
49
+ };
50
+
51
+ export const parseMetadataLock = (raw: string): MetadataLock => {
52
+ let value: unknown;
53
+ try {
54
+ value = JSON.parse(raw);
55
+ } catch {
56
+ throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
57
+ }
58
+ const parsed = metadataLockSchema.safeParse(value);
59
+ if (!parsed.success)
60
+ throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
61
+ return parsed.data;
62
+ };
63
+
64
+ /** Lenient variant for the acquire loop: an unreadable lock is classified by age. */
65
+ export const parseMetadataLockOrNull = (raw: string): MetadataLock | null => {
66
+ try {
67
+ return parseMetadataLock(raw);
68
+ } catch {
69
+ return null;
70
+ }
71
+ };
72
+
73
+ export const sameMetadataLock = (left: unknown, right: MetadataLock): boolean => {
74
+ const parsed = metadataLockSchema.safeParse(left);
75
+ return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
76
+ };
77
+
78
+ /** Field 22 (starttime) of /proc/<pid>/stat; parsed after the last ")" so a comm with spaces cannot shift it. */
79
+ export const parseProcStatStart = (stat: string): string | null => {
80
+ const close = stat.lastIndexOf(")");
81
+ if (close < 0) return null;
82
+ // After ")": field 3 (state) is index 0, so field 22 is index 19.
83
+ return (
84
+ stat
85
+ .slice(close + 1)
86
+ .trim()
87
+ .split(/\s+/)[19] ?? null
88
+ );
89
+ };
90
+
91
+ export const processStartOf = (pid: number): string | null => {
92
+ if (process.platform === "linux") {
93
+ try {
94
+ return parseProcStatStart(fs.readFileSync(`/proc/${pid}/stat`, "utf8"));
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+ if (process.platform === "darwin" || process.platform === "freebsd") {
100
+ try {
101
+ const run = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
102
+ encoding: "utf8",
103
+ timeout: 1_000,
104
+ });
105
+ const value = run.status === 0 ? run.stdout.trim() : "";
106
+ return value || null;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+ return null;
112
+ };
113
+
114
+ const readTrimmed = (file: string): string | null => {
115
+ try {
116
+ return fs.readFileSync(file, "utf8").trim() || null;
117
+ } catch {
118
+ return null;
119
+ }
120
+ };
121
+
122
+ /**
123
+ * Identity of the pid space this process lives in: hostname plus, on Linux,
124
+ * the pid-namespace inode and boot id. Containers that share the hostname
125
+ * (`--network host`) but not the pid namespace get a different identity, so
126
+ * their pids are never checked against this process table. Folded into the
127
+ * existing `host` string so older Workit versions still parse the lock.
128
+ */
129
+ let cachedLockHost: string | null = null;
130
+ export const localLockHost = (): string => {
131
+ if (cachedLockHost !== null) return cachedLockHost;
132
+ let pidns: string | null = null;
133
+ try {
134
+ pidns = /\[(\d+)\]/.exec(fs.readlinkSync("/proc/self/ns/pid"))?.[1] ?? null;
135
+ } catch {}
136
+ const boot = readTrimmed("/proc/sys/kernel/random/boot_id");
137
+ cachedLockHost = pidns || boot ? `${hostname()}#${pidns ?? "?"}:${boot ?? "?"}` : hostname();
138
+ return cachedLockHost;
139
+ };
140
+
141
+ const pidAlive = (pid: number): boolean => {
142
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
143
+ try {
144
+ process.kill(pid, 0);
145
+ return true;
146
+ } catch (error) {
147
+ // EPERM: the process exists but belongs to another user.
148
+ return (error as { code?: unknown }).code === "EPERM";
149
+ }
150
+ };
151
+
152
+ export type LockOwnerState = {
153
+ /** live: wait. stale: reclaim. unknown: wait (cannot prove the owner is gone). */
154
+ state: "live" | "stale" | "unknown";
155
+ reason: string;
156
+ };
157
+
158
+ export const classifyLockOwner = (
159
+ payload: unknown,
160
+ ageMs: number | null,
161
+ localHost: string = localLockHost(),
162
+ ): LockOwnerState => {
163
+ const lock = metadataLockSchema.safeParse(payload);
164
+ if (!lock.success)
165
+ return ageMs !== null && ageMs > UNREADABLE_LOCK_TTL_MS
166
+ ? { state: "stale", reason: "unreadable lock left behind" }
167
+ : { state: "unknown", reason: "lock is being written" };
168
+ const { pid, processStart, host } = lock.data;
169
+ // Same host and pid namespace but another boot: the machine rebooted since
170
+ // the lock was taken, so its owner cannot still be running.
171
+ const [lockName, lockSpace] = host.split("#");
172
+ const [localName, localSpace] = localHost.split("#");
173
+ if (lockSpace && localSpace && lockName === localName) {
174
+ const [lockNs, lockBoot] = lockSpace.split(":");
175
+ const [localNs, localBoot] = localSpace.split(":");
176
+ if (lockNs === localNs && lockNs !== "?" && lockBoot !== "?" && lockBoot !== localBoot)
177
+ return { state: "stale", reason: "lock was taken before this machine rebooted" };
178
+ }
179
+ // Another host, another pid namespace, or a lock written by an older Workit
180
+ // without namespace identity: its pid cannot be checked here.
181
+ if (host !== localHost)
182
+ return ageMs !== null && ageMs > FOREIGN_LOCK_TTL_MS
183
+ ? { state: "stale", reason: `lock from ${host} is older than its TTL` }
184
+ : { state: "unknown", reason: `lock is held from ${host}; its pid cannot be checked here` };
185
+ if (!pidAlive(pid)) return { state: "stale", reason: `pid ${pid} is not running` };
186
+ const currentStart = processStartOf(pid);
187
+ if (processStart !== null && currentStart !== null && processStart !== currentStart)
188
+ return { state: "stale", reason: `pid ${pid} now belongs to a different process` };
189
+ return { state: "live", reason: `held by running pid ${pid}` };
190
+ };
191
+
192
+ const ageOf = (file: string, nowMs: number): number | null => {
193
+ try {
194
+ return nowMs - fs.lstatSync(file).mtimeMs;
195
+ } catch {
196
+ return null;
197
+ }
198
+ };
199
+
200
+ export const lockPathFor = (root: string) => path.join(root, ".workit", "metadata.lock");
201
+
202
+ /** Remove a reclaim guard abandoned by a crashed reclaimer. Returns true when removed. */
203
+ export const clearAbandonedReclaimGuard = (lockPath: string, nowMs = Date.now()): boolean => {
204
+ const guard = `${lockPath}.reclaim`;
205
+ const age = ageOf(guard, nowMs);
206
+ if (age === null || age <= RECLAIM_GUARD_TTL_MS) return false;
207
+ try {
208
+ fs.rmdirSync(guard);
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ };
214
+
215
+ export type MetadataLockStatus = {
216
+ path: string;
217
+ present: boolean;
218
+ owner: MetadataLock | null;
219
+ state: LockOwnerState["state"] | "absent";
220
+ reason: string;
221
+ guard: "absent" | "fresh" | "abandoned";
222
+ /** Exact lock bytes that were classified (for compare-before-remove). */
223
+ raw?: string;
224
+ /** Age of the lock file in milliseconds, when present. */
225
+ ageMs?: number | null;
226
+ };
227
+
228
+ /** Read-only inspection of a checkout's metadata lock (doctor surface). */
229
+ export const inspectMetadataLock = (root: string, nowMs = Date.now()): MetadataLockStatus => {
230
+ const lockPath = lockPathFor(root);
231
+ const guardAge = ageOf(`${lockPath}.reclaim`, nowMs);
232
+ const guard =
233
+ guardAge === null ? "absent" : guardAge > RECLAIM_GUARD_TTL_MS ? "abandoned" : "fresh";
234
+ let raw: string;
235
+ try {
236
+ raw = fs.readFileSync(lockPath, "utf8");
237
+ } catch {
238
+ return {
239
+ path: lockPath,
240
+ present: false,
241
+ owner: null,
242
+ state: "absent",
243
+ reason: "no lock",
244
+ guard,
245
+ };
246
+ }
247
+ const owner = parseMetadataLockOrNull(raw);
248
+ const ageMs = ageOf(lockPath, nowMs);
249
+ const verdict = classifyLockOwner(owner, ageMs);
250
+ return { path: lockPath, present: true, owner, ...verdict, guard, raw, ageMs };
251
+ };
252
+
253
+ export type ClearLockOutcome = MetadataLockStatus & {
254
+ cleared: boolean;
255
+ guardCleared: boolean;
256
+ /** Why the lock was kept, when it was present and not cleared. */
257
+ skipped?: string;
258
+ };
259
+
260
+ /**
261
+ * Clear a stale metadata lock (or, with `force`, any lock) and an abandoned
262
+ * reclaim guard. Removal holds the same `.reclaim` guard that writers take
263
+ * before reclaiming, so no writer can replace the lock between the final
264
+ * byte check and the unlink; a fresh guard means a reclaim is already in
265
+ * progress and the lock is left alone.
266
+ */
267
+ export const clearStaleMetadataLock = (
268
+ root: string,
269
+ options: { force?: boolean; nowMs?: number } = {},
270
+ ): ClearLockOutcome => {
271
+ const nowMs = options.nowMs ?? Date.now();
272
+ const status = inspectMetadataLock(root, nowMs);
273
+ const guardCleared = status.guard === "abandoned" && clearAbandonedReclaimGuard(status.path);
274
+ const outcome = { ...status, cleared: false, guardCleared };
275
+ if (!status.present) return outcome;
276
+ if (status.state !== "stale" && !options.force) return { ...outcome, skipped: status.reason };
277
+ const guard = `${status.path}.reclaim`;
278
+ try {
279
+ fs.mkdirSync(guard);
280
+ } catch {
281
+ return { ...outcome, skipped: "a reclaim is in progress" };
282
+ }
283
+ try {
284
+ const before = fs.readFileSync(status.path, "utf8");
285
+ if (before !== status.raw) return { ...outcome, skipped: "the lock changed" };
286
+ if (!options.force) {
287
+ const verdict = classifyLockOwner(parseMetadataLockOrNull(before), ageOf(status.path, nowMs));
288
+ if (verdict.state !== "stale") return { ...outcome, skipped: verdict.reason };
289
+ }
290
+ if (fs.readFileSync(status.path, "utf8") !== before)
291
+ return { ...outcome, skipped: "the lock changed" };
292
+ fs.rmSync(status.path);
293
+ return { ...outcome, cleared: true };
294
+ } catch (error) {
295
+ return { ...outcome, skipped: `could not clear: ${String(error)}` };
296
+ } finally {
297
+ try {
298
+ fs.rmdirSync(guard);
299
+ } catch {}
300
+ }
301
+ };
@@ -10,7 +10,7 @@ export const SUPPORT_MATRIX = {
10
10
  // The object-form dual entry (server() + setup()) only loads from
11
11
  // 1.18.29+; the supported floor is 1.18.30 so no host can pass doctor while
12
12
  // being unable to load the entry shape.
13
- opencode: { minimum: "1.18.30", current: "1.18.30" },
13
+ opencode: { minimum: "1.18.30", current: "1.18.34" },
14
14
  // Codex is a qualification host, not a runtime dependency: the CLI version
15
15
  // below is the one live qualification evidence covers. The doctor warns when
16
16
  // an installed CLI drifts ahead so a fresh install never silently outruns
@@ -38,7 +38,7 @@ export function reconcileResume(
38
38
  if (observations.length > 0)
39
39
  return failure("permission_denied", "worker observations require native host verification");
40
40
  const candidate = captureCandidate(view.workspace.root, view.task.intent.data.scope, []);
41
- if (!candidate.ok) return candidate as Result<never>;
41
+ if (!candidate.ok) return candidate;
42
42
  const staleEvidenceIds = evaluateEvidence(view.task, candidate.data)
43
43
  .filter((entry) => entry.status === "stale")
44
44
  .map((entry) => entry.evidenceId);
@@ -127,7 +127,7 @@ const compactReference = (ref: Ref): CompactReference => {
127
127
  export function compactTaskContext(view: TaskView): string {
128
128
  const decisions: CompactDecision[] = view.task.decisions
129
129
  .slice()
130
- .sort((left, right) => {
130
+ .toSorted((left, right) => {
131
131
  const leftAt = compactTimestamp(left.recordedAt);
132
132
  const rightAt = compactTimestamp(right.recordedAt);
133
133
  if (leftAt !== rightAt) return leftAt > rightAt ? -1 : 1;
@@ -162,7 +162,7 @@ export function compactTaskContext(view: TaskView): string {
162
162
  ...view.task.progress.blockers.map((entry) => entry.reason),
163
163
  ]),
164
164
  )
165
- .sort()
165
+ .toSorted()
166
166
  .slice(0, COMPACT_MAX_ITEMS)
167
167
  .map((gap) => compactText(gap, COMPACT_TEXT_BYTES)!);
168
168
  const context: CompactTaskContext = {
@@ -84,6 +84,7 @@ export const hostSchema = z.enum([
84
84
  "codex_desktop",
85
85
  "pi",
86
86
  "workit_cli",
87
+ "claude_code",
87
88
  ]);
88
89
  export type Host = z.infer<typeof hostSchema>;
89
90
  export const assuranceSchema = z.enum(["enforced", "agent_guided", "unavailable"]);
@@ -746,9 +747,111 @@ export const taskRecordSchema = z
746
747
  actionProgress: actionProgressListSchema.optional(),
747
748
  findings: z.array(entrySchema(findingSchema)),
748
749
  workers: z.array(entrySchema(workerSchema)),
750
+ /** Paths a reader must understand; see parseStoredRecord. */
751
+ critical: z.array(nonEmpty).optional(),
749
752
  })
750
753
  .strict();
751
754
  export type TaskRecord = z.infer<typeof taskRecordSchema>;
755
+
756
+ type Strip = { path: PropertyKey[]; keys: string[] };
757
+ const stripCount = (strips: Strip[]) => strips.reduce((sum, strip) => sum + strip.keys.length, 0);
758
+ /** Unknown-key issues only, flattened; null when any issue is a real schema
759
+ * violation. For a union, the branch that strips the fewest keys wins, so a
760
+ * key one branch knows is never dropped in favor of a narrower branch. */
761
+ const strippable = (
762
+ issues: readonly z.core.$ZodIssue[],
763
+ prefix: PropertyKey[] = [],
764
+ ): Strip[] | null => {
765
+ const strips: Strip[] = [];
766
+ for (const issue of issues) {
767
+ if (issue.code === "unrecognized_keys") {
768
+ strips.push({ path: [...prefix, ...issue.path], keys: issue.keys });
769
+ continue;
770
+ }
771
+ if (issue.code !== "invalid_union") return null;
772
+ const branch = issue.errors
773
+ .map((errors) => strippable(errors, [...prefix, ...issue.path]))
774
+ .filter((found): found is Strip[] => found !== null && found.length > 0)
775
+ .reduce<Strip[] | null>(
776
+ (best, found) => (best === null || stripCount(found) < stripCount(best) ? found : best),
777
+ null,
778
+ );
779
+ if (!branch) return null;
780
+ strips.push(...branch);
781
+ }
782
+ return strips;
783
+ };
784
+
785
+ /** A dotted record path with array indices as `*`, e.g. `evidence.*.data.observer`. */
786
+ const recordPath = (path: PropertyKey[]): string =>
787
+ path.map((key) => (typeof key === "number" ? "*" : String(key))).join(".");
788
+ const overlaps = (left: string, right: string) =>
789
+ left === right || left.startsWith(`${right}.`) || right.startsWith(`${left}.`);
790
+
791
+ export type StoredRecordParse<T> =
792
+ | { success: true; data: T; stripped: string[] }
793
+ | { success: false; error: z.ZodError; critical: string[] };
794
+
795
+ /**
796
+ * Reader tolerance for stored records (D17). A record written by a newer
797
+ * runtime may carry keys this reader does not know; exactly the keys zod
798
+ * reports as unrecognized are dropped and the value is parsed again, and
799
+ * every other violation still fails. Writes keep parsing strictly.
800
+ *
801
+ * The rule for new record fields: a field must be safe for an older reader
802
+ * to ignore (and to lose when that reader rewrites the record), or the writer
803
+ * must list its path in the record's top-level `critical` array. A reader
804
+ * that would strip a critical path fails closed instead (`critical` names the
805
+ * paths), so it neither acts on nor rewrites a record it cannot represent.
806
+ */
807
+ export const parseStoredRecord = <S extends z.ZodType>(
808
+ schema: S,
809
+ value: unknown,
810
+ ): StoredRecordParse<z.output<S>> => {
811
+ let parsed = schema.safeParse(value);
812
+ if (parsed.success) return { success: true, data: parsed.data, stripped: [] };
813
+ const first = { success: false as const, error: parsed.error, critical: [] as string[] };
814
+ const declared =
815
+ typeof value === "object" &&
816
+ value !== null &&
817
+ Array.isArray((value as { critical?: unknown }).critical)
818
+ ? (value as { critical: unknown[] }).critical
819
+ .filter((item): item is string => typeof item === "string")
820
+ // An array index in a declaration means any element, like `*`.
821
+ .map((item) =>
822
+ item
823
+ .split(".")
824
+ .map((key) => (/^\d+$/.test(key) ? "*" : key))
825
+ .join("."),
826
+ )
827
+ : [];
828
+ const stripped: string[] = [];
829
+ let current: unknown = structuredClone(value);
830
+ for (let round = 0; round < 8 && !parsed.success; round++) {
831
+ const strips = strippable(parsed.error.issues);
832
+ if (strips === null || strips.length === 0) return first;
833
+ for (const strip of strips) {
834
+ let target: unknown = current;
835
+ for (const key of strip.path)
836
+ target =
837
+ typeof target === "object" && target !== null
838
+ ? (target as Record<PropertyKey, unknown>)[key]
839
+ : undefined;
840
+ if (typeof target !== "object" || target === null) return first;
841
+ for (const key of strip.keys) {
842
+ stripped.push(recordPath([...strip.path, key]));
843
+ delete (target as Record<string, unknown>)[key];
844
+ }
845
+ }
846
+ parsed = schema.safeParse(current);
847
+ }
848
+ if (!parsed.success) return first;
849
+ const critical = [
850
+ ...new Set(stripped.filter((item) => declared.some((path) => overlaps(item, path)))),
851
+ ];
852
+ if (critical.length > 0) return { ...first, critical };
853
+ return { success: true, data: parsed.data, stripped: [...new Set(stripped)] };
854
+ };
752
855
  export const workspaceRecordSchema = z
753
856
  .object({
754
857
  schemaVersion: z.literal(1),
@@ -760,6 +863,8 @@ export const workspaceRecordSchema = z
760
863
  .object({ state: z.enum(["held", "uncertain"]), owner: ownerSchema, acquiredAt: utc })
761
864
  .strict()
762
865
  .nullable(),
866
+ /** Paths a reader must understand; see parseStoredRecord. */
867
+ critical: z.array(nonEmpty).optional(),
763
868
  })
764
869
  .strict();
765
870
  export type WorkspaceRecord = z.infer<typeof workspaceRecordSchema>;
@@ -1026,6 +1131,19 @@ export const operationSchemas = {
1026
1131
  state: z.discriminatedUnion("action", Object.values(stateOperations) as any),
1027
1132
  } as const;
1028
1133
  export type OperationRequest = z.infer<(typeof operationSchemas)[OperationFamily]>;
1134
+
1135
+ /**
1136
+ * Schemas advertised to hosts. `state.recover` needs host-supplied native
1137
+ * recovery authority (`OperationContext.nativeRecovery`), which no shipped
1138
+ * host provides, so advertising it only sends agents into a guaranteed
1139
+ * permission_denied. parseOperation still accepts it for embedders that do
1140
+ * supply that authority.
1141
+ */
1142
+ const { recover: _unadvertisedRecover, ...advertisedStateOperations } = stateOperations;
1143
+ export const advertisedOperationSchemas = {
1144
+ ...operationSchemas,
1145
+ state: z.discriminatedUnion("action", Object.values(advertisedStateOperations) as any),
1146
+ } as const;
1029
1147
  export type TaskStartRequest = z.infer<typeof taskOperations.start>;
1030
1148
 
1031
1149
  const compiledOperationSchemas = Object.fromEntries(
@@ -1048,6 +1166,8 @@ export type ErrorCode =
1048
1166
  | "capability_unavailable"
1049
1167
  | "requirements_unsatisfied"
1050
1168
  | "writer_conflict"
1169
+ /** Retryable: another live Workit call holds the checkout's metadata lock. */
1170
+ | "busy"
1051
1171
  | "recovery_required"
1052
1172
  | "storage_error"
1053
1173
  | "external_outcome_unknown";
@@ -1118,7 +1238,7 @@ export function parseOperation(family: OperationFamily, input: unknown): Result<
1118
1238
  return failure("unsupported_version", "unsupported schema version", { operation: family });
1119
1239
  const compiled = compiledOperationSchemas[family].safeParse(input);
1120
1240
  const parsed = compiled.success ? operationSchemas[family].safeParse(input) : compiled;
1121
- if (parsed.success) return success(null, null, parsed.data as OperationRequest);
1241
+ if (parsed.success) return success(null, null, parsed.data);
1122
1242
  const fields = parsed.error.issues.map((issue) => ({
1123
1243
  path: issue.code === "unrecognized_keys" ? issue.keys.join(".") : issue.path.join("."),
1124
1244
  reason: issue.message,
@@ -1127,7 +1247,7 @@ export function parseOperation(family: OperationFamily, input: unknown): Result<
1127
1247
  }
1128
1248
 
1129
1249
  export function operationJsonSchema(family: OperationFamily): z.core.JSONSchema.BaseSchema {
1130
- return z.toJSONSchema(operationSchemas[family], { target: "draft-2020-12" });
1250
+ return z.toJSONSchema(advertisedOperationSchemas[family], { target: "draft-2020-12" });
1131
1251
  }
1132
1252
 
1133
1253
  /**
@@ -1293,7 +1413,7 @@ function canonical(value: unknown, seen: Set<object>): JsonValue {
1293
1413
  throw new TypeError("invalid Unicode");
1294
1414
  result = Object.fromEntries(
1295
1415
  Object.keys(value)
1296
- .sort()
1416
+ .toSorted()
1297
1417
  .map((key) => [key, canonical((value as Record<string, unknown>)[key], seen)]),
1298
1418
  );
1299
1419
  }
@@ -1339,13 +1459,13 @@ export function decisionDigest(input: Omit<Decision, "digest"> | Decision): Dige
1339
1459
  export function candidateDigest(input: Candidate): Digest {
1340
1460
  const normalizedScope = {
1341
1461
  description: input.scope.description,
1342
- paths: [...input.scope.paths].sort(compareCodeUnits),
1343
- exclusions: [...input.scope.exclusions].sort(compareCodeUnits),
1462
+ paths: [...input.scope.paths].toSorted(compareCodeUnits),
1463
+ exclusions: [...input.scope.exclusions].toSorted(compareCodeUnits),
1344
1464
  };
1345
1465
  return sha256({
1346
1466
  scope: normalizedScope,
1347
1467
  completeness: input.completeness,
1348
- files: [...input.files].sort(
1468
+ files: [...input.files].toSorted(
1349
1469
  (left, right) =>
1350
1470
  compareCodeUnits(left.path, right.path) ||
1351
1471
  compareCodeUnits(left.kind, right.kind) ||
@@ -1354,7 +1474,7 @@ export function candidateDigest(input: Candidate): Digest {
1354
1474
  ),
1355
1475
  environment: input.environment
1356
1476
  .map(({ name, value }) => ({ name, value }))
1357
- .sort(
1477
+ .toSorted(
1358
1478
  (left, right) =>
1359
1479
  compareCodeUnits(left.name, right.name) || compareNullableText(left.value, right.value),
1360
1480
  ),
@@ -1372,8 +1492,8 @@ export function requirementId(input: {
1372
1492
  ...input,
1373
1493
  scope: {
1374
1494
  description: input.scope.description,
1375
- paths: [...input.scope.paths].sort(compareCodeUnits),
1376
- exclusions: [...input.scope.exclusions].sort(compareCodeUnits),
1495
+ paths: [...input.scope.paths].toSorted(compareCodeUnits),
1496
+ exclusions: [...input.scope.exclusions].toSorted(compareCodeUnits),
1377
1497
  },
1378
1498
  });
1379
1499
  }