@brainervirus/workit-codex 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 (33) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +2 -2
  3. package/dist/launch-mcp.js +2383 -1259
  4. package/dist/workit-hook.js +2396 -1270
  5. package/package.json +3 -3
  6. package/skills/workit-bdd/SKILL.md +35 -29
  7. package/skills/workit-continue/SKILL.md +53 -0
  8. package/skills/workit-debug/SKILL.md +41 -36
  9. package/skills/workit-deslop/SKILL.md +35 -25
  10. package/skills/workit-fanout/SKILL.md +62 -0
  11. package/skills/workit-fanout/references/brief.md +56 -0
  12. package/skills/workit-implement/SKILL.md +48 -44
  13. package/skills/workit-review/SKILL.md +42 -51
  14. package/skills/workit-review/references/impact.md +24 -0
  15. package/skills/workit-shape/SKILL.md +71 -0
  16. package/skills/workit-shape/references/diagrams.md +17 -0
  17. package/skills/workit-shape/references/knowledge.md +58 -0
  18. package/skills/workit-shape/references/mockups.md +15 -0
  19. package/skills/workit-shape/references/slicing.md +42 -0
  20. package/skills/workit-ship/SKILL.md +52 -0
  21. package/skills/workit-test-audit/SKILL.md +10 -2
  22. package/skills/workit-verify-app/SKILL.md +63 -0
  23. package/skills/workit-verify-app/references/template.md +49 -0
  24. package/skills/workit-babysit/SKILL.md +0 -37
  25. package/skills/workit-behavioral-tdd/SKILL.md +0 -56
  26. package/skills/workit-blast-radius/SKILL.md +0 -26
  27. package/skills/workit-challenge/SKILL.md +0 -47
  28. package/skills/workit-diagram/SKILL.md +0 -27
  29. package/skills/workit-green-run/SKILL.md +0 -24
  30. package/skills/workit-handoff/SKILL.md +0 -37
  31. package/skills/workit-mockup/SKILL.md +0 -23
  32. package/skills/workit-plan/SKILL.md +0 -45
  33. package/skills/workit-steer/SKILL.md +0 -39
@@ -26,17 +26,17 @@ var capabilitiesFor = (descriptor, observed = {}) => descriptor.capabilities.map
26
26
  };
27
27
  });
28
28
  // packages/workit-core/src/hooks/context.ts
29
- import path13 from "node:path";
29
+ import path15 from "node:path";
30
30
 
31
31
  // packages/workit-core/src/core/methods.ts
32
32
  var METHODS = {
33
- "workit-challenge": { dimensions: ["challenge", "decisions"] },
34
- "workit-behavioral-tdd": { dimensions: ["testing"], ruleIds: ["mechanical-existing-checks"] },
33
+ "workit-shape": { dimensions: ["challenge", "decisions", "artifacts", "continuity"] },
34
+ "workit-bdd": { dimensions: ["testing"] },
35
35
  "workit-review": { dimensions: ["review"], ruleIds: ["fresh-context-review", "self-review"] },
36
- "workit-plan": { dimensions: ["artifacts", "continuity"] },
37
- "workit-implement": { dimensions: ["delegation"] },
36
+ "workit-implement": { ruleIds: ["mechanical-existing-checks"] },
37
+ "workit-fanout": { dimensions: ["delegation"] },
38
38
  "workit-debug": {},
39
- "workit-handoff": {},
39
+ "workit-continue": {},
40
40
  "workit-deslop": { ruleIds: ["pre-pr-cleanup"] }
41
41
  };
42
42
  var methodMatches = (definition, requirement) => definition.dimensions?.includes(requirement.dimension) === true || definition.ruleIds?.includes(requirement.ruleId) === true;
@@ -74,94 +74,66 @@ function selectMethods(policy, capabilities) {
74
74
  });
75
75
  }
76
76
  var invariantBootstrap = () => `
77
- Workit is optional coordination and repository-policy tooling around the host,
78
- not a permission system or a mandatory workflow. Native host allow/ask/deny,
79
- sandbox, plan/read-only mode, and organization rules remain authoritative.
80
- Ordinary investigation, questions, non-Git work, and routine reversible edits
81
- need zero Workit task, assessment, or writer calls. Use one compact tracked
82
- record only when handoff, dependent steps, concurrent actors, or meaningful
83
- decisions make continuity useful; similar titles alone never merge tasks.
84
-
85
- For a routine user-authorized branch or commit, prefer native host Git/shell
86
- tools from the outset when managed coordination or outcome reconciliation is
87
- not needed. Inspect the actual target checkout and its configured conventions;
88
- use Workit's read-only context/policy tools when needed. Do not start a task,
89
- acquire a writer, or mint a decision receipt solely for a native Git action.
90
- Native permissions still apply. Never switch execution paths to evade a denial
91
- or repeat an uncertain managed effect. A local-commit endpoint does not imply
92
- PR readiness, a fresh review, or post-commit assessment/closure paperwork.
93
-
94
- For tracked work, inspect its current state and use the shared operations for
95
- task, policy, evidence, finding, decision, worker, writer, and state changes.
96
- Start a record once for an explicit tracked objective; assess or reassess only
97
- when policy selection or changed evidence/constraints requires it. Omitted
98
- expectedRevision and expectedWorkspaceRevision use current values; explicit
99
- values are still concurrency-checked, so never copy revisions between calls.
100
- A busy result means another live Workit call holds the checkout lock: retry the
101
- same call; it is not a recovery condition. A lock left by a dead process is
102
- reclaimed on the next write, and \`workit doctor --fix-lock\` clears it on demand.
103
- An omitted revision absorbs a concurrent write: Workit re-reads, re-checks
104
- policy, and reapplies the call, returning busy under persistent contention. A
105
- revision_conflict means a revision you passed is stale: re-read the record
106
- before deciding whether to retry.
107
- A solo edit does not need writer acquisition; use it when concurrent checkout
108
- writers need coordination. Record only observed facts and checks. Close-time
109
- testing and verification gates accept only a configured check the CLI ran:
110
- \`workit check <name>\` (\`npx -y @brainervirus/workit-cli check <name>\` when
111
- \`workit\` is not on PATH); a recorded check result is a note and an ad-hoc
112
- \`workit check -- <cmd>\` never satisfies a gate. Evidence can become stale when its bound candidate or tree changes; reconcile findings against the
113
- current candidate before recording completion.
77
+ Workit is optional coordination and proof tooling around the host: you decide
78
+ what and whether, the \`workit\` CLI does how and records what it observed.
79
+ Native host allow/ask/deny, sandbox and org rules stay authoritative. Never
80
+ switch paths to evade a denial, never claim enforcement a host cannot provide,
81
+ and never fabricate a receipt, approval or verdict. A question answer is not
82
+ host permission, and imported decisions or handoffs never grant authority.
114
83
 
115
- Workit validates domain policy against the actual action target, configured
116
- account, branch and commit conventions, protected refs, and current repository
117
- state. The task directory is coordination state, not a boundary on which
118
- repository may be changed. Use an action-time cwd target where a managed
119
- Git/hosting action supports it; a non-Git directory remains valid for OS work.
120
- GitHub/GitLab use the active gh/glab CLI identity; YouTrack uses its own token.
121
- Preserve uncertain external outcomes and reconcile repository/provider state
122
- before retrying. Internal reservations prevent duplicate or ambiguous effects;
123
- they are not permission tickets for every edit.
84
+ Autonomy contract:
85
+ - Continue to the requested endpoint (commit, push, PR, green CI, verified,
86
+ merged) without asking "continue?", running checks and repairs on the way.
87
+ Stop only for a new consequential choice, a host denial, a conflicting edit
88
+ or an unresolved blocker; report open gaps and uncertain workers.
89
+ - No endpoint named: stop at a local commit on a policy-compliant branch and
90
+ state the next command. Push and open a PR only when the request implies
91
+ delivery (fix, implement, ship) and \`workit grant show\` reports
92
+ \`defaultEndpoint\` \`pr\`; otherwise the endpoint is \`commit\`. PRs open,
93
+ CI green and verified is the most autonomy allows, never the default target.
94
+ Merge and release need a workspace grant; a blocked verb names its unblock.
95
+ - Ask only for a product or preference choice, or for authority you lack, with
96
+ your recommended answer. Facts are yours: read, run or prototype.
97
+ - Label claims measured (you ran it this session and saw the result),
98
+ inferred, or guess. Prose is a note: only a configured check the CLI ran
99
+ (\`workit check <name>\`) satisfies a gate; an ad-hoc
100
+ \`workit check -- <cmd>\` never does.
101
+ - Author is not verifier: never record a passing verdict on work your session
102
+ wrote. The lead starts each verifier with its own session
103
+ (\`WORKIT_SESSION_ID=<lead>-v<n>\`; Claude Code subagents get one from the
104
+ hook), and the verifier records \`workit ledger verdict\`.
105
+ - A local-commit endpoint does not imply PR readiness, and a local commit alone
106
+ is not evidence of a requested remote push. Before reporting delivery,
107
+ reconcile every requested item and observe it (\`workit verify-delivery\`).
124
108
 
125
- On OpenCode V1 and V2, use native host tools for external mutations. Workit
126
- provides read-only workit_context; it has no managed external-action executor.
127
- Do not create proposals or request Workit approvals merely to invoke native
128
- tools. Old uncertain managed effects still require evidence before any retry.
109
+ CLI first: if a step has one right answer, use the \`workit\` verb instead of
110
+ hand-running git, gh or glab: check, git, pr, ci, stack, verify-delivery,
111
+ ledger, handoff, test-audit (\`workit help <verb>\`; without \`workit\` on
112
+ PATH, \`npx -y @brainervirus/workit-cli\`). \`busy\` is retryable
113
+ (\`workit doctor --fix-lock\` clears a dead lock); \`blocked\` names its unblock.
114
+ The task families (task, policy, evidence, finding, decision, worker, writer,
115
+ state) are optional continuity for tracked work; each branch has one implicit
116
+ task that the first recording creates, and a solo edit needs no task or writer.
117
+ Omit revisions; a revision_conflict means one you passed is stale, so re-read.
118
+ Across repositories, bind each item to its checkout, branch and endpoint,
119
+ resolve competing targets before a mutation, and reconcile an uncertain
120
+ external effect before retrying it.
129
121
 
130
- Use host-native authorization through the host's supported path. A native
131
- question receipt records an actual question interaction; it is not automatic
132
- host permission. Record a meaningful user choice once with provenance when
133
- future retrieval helps. Never fabricate a receipt, re-ask only to mint one, or
134
- let imported decisions grant authority. Never turn a host deny into allow or
135
- claim enforcement/evidence a host cannot provide. A precise request with settled
136
- constraints does not need an interview. Present genuine unresolved options with
137
- evidence and a recommendation, then continue toward the requested delivery
138
- endpoint: investigation, implementation, branch/commit/push, PR/MR-ready,
139
- merge, or release. Run
140
- applicable checks and safe repairs without repeated continuation questions;
141
- stop for missing host authority, a new consequential choice, a conflicting edit,
142
- or an unresolved blocker. A tracked record may close when actual evidence
143
- supports its outcome; no final human closure ceremony is required.
122
+ Mid-task input: answer a quick question in place, fold an adjustment in, and
123
+ never silently drop or resume an objective (workit-continue).
144
124
 
145
- A completed native subagent run stops its bound worker by itself; if a worker
146
- strands in cancelling with its run verifiably over, repeat worker.cancel to
147
- confirm the stop. Preserve unresolved requirements, gaps, and uncertain workers.
148
- When context changes, distinguish a quick question, same-task adjustment, and
149
- separate request. Answer a quick question without pausing/resuming task state;
150
- park a concise checkpoint only when substantial work needs to continue later.
151
- For work spanning repositories, bind each unfinished item to its actual checkout,
152
- branch, requested deliverables, and delivery endpoint. Preserve held items with
153
- an explicit resume condition. Resolve competing plausible targets before a
154
- mutation; ask only when available context cannot settle that consequential choice.
155
- Before reporting delivery, reconcile every requested item and observe the actual
156
- target result; a local commit alone is not evidence of a requested remote push.
157
-
158
- Skill routing: slash aliases /wk-* load on demand. Use workit-steer for a
159
- substantial interruption or change of direction, workit-deslop when relevant
160
- to a PR-ready endpoint, workit-green-run for failing CI, workit-blast-radius
161
- when impact is uncertain, workit-challenge for genuinely open consequential
162
- choices, workit-bdd to turn acceptance criteria into Given/When/Then tests, and
163
- workit-test-audit to check tests for tautologies. Load workit-plan when dependencies or handoff need durable next actions.
164
- Load the skill; never act from memory of it.
125
+ Skills: load the skill, never act from memory of it (slash aliases /wk-<name>).
126
+ - brainstorm, plan, spec, grill, should we: workit-shape
127
+ - implement, build, add a feature: workit-implement
128
+ - review, blast radius: workit-review
129
+ - bug, broken, flaky, regression: workit-debug
130
+ - ship, babysit, CI, merge: workit-ship
131
+ - resume, pick up, handoff, interruption: workit-continue
132
+ - BDD, TDD, acceptance criteria, Given/When/Then: workit-bdd
133
+ - test audit, tautology, weak tests: workit-test-audit
134
+ - deslop, slop, dead code: workit-deslop
135
+ - fan out, parallelize, parallel agents, swarm: workit-fanout
136
+ - verify the app, smoke test, prove it works: workit-verify-app
165
137
  `.trim();
166
138
 
167
139
  // packages/workit-core/src/core/task-contract.ts
@@ -8507,16 +8479,6 @@ var stateOperations = {
8507
8479
  expectedWorkspaceRevision: revision.nullable().optional(),
8508
8480
  bundle: exportBundleSchema,
8509
8481
  authorityRefs: array(refSchema)
8510
- }),
8511
- recover: operation({
8512
- action: literal("recover"),
8513
- taskId: id.optional(),
8514
- expectedWorkspaceRevision: revision.optional(),
8515
- target: _enum(["task", "workspace"]),
8516
- expectedBytes: digest,
8517
- snapshotDigest: digest,
8518
- reason: text,
8519
- authorityRefs: array(refSchema)
8520
8482
  })
8521
8483
  };
8522
8484
  var operationSchemas = {
@@ -8529,10 +8491,17 @@ var operationSchemas = {
8529
8491
  writer: discriminatedUnion("action", Object.values(writerOperations)),
8530
8492
  state: discriminatedUnion("action", Object.values(stateOperations))
8531
8493
  };
8532
- var { recover: _unadvertisedRecover, ...advertisedStateOperations } = stateOperations;
8494
+ var withImplicitTask = (options) => Object.values(options).map((option) => ("taskId" in option.shape) ? option.extend({ taskId: id.optional() }) : option);
8495
+ var advertised = (options) => discriminatedUnion("action", withImplicitTask(options));
8533
8496
  var advertisedOperationSchemas = {
8534
- ...operationSchemas,
8535
- state: discriminatedUnion("action", Object.values(advertisedStateOperations))
8497
+ task: advertised(taskOperations),
8498
+ policy: advertised(policyOperations),
8499
+ evidence: advertised(evidenceOperations),
8500
+ finding: advertised(findingOperations),
8501
+ decision: advertised(decisionOperations),
8502
+ worker: advertised(workerOperations),
8503
+ writer: advertised(writerOperations),
8504
+ state: advertised(stateOperations)
8536
8505
  };
8537
8506
  var compiledOperationSchemas = Object.fromEntries(OPERATION_FAMILIES.map((family) => [family, compile(operationSchemas[family])]));
8538
8507
  var success = (revisionValue, workspaceRevisionValue, data) => ({
@@ -8651,20 +8620,20 @@ function requirementId(input) {
8651
8620
  }
8652
8621
 
8653
8622
  // packages/workit-core/src/core/task-evaluation.ts
8654
- import { createHash as createHash8 } from "node:crypto";
8655
- import * as fs10 from "node:fs";
8656
- import { spawnSync as spawnSync5 } from "node:child_process";
8657
- import path12 from "node:path";
8623
+ import { createHash as createHash10 } from "node:crypto";
8624
+ import * as fs12 from "node:fs";
8625
+ import { spawnSync as spawnSync6 } from "node:child_process";
8626
+ import path14 from "node:path";
8658
8627
 
8659
8628
  // packages/workit-core/src/core/authority.ts
8660
- import { createHash as createHash6 } from "node:crypto";
8661
- import * as fs7 from "node:fs";
8662
- import path9 from "node:path";
8629
+ import { createHash as createHash8 } from "node:crypto";
8630
+ import * as fs9 from "node:fs";
8631
+ import path11 from "node:path";
8663
8632
 
8664
8633
  // packages/workit-core/src/core/auto-approval.ts
8665
- import { createHash as createHash5 } from "node:crypto";
8634
+ import { createHash as createHash7 } from "node:crypto";
8666
8635
  import { readFileSync as readFileSync4, realpathSync as realpathSync3 } from "node:fs";
8667
- import path8 from "node:path";
8636
+ import path10 from "node:path";
8668
8637
 
8669
8638
  // packages/workit-core/src/core/workspaces.ts
8670
8639
  import { createHash as createHash2 } from "node:crypto";
@@ -9478,24 +9447,10 @@ var verifyPushIdentity = (root, provider, account) => {
9478
9447
  };
9479
9448
 
9480
9449
  // packages/workit-core/src/core/task-store.ts
9481
- import { createHash as createHash4, randomUUID as randomUUID2 } from "node:crypto";
9482
9450
  import { AsyncLocalStorage } from "node:async_hooks";
9483
- import * as fs6 from "node:fs";
9484
- import path7 from "node:path";
9485
-
9486
- // packages/workit-core/src/core/package-root.ts
9487
- import { existsSync as existsSync2 } from "node:fs";
9488
- import path4 from "node:path";
9489
- import { fileURLToPath } from "node:url";
9490
- var packageRoot = () => {
9491
- let dir = path4.dirname(fileURLToPath(import.meta.url));
9492
- while (true) {
9493
- const parent = path4.dirname(dir);
9494
- if (existsSync2(path4.join(dir, "package.json")) || parent === dir)
9495
- return dir;
9496
- dir = parent;
9497
- }
9498
- };
9451
+ import { createHash as createHash6, randomUUID as randomUUID3 } from "node:crypto";
9452
+ import * as fs8 from "node:fs";
9453
+ import path9 from "node:path";
9499
9454
 
9500
9455
  // node_modules/@openclaw/fs-safe/dist/errors.js
9501
9456
  var OPERATIONAL_CODES = new Set([
@@ -9546,7 +9501,7 @@ function sameFileIdentity(left, right, platform = process.platform) {
9546
9501
  // node_modules/@openclaw/fs-safe/dist/sidecar-lock-reclaim.js
9547
9502
  import { randomBytes } from "node:crypto";
9548
9503
  import fsSync from "node:fs";
9549
- import path5 from "node:path";
9504
+ import path4 from "node:path";
9550
9505
 
9551
9506
  // node_modules/@openclaw/fs-safe/dist/bounded-read.js
9552
9507
  import fs2 from "node:fs";
@@ -9636,13 +9591,13 @@ ${ownershipToken}
9636
9591
  return { raw, ownershipToken };
9637
9592
  }
9638
9593
  function relativeSidecarLockPath(lockRoot, lockPath) {
9639
- const resolved = path5.resolve(lockPath);
9640
- const lexicalRelative = path5.relative(lockRoot.rootDir, resolved);
9641
- const relative = lexicalRelative !== ".." && !lexicalRelative.startsWith(`..${path5.sep}`) && !path5.isAbsolute(lexicalRelative) ? lexicalRelative : path5.relative(lockRoot.rootReal, resolved);
9642
- if (!relative || relative === ".." || relative.startsWith(`..${path5.sep}`) || path5.isAbsolute(relative)) {
9594
+ const resolved = path4.resolve(lockPath);
9595
+ const lexicalRelative = path4.relative(lockRoot.rootDir, resolved);
9596
+ const relative = lexicalRelative !== ".." && !lexicalRelative.startsWith(`..${path4.sep}`) && !path4.isAbsolute(lexicalRelative) ? lexicalRelative : path4.relative(lockRoot.rootReal, resolved);
9597
+ if (!relative || relative === ".." || relative.startsWith(`..${path4.sep}`) || path4.isAbsolute(relative)) {
9643
9598
  throw new FsSafeError("outside-workspace", "sidecar lock path is outside lockRoot");
9644
9599
  }
9645
- return relative.split(path5.sep).join(path5.posix.sep);
9600
+ return relative.split(path4.sep).join(path4.posix.sep);
9646
9601
  }
9647
9602
  function parseSidecarLockPayload(raw, parser) {
9648
9603
  if (parser) {
@@ -9789,7 +9744,7 @@ function getFsSafeLockConfig() {
9789
9744
 
9790
9745
  // node_modules/@openclaw/fs-safe/dist/file-lock-sync.js
9791
9746
  import fs4 from "node:fs";
9792
- import path6 from "node:path";
9747
+ import path5 from "node:path";
9793
9748
 
9794
9749
  // node_modules/@openclaw/fs-safe/dist/timing.js
9795
9750
  function sleepSync(ms) {
@@ -9880,26 +9835,26 @@ function canonicalLockParentSync(parent) {
9880
9835
  return process.platform === "win32" ? fs4.realpathSync.native(parent) : fs4.realpathSync(parent);
9881
9836
  }
9882
9837
  function normalizeTargetPath(targetPath) {
9883
- const resolved = path6.resolve(targetPath);
9884
- fs4.mkdirSync(path6.dirname(resolved), { recursive: true });
9838
+ const resolved = path5.resolve(targetPath);
9839
+ fs4.mkdirSync(path5.dirname(resolved), { recursive: true });
9885
9840
  try {
9886
- return path6.join(canonicalLockParentSync(path6.dirname(resolved)), path6.basename(resolved));
9841
+ return path5.join(canonicalLockParentSync(path5.dirname(resolved)), path5.basename(resolved));
9887
9842
  } catch {
9888
9843
  return resolved;
9889
9844
  }
9890
9845
  }
9891
9846
  function boundedLockPath(lockPath, lockRoot) {
9892
- const resolved = path6.resolve(lockPath);
9847
+ const resolved = path5.resolve(lockPath);
9893
9848
  if (!lockRoot)
9894
9849
  return resolved;
9895
9850
  relativeSidecarLockPath(lockRoot, resolved);
9896
- const parent = path6.dirname(resolved);
9851
+ const parent = path5.dirname(resolved);
9897
9852
  const parentReal = canonicalLockParentSync(parent);
9898
- const parentRelative = path6.relative(lockRoot.rootReal, parentReal);
9899
- if (parentRelative === ".." || parentRelative.startsWith(`..${path6.sep}`) || path6.isAbsolute(parentRelative)) {
9853
+ const parentRelative = path5.relative(lockRoot.rootReal, parentReal);
9854
+ if (parentRelative === ".." || parentRelative.startsWith(`..${path5.sep}`) || path5.isAbsolute(parentRelative)) {
9900
9855
  throw new FsSafeError("outside-workspace", "sidecar lock parent is outside lockRoot");
9901
9856
  }
9902
- return path6.join(parentReal, path6.basename(resolved));
9857
+ return path5.join(parentReal, path5.basename(resolved));
9903
9858
  }
9904
9859
  function defaultShouldReclaim(snapshot, staleMs, nowMs) {
9905
9860
  const createdAtMs = sidecarLockPayloadCreatedAtMs(snapshot.payload);
@@ -10098,10 +10053,306 @@ function acquireFileLockSync(targetPath, options) {
10098
10053
  }
10099
10054
  }
10100
10055
  }
10056
+ // packages/workit-core/src/core/package-root.ts
10057
+ import { existsSync as existsSync2 } from "node:fs";
10058
+ import path6 from "node:path";
10059
+ import { fileURLToPath } from "node:url";
10060
+ var packageRoot = () => {
10061
+ let dir = path6.dirname(fileURLToPath(import.meta.url));
10062
+ while (true) {
10063
+ const parent = path6.dirname(dir);
10064
+ if (existsSync2(path6.join(dir, "package.json")) || parent === dir)
10065
+ return dir;
10066
+ dir = parent;
10067
+ }
10068
+ };
10069
+
10101
10070
  // packages/workit-core/src/core/store-lock.ts
10071
+ import { spawnSync as spawnSync3 } from "node:child_process";
10072
+ import fs6 from "node:fs";
10073
+ import { hostname } from "node:os";
10074
+
10075
+ // packages/workit-core/src/store/paths.ts
10102
10076
  import { spawnSync as spawnSync2 } from "node:child_process";
10077
+ import { createHash as createHash4 } from "node:crypto";
10103
10078
  import fs5 from "node:fs";
10104
- import { hostname } from "node:os";
10079
+ import path7 from "node:path";
10080
+ var NOT_A_REPO = /not a git repository \(or any (of the parent directories|parent up to)/u;
10081
+ var gitRun = (cwd, args) => {
10082
+ const run = spawnSync2("git", args, {
10083
+ cwd,
10084
+ encoding: "utf8",
10085
+ timeout: 20000,
10086
+ killSignal: "SIGKILL",
10087
+ windowsHide: true,
10088
+ env: {
10089
+ ...process.env,
10090
+ GIT_TERMINAL_PROMPT: "0",
10091
+ GIT_OPTIONAL_LOCKS: "0",
10092
+ LC_ALL: "C",
10093
+ LANGUAGE: "C"
10094
+ }
10095
+ });
10096
+ const error = run.error;
10097
+ return {
10098
+ ok: run.status === 0,
10099
+ stdout: run.stdout ?? "",
10100
+ stderr: error ? String(error.message) : run.stderr ?? "",
10101
+ missing: error?.code === "ENOENT"
10102
+ };
10103
+ };
10104
+ var shortHash = (value) => createHash4("sha256").update(value).digest("hex").slice(0, 12);
10105
+ var real = (file) => {
10106
+ try {
10107
+ return fs5.realpathSync.native(file);
10108
+ } catch {
10109
+ return file;
10110
+ }
10111
+ };
10112
+ var DISCOVERY_ENV = [
10113
+ "GIT_DIR",
10114
+ "GIT_COMMON_DIR",
10115
+ "GIT_WORK_TREE",
10116
+ "GIT_CEILING_DIRECTORIES",
10117
+ "GIT_DISCOVERY_ACROSS_FILESYSTEM"
10118
+ ];
10119
+ var isGitDir = (dir) => {
10120
+ try {
10121
+ return fs5.statSync(path7.join(dir, "HEAD")).isFile() && fs5.statSync(path7.join(dir, "objects")).isDirectory() && fs5.statSync(path7.join(dir, "refs")).isDirectory();
10122
+ } catch {
10123
+ return false;
10124
+ }
10125
+ };
10126
+ var reftableRefs = (common) => {
10127
+ try {
10128
+ return /^\s*refstorage\s*=\s*reftable\s*$/imu.test(fs5.readFileSync(path7.join(common, "config"), "utf8"));
10129
+ } catch {
10130
+ return false;
10131
+ }
10132
+ };
10133
+ function fastGit(root) {
10134
+ if (DISCOVERY_ENV.some((name) => process.env[name]))
10135
+ return null;
10136
+ let dir = real(root);
10137
+ let device = null;
10138
+ for (;; ) {
10139
+ let stat;
10140
+ try {
10141
+ stat = fs5.statSync(dir);
10142
+ } catch {
10143
+ return null;
10144
+ }
10145
+ if (device !== null && stat.dev !== device)
10146
+ return { kind: "none" };
10147
+ device = stat.dev;
10148
+ const dotGit = path7.join(dir, ".git");
10149
+ let entry = null;
10150
+ try {
10151
+ entry = fs5.statSync(dotGit);
10152
+ } catch {}
10153
+ let gitDir = null;
10154
+ let top = null;
10155
+ if (entry) {
10156
+ top = dir;
10157
+ if (entry.isDirectory())
10158
+ gitDir = dotGit;
10159
+ else if (entry.isFile()) {
10160
+ let text = "";
10161
+ try {
10162
+ text = fs5.readFileSync(dotGit, "utf8");
10163
+ } catch {
10164
+ return null;
10165
+ }
10166
+ const match = /^gitdir: (.+)$/mu.exec(text);
10167
+ if (!match)
10168
+ return null;
10169
+ gitDir = path7.resolve(dir, match[1].trim());
10170
+ } else
10171
+ return null;
10172
+ if (!fs5.existsSync(path7.join(gitDir, "HEAD")))
10173
+ return null;
10174
+ } else if (isGitDir(dir))
10175
+ gitDir = dir;
10176
+ if (gitDir) {
10177
+ let common = gitDir;
10178
+ try {
10179
+ common = path7.resolve(gitDir, fs5.readFileSync(path7.join(gitDir, "commondir"), "utf8").trim());
10180
+ } catch {}
10181
+ if (!fs5.existsSync(path7.join(common, "objects")))
10182
+ return null;
10183
+ return {
10184
+ kind: "repo",
10185
+ common: real(common),
10186
+ gitDir: real(gitDir),
10187
+ top: top === null ? null : real(top),
10188
+ reftable: reftableRefs(common)
10189
+ };
10190
+ }
10191
+ const parent = path7.dirname(dir);
10192
+ if (parent === dir)
10193
+ return { kind: "none" };
10194
+ dir = parent;
10195
+ }
10196
+ }
10197
+ function resolveStore(root) {
10198
+ root = path7.resolve(root);
10199
+ let directory = false;
10200
+ try {
10201
+ directory = fs5.statSync(root).isDirectory();
10202
+ } catch {}
10203
+ if (!directory)
10204
+ return { dir: path7.join(root, ".workit"), shared: false, top: null };
10205
+ const fast = fastGit(root);
10206
+ if (fast?.kind === "none")
10207
+ return { dir: path7.join(root, ".workit"), shared: false, top: null };
10208
+ if (fast?.kind === "repo")
10209
+ return {
10210
+ dir: path7.join(fast.common, "workit"),
10211
+ shared: true,
10212
+ top: fast.top,
10213
+ git: { gitDir: fast.gitDir, top: fast.top, reftable: fast.reftable }
10214
+ };
10215
+ const run = gitRun(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
10216
+ const common = run.stdout.trim();
10217
+ if (run.ok && common) {
10218
+ const top = gitRun(root, ["rev-parse", "--path-format=absolute", "--show-toplevel"]);
10219
+ return {
10220
+ dir: path7.join(real(path7.resolve(root, common)), "workit"),
10221
+ shared: true,
10222
+ top: top.ok && top.stdout.trim() ? real(top.stdout.trim()) : null
10223
+ };
10224
+ }
10225
+ if (run.missing || !run.ok && NOT_A_REPO.test(run.stderr))
10226
+ return { dir: path7.join(root, ".workit"), shared: false, top: null };
10227
+ return new Error(`cannot locate the workit store: git rev-parse failed (${run.stderr.trim().split(`
10228
+ `)[0] || "no output"}); fix the repository (git status should work here)`);
10229
+ }
10230
+ var rebasingBranch = (gitDir) => {
10231
+ for (const state of ["rebase-merge", "rebase-apply"]) {
10232
+ try {
10233
+ const ref = fs5.readFileSync(path7.join(gitDir, state, "head-name"), "utf8").trim();
10234
+ const match = /^refs\/heads\/(.+)$/u.exec(ref);
10235
+ if (match)
10236
+ return match[1];
10237
+ } catch {}
10238
+ }
10239
+ return null;
10240
+ };
10241
+ function resolveTaskKey(root, location) {
10242
+ if (!location.shared)
10243
+ return { key: `dir-${shortHash(root)}`, kind: "dir", branch: null };
10244
+ const branchKey = (name) => ({ key: name, kind: "branch", branch: name });
10245
+ if (location.git && !location.git.reftable) {
10246
+ let head = "";
10247
+ try {
10248
+ head = fs5.readFileSync(path7.join(location.git.gitDir, "HEAD"), "utf8").trim();
10249
+ } catch {}
10250
+ const ref = /^ref: refs\/heads\/(.+)$/u.exec(head);
10251
+ if (ref && ref[1] !== ".invalid")
10252
+ return branchKey(ref[1]);
10253
+ if (/^[0-9a-f]{40,64}$/u.test(head)) {
10254
+ const rebasing = rebasingBranch(location.git.gitDir);
10255
+ if (rebasing)
10256
+ return branchKey(rebasing);
10257
+ return {
10258
+ key: `detached-${shortHash(location.git.top ?? location.git.gitDir)}`,
10259
+ kind: "detached",
10260
+ branch: null
10261
+ };
10262
+ }
10263
+ }
10264
+ const branch = gitRun(root, ["symbolic-ref", "-q", "--short", "HEAD"]);
10265
+ const name = branch.ok ? branch.stdout.trim() : "";
10266
+ if (name && name !== ".invalid")
10267
+ return branchKey(name);
10268
+ const gitDir = gitRun(root, ["rev-parse", "--path-format=absolute", "--git-dir"]);
10269
+ if (gitDir.ok && gitDir.stdout.trim()) {
10270
+ const rebasing = rebasingBranch(gitDir.stdout.trim());
10271
+ if (rebasing)
10272
+ return branchKey(rebasing);
10273
+ }
10274
+ const top = gitRun(root, ["rev-parse", "--path-format=absolute", "--show-toplevel"]);
10275
+ const worktree = top.ok && top.stdout.trim() ? top.stdout.trim() : root;
10276
+ return { key: `detached-${shortHash(real(worktree))}`, kind: "detached", branch: null };
10277
+ }
10278
+ function checkoutRootOf(root, location) {
10279
+ const resolved = path7.resolve(root);
10280
+ let canonical = resolved;
10281
+ try {
10282
+ canonical = fs5.realpathSync(resolved);
10283
+ } catch {
10284
+ return resolved;
10285
+ }
10286
+ const found = location ?? resolveStore(canonical);
10287
+ if (found instanceof Error || !found.shared || !found.top)
10288
+ return canonical;
10289
+ if (real(canonical) === found.top)
10290
+ return canonical;
10291
+ try {
10292
+ return fs5.realpathSync(found.top);
10293
+ } catch {
10294
+ return canonical;
10295
+ }
10296
+ }
10297
+ function branchRenames(location) {
10298
+ if (!location.shared)
10299
+ return [];
10300
+ const files = [];
10301
+ const walk = (dir) => {
10302
+ let entries;
10303
+ try {
10304
+ entries = fs5.readdirSync(dir, { withFileTypes: true });
10305
+ } catch {
10306
+ return;
10307
+ }
10308
+ for (const entry of entries)
10309
+ if (entry.isDirectory())
10310
+ walk(path7.join(dir, entry.name));
10311
+ else
10312
+ files.push(path7.join(dir, entry.name));
10313
+ };
10314
+ walk(path7.join(path7.dirname(location.dir), "logs", "refs", "heads"));
10315
+ if (location.git)
10316
+ files.push(path7.join(location.git.gitDir, "logs", "HEAD"));
10317
+ const seen = new Set;
10318
+ const renames = [];
10319
+ for (const file of files) {
10320
+ let text = "";
10321
+ try {
10322
+ text = fs5.readFileSync(file, "utf8");
10323
+ } catch {
10324
+ continue;
10325
+ }
10326
+ for (const match of text.matchAll(/ (\d+) [+-]\d{4}\tBranch: renamed refs\/heads\/(\S+) to refs\/heads\/(\S+)/gu)) {
10327
+ const rename = { from: match[2], to: match[3], at: Number(match[1]) };
10328
+ const id = `${rename.at}\x00${rename.from}\x00${rename.to}`;
10329
+ if (seen.has(id))
10330
+ continue;
10331
+ seen.add(id);
10332
+ renames.push(rename);
10333
+ }
10334
+ }
10335
+ return renames.toSorted((left, right) => left.at - right.at);
10336
+ }
10337
+ function followRenames(key, boundAt, renames) {
10338
+ let current = key;
10339
+ let since = Math.floor(Date.parse(boundAt) / 1000);
10340
+ for (const rename of renames)
10341
+ if (rename.from === current && rename.at >= since) {
10342
+ current = rename.to;
10343
+ since = rename.at;
10344
+ }
10345
+ return current;
10346
+ }
10347
+ var checkoutSlug = (root) => {
10348
+ let canonical = real(root);
10349
+ if (process.platform === "win32")
10350
+ canonical = canonical.toLowerCase();
10351
+ const base = path7.basename(canonical).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 40) || "root";
10352
+ return `${base}-${shortHash(canonical)}`;
10353
+ };
10354
+
10355
+ // packages/workit-core/src/core/store-lock.ts
10105
10356
  var metadataLockSchema = object({
10106
10357
  pid: number2().int().nonnegative().safe(),
10107
10358
  processStart: string2().nullable(),
@@ -10133,10 +10384,6 @@ var parseMetadataLockOrNull = (raw) => {
10133
10384
  return null;
10134
10385
  }
10135
10386
  };
10136
- var sameMetadataLock = (left, right) => {
10137
- const parsed = metadataLockSchema.safeParse(left);
10138
- return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
10139
- };
10140
10387
  var parseProcStatStart = (stat) => {
10141
10388
  const close = stat.lastIndexOf(")");
10142
10389
  if (close < 0)
@@ -10146,14 +10393,14 @@ var parseProcStatStart = (stat) => {
10146
10393
  var processStartOf = (pid) => {
10147
10394
  if (process.platform === "linux") {
10148
10395
  try {
10149
- return parseProcStatStart(fs5.readFileSync(`/proc/${pid}/stat`, "utf8"));
10396
+ return parseProcStatStart(fs6.readFileSync(`/proc/${pid}/stat`, "utf8"));
10150
10397
  } catch {
10151
10398
  return null;
10152
10399
  }
10153
10400
  }
10154
10401
  if (process.platform === "darwin" || process.platform === "freebsd") {
10155
10402
  try {
10156
- const run = spawnSync2("ps", ["-o", "lstart=", "-p", String(pid)], {
10403
+ const run = spawnSync3("ps", ["-o", "lstart=", "-p", String(pid)], {
10157
10404
  encoding: "utf8",
10158
10405
  timeout: 1000
10159
10406
  });
@@ -10167,7 +10414,7 @@ var processStartOf = (pid) => {
10167
10414
  };
10168
10415
  var readTrimmed = (file) => {
10169
10416
  try {
10170
- return fs5.readFileSync(file, "utf8").trim() || null;
10417
+ return fs6.readFileSync(file, "utf8").trim() || null;
10171
10418
  } catch {
10172
10419
  return null;
10173
10420
  }
@@ -10178,7 +10425,7 @@ var localLockHost = () => {
10178
10425
  return cachedLockHost;
10179
10426
  let pidns = null;
10180
10427
  try {
10181
- pidns = /\[(\d+)\]/.exec(fs5.readlinkSync("/proc/self/ns/pid"))?.[1] ?? null;
10428
+ pidns = /\[(\d+)\]/.exec(fs6.readlinkSync("/proc/self/ns/pid"))?.[1] ?? null;
10182
10429
  } catch {}
10183
10430
  const boot = readTrimmed("/proc/sys/kernel/random/boot_id");
10184
10431
  cachedLockHost = pidns || boot ? `${hostname()}#${pidns ?? "?"}:${boot ?? "?"}` : hostname();
@@ -10218,7 +10465,7 @@ var classifyLockOwner = (payload, ageMs, localHost = localLockHost()) => {
10218
10465
  };
10219
10466
  var ageOf = (file, nowMs) => {
10220
10467
  try {
10221
- return nowMs - fs5.lstatSync(file).mtimeMs;
10468
+ return nowMs - fs6.lstatSync(file).mtimeMs;
10222
10469
  } catch {
10223
10470
  return null;
10224
10471
  }
@@ -10229,117 +10476,151 @@ var clearAbandonedReclaimGuard = (lockPath, nowMs = Date.now()) => {
10229
10476
  if (age === null || age <= RECLAIM_GUARD_TTL_MS)
10230
10477
  return false;
10231
10478
  try {
10232
- fs5.rmdirSync(guard);
10479
+ fs6.rmdirSync(guard);
10233
10480
  return true;
10234
10481
  } catch {
10235
10482
  return false;
10236
10483
  }
10237
10484
  };
10238
10485
 
10239
- // packages/workit-core/src/core/task-store.ts
10240
- var RECOVERY_COPIES_PER_RECORD = 3;
10241
- var STALE_TEMPORARY_MS = 60 * 60000;
10242
- var RECOVERY_NAME = /^(task|workspace)\.([^.]+)\.([0-9a-f]{64})\.json$/;
10243
- var processEvidenceSchema = object({
10244
- state: _enum(["stopped", "accounted_for"]),
10245
- pid: number2().int().nonnegative(),
10246
- processStart: string2().nullable(),
10247
- ownerDigest: string2().regex(/^[0-9a-f]{64}$/).nullable()
10248
- }).strict();
10249
- var INDEX_VERSION = 1;
10250
- var hostSession = (value) => isObject2(value) && value.kind === "host" && typeof value.host === "string" && typeof value.handle === "string" ? { host: value.host, handle: value.handle } : null;
10251
- var indexEntry = (task, file) => {
10252
- const sessions = [];
10253
- const intent = hostSession(task.intent.provenance.session);
10254
- if (intent)
10255
- sessions.push({ ...intent, workerId: null });
10256
- for (const worker of task.workers) {
10257
- const session = hostSession(worker.data.session);
10258
- if (session)
10259
- sessions.push({ ...session, workerId: worker.id });
10486
+ // packages/workit-core/src/store/event-log.ts
10487
+ import { randomUUID as randomUUID2 } from "node:crypto";
10488
+ import fs7 from "node:fs";
10489
+ import path8 from "node:path";
10490
+ var EVENT_VERSION = 1;
10491
+
10492
+ class LogDamage extends Error {
10493
+ upgrade;
10494
+ constructor(message, upgrade = false) {
10495
+ super(message);
10496
+ this.upgrade = upgrade;
10260
10497
  }
10261
- return {
10262
- id: task.id,
10263
- revision: task.revision,
10264
- status: task.status,
10265
- createdAt: task.createdAt,
10266
- updatedAt: task.updatedAt,
10267
- objective: task.intent.data.objective,
10268
- source: { host: task.intent.provenance.host, kind: task.intent.provenance.kind },
10269
- progress: { summary: task.progress.summary, nextAction: task.progress.nextAction },
10270
- sessions,
10271
- file
10272
- };
10273
- };
10274
- var fileSignature = (file) => {
10498
+ }
10499
+ var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10500
+ var parseEvent = (line, file, offset) => {
10501
+ let value;
10275
10502
  try {
10276
- const stat = fs6.statSync(file, { bigint: true });
10277
- return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
10503
+ value = JSON.parse(line);
10278
10504
  } catch {
10279
- return null;
10280
- }
10281
- };
10282
- var RACY_WINDOW_NS = 2000000000n;
10283
- var racySignature = (signature) => {
10284
- const [mtime = 0n, ctime = 0n] = signature.split(":").slice(3, 5).map(BigInt);
10285
- const changed = mtime > ctime ? mtime : ctime;
10286
- return BigInt(Date.now()) * 1000000n - changed < RACY_WINDOW_NS;
10505
+ throw new LogDamage(`${file}: unreadable event at byte ${offset}`);
10506
+ }
10507
+ if (!isObject2(value) || typeof value.v !== "number")
10508
+ throw new LogDamage(`${file}: malformed event at byte ${offset}`);
10509
+ if (value.v > EVENT_VERSION)
10510
+ throw new LogDamage(`${file}: event format v${value.v} was written by a newer Workit; upgrade Workit to read this task`, true);
10511
+ if (typeof value.seq !== "number" || !Number.isInteger(value.seq) || typeof value.type !== "string" || typeof value.id !== "string" || typeof value.task !== "string" || !isObject2(value.data))
10512
+ throw new LogDamage(`${file}: malformed event at byte ${offset}`);
10513
+ return value;
10287
10514
  };
10288
- var now = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
10289
- var cachedRuntimeVersion = null;
10290
- var runtimeVersion = () => {
10291
- if (cachedRuntimeVersion === null) {
10515
+ function readLog(file, from = 0, expectSeq = null) {
10516
+ let fd;
10517
+ try {
10518
+ fd = fs7.openSync(file, "r");
10519
+ } catch (error) {
10520
+ if (error.code === "ENOENT")
10521
+ return null;
10522
+ throw error;
10523
+ }
10524
+ let bytes;
10525
+ let size;
10526
+ try {
10527
+ size = fs7.fstatSync(fd).size;
10528
+ if (from > size)
10529
+ throw new LogDamage(`${file}: shorter than its snapshot`);
10530
+ bytes = Buffer.alloc(size - from);
10531
+ let read = 0;
10532
+ while (read < bytes.length) {
10533
+ const got = fs7.readSync(fd, bytes, read, bytes.length - read, from + read);
10534
+ if (got === 0)
10535
+ break;
10536
+ read += got;
10537
+ }
10538
+ bytes = bytes.subarray(0, read);
10539
+ size = from + read;
10540
+ } finally {
10541
+ fs7.closeSync(fd);
10542
+ }
10543
+ const events = [];
10544
+ let start = 0;
10545
+ let lastStart = from;
10546
+ let seq = expectSeq;
10547
+ for (;; ) {
10548
+ const newline = bytes.indexOf(10, start);
10549
+ if (newline < 0)
10550
+ break;
10551
+ const line = bytes.subarray(start, newline).toString("utf8");
10552
+ if (line.trim() !== "") {
10553
+ const event = parseEvent(line, file, from + start);
10554
+ if (seq !== null && event.seq !== seq)
10555
+ throw new LogDamage(`${file}: event seq ${event.seq} where ${seq} was expected`);
10556
+ seq = event.seq + 1;
10557
+ events.push(event);
10558
+ lastStart = from + start;
10559
+ }
10560
+ start = newline + 1;
10561
+ }
10562
+ return { events, end: from + start, lastStart, size };
10563
+ }
10564
+ var fsyncDirectory = (dir) => {
10565
+ try {
10566
+ const fd = fs7.openSync(dir, "r");
10292
10567
  try {
10293
- const pkg = JSON.parse(fs6.readFileSync(path7.join(packageRoot(), "package.json"), "utf8"));
10294
- cachedRuntimeVersion = typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
10295
- } catch {
10296
- cachedRuntimeVersion = "0.0.0";
10568
+ fs7.fsyncSync(fd);
10569
+ } finally {
10570
+ fs7.closeSync(fd);
10297
10571
  }
10572
+ } catch (error) {
10573
+ if (process.platform !== "win32")
10574
+ throw error;
10298
10575
  }
10299
- return cachedRuntimeVersion;
10300
10576
  };
10301
- var stampNew = (record) => ({
10302
- createdWith: record.runtime?.createdWith ?? null,
10303
- updatedWith: runtimeVersion()
10304
- });
10305
- var isNewerVersion = (candidate, current) => {
10306
- const parts = (value) => value.split("-")[0].split(".").map((item) => Number.parseInt(item, 10) || 0);
10307
- const [candidateParts, currentParts] = [parts(candidate), parts(current)];
10308
- for (let index = 0;index < 3; index += 1) {
10309
- const left = candidateParts[index] ?? 0;
10310
- const right = currentParts[index] ?? 0;
10311
- if (left !== right)
10312
- return left > right;
10577
+ function appendEvent(file, event, validEnd) {
10578
+ const line = Buffer.from(`${JSON.stringify(event)}
10579
+ `, "utf8");
10580
+ const created = !fs7.existsSync(file);
10581
+ if (!created && fs7.statSync(file).size !== validEnd) {
10582
+ const cut = fs7.openSync(file, "r+");
10583
+ try {
10584
+ fs7.ftruncateSync(cut, validEnd);
10585
+ fs7.fsyncSync(cut);
10586
+ } finally {
10587
+ fs7.closeSync(cut);
10588
+ }
10313
10589
  }
10314
- return false;
10315
- };
10316
- var jsonBytes = (value) => `${canonicalJson(value)}
10317
- `;
10318
- var digestBytes = (value) => createHash4("sha256").update(value).digest("hex");
10319
- var isObject2 = (value) => typeof value === "object" && value !== null;
10320
- var validId = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
10321
- var validDigest = (value) => /^[0-9a-f]{64}$/.test(value);
10322
- var sameDirectoryIdentity = (left, right) => {
10323
- if (!path7.isAbsolute(left) || !path7.isAbsolute(right))
10324
- return false;
10325
- const normalizedLeft = path7.resolve(left);
10326
- const normalizedRight = path7.resolve(right);
10590
+ const fd = fs7.openSync(file, "a", 384);
10327
10591
  try {
10328
- const a = fs6.statSync(normalizedLeft, { bigint: true });
10329
- const b = fs6.statSync(normalizedRight, { bigint: true });
10330
- if (!a.isDirectory() || !b.isDirectory())
10331
- return false;
10332
- if (process.platform === "win32" ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() : normalizedLeft === normalizedRight)
10333
- return true;
10334
- const canonicalLeft = fs6.realpathSync(normalizedLeft);
10335
- const canonicalRight = fs6.realpathSync(normalizedRight);
10336
- if (process.platform === "win32" ? canonicalLeft.toLowerCase() === canonicalRight.toLowerCase() : canonicalLeft === canonicalRight)
10337
- return true;
10338
- return a.dev === b.dev && a.ino !== 0n && a.ino === b.ino;
10339
- } catch {
10340
- return false;
10592
+ let written = 0;
10593
+ while (written < line.length)
10594
+ written += fs7.writeSync(fd, line, written, line.length - written);
10595
+ fs7.fsyncSync(fd);
10596
+ } finally {
10597
+ fs7.closeSync(fd);
10341
10598
  }
10342
- };
10599
+ if (created)
10600
+ fsyncDirectory(path8.dirname(file));
10601
+ return validEnd;
10602
+ }
10603
+ function replaceFile(file, bytes, durable) {
10604
+ const temporary = `${file}.${process.pid}.${randomUUID2()}.tmp`;
10605
+ try {
10606
+ const fd = fs7.openSync(temporary, "wx", 384);
10607
+ try {
10608
+ fs7.writeSync(fd, bytes);
10609
+ if (durable)
10610
+ fs7.fsyncSync(fd);
10611
+ } finally {
10612
+ fs7.closeSync(fd);
10613
+ }
10614
+ renameWithRetry(temporary, file);
10615
+ if (durable)
10616
+ fsyncDirectory(path8.dirname(file));
10617
+ } catch (error) {
10618
+ try {
10619
+ fs7.unlinkSync(temporary);
10620
+ } catch {}
10621
+ throw error;
10622
+ }
10623
+ }
10343
10624
  var TRANSIENT_WINDOWS_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
10344
10625
  var isTransientWindowsError = (error) => {
10345
10626
  if (process.platform !== "win32")
@@ -10360,138 +10641,596 @@ var retryTransient = (run) => {
10360
10641
  }
10361
10642
  }
10362
10643
  };
10644
+ var renameWithRetry = (from, to) => retryTransient(() => fs7.renameSync(from, to));
10645
+
10646
+ // packages/workit-core/src/store/patch.ts
10647
+ var isPlainObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10648
+ var equal = (left, right) => {
10649
+ if (left === right)
10650
+ return true;
10651
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null)
10652
+ return false;
10653
+ if (Array.isArray(left) !== Array.isArray(right))
10654
+ return false;
10655
+ if (Array.isArray(left)) {
10656
+ const other = right;
10657
+ return left.length === other.length && left.every((item, index) => equal(item, other[index]));
10658
+ }
10659
+ const leftKeys = Object.keys(left).filter((key) => left[key] !== undefined);
10660
+ const rightKeys = Object.keys(right).filter((key) => right[key] !== undefined);
10661
+ return leftKeys.length === rightKeys.length && leftKeys.every((key) => equal(left[key], right[key]));
10662
+ };
10663
+ function diff(before, after, path = []) {
10664
+ if (equal(before, after))
10665
+ return [];
10666
+ if (isPlainObject2(before) && isPlainObject2(after)) {
10667
+ const ops = [];
10668
+ for (const key of Object.keys(before))
10669
+ if (before[key] !== undefined && after[key] === undefined)
10670
+ ops.push({ op: "del", path: [...path, key] });
10671
+ for (const key of Object.keys(after)) {
10672
+ if (after[key] === undefined)
10673
+ continue;
10674
+ if (before[key] === undefined)
10675
+ ops.push({ op: "set", path: [...path, key], value: after[key] });
10676
+ else
10677
+ ops.push(...diff(before[key], after[key], [...path, key]));
10678
+ }
10679
+ return ops;
10680
+ }
10681
+ if (Array.isArray(before) && Array.isArray(after)) {
10682
+ const ops = [];
10683
+ if (after.length < before.length)
10684
+ ops.push({ op: "cut", path, length: after.length });
10685
+ const shared = Math.min(before.length, after.length);
10686
+ let changed = 0;
10687
+ for (let index = 0;index < shared; index += 1)
10688
+ if (!equal(before[index], after[index]))
10689
+ changed += 1;
10690
+ if (shared > 0 && changed > shared / 2 && changed > 4)
10691
+ return [{ op: "set", path, value: after }];
10692
+ for (let index = 0;index < shared; index += 1)
10693
+ ops.push(...diff(before[index], after[index], [...path, index]));
10694
+ if (after.length > before.length)
10695
+ ops.push({ op: "push", path, values: after.slice(before.length) });
10696
+ return ops;
10697
+ }
10698
+ return [{ op: "set", path, value: after }];
10699
+ }
10700
+ var clone2 = (value) => structuredClone(value);
10701
+ function apply(target, ops) {
10702
+ let root = target;
10703
+ for (const op of ops) {
10704
+ if (op.path.length === 0) {
10705
+ if (op.op === "set")
10706
+ root = clone2(op.value);
10707
+ else if (op.op === "push" && Array.isArray(root))
10708
+ root.push(...clone2(op.values));
10709
+ else if (op.op === "cut" && Array.isArray(root))
10710
+ root.length = op.length;
10711
+ else
10712
+ throw new Error(`patch: ${op.op} at the root is invalid`);
10713
+ continue;
10714
+ }
10715
+ let parent = root;
10716
+ for (const key of op.path.slice(0, -1)) {
10717
+ if (typeof parent !== "object" || parent === null)
10718
+ throw new Error(`patch: missing path ${op.path.join(".")}`);
10719
+ parent = parent[key];
10720
+ }
10721
+ if (typeof parent !== "object" || parent === null)
10722
+ throw new Error(`patch: missing path ${op.path.join(".")}`);
10723
+ const key = op.path.at(-1);
10724
+ const container = parent;
10725
+ if (op.op === "set")
10726
+ container[key] = clone2(op.value);
10727
+ else if (op.op === "del")
10728
+ delete container[key];
10729
+ else {
10730
+ const array = container[key];
10731
+ if (!Array.isArray(array))
10732
+ throw new Error(`patch: ${op.path.join(".")} is not an array`);
10733
+ if (op.op === "push")
10734
+ array.push(...clone2(op.values));
10735
+ else
10736
+ array.length = op.length;
10737
+ }
10738
+ }
10739
+ return root;
10740
+ }
10741
+ var isPath = (value) => Array.isArray(value) && value.every((key) => typeof key === "string" || typeof key === "number" && Number.isInteger(key) && key >= 0);
10742
+ var isPatch = (value) => Array.isArray(value) && value.every((op) => {
10743
+ if (!isPlainObject2(op) || !isPath(op.path))
10744
+ return false;
10745
+ if (op.op === "set")
10746
+ return "value" in op;
10747
+ if (op.op === "del")
10748
+ return op.path.length > 0;
10749
+ if (op.op === "push")
10750
+ return Array.isArray(op.values);
10751
+ if (op.op === "cut")
10752
+ return typeof op.length === "number" && Number.isInteger(op.length) && op.length >= 0;
10753
+ return false;
10754
+ });
10755
+
10756
+ // packages/workit-core/src/store/reduce.ts
10757
+ import { createHash as createHash5 } from "node:crypto";
10758
+ var OPENING_TYPES = new Set([
10759
+ "task.opened",
10760
+ "task.imported",
10761
+ "migrated.from_v2",
10762
+ "task.checkpoint"
10763
+ ]);
10764
+ var KNOWN_TYPES = new Set([
10765
+ ...OPENING_TYPES,
10766
+ "task.bound",
10767
+ "task.noted",
10768
+ "task.revised",
10769
+ "task.judged",
10770
+ "task.paused",
10771
+ "task.resumed",
10772
+ "task.closed",
10773
+ "task.updated",
10774
+ "check.ran",
10775
+ "evidence.recorded",
10776
+ "finding.recorded",
10777
+ "finding.updated",
10778
+ "decision.recorded",
10779
+ "decision.updated",
10780
+ "worker.updated"
10781
+ ]);
10782
+ var isObject3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10783
+ var isKey = (value) => isObject3(value) && typeof value.key === "string" && (value.kind === "branch" || value.kind === "detached" || value.kind === "dir") && (value.branch === null || typeof value.branch === "string");
10784
+ var BLOB_REF = "$blob";
10785
+ var blobRef = (value) => isObject3(value) && Object.keys(value).length === 1 && typeof value[BLOB_REF] === "string" && /^[0-9a-f]{64}$/.test(value[BLOB_REF]) ? value[BLOB_REF] : null;
10786
+ var blobDigest = (value) => createHash5("sha256").update(JSON.stringify(value)).digest("hex");
10787
+ var toBlob = (value, blobs) => {
10788
+ if (!isObject3(value))
10789
+ return value;
10790
+ const digest = blobDigest(value);
10791
+ blobs.write(digest, value);
10792
+ return { [BLOB_REF]: digest };
10793
+ };
10794
+ var encodeRecord = (record, blobs) => Array.isArray(record.candidates) ? { ...record, candidates: record.candidates.map((item) => toBlob(item, blobs)) } : record;
10795
+ var encodeOps = (ops, blobs) => ops.map((op) => {
10796
+ if (op.path[0] !== "candidates")
10797
+ return op;
10798
+ if (op.op === "push" && op.path.length === 1)
10799
+ return { ...op, values: op.values.map((item) => toBlob(item, blobs)) };
10800
+ if (op.op === "set" && op.path.length === 1 && Array.isArray(op.value))
10801
+ return { ...op, value: op.value.map((item) => toBlob(item, blobs)) };
10802
+ if (op.op === "set" && op.path.length === 2)
10803
+ return { ...op, value: toBlob(op.value, blobs) };
10804
+ return op;
10805
+ });
10806
+ var decode2 = (value, blobs) => {
10807
+ const digest = blobRef(value);
10808
+ if (digest)
10809
+ return blobs.read(digest);
10810
+ if (Array.isArray(value))
10811
+ return value.map((item) => decode2(item, blobs));
10812
+ if (isObject3(value)) {
10813
+ const out = {};
10814
+ for (const [key, item] of Object.entries(value))
10815
+ out[key] = decode2(item, blobs);
10816
+ return out;
10817
+ }
10818
+ return value;
10819
+ };
10820
+ function reduce(state, event, blobs) {
10821
+ const data = event.data;
10822
+ const known = KNOWN_TYPES.has(event.type);
10823
+ const carries = "record" in data || "ops" in data || "key" in data;
10824
+ if (!known && !carries) {
10825
+ if (event.critical === true)
10826
+ throw new LogDamage(`event ${event.type} was written by a newer Workit and must be understood; upgrade Workit to read this task`, true);
10827
+ return state;
10828
+ }
10829
+ let next = state;
10830
+ if ("record" in data) {
10831
+ if (!isObject3(data.record))
10832
+ throw new LogDamage(`event ${event.seq} carries a malformed record`);
10833
+ next = {
10834
+ record: decode2(data.record, blobs),
10835
+ key: null,
10836
+ legacy: null
10837
+ };
10838
+ } else if (next === null) {
10839
+ throw new LogDamage(`the log does not start with an opening event (found ${event.type})`);
10840
+ } else
10841
+ next = { ...next };
10842
+ if ("ops" in data) {
10843
+ if (!isPatch(data.ops))
10844
+ throw new LogDamage(`event ${event.seq} carries a malformed patch`);
10845
+ try {
10846
+ next.record = apply(next.record, decode2(data.ops, blobs));
10847
+ } catch (error) {
10848
+ throw new LogDamage(`event ${event.seq} does not apply: ${String(error)}`);
10849
+ }
10850
+ }
10851
+ if ("key" in data)
10852
+ next.key = isKey(data.key) ? data.key : null;
10853
+ if ("legacy" in data && isObject3(data.legacy))
10854
+ next.legacy = {
10855
+ path: String(data.legacy.path ?? ""),
10856
+ digest: String(data.legacy.digest ?? "")
10857
+ };
10858
+ return next;
10859
+ }
10860
+ function eventType(before, after) {
10861
+ const count = (value) => Array.isArray(value) ? value.length : 0;
10862
+ if (before.status !== after.status)
10863
+ return after.status === "closed" ? "task.closed" : after.status === "paused" ? "task.paused" : "task.resumed";
10864
+ if (count(after.evidence) > count(before.evidence)) {
10865
+ const last = after.evidence.at(-1);
10866
+ return last?.data?.observation ? "check.ran" : "evidence.recorded";
10867
+ }
10868
+ if (count(after.findings) > count(before.findings))
10869
+ return "finding.recorded";
10870
+ if (count(after.decisions) > count(before.decisions))
10871
+ return "decision.recorded";
10872
+ if (JSON.stringify(before.findings) !== JSON.stringify(after.findings))
10873
+ return "finding.updated";
10874
+ if (JSON.stringify(before.decisions) !== JSON.stringify(after.decisions))
10875
+ return "decision.updated";
10876
+ if (JSON.stringify(before.workers) !== JSON.stringify(after.workers))
10877
+ return "worker.updated";
10878
+ if (JSON.stringify(before.intent) !== JSON.stringify(after.intent))
10879
+ return "task.revised";
10880
+ if (JSON.stringify(before.assessments) !== JSON.stringify(after.assessments) || JSON.stringify(before.policy) !== JSON.stringify(after.policy))
10881
+ return "task.judged";
10882
+ if (JSON.stringify(before.progress) !== JSON.stringify(after.progress))
10883
+ return "task.noted";
10884
+ return "task.updated";
10885
+ }
10886
+
10887
+ // packages/workit-core/src/store/notes.ts
10888
+ var SLOT = Symbol.for("workit.migrationReporter");
10889
+ var reportMigration = (report) => {
10890
+ try {
10891
+ const reporter = globalThis[SLOT];
10892
+ if (typeof reporter === "function")
10893
+ reporter(report);
10894
+ } catch {}
10895
+ };
10896
+ var migrationReporterInstalled = () => typeof globalThis[SLOT] === "function";
10897
+
10898
+ // packages/workit-core/src/core/task-store.ts
10899
+ var MIGRATION_PENDING = "workit migration pending";
10900
+ var STORE_FORMAT = 3;
10901
+ var STORE_MIN_RUNTIME = "3.0.0";
10902
+ var SNAPSHOT_EVERY = 32;
10903
+ var COMPACT_ABOVE = 200;
10904
+ var COMPACT_KEEP = 50;
10905
+ var AUTO_COMPACT_BYTES = 2 * 1024 * 1024;
10906
+ var TOMBSTONE = Symbol("tombstone");
10907
+ var STALE_TEMPORARY_MS = 60 * 60000;
10908
+ var INDEX_VERSION = 3;
10909
+ var isObject4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10910
+ var hostSession = (value) => isObject4(value) && value.kind === "host" && typeof value.host === "string" && typeof value.handle === "string" ? { host: value.host, handle: value.handle } : null;
10911
+ var indexEntry = (task, key, legacy, file) => {
10912
+ const sessions = [];
10913
+ const intent = hostSession(task.intent.provenance.session);
10914
+ if (intent)
10915
+ sessions.push({ ...intent, workerId: null });
10916
+ for (const worker of task.workers) {
10917
+ const session = hostSession(worker.data.session);
10918
+ if (session)
10919
+ sessions.push({ ...session, workerId: worker.id });
10920
+ }
10921
+ return {
10922
+ id: task.id,
10923
+ revision: task.revision,
10924
+ status: task.status,
10925
+ createdAt: task.createdAt,
10926
+ updatedAt: task.updatedAt,
10927
+ objective: task.intent.data.objective,
10928
+ source: { host: task.intent.provenance.host, kind: task.intent.provenance.kind },
10929
+ progress: { summary: task.progress.summary, nextAction: task.progress.nextAction },
10930
+ sessions,
10931
+ workspaceId: task.workspaceId,
10932
+ key: key?.key ?? null,
10933
+ branch: key?.branch ?? null,
10934
+ boundAt: key?.boundAt ?? null,
10935
+ legacy,
10936
+ file
10937
+ };
10938
+ };
10939
+ var fileSignature = (file) => {
10940
+ try {
10941
+ const stat = fs8.statSync(file, { bigint: true });
10942
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
10943
+ } catch {
10944
+ return null;
10945
+ }
10946
+ };
10947
+ var RACY_WINDOW_NS = 2000000000n;
10948
+ var racySignature = (signature) => {
10949
+ const [mtime = 0n, ctime = 0n] = signature.split(":").slice(3, 5).map(BigInt);
10950
+ const changed = mtime > ctime ? mtime : ctime;
10951
+ return BigInt(Date.now()) * 1000000n - changed < RACY_WINDOW_NS;
10952
+ };
10953
+ var now = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
10954
+ var oldestFirst = (left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);
10955
+ var cachedRuntimeVersion = null;
10956
+ var runtimeVersion = () => {
10957
+ if (cachedRuntimeVersion === null) {
10958
+ try {
10959
+ const pkg = JSON.parse(fs8.readFileSync(path9.join(packageRoot(), "package.json"), "utf8"));
10960
+ cachedRuntimeVersion = typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
10961
+ } catch {
10962
+ cachedRuntimeVersion = "0.0.0";
10963
+ }
10964
+ }
10965
+ return cachedRuntimeVersion;
10966
+ };
10967
+ var stampNew = (record) => ({
10968
+ createdWith: record.runtime?.createdWith ?? null,
10969
+ updatedWith: runtimeVersion()
10970
+ });
10971
+ var isNewerVersion = (candidate, current) => {
10972
+ const parts = (value) => value.split("-")[0].split(".").map((item) => Number.parseInt(item, 10) || 0);
10973
+ const [candidateParts, currentParts] = [parts(candidate), parts(current)];
10974
+ for (let index = 0;index < 3; index += 1) {
10975
+ const left = candidateParts[index] ?? 0;
10976
+ const right = currentParts[index] ?? 0;
10977
+ if (left !== right)
10978
+ return left > right;
10979
+ }
10980
+ return false;
10981
+ };
10982
+ var validId = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
10983
+ var sameDirectoryIdentity = (left, right) => {
10984
+ if (!path9.isAbsolute(left) || !path9.isAbsolute(right))
10985
+ return false;
10986
+ const normalizedLeft = path9.resolve(left);
10987
+ const normalizedRight = path9.resolve(right);
10988
+ try {
10989
+ const a = fs8.statSync(normalizedLeft, { bigint: true });
10990
+ const b = fs8.statSync(normalizedRight, { bigint: true });
10991
+ if (!a.isDirectory() || !b.isDirectory())
10992
+ return false;
10993
+ if (process.platform === "win32" ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() : normalizedLeft === normalizedRight)
10994
+ return true;
10995
+ const canonicalLeft = fs8.realpathSync(normalizedLeft);
10996
+ const canonicalRight = fs8.realpathSync(normalizedRight);
10997
+ if (process.platform === "win32" ? canonicalLeft.toLowerCase() === canonicalRight.toLowerCase() : canonicalLeft === canonicalRight)
10998
+ return true;
10999
+ return a.dev === b.dev && a.ino !== 0n && a.ino === b.ino;
11000
+ } catch {
11001
+ return false;
11002
+ }
11003
+ };
10363
11004
  var externalActionLockRoots = new AsyncLocalStorage;
10364
11005
  var heldInProcess = new Map;
10365
- var holdRoot = (root) => heldInProcess.set(root, (heldInProcess.get(root) ?? 0) + 1);
10366
- var dropRoot = (root) => {
10367
- const count = (heldInProcess.get(root) ?? 1) - 1;
11006
+ var hold = (lock) => heldInProcess.set(lock, (heldInProcess.get(lock) ?? 0) + 1);
11007
+ var drop = (lock) => {
11008
+ const count = (heldInProcess.get(lock) ?? 1) - 1;
10368
11009
  if (count > 0)
10369
- heldInProcess.set(root, count);
11010
+ heldInProcess.set(lock, count);
10370
11011
  else
10371
- heldInProcess.delete(root);
11012
+ heldInProcess.delete(lock);
10372
11013
  };
10373
- var dedupeCandidates = (candidates) => {
10374
- const last = new Map(candidates.map((candidate, index) => [candidate.id, index]));
10375
- return candidates.filter((candidate, index) => last.get(candidate.id) === index);
11014
+ var envActor = () => {
11015
+ const value = (key) => process.env[key]?.trim() || null;
11016
+ return {
11017
+ host: value("WORKIT_HOST") ?? "runtime",
11018
+ session: value("WORKIT_SESSION_ID"),
11019
+ agentId: value("WORKIT_AGENT_ID")
11020
+ };
10376
11021
  };
10377
11022
 
10378
11023
  class TaskStore {
10379
11024
  root;
11025
+ origin;
10380
11026
  lockTimeoutMs;
11027
+ actor;
11028
+ migrateOnRead;
11029
+ located;
11030
+ migrationChecked = false;
11031
+ compactDue = new Set;
10381
11032
  constructor(root, options = {}) {
10382
- this.root = fs6.existsSync(root) ? fs6.realpathSync(root) : path7.resolve(root);
11033
+ const resolved = path9.resolve(root);
11034
+ this.origin = fs8.existsSync(resolved) ? fs8.realpathSync(resolved) : resolved;
11035
+ this.located = resolveStore(this.origin);
11036
+ this.root = checkoutRootOf(this.origin, this.located);
10383
11037
  this.lockTimeoutMs = options.lockTimeoutMs ?? defaultLockTimeout();
11038
+ this.actor = options.actor ?? envActor();
11039
+ this.migrateOnRead = options.migrateOnRead;
11040
+ }
11041
+ location() {
11042
+ if (this.located !== undefined && !(this.located instanceof Error) && !this.located.shared && fs8.existsSync(path9.join(this.root, ".git"))) {
11043
+ this.located = undefined;
11044
+ this.migrationChecked = false;
11045
+ }
11046
+ if (this.located === undefined)
11047
+ this.located = resolveStore(this.root);
11048
+ return this.located instanceof Error ? failure2("storage_error", this.located.message, { path: this.root }) : success(null, null, this.located);
11049
+ }
11050
+ currentKey() {
11051
+ const location = this.location();
11052
+ if (!location.ok)
11053
+ return location;
11054
+ return success(null, null, resolveTaskKey(this.root, location.data));
10384
11055
  }
10385
11056
  readTask(taskId) {
10386
11057
  if (!validId(taskId))
10387
11058
  return failure2("invalid_input", "task ID is invalid", { taskId });
10388
- const result = this.readRecord(path7.join(this.tasksDir, `${taskId}.json`), taskRecordSchema);
10389
- if (!result.exists)
11059
+ const ready = this.ready();
11060
+ if (!ready.ok)
11061
+ return ready;
11062
+ const loaded = this.load(taskId);
11063
+ if (!loaded.ok)
11064
+ return loaded;
11065
+ if (!loaded.data)
10390
11066
  return failure2("not_found", "task not found", { taskId });
10391
- if (result.result.ok && result.result.data.id !== taskId)
10392
- return failure2("recovery_required", "task filename and record ID differ", { taskId });
10393
- if (result.result.ok) {
10394
- const workspace = this.readWorkspace();
10395
- if (!workspace.ok)
10396
- return workspace;
10397
- if (!workspace.data || result.result.data.workspaceId !== workspace.data.id)
10398
- return failure2("recovery_required", "task workspace binding is invalid", { taskId });
10399
- }
10400
- return result.result;
10401
- }
10402
- listTasks() {
10403
- if (!fs6.existsSync(this.tasksDir))
10404
- return success(null, null, []);
10405
- let names;
10406
- try {
10407
- names = fs6.readdirSync(this.tasksDir).filter((name) => name.endsWith(".json"));
10408
- } catch (error) {
10409
- return failure2("storage_error", `unable to list tasks: ${String(error)}`, {
10410
- path: this.tasksDir
10411
- });
10412
- }
10413
11067
  const workspace = this.readWorkspace();
10414
11068
  if (!workspace.ok)
10415
11069
  return workspace;
10416
- if (!workspace.data)
10417
- return failure2("recovery_required", "task workspace binding is invalid", {
10418
- path: this.workspacePath
10419
- });
11070
+ const bound = this.bindingError(loaded.data.record, workspace.data);
11071
+ return bound ?? success(null, null, loaded.data.record);
11072
+ }
11073
+ listTasks() {
11074
+ const listed = this.listTaskIndex();
11075
+ if (!listed.ok)
11076
+ return listed;
10420
11077
  const tasks = [];
10421
- for (const name of names.toSorted()) {
10422
- const item = this.readRecord(path7.join(this.tasksDir, name), taskRecordSchema);
10423
- if (!item.exists)
10424
- continue;
10425
- if (!item.result.ok)
10426
- return item.result;
10427
- if (!validId(name.slice(0, -5)) || item.result.data.id !== name.slice(0, -5))
10428
- return failure2("recovery_required", "task filename and record ID differ", { path: name });
10429
- if (item.result.data.workspaceId !== workspace.data.id)
10430
- return failure2("recovery_required", "task workspace binding is invalid", { path: name });
10431
- tasks.push(item.result.data);
11078
+ for (const entry of listed.data.toSorted((left, right) => left.id.localeCompare(right.id))) {
11079
+ const loaded = this.load(entry.id);
11080
+ if (!loaded.ok)
11081
+ return loaded;
11082
+ if (loaded.data)
11083
+ tasks.push(loaded.data.record);
10432
11084
  }
10433
11085
  return success(null, null, tasks);
10434
11086
  }
10435
11087
  listTaskIndex() {
10436
- if (!fs6.existsSync(this.tasksDir))
11088
+ const all = this.listStoreIndex();
11089
+ if (!all.ok)
11090
+ return all;
11091
+ const workspace = this.readWorkspace();
11092
+ if (!workspace.ok)
11093
+ return workspace;
11094
+ if (!workspace.data)
10437
11095
  return success(null, null, []);
11096
+ const id = workspace.data.id;
11097
+ return success(null, null, all.data.filter((entry) => entry.workspaceId === id));
11098
+ }
11099
+ listStoreIndex() {
11100
+ const ready = this.ready();
11101
+ if (!ready.ok)
11102
+ return ready;
11103
+ const tasksDir = this.paths().tasks;
10438
11104
  let names;
10439
11105
  try {
10440
- names = fs6.readdirSync(this.tasksDir).filter((name) => name.endsWith(".json"));
11106
+ names = fs8.existsSync(tasksDir) ? fs8.readdirSync(tasksDir).filter((name) => validId(name)) : [];
10441
11107
  } catch (error) {
10442
11108
  return failure2("storage_error", `unable to list tasks: ${String(error)}`, {
10443
- path: this.tasksDir
11109
+ path: tasksDir
10444
11110
  });
10445
11111
  }
10446
- const workspace = this.readWorkspace();
10447
- if (!workspace.ok)
10448
- return workspace;
10449
- if (!workspace.data)
10450
- return failure2("recovery_required", "task workspace binding is invalid", {
10451
- path: this.workspacePath
10452
- });
10453
- const stored = this.readIndex(workspace.data.id);
11112
+ const stored = this.readIndex();
10454
11113
  const entries = [];
10455
11114
  let changed = Object.keys(stored).length !== names.length;
10456
11115
  for (const name of names.toSorted()) {
10457
- const file = path7.join(this.tasksDir, name);
10458
- const signature = fileSignature(file);
11116
+ const signature = fileSignature(this.eventsPath(name));
10459
11117
  if (signature === null) {
10460
11118
  changed = true;
10461
11119
  continue;
10462
11120
  }
10463
- const cached = stored[name.slice(0, -5)];
11121
+ const cached = stored[name];
10464
11122
  if (cached && cached.file === signature && !racySignature(signature)) {
10465
11123
  entries.push(cached);
10466
11124
  continue;
10467
11125
  }
10468
- const item = this.readRecord(file, taskRecordSchema);
10469
- if (!item.exists)
11126
+ const loaded = this.load(name);
11127
+ if (!loaded.ok)
11128
+ return loaded;
11129
+ if (!loaded.data)
10470
11130
  continue;
10471
- if (!item.result.ok)
10472
- return item.result;
10473
- if (!validId(name.slice(0, -5)) || item.result.data.id !== name.slice(0, -5))
10474
- return failure2("recovery_required", "task filename and record ID differ", { path: name });
10475
- if (item.result.data.workspaceId !== workspace.data.id)
10476
- return failure2("recovery_required", "task workspace binding is invalid", { path: name });
10477
- const entry = indexEntry(item.result.data, signature);
11131
+ const entry = indexEntry(loaded.data.record, loaded.data.key, loaded.data.legacy !== null, signature);
10478
11132
  if (!cached || canonicalJson(cached) !== canonicalJson(entry))
10479
11133
  changed = true;
10480
11134
  entries.push(entry);
10481
11135
  }
10482
11136
  if (changed)
10483
- this.writeIndex(workspace.data.id, entries);
11137
+ this.writeIndex(entries);
10484
11138
  return success(null, null, entries);
10485
11139
  }
10486
11140
  readWorkspace() {
10487
- const item = this.readRecord(this.workspacePath, workspaceRecordSchema);
10488
- if (!item.exists)
10489
- return success(null, null, null);
10490
- if (item.result.ok && item.result.data.root !== this.root && !sameDirectoryIdentity(item.result.data.root, this.root))
10491
- return failure2("recovery_required", "workspace root binding is invalid", {
10492
- path: this.workspacePath
11141
+ const ready = this.ready();
11142
+ if (!ready.ok)
11143
+ return ready;
11144
+ const file = this.paths().workspace;
11145
+ let bytes;
11146
+ try {
11147
+ bytes = retryTransient(() => fs8.readFileSync(file, "utf8"));
11148
+ } catch (error) {
11149
+ if (error.code === "ENOENT")
11150
+ return success(null, null, null);
11151
+ return failure2("recovery_required", `workspace record cannot be read: ${String(error)}`, {
11152
+ path: file
10493
11153
  });
10494
- return item.result;
11154
+ }
11155
+ const parsed = this.parseRecord(bytes, workspaceRecordSchema, "workspace");
11156
+ if (!parsed.ok)
11157
+ return parsed;
11158
+ if (parsed.data.root !== this.root && !sameDirectoryIdentity(parsed.data.root, this.root))
11159
+ return failure2("recovery_required", "workspace root binding is invalid", { path: file });
11160
+ return parsed;
11161
+ }
11162
+ implicitTask(input) {
11163
+ const ready = this.ready(input.create !== false);
11164
+ if (!ready.ok)
11165
+ return ready;
11166
+ const key = this.currentKey();
11167
+ if (!key.ok)
11168
+ return key;
11169
+ const found = this.openTaskForKey(key.data.key);
11170
+ if (!found.ok)
11171
+ return found;
11172
+ const workspace = this.readWorkspace();
11173
+ if (!workspace.ok)
11174
+ return workspace;
11175
+ if (found.data && workspace.data && found.data.workspaceId === workspace.data.id && (found.data.key === key.data.key || input.create === false)) {
11176
+ const task = this.readTask(found.data.id);
11177
+ return task.ok ? success(task.revision, null, { task: task.data, created: false, key: key.data }) : task;
11178
+ }
11179
+ if (input.create === false) {
11180
+ if (!found.data)
11181
+ return success(null, null, null);
11182
+ const loaded = this.load(found.data.id);
11183
+ if (!loaded.ok)
11184
+ return loaded;
11185
+ return loaded.data ? success(null, null, { task: loaded.data.record, created: false, key: key.data }) : success(null, null, null);
11186
+ }
11187
+ return this.withLock(this.paths().checkoutLock, () => this.withKeyLock(key.data.key, () => this.implicitLocked(input, key.data)));
11188
+ }
11189
+ implicitLocked(input, key) {
11190
+ {
11191
+ const again = this.openTaskForKey(key.key);
11192
+ if (!again.ok)
11193
+ return again;
11194
+ if (again.data) {
11195
+ const moved = this.bindLocked(again.data.id, key, input.now);
11196
+ return moved.ok ? success(moved.data.revision, null, { task: moved.data, created: false, key }) : moved;
11197
+ }
11198
+ const label = key.kind === "branch" ? `branch ${key.branch}` : key.kind === "detached" ? "a detached HEAD" : path9.basename(this.root) || this.root;
11199
+ const intent = {
11200
+ objective: input.objective?.trim() || `Work on ${label}`,
11201
+ scope: { description: `the checkout (${label})`, paths: ["."], exclusions: [] },
11202
+ authorityRefs: []
11203
+ };
11204
+ const created = this.createLocked({ intent, provenance: input.provenance, expectedWorkspaceRevision: null, now: input.now }, false, key, false);
11205
+ return created.ok ? success(created.data.revision, null, { task: created.data, created: true, key }) : created;
11206
+ }
11207
+ }
11208
+ adoptTask(taskId) {
11209
+ if (!validId(taskId))
11210
+ return failure2("invalid_input", "task ID is invalid", { taskId });
11211
+ const ready = this.ready(true);
11212
+ if (!ready.ok)
11213
+ return ready;
11214
+ const key = this.currentKey();
11215
+ if (!key.ok)
11216
+ return key;
11217
+ return this.withLock(this.paths().checkoutLock, () => this.withKeyLock(key.data.key, () => {
11218
+ const holder = this.openTaskForKey(key.data.key);
11219
+ if (!holder.ok)
11220
+ return holder;
11221
+ if (holder.data && holder.data.id !== taskId)
11222
+ return failure2("invalid_transition", `${key.data.key} already has open task ${holder.data.id}; close it (\`workit task close\`) before adopting another`, { taskId: holder.data.id });
11223
+ return this.bindLocked(taskId, key.data);
11224
+ }));
11225
+ }
11226
+ keyReport() {
11227
+ const key = this.currentKey();
11228
+ if (!key.ok)
11229
+ return key;
11230
+ const bound = this.openTasksForKey(key.data.key);
11231
+ if (!bound.ok)
11232
+ return bound;
11233
+ return success(null, null, { key: key.data, bound: bound.data });
10495
11234
  }
10496
11235
  create(input, provenance, expectedWorkspaceRevision) {
10497
11236
  const value = "intent" in input ? input : {
@@ -10499,82 +11238,16 @@ class TaskStore {
10499
11238
  provenance,
10500
11239
  expectedWorkspaceRevision: expectedWorkspaceRevision ?? null
10501
11240
  };
10502
- return this.withLock(() => {
10503
- const current = this.readWorkspace();
10504
- if (!current.ok)
10505
- return current;
10506
- if (current.data ? value.expectedWorkspaceRevision !== current.data.revision : value.expectedWorkspaceRevision !== null) {
10507
- return failure2("revision_conflict", "workspace revision does not match; omit expectedWorkspaceRevision to use the current record", {
10508
- expectedWorkspaceRevision: value.expectedWorkspaceRevision,
10509
- actualWorkspaceRevision: current.data?.revision ?? null
10510
- });
10511
- }
10512
- if (!value.provenance)
10513
- return failure2("invalid_input", "provenance is required");
10514
- if (!intentSchema.safeParse(value.intent).success || !provenanceSchema.safeParse(value.provenance).success)
10515
- return failure2("invalid_input", "task intent or provenance is invalid");
10516
- const previousWorkspaceBytes = current.data ? this.snapshotBytes(this.workspacePath) : null;
10517
- if (current.data && !previousWorkspaceBytes)
10518
- return failure2("storage_error", "workspace snapshot disappeared during creation");
10519
- const workspace = current.data ? {
10520
- ...current.data,
10521
- revision: newRevision(),
10522
- root: this.root,
10523
- runtime: stampNew(current.data)
10524
- } : {
10525
- schemaVersion: SCHEMA_VERSION,
10526
- id: newId(),
10527
- revision: newRevision(),
10528
- root: this.root,
10529
- runtime: { createdWith: runtimeVersion(), updatedWith: runtimeVersion() },
10530
- writer: null
10531
- };
10532
- const timestamp = value.now ?? now();
10533
- const task = {
10534
- schemaVersion: SCHEMA_VERSION,
10535
- id: newId(),
10536
- workspaceId: workspace.id,
10537
- revision: newRevision(),
10538
- createdAt: timestamp,
10539
- updatedAt: timestamp,
10540
- origin: null,
10541
- intent: {
10542
- id: newId(),
10543
- recordedAt: timestamp,
10544
- provenance: value.provenance,
10545
- data: value.intent
10546
- },
10547
- constraints: [],
10548
- status: "active",
10549
- closure: null,
10550
- progress: { summary: "", nextAction: null, blockers: [] },
10551
- runtime: { createdWith: runtimeVersion(), updatedWith: runtimeVersion() },
10552
- assessments: [],
10553
- policy: null,
10554
- policyChanges: [],
10555
- candidates: [],
10556
- evidence: [],
10557
- decisions: [],
10558
- findings: [],
10559
- workers: []
10560
- };
10561
- const writtenWorkspace = this.replaceSnapshot(this.workspacePath, workspace, previousWorkspaceBytes);
10562
- if (!writtenWorkspace.ok)
10563
- return writtenWorkspace;
10564
- const writtenTask = this.replaceSnapshot(this.taskPath(task.id), task, null);
10565
- if (!writtenTask.ok) {
10566
- if (current.data && previousWorkspaceBytes) {
10567
- const restored = this.replaceSnapshot(this.workspacePath, current.data, this.snapshotBytes(this.workspacePath));
10568
- if (!restored.ok)
10569
- return failure2("external_outcome_unknown", "task creation failed and workspace restoration is uncertain", { operation: "create", outcome: "unknown" });
10570
- }
10571
- return failure2("external_outcome_unknown", "workspace created but task write is uncertain", { operation: "create", outcome: "unknown" });
10572
- }
10573
- return success(task.revision, workspace.revision, task);
10574
- });
11241
+ const ready = this.ready(true);
11242
+ if (!ready.ok)
11243
+ return ready;
11244
+ return this.withLock(this.paths().checkoutLock, () => this.createLocked(value, true, null));
10575
11245
  }
10576
11246
  importTask(input) {
10577
- return this.withLock(() => {
11247
+ const ready = this.ready(true);
11248
+ if (!ready.ok)
11249
+ return ready;
11250
+ return this.withLock(this.paths().checkoutLock, () => {
10578
11251
  const current = this.readWorkspace();
10579
11252
  if (!current.ok)
10580
11253
  return current;
@@ -10606,23 +11279,8 @@ class TaskStore {
10606
11279
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
10607
11280
  actualWorkspaceRevision: current.data?.revision ?? null
10608
11281
  });
10609
- const previousWorkspaceBytes = current.data ? this.snapshotBytes(this.workspacePath) : null;
10610
- if (current.data && !previousWorkspaceBytes)
10611
- return failure2("storage_error", "workspace snapshot disappeared during import");
10612
11282
  const timestamp = input.now ?? now();
10613
- const workspace = current.data ? {
10614
- ...current.data,
10615
- revision: newRevision(),
10616
- root: this.root,
10617
- runtime: stampNew(current.data)
10618
- } : {
10619
- schemaVersion: SCHEMA_VERSION,
10620
- id: input.workspaceId ?? newId(),
10621
- revision: newRevision(),
10622
- root: this.root,
10623
- runtime: { createdWith: runtimeVersion(), updatedWith: runtimeVersion() },
10624
- writer: null
10625
- };
11283
+ const workspace = this.nextWorkspace(current.data, input.workspaceId);
10626
11284
  const task = {
10627
11285
  ...input.task,
10628
11286
  workspaceId: workspace.id,
@@ -10637,62 +11295,46 @@ class TaskStore {
10637
11295
  const validTask = taskRecordSchema.safeParse(task);
10638
11296
  if (!validTask.success)
10639
11297
  return failure2("invalid_input", "imported task does not satisfy its schema");
10640
- const writtenWorkspace = this.replaceSnapshot(this.workspacePath, workspace, previousWorkspaceBytes);
10641
- if (!writtenWorkspace.ok)
10642
- return writtenWorkspace;
10643
- const writtenTask = this.replaceSnapshot(this.taskPath(task.id), validTask.data, null);
10644
- if (!writtenTask.ok) {
10645
- if (current.data && previousWorkspaceBytes) {
10646
- const restored = this.replaceSnapshot(this.workspacePath, current.data, this.snapshotBytes(this.workspacePath));
10647
- if (!restored.ok)
10648
- return failure2("external_outcome_unknown", "import failed and workspace restoration is uncertain", { operation: "import", outcome: "unknown" });
10649
- }
10650
- return failure2("external_outcome_unknown", `workspace created but task import is uncertain: ${writtenTask.error}`, {
10651
- operation: "import",
10652
- outcome: "unknown",
10653
- path: writtenTask.details.path
10654
- });
10655
- }
10656
- return success(validTask.data.revision, workspace.revision, validTask.data);
11298
+ return this.writeNewTask(current.data, workspace, validTask.data, "task.imported", null, {
11299
+ operation: "import"
11300
+ });
10657
11301
  });
10658
11302
  }
10659
11303
  mutateTask(taskId, expected, update, timestamp) {
10660
- return this.withLock(() => {
10661
- const current = this.readTask(taskId);
10662
- if (!current.ok)
10663
- return current;
10664
- if (current.data.revision !== expected)
10665
- return this.conflict(expected, current.data.revision);
10666
- const previousBytes = this.snapshotBytes(this.taskPath(taskId));
10667
- if (!previousBytes)
10668
- return failure2("storage_error", "task snapshot disappeared during mutation");
11304
+ if (!validId(taskId))
11305
+ return failure2("invalid_input", "task ID is invalid", { taskId });
11306
+ const ready = this.ready(true);
11307
+ if (!ready.ok)
11308
+ return ready;
11309
+ const result = this.withLock(this.taskLockPath(taskId), () => {
11310
+ const loaded = this.loadBound(taskId);
11311
+ if (!loaded.ok)
11312
+ return loaded;
11313
+ if (loaded.data.record.revision !== expected)
11314
+ return this.conflict(expected, loaded.data.record.revision);
10669
11315
  const context = { now: timestamp ?? now(), revision: newRevision() };
10670
11316
  let changed;
10671
11317
  try {
10672
- changed = update(current.data, Object.freeze({ ...context }));
11318
+ changed = update(structuredClone(loaded.data.record), Object.freeze({ ...context }));
10673
11319
  } catch (error) {
10674
11320
  return failure2("storage_error", `task mutation failed: ${String(error)}`);
10675
11321
  }
10676
11322
  if (!changed.ok)
10677
11323
  return changed;
10678
- const record = {
10679
- ...changed.data,
10680
- id: taskId,
10681
- workspaceId: current.data.workspaceId,
10682
- createdAt: current.data.createdAt,
10683
- revision: context.revision,
10684
- updatedAt: context.now,
10685
- runtime: stampNew(current.data)
10686
- };
10687
- const valid = taskRecordSchema.safeParse(record);
10688
- if (!valid.success)
11324
+ const valid = this.nextTask(loaded.data.record, changed.data, context);
11325
+ if (!valid)
10689
11326
  return failure2("invalid_input", "task mutation produced an invalid record");
10690
- const written = this.replaceSnapshot(this.taskPath(taskId), valid.data, previousBytes);
10691
- return written.ok ? success(valid.data.revision, null, valid.data) : written;
11327
+ const written = this.appendPatch(loaded.data, valid);
11328
+ return written.ok ? success(valid.revision, null, valid) : written;
10692
11329
  });
11330
+ this.compactPending();
11331
+ return result;
10693
11332
  }
10694
11333
  mutateWorkspace(expected, update) {
10695
- return this.withLock(() => {
11334
+ const ready = this.ready(true);
11335
+ if (!ready.ok)
11336
+ return ready;
11337
+ return this.withLock(this.paths().checkoutLock, () => {
10696
11338
  const current = this.readWorkspace();
10697
11339
  if (!current.ok)
10698
11340
  return current;
@@ -10700,9 +11342,6 @@ class TaskStore {
10700
11342
  return failure2("not_found", "workspace not found");
10701
11343
  if (current.data.revision !== expected)
10702
11344
  return this.workspaceConflict(expected, current.data.revision);
10703
- const previousBytes = this.snapshotBytes(this.workspacePath);
10704
- if (!previousBytes)
10705
- return failure2("storage_error", "workspace snapshot disappeared during mutation");
10706
11345
  const context = { now: now(), revision: newRevision() };
10707
11346
  let changed;
10708
11347
  try {
@@ -10712,23 +11351,27 @@ class TaskStore {
10712
11351
  }
10713
11352
  if (!changed.ok)
10714
11353
  return changed;
10715
- const record = {
11354
+ const valid = workspaceRecordSchema.safeParse({
10716
11355
  ...changed.data,
10717
11356
  id: current.data.id,
10718
11357
  root: this.root,
10719
11358
  revision: context.revision,
10720
11359
  runtime: stampNew(current.data)
10721
- };
10722
- const valid = workspaceRecordSchema.safeParse(record);
11360
+ });
10723
11361
  if (!valid.success)
10724
11362
  return failure2("invalid_input", "workspace mutation produced an invalid record");
10725
- const written = this.replaceSnapshot(this.workspacePath, valid.data, previousBytes);
11363
+ const written = this.writeWorkspace(valid.data);
10726
11364
  return written.ok ? success(valid.data.revision, valid.data.revision, valid.data) : written;
10727
11365
  });
10728
11366
  }
10729
11367
  mutateTaskAndWorkspace(input) {
10730
- return this.withLock(() => {
10731
- const task = this.readTask(input.taskId);
11368
+ if (!validId(input.taskId))
11369
+ return failure2("invalid_input", "task ID is invalid", { taskId: input.taskId });
11370
+ const ready = this.ready(true);
11371
+ if (!ready.ok)
11372
+ return ready;
11373
+ const result = this.withLock(this.paths().checkoutLock, () => this.withLock(this.taskLockPath(input.taskId), () => {
11374
+ const task = this.loadBound(input.taskId);
10732
11375
  if (!task.ok)
10733
11376
  return task;
10734
11377
  const workspace = this.readWorkspace();
@@ -10739,14 +11382,10 @@ class TaskStore {
10739
11382
  const expected = input.expectedRevision;
10740
11383
  if (!expected)
10741
11384
  return failure2("invalid_input", "task revision is required");
10742
- if (task.data.revision !== expected)
10743
- return this.conflict(expected, task.data.revision);
11385
+ if (task.data.record.revision !== expected)
11386
+ return this.conflict(expected, task.data.record.revision);
10744
11387
  if (workspace.data.revision !== input.expectedWorkspaceRevision)
10745
11388
  return this.workspaceConflict(input.expectedWorkspaceRevision, workspace.data.revision);
10746
- const previousWorkspaceBytes = this.snapshotBytes(this.workspacePath);
10747
- const previousTaskBytes = this.snapshotBytes(this.taskPath(input.taskId));
10748
- if (!previousWorkspaceBytes || !previousTaskBytes)
10749
- return failure2("storage_error", "snapshot disappeared during coupled mutation");
10750
11389
  const workspaceContext = { now: input.now ?? now(), revision: newRevision() };
10751
11390
  let changedWorkspace;
10752
11391
  try {
@@ -10765,286 +11404,69 @@ class TaskStore {
10765
11404
  });
10766
11405
  if (!nextWorkspace.success)
10767
11406
  return failure2("invalid_input", "workspace mutation produced an invalid record");
10768
- const reserved = this.replaceSnapshot(this.workspacePath, nextWorkspace.data, previousWorkspaceBytes);
11407
+ const reserved = this.writeWorkspace(nextWorkspace.data);
10769
11408
  if (!reserved.ok)
10770
11409
  return reserved;
11410
+ const uncertain = (message) => {
11411
+ this.markUncertain(nextWorkspace.data);
11412
+ return failure2("external_outcome_unknown", message, {
11413
+ operation: "coupled_mutation",
11414
+ outcome: "unknown"
11415
+ });
11416
+ };
10771
11417
  const taskContext = { now: input.now ?? now(), revision: newRevision() };
10772
11418
  let changedTask;
10773
11419
  try {
10774
- changedTask = input.task(task.data, Object.freeze({ ...taskContext }));
11420
+ changedTask = input.task(structuredClone(task.data.record), Object.freeze({ ...taskContext }));
10775
11421
  } catch (error) {
10776
- this.markUncertain(nextWorkspace.data);
10777
- return failure2("external_outcome_unknown", `workspace reserved but task mutation threw: ${String(error)}`, { operation: "coupled_mutation", outcome: "unknown" });
10778
- }
10779
- if (!changedTask.ok) {
10780
- this.markUncertain(nextWorkspace.data);
10781
- return failure2("external_outcome_unknown", "workspace reserved but task update is uncertain", { operation: "coupled_mutation", outcome: "unknown" });
11422
+ return uncertain(`workspace reserved but task mutation threw: ${String(error)}`);
10782
11423
  }
10783
- const nextTask = taskRecordSchema.safeParse({
10784
- ...changedTask.data,
10785
- id: input.taskId,
10786
- workspaceId: task.data.workspaceId,
10787
- createdAt: task.data.createdAt,
10788
- revision: taskContext.revision,
10789
- updatedAt: taskContext.now,
10790
- runtime: stampNew(task.data)
11424
+ if (!changedTask.ok)
11425
+ return uncertain("workspace reserved but task update is uncertain");
11426
+ const nextTask = this.nextTask(task.data.record, changedTask.data, taskContext);
11427
+ if (!nextTask)
11428
+ return uncertain("workspace reserved but task update is uncertain");
11429
+ const written = this.appendPatch(task.data, nextTask);
11430
+ if (!written.ok)
11431
+ return uncertain("workspace reserved but task append is uncertain");
11432
+ return success(nextTask.revision, nextWorkspace.data.revision, {
11433
+ task: nextTask,
11434
+ workspace: nextWorkspace.data
10791
11435
  });
10792
- if (!nextTask.success) {
10793
- this.markUncertain(nextWorkspace.data);
10794
- return failure2("external_outcome_unknown", "workspace reserved but task update is uncertain", { operation: "coupled_mutation", outcome: "unknown" });
10795
- }
10796
- const written = this.replaceSnapshot(this.taskPath(input.taskId), nextTask.data, previousTaskBytes);
10797
- if (!written.ok) {
10798
- this.markUncertain(nextWorkspace.data);
10799
- return failure2("external_outcome_unknown", "workspace reserved but task replacement is uncertain", { operation: "coupled_mutation", outcome: "unknown" });
10800
- }
10801
- return success(nextTask.data.revision, nextWorkspace.data.revision, {
10802
- task: nextTask.data,
10803
- workspace: nextWorkspace.data
10804
- });
10805
- });
10806
- }
10807
- recoveryCandidates() {
10808
- if (!fs6.existsSync(this.recoveryDir))
10809
- return success(null, null, []);
10810
- try {
10811
- const candidates = [];
10812
- for (const name of fs6.readdirSync(this.recoveryDir)) {
10813
- const match = RECOVERY_NAME.exec(name);
10814
- if (match)
10815
- candidates.push({
10816
- target: match[1],
10817
- path: path7.join(this.recoveryDir, name),
10818
- digest: match[3]
10819
- });
10820
- }
10821
- return success(null, null, candidates);
10822
- } catch (error) {
10823
- return failure2("storage_error", `unable to list recovery: ${String(error)}`, {
10824
- path: this.recoveryDir
10825
- });
10826
- }
10827
- }
10828
- recoverTask(taskId, input) {
10829
- return this.recover("task", taskId, input);
10830
- }
10831
- recoverWorkspace(input) {
10832
- return this.recover("workspace", null, input);
10833
- }
10834
- recover(target, taskId, input) {
10835
- if (taskId !== null && !validId(taskId))
10836
- return failure2("invalid_input", "task ID is invalid", { taskId });
10837
- if (!input.reason || !Array.isArray(input.authorityRefs) || typeof input.processEvidence !== "function" || !validDigest(input.expectedBytes) || !validDigest(input.snapshotDigest))
10838
- return failure2("invalid_input", "recovery authority is invalid");
10839
- if (input.authorityRefs.some((ref) => !refSchema.safeParse(ref).success))
10840
- return failure2("invalid_input", "recovery references are invalid");
10841
- try {
10842
- const workspace = this.readWorkspace();
10843
- if (!workspace.ok && target === "task")
10844
- return workspace;
10845
- const workspaceValue = workspace.ok ? workspace.data : null;
10846
- if (workspaceValue && workspaceValue.revision !== input.expectedWorkspaceRevision)
10847
- return this.conflict(input.expectedWorkspaceRevision, workspaceValue.revision);
10848
- const file = target === "workspace" ? this.workspacePath : this.taskPath(taskId);
10849
- const currentBytes = this.snapshotBytes(file);
10850
- if (!currentBytes)
10851
- return failure2("not_found", "snapshot not found");
10852
- if (digestBytes(currentBytes) !== input.expectedBytes)
10853
- return failure2("revision_conflict", "snapshot bytes do not match expected bytes");
10854
- let selectedBytes = currentBytes;
10855
- if (digestBytes(currentBytes) !== input.snapshotDigest) {
10856
- const candidate = this.findRecovery(target, taskId, input.snapshotDigest, workspaceValue);
10857
- if (!candidate)
10858
- return failure2("recovery_required", "validated recovery snapshot not found");
10859
- selectedBytes = candidate;
10860
- }
10861
- const selectedRecord = target === "workspace" ? this.parseBytes(selectedBytes, workspaceRecordSchema, "rewrite") : this.parseBytes(selectedBytes, taskRecordSchema, "rewrite");
10862
- if (!selectedRecord.ok)
10863
- return selectedRecord;
10864
- if (target === "workspace") {
10865
- const parsed = selectedRecord;
10866
- if (!parsed.ok || parsed.data.root !== this.root)
10867
- return failure2("recovery_required", "workspace recovery binding is invalid");
10868
- } else {
10869
- const parsed = selectedRecord;
10870
- if (!parsed.ok || parsed.data.id !== taskId || !workspaceValue || parsed.data.workspaceId !== workspaceValue.id)
10871
- return failure2("recovery_required", "task recovery binding is invalid");
10872
- }
10873
- const lock = this.readLockSnapshot();
10874
- if (!lock.ok)
10875
- return lock;
10876
- const writer = target === "workspace" ? selectedRecord.data.writer : workspaceValue?.writer ?? null;
10877
- const evidence = this.processEvidence(input, lock.data?.data ?? null, writer);
10878
- if (!evidence.ok)
10879
- return evidence;
10880
- if (lock.data && (evidence.data.pid !== lock.data.data.pid || evidence.data.processStart !== lock.data.data.processStart))
10881
- return failure2("recovery_required", "process evidence does not match metadata lock");
10882
- if (writer && (evidence.data.state !== "accounted_for" || evidence.data.ownerDigest !== sha256(canonicalJson(writer))))
10883
- return failure2("recovery_required", "workspace writer is not accounted for");
10884
- const recoveryGate = { reclaimed: false };
10885
- const result = this.withLock((handle) => {
10886
- if (lock.data && !recoveryGate.reclaimed)
10887
- return failure2("recovery_required", "metadata lock changed during recovery", {
10888
- path: this.lockPath
10889
- });
10890
- if (!handle.verifyStillHeld())
10891
- return failure2("recovery_required", "metadata lock was compromised", {
10892
- path: this.lockPath
10893
- });
10894
- const reacquiredBytes = this.snapshotBytes(file);
10895
- if (!reacquiredBytes)
10896
- return failure2("recovery_required", "snapshot disappeared during recovery");
10897
- if (digestBytes(reacquiredBytes) !== input.expectedBytes)
10898
- return failure2("revision_conflict", "snapshot changed during recovery");
10899
- if (target === "task") {
10900
- const currentWorkspace = this.readWorkspace();
10901
- if (!currentWorkspace.ok || !currentWorkspace.data)
10902
- return failure2("recovery_required", "workspace changed during recovery");
10903
- if (currentWorkspace.data.revision !== input.expectedWorkspaceRevision)
10904
- return this.conflict(input.expectedWorkspaceRevision, currentWorkspace.data.revision);
10905
- }
10906
- if (target === "workspace") {
10907
- const parsed = this.parseBytes(selectedBytes, workspaceRecordSchema, "rewrite");
10908
- if (!parsed.ok)
10909
- return parsed;
10910
- const value = { ...parsed.data, revision: newRevision(), writer: null };
10911
- const replaced = this.replaceSnapshot(file, value, reacquiredBytes);
10912
- return replaced.ok ? success(value.revision, value.revision, value) : replaced;
10913
- }
10914
- const parsed = this.parseBytes(selectedBytes, taskRecordSchema, "rewrite");
10915
- if (!parsed.ok)
10916
- return parsed;
10917
- const value = {
10918
- ...parsed.data,
10919
- revision: newRevision(),
10920
- updatedAt: now(),
10921
- status: parsed.data.status === "active" ? "paused" : parsed.data.status
10922
- };
10923
- const replaced = this.replaceSnapshot(file, value, reacquiredBytes);
10924
- return replaced.ok ? success(value.revision, null, value) : replaced;
10925
- }, this.recoveryLockOptions(lock.data, evidence.data, recoveryGate), "recovery_required");
10926
- return result;
10927
- } catch (error) {
10928
- return failure2("recovery_required", `recovery protocol failed: ${String(error)}`);
10929
- }
10930
- }
10931
- processEvidence(input, lock, writer) {
10932
- const lockCopy = lock ? this.deepFreeze(structuredClone(lock)) : null;
10933
- const writerCopy = writer ? this.deepFreeze(structuredClone(writer)) : null;
10934
- const lockDigest = lockCopy ? sha256(canonicalJson(lockCopy)) : null;
10935
- const writerDigest = writerCopy ? sha256(canonicalJson(writerCopy)) : null;
10936
- try {
10937
- const result = input.processEvidence(lockCopy, writerCopy);
10938
- if (lockCopy && sha256(canonicalJson(lockCopy)) !== lockDigest)
10939
- return failure2("recovery_required", "process evidence mutated lock identity");
10940
- if (writerCopy && sha256(canonicalJson(writerCopy)) !== writerDigest)
10941
- return failure2("recovery_required", "process evidence mutated writer identity");
10942
- if (!result.ok)
10943
- return result;
10944
- const parsed = processEvidenceSchema.safeParse(result.data);
10945
- return parsed.success ? success(null, null, parsed.data) : failure2("recovery_required", "process evidence is invalid");
10946
- } catch (error) {
10947
- return failure2("recovery_required", `process evidence failed: ${String(error)}`);
10948
- }
10949
- }
10950
- deepFreeze(value) {
10951
- if (value && typeof value === "object") {
10952
- for (const child of Object.values(value))
10953
- this.deepFreeze(child);
10954
- Object.freeze(value);
10955
- }
10956
- return value;
10957
- }
10958
- readLockSnapshot() {
10959
- try {
10960
- const stat = fs6.lstatSync(this.lockPath);
10961
- if (!stat.isFile() || stat.isSymbolicLink())
10962
- return failure2("recovery_required", "metadata lock is not a regular file", {
10963
- path: this.lockPath
10964
- });
10965
- const raw = fs6.readFileSync(this.lockPath, "utf8");
10966
- return success(null, null, { raw, data: parseMetadataLock(raw) });
10967
- } catch (error) {
10968
- if (error?.code === "ENOENT")
10969
- return success(null, null, null);
10970
- return failure2("recovery_required", `metadata lock is invalid: ${String(error)}`, {
10971
- path: this.lockPath
10972
- });
10973
- }
10974
- }
10975
- metadataLockOptions() {
10976
- const inProcess = heldInProcess.has(this.root);
10977
- return {
10978
- lockPath: this.lockPath,
10979
- staleMs: Number.MAX_SAFE_INTEGER,
10980
- timeoutMs: inProcess ? 0 : this.lockTimeoutMs,
10981
- retry: inProcess ? { retries: 0 } : { minTimeout: 5, maxTimeout: 100, factor: 1.5, randomize: true },
10982
- staleRecovery: "remove-if-unchanged",
10983
- shouldReclaim: ({ payload, nowMs }) => {
10984
- let ageMs = null;
10985
- try {
10986
- ageMs = nowMs - fs6.lstatSync(this.lockPath).mtimeMs;
10987
- } catch {}
10988
- return classifyLockOwner(payload, ageMs).state === "stale";
10989
- },
10990
- shouldRemoveStaleLock: () => true,
10991
- parsePayload: parseMetadataLockOrNull,
10992
- payload: () => ({
10993
- pid: process.pid,
10994
- processStart: processStartOf(process.pid),
10995
- host: localLockHost(),
10996
- nonce: randomUUID2()
10997
- })
10998
- };
10999
- }
11000
- acquireMetadataLock(options) {
11001
- clearAbandonedReclaimGuard(this.lockPath);
11002
- const deadline = Date.now() + (options.timeoutMs ?? 0);
11003
- while (true) {
11004
- try {
11005
- return acquireFileLockSync(this.workspacePath, {
11006
- ...options,
11007
- timeoutMs: Math.max(0, deadline - Date.now())
11008
- });
11009
- } catch (error) {
11010
- const code = error?.code;
11011
- if (code !== "file_lock_stale" && !isTransientWindowsError(error) || Date.now() >= deadline)
11012
- throw error;
11013
- if (code !== "file_lock_stale" && !this.lockHolderPresent() && !this.lockDirWritable())
11014
- throw error;
11015
- if (code !== "file_lock_stale")
11016
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
11017
- }
11018
- }
11019
- }
11020
- externalActionLockOptions() {
11021
- const options = this.metadataLockOptions();
11022
- return { ...options, payload: () => ({ ...options.payload(), externalAction: true }) };
11436
+ }));
11437
+ this.compactPending();
11438
+ return result;
11023
11439
  }
11024
11440
  async withExternalActionLock(operation, reentrant = false) {
11025
11441
  const activeRoots = externalActionLockRoots.getStore();
11026
11442
  if (activeRoots && [...activeRoots].some((root) => root === this.root || sameDirectoryIdentity(root, this.root)))
11027
11443
  return operation();
11444
+ const ready = this.ready(true);
11445
+ if (!ready.ok)
11446
+ return ready;
11447
+ const lockPath = this.paths().checkoutLock;
11028
11448
  try {
11029
- this.initializeMutationStorage();
11449
+ this.initializeStorage();
11030
11450
  } catch (error) {
11031
11451
  return failure2("storage_error", `unable to initialize store: ${String(error)}`, {
11032
- path: this.workitDir
11452
+ path: this.paths().dir
11033
11453
  });
11034
11454
  }
11035
11455
  let handle;
11036
11456
  try {
11037
- handle = this.acquireMetadataLock(this.externalActionLockOptions());
11457
+ const options = this.lockOptions(lockPath);
11458
+ handle = this.acquire(lockPath, {
11459
+ ...options,
11460
+ payload: () => ({ ...options.payload(), externalAction: true })
11461
+ });
11038
11462
  } catch (error) {
11039
- return this.lockFailure(error);
11463
+ return this.lockFailure(lockPath, error);
11040
11464
  }
11041
- holdRoot(this.root);
11465
+ hold(lockPath);
11042
11466
  let result;
11043
11467
  try {
11044
11468
  if (!handle.verifyStillHeld())
11045
- result = failure2("recovery_required", "metadata lock was compromised", {
11046
- path: this.lockPath
11047
- });
11469
+ result = failure2("recovery_required", "metadata lock was compromised", { path: lockPath });
11048
11470
  else {
11049
11471
  try {
11050
11472
  const run = () => operation();
@@ -11058,526 +11480,1144 @@ class TaskStore {
11058
11480
  if (!handle.verifyStillHeld())
11059
11481
  result = failure2("external_outcome_unknown", "metadata lock was compromised", {
11060
11482
  outcome: "unknown",
11061
- path: this.lockPath
11483
+ path: lockPath
11062
11484
  });
11063
11485
  } catch {
11064
11486
  result = failure2("external_outcome_unknown", "metadata lock could not be verified", {
11065
11487
  outcome: "unknown",
11066
- path: this.lockPath
11488
+ path: lockPath
11067
11489
  });
11068
11490
  }
11069
11491
  }
11070
11492
  } catch {
11071
11493
  result = failure2("external_outcome_unknown", "external action lock operation failed", {
11072
11494
  outcome: "unknown",
11073
- path: this.lockPath
11495
+ path: lockPath
11074
11496
  });
11075
11497
  }
11076
- dropRoot(this.root);
11498
+ drop(lockPath);
11077
11499
  try {
11078
11500
  retryTransient(() => handle.release());
11079
11501
  } catch (error) {
11080
- const released = this.lockFailure(error);
11502
+ const released = this.lockFailure(lockPath, error);
11081
11503
  const releaseError = released.ok ? "metadata lock release failed" : released.error;
11082
11504
  return result.ok ? failure2("external_outcome_unknown", "external action metadata lock release is uncertain", {
11083
11505
  outcome: "unknown",
11084
- path: this.lockPath
11506
+ path: lockPath
11085
11507
  }) : failure2("recovery_required", `${result.error}; ${releaseError}`, result.details);
11086
11508
  }
11087
11509
  return result;
11088
11510
  }
11089
- recoveryLockOptions(observed, evidence, gate) {
11090
- const options = this.metadataLockOptions();
11091
- options.timeoutMs = 0;
11092
- options.retry = { retries: 0 };
11093
- options.parsePayload = parseMetadataLock;
11094
- options.staleRecovery = "remove-if-unchanged";
11095
- options.shouldReclaim = ({ payload }) => Boolean(observed && sameMetadataLock(payload, observed.data) && (evidence.state === "stopped" || evidence.state === "accounted_for"));
11096
- options.shouldRemoveStaleLock = ({ raw, payload }) => {
11097
- if (!observed || raw !== observed.raw || !sameMetadataLock(payload, observed.data))
11098
- return false;
11099
- gate.reclaimed = true;
11100
- return true;
11511
+ collectGarbage(options = {}) {
11512
+ const dryRun = options.dryRun === true;
11513
+ const compactAbove = options.compactAbove ?? COMPACT_ABOVE;
11514
+ const report = {
11515
+ dryRun,
11516
+ compacted: { tasks: [], eventsFolded: 0, bytesBefore: 0, bytesAfter: 0 },
11517
+ blobs: { removed: 0, removedBytes: 0, kept: 0 },
11518
+ temporary: { removed: 0 },
11519
+ legacyRecovery: null,
11520
+ retried: [],
11521
+ failed: []
11522
+ };
11523
+ const ready = this.ready(!dryRun);
11524
+ if (!ready.ok)
11525
+ return ready;
11526
+ const dirs = this.paths();
11527
+ const recovery = path9.join(this.root, ".workit", "recovery");
11528
+ if (fs8.existsSync(recovery)) {
11529
+ let files = 0;
11530
+ let bytes = 0;
11531
+ try {
11532
+ for (const name of fs8.readdirSync(recovery)) {
11533
+ const stat = fs8.lstatSync(path9.join(recovery, name));
11534
+ if (!stat.isFile())
11535
+ continue;
11536
+ files += 1;
11537
+ bytes += stat.size;
11538
+ }
11539
+ } catch {}
11540
+ const remove = options.pruneRecovery === true && !dryRun;
11541
+ if (remove)
11542
+ fs8.rmSync(recovery, { recursive: true, force: true });
11543
+ report.legacyRecovery = { path: recovery, files, bytes, removed: remove };
11544
+ }
11545
+ if (!fs8.existsSync(dirs.tasks))
11546
+ return success(null, null, report);
11547
+ const names = fs8.readdirSync(dirs.tasks).filter((name) => validId(name));
11548
+ for (const name of names) {
11549
+ const file = this.eventsPath(name);
11550
+ let size = 0;
11551
+ try {
11552
+ size = fs8.statSync(file).size;
11553
+ } catch {
11554
+ continue;
11555
+ }
11556
+ const lines = this.countEvents(file);
11557
+ if (lines > compactAbove) {
11558
+ if (dryRun) {
11559
+ report.compacted.tasks.push(name);
11560
+ report.compacted.eventsFolded += lines - COMPACT_KEEP;
11561
+ report.compacted.bytesBefore += size;
11562
+ } else {
11563
+ const compacted = this.compact(name);
11564
+ if (!compacted.ok)
11565
+ (compacted.code === "busy" ? report.retried : report.failed).push(name);
11566
+ else {
11567
+ report.compacted.tasks.push(name);
11568
+ report.compacted.eventsFolded += compacted.data.folded;
11569
+ report.compacted.bytesBefore += size;
11570
+ report.compacted.bytesAfter += compacted.data.bytes;
11571
+ }
11572
+ }
11573
+ }
11574
+ }
11575
+ const nowMs = Date.now();
11576
+ const sweep = () => {
11577
+ const references = new Set;
11578
+ for (const name of names)
11579
+ try {
11580
+ for (const match of fs8.readFileSync(this.eventsPath(name), "utf8").matchAll(/"\$blob":"([0-9a-f]{64})"/g))
11581
+ references.add(match[1]);
11582
+ } catch {}
11583
+ if (fs8.existsSync(dirs.blobs))
11584
+ for (const name of fs8.readdirSync(dirs.blobs)) {
11585
+ const digest = name.replace(/\.json$/, "");
11586
+ const file = path9.join(dirs.blobs, name);
11587
+ let stat;
11588
+ try {
11589
+ stat = fs8.lstatSync(file);
11590
+ } catch {
11591
+ continue;
11592
+ }
11593
+ if (references.has(digest) || nowMs - stat.mtimeMs <= STALE_TEMPORARY_MS) {
11594
+ report.blobs.kept += 1;
11595
+ continue;
11596
+ }
11597
+ report.blobs.removed += 1;
11598
+ report.blobs.removedBytes += stat.size;
11599
+ if (!dryRun)
11600
+ fs8.rmSync(file, { force: true });
11601
+ }
11602
+ return success(null, null, null);
11101
11603
  };
11102
- return options;
11604
+ const swept = dryRun ? sweep() : this.withLock(path9.join(dirs.dir, "gc.lock"), sweep);
11605
+ if (!swept.ok)
11606
+ return swept;
11607
+ const temporaryDirs = [
11608
+ dirs.dir,
11609
+ dirs.tasks,
11610
+ dirs.blobs,
11611
+ dirs.checkout,
11612
+ ...names.map((name) => path9.join(dirs.tasks, name))
11613
+ ];
11614
+ for (const directory of temporaryDirs) {
11615
+ if (!fs8.existsSync(directory))
11616
+ continue;
11617
+ for (const name of fs8.readdirSync(directory)) {
11618
+ if (!name.endsWith(".tmp") && !name.endsWith(".probe"))
11619
+ continue;
11620
+ const file = path9.join(directory, name);
11621
+ try {
11622
+ const stat = fs8.lstatSync(file);
11623
+ if (!stat.isFile() || nowMs - stat.mtimeMs <= STALE_TEMPORARY_MS)
11624
+ continue;
11625
+ } catch {
11626
+ continue;
11627
+ }
11628
+ report.temporary.removed += 1;
11629
+ if (!dryRun)
11630
+ fs8.rmSync(file, { force: true });
11631
+ }
11632
+ }
11633
+ return success(null, null, report);
11103
11634
  }
11104
- markUncertain(workspace) {
11105
- if (!workspace.writer || workspace.writer.state === "uncertain")
11106
- return;
11107
- const value = {
11108
- ...workspace,
11109
- writer: { ...workspace.writer, state: "uncertain" },
11110
- revision: newRevision(),
11111
- runtime: stampNew(workspace)
11635
+ paths() {
11636
+ const location = this.location();
11637
+ const dir = location.ok ? location.data.dir : path9.join(this.root, ".workit");
11638
+ const checkout = path9.join(dir, "checkouts", checkoutSlug(this.root));
11639
+ return {
11640
+ dir,
11641
+ shared: location.ok ? location.data.shared : false,
11642
+ marker: path9.join(dir, "store.json"),
11643
+ checkout,
11644
+ workspace: path9.join(checkout, "workspace.json"),
11645
+ checkoutLock: path9.join(checkout, "metadata.lock"),
11646
+ tasks: path9.join(dir, "tasks"),
11647
+ index: path9.join(dir, "task-index.json"),
11648
+ blobs: path9.join(dir, "blobs", "candidates"),
11649
+ legacy: path9.join(dir, "legacy", checkoutSlug(this.root), "v2"),
11650
+ v2: path9.join(this.root, ".workit")
11112
11651
  };
11113
- this.replaceSnapshot(this.workspacePath, value, workspace);
11114
11652
  }
11115
- withLock(operation, options = this.metadataLockOptions(), contention = "busy") {
11653
+ eventsPath(taskId) {
11654
+ return path9.join(this.paths().tasks, taskId, "events.jsonl");
11655
+ }
11656
+ snapshotPath(taskId) {
11657
+ return path9.join(this.paths().tasks, taskId, "snapshot.json");
11658
+ }
11659
+ taskLockPath(taskId) {
11660
+ return path9.join(this.paths().tasks, taskId, "lock");
11661
+ }
11662
+ ready(write = false) {
11663
+ const location = this.location();
11664
+ if (!location.ok)
11665
+ return location;
11666
+ if (this.migrationChecked)
11667
+ return success(null, null, null);
11668
+ const dirs = this.paths();
11116
11669
  try {
11117
- this.initializeMutationStorage();
11118
- } catch (error) {
11119
- return failure2("storage_error", `unable to initialize store: ${String(error)}`, {
11120
- path: this.workitDir
11121
- });
11670
+ const marker = JSON.parse(fs8.readFileSync(dirs.marker, "utf8"));
11671
+ if (typeof marker.version === "number" && marker.version > STORE_FORMAT)
11672
+ return failure2("recovery_required", `the workit store at ${dirs.dir} has format ${marker.version}; upgrade Workit to use it`, { path: dirs.marker });
11673
+ } catch {}
11674
+ this.migrationChecked = true;
11675
+ const settled = this.settleMigrations(write);
11676
+ if (!settled.ok)
11677
+ this.migrationChecked = false;
11678
+ return settled;
11679
+ }
11680
+ settleMigrations(write) {
11681
+ const moved = this.adoptLocalStore();
11682
+ if (!moved.ok)
11683
+ return moved;
11684
+ const pending = this.pendingMigrations();
11685
+ if (pending.length > 0 && !write && !(this.migrateOnRead ?? migrationReporterInstalled()))
11686
+ return failure2("needs_input", `${MIGRATION_PENDING}: the task store at ${pending[0]} has not been migrated yet; run \`workit task status\` to migrate it`, { path: pending[0], guidance: "workit task status" });
11687
+ for (const v2 of this.v2Dirs()) {
11688
+ const migrated = this.migrateV2(v2);
11689
+ if (!migrated.ok)
11690
+ return migrated;
11122
11691
  }
11123
- let handle;
11124
- let result = failure2("storage_error", "metadata lock operation did not produce a result");
11692
+ return success(null, null, null);
11693
+ }
11694
+ blobStore() {
11695
+ const dir = this.paths().blobs;
11696
+ return {
11697
+ read: (digest) => {
11698
+ try {
11699
+ return JSON.parse(fs8.readFileSync(path9.join(dir, `${digest}.json`), "utf8"));
11700
+ } catch (error) {
11701
+ throw new LogDamage(`stored candidate ${digest} is missing or unreadable: ${String(error)}`);
11702
+ }
11703
+ },
11704
+ write: (digest, value) => {
11705
+ const file = path9.join(dir, `${digest}.json`);
11706
+ if (fs8.existsSync(file)) {
11707
+ const stamp = new Date;
11708
+ try {
11709
+ fs8.utimesSync(file, stamp, stamp);
11710
+ return;
11711
+ } catch {}
11712
+ }
11713
+ fs8.mkdirSync(dir, { recursive: true });
11714
+ replaceFile(file, JSON.stringify(value), true);
11715
+ }
11716
+ };
11717
+ }
11718
+ load(taskId) {
11125
11719
  try {
11126
- handle = this.acquireMetadataLock(options);
11720
+ const loaded = this.loadOrThrow(taskId);
11721
+ if (!loaded)
11722
+ return success(null, null, null);
11723
+ const parsed = this.parseTask(loaded.state.record, taskId);
11724
+ if (!parsed.ok)
11725
+ return parsed;
11726
+ return success(null, null, {
11727
+ record: parsed.data,
11728
+ key: loaded.state.key,
11729
+ legacy: loaded.state.legacy,
11730
+ seq: loaded.seq,
11731
+ lastId: loaded.lastId,
11732
+ lastStart: loaded.lastStart,
11733
+ end: loaded.end,
11734
+ size: loaded.size,
11735
+ snapshotSeq: loaded.snapshotSeq
11736
+ });
11127
11737
  } catch (error) {
11128
- result = this.lockFailure(error, contention);
11738
+ if (error instanceof LogDamage)
11739
+ return failure2("recovery_required", error.upgrade ? error.message : `task ${taskId} log is damaged: ${error.message}`, { taskId, path: this.eventsPath(taskId) });
11740
+ return failure2("recovery_required", `task ${taskId} cannot be read: ${String(error)}`, {
11741
+ taskId
11742
+ });
11129
11743
  }
11130
- if (handle)
11131
- holdRoot(this.root);
11132
- if (handle) {
11744
+ }
11745
+ loadOrThrow(taskId) {
11746
+ const blobs = this.blobStore();
11747
+ const file = this.eventsPath(taskId);
11748
+ const snapshot = this.readSnapshot(taskId);
11749
+ if (snapshot) {
11133
11750
  try {
11134
- if (!handle.verifyStillHeld())
11135
- result = failure2("recovery_required", "metadata lock was compromised", {
11136
- path: this.lockPath
11137
- });
11138
- else {
11139
- try {
11140
- result = operation(handle);
11141
- } catch (error) {
11142
- result = failure2("storage_error", `mutation failed: ${String(error)}`);
11143
- }
11751
+ const tail = readLog(file, snapshot.lastStart, snapshot.seq);
11752
+ const first = tail?.events[0];
11753
+ if (tail && first && first.id === snapshot.lastId) {
11754
+ let state = {
11755
+ record: snapshot.record,
11756
+ key: snapshot.key,
11757
+ legacy: snapshot.legacy
11758
+ };
11759
+ for (const event of tail.events.slice(1))
11760
+ state = reduce(state, event, blobs);
11761
+ return this.loadedFrom(state, tail, snapshot.seq);
11144
11762
  }
11145
11763
  } catch (error) {
11146
- result = this.lockFailure(error);
11764
+ if (error instanceof LogDamage && error.upgrade)
11765
+ throw error;
11147
11766
  }
11148
11767
  }
11149
- if (handle) {
11150
- dropRoot(this.root);
11151
- try {
11152
- retryTransient(() => handle.release());
11153
- } catch (error) {
11154
- const releaseFailure = this.lockFailure(error);
11155
- const releaseError = releaseFailure.ok ? "metadata lock release failed" : releaseFailure.error;
11156
- result = result.ok ? releaseFailure : failure2("recovery_required", `${result.error}; ${releaseError}`, result.details);
11157
- }
11768
+ const log = readLog(file, 0, null);
11769
+ if (!log || log.events.length === 0) {
11770
+ if (log && log.size > 0)
11771
+ throw new LogDamage("the log holds no complete event");
11772
+ return null;
11158
11773
  }
11159
- return result;
11774
+ if (!OPENING_TYPES.has(log.events[0].type))
11775
+ throw new LogDamage(`the log starts with ${log.events[0].type}, not an opening event`);
11776
+ let state = null;
11777
+ for (const event of log.events)
11778
+ state = reduce(state, event, blobs);
11779
+ return this.loadedFrom(state, log, 0);
11780
+ }
11781
+ loadedFrom(state, log, snapshotSeq) {
11782
+ const last = log.events.at(-1);
11783
+ return {
11784
+ state,
11785
+ seq: last.seq,
11786
+ lastId: last.id,
11787
+ lastStart: log.lastStart,
11788
+ end: log.end,
11789
+ size: log.size,
11790
+ snapshotSeq
11791
+ };
11160
11792
  }
11161
- lockDirWritable() {
11162
- const probe = `${this.lockPath}.${process.pid}.${randomUUID2()}.probe`;
11793
+ readSnapshot(taskId) {
11163
11794
  try {
11164
- fs6.closeSync(fs6.openSync(probe, "wx", 384));
11795
+ const value = JSON.parse(fs8.readFileSync(this.snapshotPath(taskId), "utf8"));
11796
+ if (!isObject4(value) || value.v !== 1 || value.task !== taskId || typeof value.seq !== "number" || typeof value.lastId !== "string" || typeof value.lastStart !== "number" || typeof value.end !== "number" || !isObject4(value.record))
11797
+ return null;
11798
+ return value;
11165
11799
  } catch {
11166
- return false;
11800
+ return null;
11167
11801
  }
11802
+ }
11803
+ writeSnapshot(taskId, loaded) {
11804
+ const snapshot = {
11805
+ v: 1,
11806
+ task: taskId,
11807
+ seq: loaded.seq,
11808
+ lastId: loaded.lastId,
11809
+ lastStart: loaded.lastStart,
11810
+ end: loaded.end,
11811
+ key: loaded.key,
11812
+ legacy: loaded.legacy,
11813
+ record: loaded.record
11814
+ };
11168
11815
  try {
11169
- fs6.rmSync(probe, { force: true });
11816
+ replaceFile(this.snapshotPath(taskId), JSON.stringify(snapshot), false);
11170
11817
  } catch {}
11171
- return true;
11172
11818
  }
11173
- lockHolderPresent() {
11819
+ loadBound(taskId) {
11820
+ const loaded = this.load(taskId);
11821
+ if (!loaded.ok)
11822
+ return loaded;
11823
+ if (!loaded.data)
11824
+ return failure2("not_found", "task not found", { taskId });
11825
+ const workspace = this.readWorkspace();
11826
+ if (!workspace.ok)
11827
+ return workspace;
11828
+ const bound = this.bindingError(loaded.data.record, workspace.data);
11829
+ return bound ?? success(null, null, loaded.data);
11830
+ }
11831
+ bindingError(task, workspace) {
11832
+ if (workspace && task.workspaceId === workspace.id)
11833
+ return null;
11834
+ return failure2("not_found", `task ${task.id} belongs to another checkout of this repository; run there, or move it here with \`workit task adopt ${task.id}\``, { taskId: task.id });
11835
+ }
11836
+ nextTask(current, changed, context) {
11837
+ const valid = taskRecordSchema.safeParse({
11838
+ ...changed,
11839
+ id: current.id,
11840
+ workspaceId: current.workspaceId,
11841
+ createdAt: current.createdAt,
11842
+ revision: context.revision,
11843
+ updatedAt: context.now,
11844
+ runtime: stampNew(current)
11845
+ });
11846
+ return valid.success ? valid.data : null;
11847
+ }
11848
+ event(taskId, seq, type, data) {
11849
+ return {
11850
+ v: EVENT_VERSION,
11851
+ seq,
11852
+ at: new Date().toISOString(),
11853
+ id: newId(),
11854
+ task: taskId,
11855
+ actor: this.actor,
11856
+ type,
11857
+ data
11858
+ };
11859
+ }
11860
+ appendPatch(loaded, next, extra = {}) {
11861
+ const taskId = next.id;
11174
11862
  try {
11175
- fs6.lstatSync(this.lockPath);
11176
- return true;
11863
+ const blobs = this.blobStore();
11864
+ const before = loaded.record;
11865
+ const after = next;
11866
+ const ops = encodeOps(diff(before, after), blobs);
11867
+ const { type, ...data } = extra;
11868
+ if (data.key)
11869
+ data.key = { ...data.key, boundAt: data.key.boundAt ?? new Date().toISOString() };
11870
+ const event = this.event(taskId, loaded.seq + 1, type ?? ("key" in extra ? "task.bound" : eventType(before, after)), { ops, ...data });
11871
+ const start = appendEvent(this.eventsPath(taskId), event, loaded.end);
11872
+ const end = start + Buffer.byteLength(`${JSON.stringify(event)}
11873
+ `);
11874
+ const key = "key" in data ? data.key ?? null : loaded.key;
11875
+ const state = {
11876
+ record: next,
11877
+ key,
11878
+ legacy: extra.legacy ?? loaded.legacy,
11879
+ seq: event.seq,
11880
+ lastId: event.id,
11881
+ lastStart: start,
11882
+ end
11883
+ };
11884
+ if (event.seq - loaded.snapshotSeq >= SNAPSHOT_EVERY || next.status !== loaded.record.status || loaded.snapshotSeq === 0)
11885
+ this.writeSnapshot(taskId, state);
11886
+ if (end > AUTO_COMPACT_BYTES)
11887
+ this.compactDue.add(taskId);
11888
+ this.indexTask(next, key, (extra.legacy ?? loaded.legacy) !== null);
11889
+ return success(null, null, null);
11177
11890
  } catch (error) {
11178
- return error?.code !== "ENOENT";
11891
+ return failure2("storage_error", `task append failed: ${String(error)}`, {
11892
+ path: this.eventsPath(taskId)
11893
+ });
11179
11894
  }
11180
11895
  }
11181
- lockFailure(error, contention = "busy") {
11182
- const value = error;
11183
- const code = typeof value?.code === "string" ? value.code : "";
11184
- if (isTransientWindowsError(error) && !this.lockHolderPresent() && !this.lockDirWritable())
11185
- return failure2("storage_error", `cannot create the workspace metadata lock (${code}); no other Workit call holds it`, {
11186
- path: this.lockPath,
11187
- guidance: `Check permissions and the read-only attribute on ${path7.dirname(this.lockPath)}.`
11896
+ openTask(record, type, key, legacy = null) {
11897
+ const dir = path9.join(this.paths().tasks, record.id);
11898
+ fs8.mkdirSync(dir, { recursive: true });
11899
+ const blobs = this.blobStore();
11900
+ const bound = key ? { ...key, boundAt: key.boundAt ?? new Date().toISOString() } : null;
11901
+ const event = this.event(record.id, 1, type, {
11902
+ record: encodeRecord(record, blobs),
11903
+ key: bound,
11904
+ ...legacy ? { legacy } : {}
11905
+ });
11906
+ const file = this.eventsPath(record.id);
11907
+ const existing = readLog(file, 0, null);
11908
+ if (existing && existing.events.length > 0)
11909
+ throw new Error(`task ${record.id} already has a log`);
11910
+ appendEvent(file, event, 0);
11911
+ this.indexTask(record, bound, legacy !== null);
11912
+ }
11913
+ compactPending() {
11914
+ for (const taskId of this.compactDue) {
11915
+ this.compactDue.delete(taskId);
11916
+ this.compact(taskId);
11917
+ }
11918
+ }
11919
+ compact(taskId) {
11920
+ try {
11921
+ const file = this.eventsPath(taskId);
11922
+ const log = readLog(file, 0, null);
11923
+ if (!log || log.events.length <= COMPACT_KEEP + 1)
11924
+ return success(null, null, { folded: 0, bytes: log?.size ?? 0 });
11925
+ const blobs = this.blobStore();
11926
+ const cut = log.events.length - COMPACT_KEEP;
11927
+ let state = null;
11928
+ for (const event of log.events.slice(0, cut))
11929
+ state = reduce(state, event, blobs);
11930
+ const folded = log.events[cut - 1];
11931
+ const checkpoint = {
11932
+ ...this.event(taskId, folded.seq, "task.checkpoint", {
11933
+ record: encodeRecord(state.record, blobs),
11934
+ key: state.key,
11935
+ ...state.legacy ? { legacy: state.legacy } : {},
11936
+ folded: { from: log.events[0].seq, to: folded.seq }
11937
+ })
11938
+ };
11939
+ const prefix = `${[checkpoint, ...log.events.slice(cut)].map((event) => JSON.stringify(event)).join(`
11940
+ `)}
11941
+ `;
11942
+ const last = log.events.at(-1);
11943
+ return this.withLock(this.taskLockPath(taskId), () => {
11944
+ let latest = null;
11945
+ try {
11946
+ latest = readLog(file, log.lastStart, last.seq);
11947
+ } catch {}
11948
+ if (!latest || latest.events[0]?.id !== last.id)
11949
+ return failure2("busy", "the task log changed during compaction; retry", { taskId });
11950
+ const appended = fs8.readFileSync(file).subarray(log.end, latest.end);
11951
+ const bytes = Buffer.concat([Buffer.from(prefix, "utf8"), appended]);
11952
+ replaceFile(file, bytes.toString("utf8"), true);
11953
+ try {
11954
+ fs8.rmSync(this.snapshotPath(taskId), { force: true });
11955
+ } catch {}
11956
+ const reloaded = this.load(taskId);
11957
+ if (reloaded.ok && reloaded.data)
11958
+ this.writeSnapshot(taskId, reloaded.data);
11959
+ return success(null, null, { folded: cut, bytes: bytes.length });
11188
11960
  });
11189
- if (code === "EEXIST" || code === "file_lock_timeout" || code === "file_lock_stale" || isTransientWindowsError(error)) {
11190
- let lock = null;
11191
- try {
11192
- lock = parseMetadataLockOrNull(fs6.readFileSync(this.lockPath, "utf8"));
11193
- } catch {}
11194
- if (lock?.externalAction)
11195
- return failure2("writer_conflict", "workspace is reserved by a managed external action", {
11196
- outcome: "not_started",
11197
- path: this.lockPath
11198
- });
11199
- if (contention === "busy")
11200
- return failure2("busy", `workspace metadata lock is held by another Workit call${lock ? ` (pid ${lock.pid} on ${lock.host})` : ""}; retry shortly`, {
11201
- outcome: "not_started",
11202
- path: this.lockPath,
11203
- guidance: "Retry the same call. If it stays busy, run `workit doctor --fix-lock` to clear a lock left by a dead process."
11961
+ } catch (error) {
11962
+ return failure2("storage_error", `compaction failed: ${String(error)}`, { taskId });
11963
+ }
11964
+ }
11965
+ countEvents(file) {
11966
+ try {
11967
+ const bytes = fs8.readFileSync(file);
11968
+ let count = 0;
11969
+ for (let index = bytes.indexOf(10);index >= 0; index = bytes.indexOf(10, index + 1))
11970
+ count += 1;
11971
+ return count;
11972
+ } catch {
11973
+ return 0;
11974
+ }
11975
+ }
11976
+ openTasksForKey(key) {
11977
+ const all = this.listStoreIndex();
11978
+ if (!all.ok)
11979
+ return all;
11980
+ const location = this.location();
11981
+ const renames = location.ok ? branchRenames(location.data) : [];
11982
+ return success(null, null, all.data.filter((entry) => entry.key !== null && entry.status !== "closed").filter((entry) => (renames.length ? followRenames(entry.key, entry.boundAt ?? entry.createdAt, renames) : entry.key) === key).toSorted(oldestFirst));
11983
+ }
11984
+ openTaskForKey(key) {
11985
+ const bound = this.openTasksForKey(key);
11986
+ return bound.ok ? success(null, null, bound.data[0] ?? null) : bound;
11987
+ }
11988
+ withKeyLock(key, operation) {
11989
+ const digest = createHash6("sha256").update(key).digest("hex").slice(0, 32);
11990
+ return this.withLock(path9.join(this.paths().dir, "keys", `${digest}.lock`), operation);
11991
+ }
11992
+ bindLocked(taskId, key, timestamp) {
11993
+ const current = this.readWorkspace();
11994
+ if (!current.ok)
11995
+ return current;
11996
+ let workspace = current.data;
11997
+ if (!workspace) {
11998
+ workspace = this.nextWorkspace(null);
11999
+ const written = this.writeWorkspace(workspace);
12000
+ if (!written.ok)
12001
+ return written;
12002
+ }
12003
+ const target = workspace;
12004
+ return this.withLock(this.taskLockPath(taskId), () => {
12005
+ const loaded = this.load(taskId);
12006
+ if (!loaded.ok)
12007
+ return loaded;
12008
+ if (!loaded.data)
12009
+ return failure2("not_found", "task not found", { taskId });
12010
+ if (loaded.data.record.status === "closed")
12011
+ return failure2("invalid_transition", "a closed task cannot be adopted", { taskId });
12012
+ if (loaded.data.record.workspaceId === target.id && loaded.data.key?.key === key.key)
12013
+ return success(loaded.data.record.revision, null, loaded.data.record);
12014
+ const next = taskRecordSchema.safeParse({
12015
+ ...loaded.data.record,
12016
+ workspaceId: target.id,
12017
+ revision: newRevision(),
12018
+ updatedAt: timestamp ?? now(),
12019
+ runtime: stampNew(loaded.data.record)
12020
+ });
12021
+ if (!next.success)
12022
+ return failure2("invalid_input", "rebound task is invalid");
12023
+ const written = this.appendPatch(loaded.data, next.data, { key });
12024
+ return written.ok ? success(next.data.revision, null, next.data) : written;
12025
+ });
12026
+ }
12027
+ unbindLocked(taskId) {
12028
+ return this.withLock(this.taskLockPath(taskId), () => {
12029
+ const loaded = this.load(taskId);
12030
+ if (!loaded.ok)
12031
+ return loaded;
12032
+ if (!loaded.data || loaded.data.key === null)
12033
+ return success(null, null, null);
12034
+ return this.appendPatch(loaded.data, loaded.data.record, { key: null });
12035
+ });
12036
+ }
12037
+ createLocked(value, bindCurrent, key, checkRevision = true) {
12038
+ const current = this.readWorkspace();
12039
+ if (!current.ok)
12040
+ return current;
12041
+ if (checkRevision && (current.data ? value.expectedWorkspaceRevision !== current.data.revision : value.expectedWorkspaceRevision !== null))
12042
+ return failure2("revision_conflict", "workspace revision does not match; omit expectedWorkspaceRevision to use the current record", {
12043
+ expectedWorkspaceRevision: value.expectedWorkspaceRevision,
12044
+ actualWorkspaceRevision: current.data?.revision ?? null
12045
+ });
12046
+ if (!value.provenance)
12047
+ return failure2("invalid_input", "provenance is required");
12048
+ if (!intentSchema.safeParse(value.intent).success || !provenanceSchema.safeParse(value.provenance).success)
12049
+ return failure2("invalid_input", "task intent or provenance is invalid");
12050
+ if (bindCurrent) {
12051
+ const currentKey = this.currentKey();
12052
+ if (currentKey.ok)
12053
+ return this.withKeyLock(currentKey.data.key, () => {
12054
+ const holder = this.openTaskForKey(currentKey.data.key);
12055
+ if (!holder.ok)
12056
+ return holder;
12057
+ if (holder.data) {
12058
+ const released = this.unbindLocked(holder.data.id);
12059
+ if (!released.ok)
12060
+ return released;
12061
+ }
12062
+ return this.newTask(value, current.data, currentKey.data);
11204
12063
  });
11205
12064
  }
11206
- const recovery = code === "EEXIST" || code === "file_lock_timeout" || code === "file_lock_stale" || code === "metadata_lock_invalid" || code === "not-file" || /metadata lock|file lock|reclaim/i.test(String(value?.message ?? error));
11207
- return failure2(recovery ? "recovery_required" : "storage_error", `metadata lock operation failed: ${String(error)}`, { path: this.lockPath });
12065
+ return this.newTask(value, current.data, key);
12066
+ }
12067
+ newTask(value, previous, binding) {
12068
+ const current = { data: previous };
12069
+ const workspace = this.nextWorkspace(current.data);
12070
+ const timestamp = value.now ?? now();
12071
+ const task = {
12072
+ schemaVersion: SCHEMA_VERSION,
12073
+ id: newId(),
12074
+ workspaceId: workspace.id,
12075
+ revision: newRevision(),
12076
+ createdAt: timestamp,
12077
+ updatedAt: timestamp,
12078
+ origin: null,
12079
+ intent: {
12080
+ id: newId(),
12081
+ recordedAt: timestamp,
12082
+ provenance: value.provenance,
12083
+ data: value.intent
12084
+ },
12085
+ constraints: [],
12086
+ status: "active",
12087
+ closure: null,
12088
+ progress: { summary: "", nextAction: null, blockers: [] },
12089
+ runtime: { createdWith: runtimeVersion(), updatedWith: runtimeVersion() },
12090
+ assessments: [],
12091
+ policy: null,
12092
+ policyChanges: [],
12093
+ candidates: [],
12094
+ evidence: [],
12095
+ decisions: [],
12096
+ findings: [],
12097
+ workers: []
12098
+ };
12099
+ return this.writeNewTask(current.data, workspace, task, "task.opened", binding, {
12100
+ operation: "create"
12101
+ });
12102
+ }
12103
+ nextWorkspace(current, id) {
12104
+ return current ? { ...current, revision: newRevision(), root: this.root, runtime: stampNew(current) } : {
12105
+ schemaVersion: SCHEMA_VERSION,
12106
+ id: id ?? newId(),
12107
+ revision: newRevision(),
12108
+ root: this.root,
12109
+ runtime: { createdWith: runtimeVersion(), updatedWith: runtimeVersion() },
12110
+ writer: null
12111
+ };
12112
+ }
12113
+ writeNewTask(previous, workspace, task, type, key, details) {
12114
+ const written = this.writeWorkspace(workspace);
12115
+ if (!written.ok)
12116
+ return written;
12117
+ try {
12118
+ this.openTask(task, type, key);
12119
+ } catch (error) {
12120
+ if (previous) {
12121
+ const restored = this.writeWorkspace(previous);
12122
+ if (!restored.ok)
12123
+ return failure2("external_outcome_unknown", `task ${details.operation} failed and workspace restoration is uncertain`, { ...details, outcome: "unknown" });
12124
+ }
12125
+ return failure2("external_outcome_unknown", `workspace written but the task log is uncertain: ${String(error)}`, { ...details, outcome: "unknown" });
12126
+ }
12127
+ return success(task.revision, workspace.revision, task);
12128
+ }
12129
+ writeWorkspace(record) {
12130
+ const file = this.paths().workspace;
12131
+ try {
12132
+ fs8.mkdirSync(path9.dirname(file), { recursive: true });
12133
+ replaceFile(file, `${canonicalJson(record)}
12134
+ `, true);
12135
+ return success(null, null, null);
12136
+ } catch (error) {
12137
+ return failure2("storage_error", `workspace write failed: ${String(error)}`, { path: file });
12138
+ }
12139
+ }
12140
+ markUncertain(workspace) {
12141
+ if (!workspace.writer || workspace.writer.state === "uncertain")
12142
+ return;
12143
+ this.writeWorkspace({
12144
+ ...workspace,
12145
+ writer: { ...workspace.writer, state: "uncertain" },
12146
+ revision: newRevision(),
12147
+ runtime: stampNew(workspace)
12148
+ });
12149
+ }
12150
+ readIndex() {
12151
+ try {
12152
+ const value = JSON.parse(fs8.readFileSync(this.paths().index, "utf8"));
12153
+ if (!isObject4(value) || value.version !== INDEX_VERSION || !isObject4(value.tasks))
12154
+ return {};
12155
+ const tasks = {};
12156
+ for (const [id, entry] of Object.entries(value.tasks))
12157
+ if (isObject4(entry) && entry.id === id && typeof entry.file === "string" && typeof entry.status === "string" && typeof entry.updatedAt === "string" && typeof entry.workspaceId === "string" && Array.isArray(entry.sessions) && isObject4(entry.progress) && isObject4(entry.source))
12158
+ tasks[id] = entry;
12159
+ return tasks;
12160
+ } catch {
12161
+ return {};
12162
+ }
11208
12163
  }
11209
- initializeMutationStorage() {
11210
- retryTransient(() => fs6.mkdirSync(this.tasksDir, { recursive: true }));
11211
- retryTransient(() => fs6.mkdirSync(this.recoveryDir, { recursive: true }));
11212
- let current = null;
12164
+ writeIndex(entries) {
11213
12165
  try {
11214
- current = retryTransient(() => fs6.readFileSync(this.gitignorePath, "utf8"));
12166
+ fs8.mkdirSync(this.paths().tasks, { recursive: true });
12167
+ replaceFile(this.paths().index, JSON.stringify({
12168
+ version: INDEX_VERSION,
12169
+ tasks: Object.fromEntries(entries.map((entry) => [entry.id, entry]))
12170
+ }), false);
12171
+ } catch {}
12172
+ }
12173
+ indexTask(task, key, legacy) {
12174
+ try {
12175
+ const signature = fileSignature(this.eventsPath(task.id));
12176
+ if (signature === null)
12177
+ return;
12178
+ const tasks = this.readIndex();
12179
+ tasks[task.id] = indexEntry(task, key, legacy, signature);
12180
+ this.writeIndex(Object.values(tasks));
11215
12181
  } catch {}
11216
- if (current !== `*
11217
- `)
11218
- retryTransient(() => fs6.writeFileSync(this.gitignorePath, `*
11219
- `));
11220
12182
  }
11221
- replaceSnapshot(file, value, previous) {
11222
- let temporary;
12183
+ parseTask(record, taskId) {
12184
+ const parsed = this.parseValue(record, taskRecordSchema, "task");
12185
+ if (!parsed.ok)
12186
+ return { ...parsed, details: { ...parsed.details, taskId } };
12187
+ if (parsed.data.id !== taskId)
12188
+ return failure2("recovery_required", "task log and record ID differ", { taskId });
12189
+ return parsed;
12190
+ }
12191
+ parseRecord(bytes, schema, label) {
12192
+ let value;
11223
12193
  try {
11224
- if (Buffer.isBuffer(previous))
11225
- this.saveRecovery(file, previous);
11226
- else if (previous !== null && typeof previous === "object")
11227
- this.saveRecovery(file, jsonBytes(previous));
11228
- else if (typeof previous === "string")
11229
- this.saveRecovery(file, previous);
11230
- temporary = `${file}.${process.pid}.${randomUUID2()}.tmp`;
11231
- const fd = fs6.openSync(temporary, "wx", 384);
12194
+ value = JSON.parse(bytes);
12195
+ } catch {
12196
+ return failure2("recovery_required", `${label} record JSON is corrupt`);
12197
+ }
12198
+ return this.parseValue(value, schema, label);
12199
+ }
12200
+ parseValue(value, schema, label) {
12201
+ if (isObject4(value) && "schemaVersion" in value && value.schemaVersion !== SCHEMA_VERSION)
12202
+ return failure2("unsupported_version", `unsupported ${label} schema version`);
12203
+ const parsed = parseStoredRecord(schema, value);
12204
+ if (parsed.success)
12205
+ return success(null, null, parsed.data);
12206
+ const writerVersion = isObject4(value) && isObject4(value.runtime) ? value.runtime.updatedWith : null;
12207
+ if (parsed.critical.length > 0)
12208
+ return failure2("recovery_required", `${label} record requires fields this Workit cannot read (${parsed.critical.join(", ")}); upgrade Workit before mutating this checkout`);
12209
+ if (typeof writerVersion === "string" && isNewerVersion(writerVersion, runtimeVersion()))
12210
+ return failure2("recovery_required", `${label} record was written by workit ${writerVersion}; upgrade Workit before mutating this checkout`);
12211
+ return failure2("recovery_required", `${label} record does not satisfy its schema`);
12212
+ }
12213
+ conflict(expected, actual) {
12214
+ return failure2("revision_conflict", "snapshot revision does not match; omit expectedRevision to use the current record", { expectedRevision: expected, actualRevision: actual });
12215
+ }
12216
+ workspaceConflict(expected, actual) {
12217
+ return failure2("revision_conflict", "workspace revision does not match; omit expectedWorkspaceRevision to use the current record", { expectedWorkspaceRevision: expected, actualWorkspaceRevision: actual });
12218
+ }
12219
+ v2Dirs() {
12220
+ return [...new Set([this.root, this.origin])].map((dir) => path9.join(dir, ".workit"));
12221
+ }
12222
+ pendingMigrations() {
12223
+ return this.v2Dirs().filter((dir) => {
12224
+ const raw = this.readV2Workspace(path9.join(dir, "workspace.json"));
12225
+ return raw !== null && raw !== TOMBSTONE;
12226
+ });
12227
+ }
12228
+ migrateV2(v2) {
12229
+ const dirs = this.paths();
12230
+ const v2Workspace = path9.join(v2, "workspace.json");
12231
+ const v2Tasks = path9.join(v2, "tasks");
12232
+ const legacy = path9.join(dirs.dir, "legacy", checkoutSlug(path9.dirname(v2)), "v2");
12233
+ const raw = this.readV2Workspace(v2Workspace);
12234
+ if (raw === null || raw === TOMBSTONE)
12235
+ return success(null, null, null);
12236
+ return this.withLock(dirs.checkoutLock, () => this.withLock(path9.join(v2, "metadata.lock"), () => {
12237
+ const workspace = this.readV2Workspace(v2Workspace);
12238
+ if (workspace === null || workspace === TOMBSTONE)
12239
+ return success(null, null, null);
12240
+ const parsedWorkspace = this.parseRecord(workspace.bytes, workspaceRecordSchema, "2.x workspace");
12241
+ if (!parsedWorkspace.ok)
12242
+ return failure2("recovery_required", `cannot migrate the 2.x store at ${v2}: ${parsedWorkspace.error}; move it aside to start fresh`, { path: v2Workspace });
12243
+ let files = [];
12244
+ try {
12245
+ files = fs8.readdirSync(v2Tasks).filter((name) => name.endsWith(".json") && validId(name.slice(0, -5))).filter((name) => !this.isStub(path9.join(v2Tasks, name)));
12246
+ } catch {}
12247
+ const current = this.readWorkspace();
12248
+ if (!current.ok)
12249
+ return current;
12250
+ let workspaceMoved = false;
12251
+ let target = current.data;
12252
+ if (!target) {
12253
+ target = { ...parsedWorkspace.data, root: this.root };
12254
+ const written = this.writeWorkspace(target);
12255
+ if (!written.ok)
12256
+ return written;
12257
+ workspaceMoved = true;
12258
+ }
12259
+ let migrated = 0;
12260
+ let skipped = 0;
12261
+ const done = new Map;
12262
+ try {
12263
+ fs8.mkdirSync(path9.join(legacy, "tasks"), { recursive: true });
12264
+ for (const name of ["workspace.json", "index.json"])
12265
+ if (fs8.existsSync(path9.join(v2, name)))
12266
+ fs8.copyFileSync(path9.join(v2, name), path9.join(legacy, name));
12267
+ for (const name of files) {
12268
+ const id = name.slice(0, -5);
12269
+ const file = path9.join(v2Tasks, name);
12270
+ const bytes = fs8.readFileSync(file, "utf8");
12271
+ const digest = sha256(bytes);
12272
+ fs8.writeFileSync(path9.join(legacy, "tasks", name), bytes);
12273
+ const parsed = this.parseRecord(bytes, taskRecordSchema, "2.x task");
12274
+ if (!parsed.ok || parsed.data.id !== id)
12275
+ return failure2("recovery_required", `cannot migrate 2.x task ${id}: ${parsed.ok ? "file name and record ID differ" : parsed.error}; move ${file} aside to continue`, { path: file });
12276
+ const record = { ...parsed.data, workspaceId: target.id };
12277
+ const origin = { path: path9.join(legacy, "tasks", name), digest };
12278
+ if (!fs8.existsSync(this.eventsPath(id))) {
12279
+ this.openTask(record, "migrated.from_v2", null, origin);
12280
+ migrated += 1;
12281
+ } else {
12282
+ const resynced = this.withLock(this.taskLockPath(id), () => {
12283
+ const loaded = this.load(id);
12284
+ if (!loaded.ok)
12285
+ return loaded;
12286
+ if (!loaded.data)
12287
+ return failure2("not_found", "task not found", { taskId: id });
12288
+ if (loaded.data.legacy?.digest === digest)
12289
+ return success(null, null, false);
12290
+ const appended = this.appendPatch(loaded.data, record, {
12291
+ type: "migrated.from_v2",
12292
+ legacy: origin
12293
+ });
12294
+ return appended.ok ? success(null, null, true) : appended;
12295
+ });
12296
+ if (!resynced.ok)
12297
+ return resynced;
12298
+ if (resynced.data)
12299
+ migrated += 1;
12300
+ else
12301
+ skipped += 1;
12302
+ }
12303
+ done.set(name, digest);
12304
+ }
12305
+ } catch (error) {
12306
+ return failure2("storage_error", `cannot migrate the 2.x store at ${v2}: ${String(error)}`, {
12307
+ path: v2
12308
+ });
12309
+ }
12310
+ try {
12311
+ let complete = true;
12312
+ for (const [name, digest] of done) {
12313
+ const file = path9.join(v2Tasks, name);
12314
+ if (sha256(fs8.readFileSync(file, "utf8")) !== digest) {
12315
+ complete = false;
12316
+ continue;
12317
+ }
12318
+ replaceFile(file, this.stubFor(name.slice(0, -5)), false);
12319
+ }
12320
+ if (!complete)
12321
+ return failure2("busy", `the 2.x store at ${v2} changed during migration; retry`, {
12322
+ path: v2
12323
+ });
12324
+ fs8.rmSync(path9.join(v2, "index.json"), { force: true });
12325
+ this.writeTombstone(v2, target.id);
12326
+ } catch (error) {
12327
+ return failure2("storage_error", `cannot finish the 2.x migration: ${String(error)}`, {
12328
+ path: v2
12329
+ });
12330
+ }
12331
+ reportMigration({
12332
+ from: v2,
12333
+ to: dirs.dir,
12334
+ backup: legacy,
12335
+ tasks: migrated,
12336
+ skipped,
12337
+ workspace: workspaceMoved
12338
+ });
12339
+ return success(null, null, null);
12340
+ }));
12341
+ }
12342
+ adoptLocalStore() {
12343
+ const dirs = this.paths();
12344
+ const marker = path9.join(dirs.v2, "store.json");
12345
+ if (!dirs.shared || !fs8.existsSync(marker))
12346
+ return success(null, null, null);
12347
+ return this.withLock(dirs.checkoutLock, () => {
12348
+ if (!fs8.existsSync(marker))
12349
+ return success(null, null, null);
12350
+ let tasks = 0;
11232
12351
  try {
11233
- fs6.writeSync(fd, jsonBytes(value));
11234
- fs6.fsyncSync(fd);
11235
- } finally {
11236
- fs6.closeSync(fd);
12352
+ const merge = (from, to) => {
12353
+ if (!fs8.existsSync(to)) {
12354
+ fs8.mkdirSync(path9.dirname(to), { recursive: true });
12355
+ try {
12356
+ fs8.renameSync(from, to);
12357
+ } catch {
12358
+ fs8.cpSync(from, to, { recursive: true, errorOnExist: true, force: false });
12359
+ fs8.rmSync(from, { recursive: true, force: true });
12360
+ }
12361
+ return;
12362
+ }
12363
+ if (fs8.lstatSync(from).isDirectory() && fs8.lstatSync(to).isDirectory())
12364
+ for (const name of fs8.readdirSync(from))
12365
+ merge(path9.join(from, name), path9.join(to, name));
12366
+ };
12367
+ for (const sub of ["tasks", "checkouts", "blobs", "legacy"]) {
12368
+ const from = path9.join(dirs.v2, sub);
12369
+ if (!fs8.existsSync(from))
12370
+ continue;
12371
+ if (sub === "tasks") {
12372
+ for (const name of fs8.readdirSync(from))
12373
+ if (validId(name) && !fs8.existsSync(path9.join(dirs.dir, sub, name)))
12374
+ tasks += 1;
12375
+ }
12376
+ merge(from, path9.join(dirs.dir, sub));
12377
+ }
12378
+ const ledger = path9.join(dirs.v2, "ledger");
12379
+ if (fs8.existsSync(ledger) && !fs8.existsSync(path9.join(dirs.dir, "ledger")))
12380
+ merge(ledger, path9.join(dirs.dir, "ledger"));
12381
+ fs8.rmSync(path9.join(dirs.v2, "task-index.json"), { force: true });
12382
+ fs8.renameSync(marker, path9.join(dirs.v2, "store.moved.json"));
12383
+ } catch (error) {
12384
+ return failure2("storage_error", `cannot move ${dirs.v2} into ${dirs.dir}: ${String(error)}`, {
12385
+ path: dirs.v2
12386
+ });
11237
12387
  }
11238
- retryTransient(() => fs6.renameSync(temporary, file));
11239
- temporary = undefined;
11240
- this.fsyncDirectory(path7.dirname(file));
11241
- if (path7.dirname(file) === this.tasksDir)
11242
- this.indexTaskWrite(file, value);
11243
- return success(null, null, value);
11244
- } catch (error) {
11245
- return failure2("storage_error", `snapshot replacement failed: ${String(error)}`, {
11246
- path: file
12388
+ reportMigration({
12389
+ from: dirs.v2,
12390
+ to: dirs.dir,
12391
+ backup: dirs.v2,
12392
+ tasks,
12393
+ skipped: 0,
12394
+ workspace: false
11247
12395
  });
11248
- } finally {
11249
- if (temporary)
11250
- try {
11251
- fs6.unlinkSync(temporary);
11252
- } catch {}
11253
- }
12396
+ return success(null, null, null);
12397
+ });
11254
12398
  }
11255
- readIndex(workspaceId) {
12399
+ readV2Workspace(file) {
12400
+ let bytes;
11256
12401
  try {
11257
- const value = JSON.parse(fs6.readFileSync(this.indexPath, "utf8"));
11258
- if (!isObject2(value) || value.version !== INDEX_VERSION || value.workspaceId !== workspaceId || !isObject2(value.tasks))
11259
- return {};
11260
- const tasks = {};
11261
- for (const [id, entry] of Object.entries(value.tasks))
11262
- if (isObject2(entry) && entry.id === id && typeof entry.file === "string" && typeof entry.status === "string" && typeof entry.updatedAt === "string" && Array.isArray(entry.sessions) && isObject2(entry.progress) && isObject2(entry.source))
11263
- tasks[id] = entry;
11264
- return tasks;
12402
+ bytes = fs8.readFileSync(file, "utf8");
11265
12403
  } catch {
11266
- return {};
12404
+ return null;
11267
12405
  }
12406
+ try {
12407
+ const value = JSON.parse(bytes);
12408
+ if (isObject4(value) && isObject4(value.store) && value.store.format === "workit-store")
12409
+ return TOMBSTONE;
12410
+ } catch {}
12411
+ return { bytes };
12412
+ }
12413
+ writeTombstone(v2, workspaceId) {
12414
+ const dirs = this.paths();
12415
+ const version = isNewerVersion(runtimeVersion(), STORE_MIN_RUNTIME) ? runtimeVersion() : STORE_MIN_RUNTIME;
12416
+ const marker = {
12417
+ schemaVersion: SCHEMA_VERSION,
12418
+ id: workspaceId ?? newId(),
12419
+ revision: newRevision(),
12420
+ root: path9.dirname(v2),
12421
+ runtime: { createdWith: version, updatedWith: version },
12422
+ writer: null,
12423
+ store: {
12424
+ format: "workit-store",
12425
+ version: STORE_FORMAT,
12426
+ path: dirs.dir,
12427
+ note: `workit ${STORE_MIN_RUNTIME}+ keeps task state in ${dirs.dir}; upgrade Workit`
12428
+ },
12429
+ critical: ["store"]
12430
+ };
12431
+ fs8.mkdirSync(v2, { recursive: true });
12432
+ const ignore = path9.join(v2, ".gitignore");
12433
+ if (!fs8.existsSync(ignore))
12434
+ fs8.writeFileSync(ignore, `*
12435
+ `);
12436
+ replaceFile(path9.join(v2, "workspace.json"), `${JSON.stringify(marker, null, 2)}
12437
+ `, true);
12438
+ }
12439
+ stubFor(taskId) {
12440
+ const version = isNewerVersion(runtimeVersion(), STORE_MIN_RUNTIME) ? runtimeVersion() : STORE_MIN_RUNTIME;
12441
+ return `${JSON.stringify({
12442
+ schemaVersion: SCHEMA_VERSION,
12443
+ id: taskId,
12444
+ runtime: { createdWith: version, updatedWith: version },
12445
+ store: { format: "workit-store", version: STORE_FORMAT, path: this.paths().dir },
12446
+ critical: ["store"]
12447
+ })}
12448
+ `;
11268
12449
  }
11269
- writeIndex(workspaceId, entries) {
11270
- const temporary = `${this.indexPath}.${process.pid}.${randomUUID2()}.tmp`;
12450
+ isStub(file) {
11271
12451
  try {
11272
- fs6.writeFileSync(temporary, JSON.stringify({
11273
- version: INDEX_VERSION,
11274
- workspaceId,
11275
- tasks: Object.fromEntries(entries.map((entry) => [entry.id, entry]))
11276
- }), { mode: 384, flag: "wx" });
11277
- retryTransient(() => fs6.renameSync(temporary, this.indexPath));
12452
+ const value = JSON.parse(fs8.readFileSync(file, "utf8"));
12453
+ return isObject4(value) && isObject4(value.store) && value.store.format === "workit-store";
11278
12454
  } catch {
11279
- try {
11280
- fs6.unlinkSync(temporary);
11281
- } catch {}
12455
+ return false;
11282
12456
  }
11283
12457
  }
11284
- indexTaskWrite(file, task) {
12458
+ writeTombstoneIfMissing() {
11285
12459
  try {
11286
- const signature = fileSignature(file);
11287
- if (signature === null)
12460
+ const v2 = path9.join(this.root, ".workit");
12461
+ if (fs8.existsSync(path9.join(v2, "workspace.json")))
11288
12462
  return;
11289
- const tasks = this.readIndex(task.workspaceId);
11290
- tasks[task.id] = indexEntry(task, signature);
11291
- this.writeIndex(task.workspaceId, Object.values(tasks));
11292
- } catch {}
11293
- }
11294
- saveRecovery(file, bytes) {
11295
- const target = path7.basename(file) === "workspace.json" ? "workspace" : "task";
11296
- const id = target === "task" ? path7.basename(file, ".json") : "workspace";
11297
- const destination = path7.join(this.recoveryDir, `${target}.${id}.${digestBytes(bytes)}.json`);
11298
- if (fs6.existsSync(destination)) {
11299
- if (digestBytes(retryTransient(() => fs6.readFileSync(destination))) !== digestBytes(bytes))
11300
- throw new Error("recovery copy already exists with different bytes");
11301
- const stamp = new Date;
11302
- retryTransient(() => fs6.utimesSync(destination, stamp, stamp));
11303
- this.pruneRecovery(`${target}.${id}.`, destination);
11304
- return;
11305
- }
11306
- let temporary;
11307
- try {
11308
- temporary = `${destination}.${process.pid}.${randomUUID2()}.tmp`;
11309
- const fd = fs6.openSync(temporary, "wx", 384);
12463
+ let id = null;
11310
12464
  try {
11311
- fs6.writeSync(fd, bytes);
11312
- fs6.fsyncSync(fd);
11313
- } finally {
11314
- fs6.closeSync(fd);
11315
- }
11316
- retryTransient(() => fs6.renameSync(temporary, destination));
11317
- temporary = undefined;
11318
- this.fsyncDirectory(this.recoveryDir);
11319
- this.pruneRecovery(`${target}.${id}.`, destination);
11320
- } finally {
11321
- if (temporary)
11322
- try {
11323
- fs6.unlinkSync(temporary);
11324
- } catch {}
11325
- }
11326
- }
11327
- pruneRecovery(prefix, keep) {
11328
- try {
11329
- const copies = this.recoveryCopies(prefix);
11330
- const surplus = this.surplusCopies(copies, path7.basename(keep));
11331
- for (const copy of surplus)
11332
- fs6.rmSync(copy.path, { force: true });
12465
+ id = JSON.parse(fs8.readFileSync(this.paths().workspace, "utf8")).id ?? null;
12466
+ } catch {}
12467
+ this.writeTombstone(v2, id);
11333
12468
  } catch {}
11334
12469
  }
11335
- recoveryCopies(prefix = "") {
11336
- if (!fs6.existsSync(this.recoveryDir))
11337
- return [];
11338
- const copies = [];
11339
- for (const name of fs6.readdirSync(this.recoveryDir)) {
11340
- if (!name.startsWith(prefix))
11341
- continue;
11342
- const match = RECOVERY_NAME.exec(name);
11343
- if (!match)
11344
- continue;
11345
- const file = path7.join(this.recoveryDir, name);
12470
+ initializeStorage() {
12471
+ const dirs = this.paths();
12472
+ retryTransient(() => fs8.mkdirSync(dirs.checkout, { recursive: true }));
12473
+ retryTransient(() => fs8.mkdirSync(dirs.tasks, { recursive: true }));
12474
+ if (!fs8.existsSync(dirs.marker))
12475
+ replaceFile(dirs.marker, `${JSON.stringify({ format: "workit-store", version: STORE_FORMAT, createdWith: runtimeVersion() })}
12476
+ `, false);
12477
+ if (!dirs.shared) {
12478
+ const ignore = path9.join(dirs.dir, ".gitignore");
12479
+ let current = null;
11346
12480
  try {
11347
- const stat = fs6.lstatSync(file, { bigint: true });
11348
- if (!stat.isFile())
11349
- continue;
11350
- copies.push({ name, path: file, group: `${match[1]}.${match[2]}`, mtimeNs: stat.mtimeNs });
12481
+ current = fs8.readFileSync(ignore, "utf8");
11351
12482
  } catch {}
12483
+ if (current !== `*
12484
+ `)
12485
+ retryTransient(() => fs8.writeFileSync(ignore, `*
12486
+ `));
11352
12487
  }
11353
- return copies;
11354
- }
11355
- surplusCopies(copies, keep) {
11356
- const ordered = copies.toSorted((left, right) => left.name === keep ? -1 : right.name === keep ? 1 : left.mtimeNs === right.mtimeNs ? left.name.localeCompare(right.name) : left.mtimeNs > right.mtimeNs ? -1 : 1);
11357
- return ordered.slice(RECOVERY_COPIES_PER_RECORD);
12488
+ this.writeTombstoneIfMissing();
11358
12489
  }
11359
- collectGarbage(options = {}) {
11360
- const dryRun = options.dryRun === true;
11361
- const report = {
11362
- dryRun,
11363
- recovery: { removed: 0, removedBytes: 0, kept: 0 },
11364
- temporary: { removed: 0 },
11365
- candidates: { removed: 0, tasks: [], skippedActive: [], skippedClosed: [], failed: [] }
12490
+ lockOptions(lockPath) {
12491
+ const inProcess = heldInProcess.has(lockPath);
12492
+ return {
12493
+ lockPath,
12494
+ staleMs: Number.MAX_SAFE_INTEGER,
12495
+ timeoutMs: inProcess ? 0 : this.lockTimeoutMs,
12496
+ retry: inProcess ? { retries: 0 } : { minTimeout: 5, maxTimeout: 100, factor: 1.5, randomize: true },
12497
+ staleRecovery: "remove-if-unchanged",
12498
+ shouldReclaim: ({ payload, nowMs }) => {
12499
+ let ageMs = null;
12500
+ try {
12501
+ ageMs = nowMs - fs8.lstatSync(lockPath).mtimeMs;
12502
+ } catch {}
12503
+ return classifyLockOwner(payload, ageMs).state === "stale";
12504
+ },
12505
+ shouldRemoveStaleLock: () => true,
12506
+ parsePayload: parseMetadataLockOrNull,
12507
+ payload: () => ({
12508
+ pid: process.pid,
12509
+ processStart: processStartOf(process.pid),
12510
+ host: localLockHost(),
12511
+ nonce: randomUUID3()
12512
+ })
11366
12513
  };
11367
- if (!fs6.existsSync(this.workitDir))
11368
- return success(null, null, report);
11369
- const sweep = () => {
12514
+ }
12515
+ acquire(lockPath, options) {
12516
+ clearAbandonedReclaimGuard(lockPath);
12517
+ const deadline = Date.now() + (options.timeoutMs ?? 0);
12518
+ while (true) {
11370
12519
  try {
11371
- const groups = new Map;
11372
- for (const copy of this.recoveryCopies())
11373
- groups.set(copy.group, [...groups.get(copy.group) ?? [], copy]);
11374
- for (const copies of groups.values()) {
11375
- const surplus = this.surplusCopies(copies);
11376
- report.recovery.kept += copies.length - surplus.length;
11377
- for (const copy of surplus) {
11378
- report.recovery.removed += 1;
11379
- try {
11380
- report.recovery.removedBytes += fs6.statSync(copy.path).size;
11381
- } catch {}
11382
- if (!dryRun)
11383
- fs6.rmSync(copy.path, { force: true });
11384
- }
11385
- }
11386
- const nowMs = Date.now();
11387
- for (const directory of [this.recoveryDir, this.tasksDir, this.workitDir]) {
11388
- if (!fs6.existsSync(directory))
11389
- continue;
11390
- for (const name of fs6.readdirSync(directory)) {
11391
- if (!name.endsWith(".tmp") && !name.endsWith(".probe"))
11392
- continue;
11393
- const file = path7.join(directory, name);
11394
- const stat = fs6.lstatSync(file);
11395
- if (!stat.isFile() || nowMs - stat.mtimeMs <= STALE_TEMPORARY_MS)
11396
- continue;
11397
- report.temporary.removed += 1;
11398
- if (!dryRun)
11399
- fs6.rmSync(file, { force: true });
11400
- }
11401
- }
11402
- return success(null, null, null);
11403
- } catch (error) {
11404
- return failure2("storage_error", `garbage collection failed: ${String(error)}`, {
11405
- path: this.recoveryDir
12520
+ return acquireFileLockSync(lockPath, {
12521
+ ...options,
12522
+ timeoutMs: Math.max(0, deadline - Date.now())
11406
12523
  });
12524
+ } catch (error) {
12525
+ const code = error?.code;
12526
+ if (code !== "file_lock_stale" && !isTransientWindowsError(error) || Date.now() >= deadline)
12527
+ throw error;
12528
+ if (code !== "file_lock_stale" && !this.lockHolderPresent(lockPath) && !this.lockDirWritable(lockPath))
12529
+ throw error;
12530
+ if (code !== "file_lock_stale")
12531
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
11407
12532
  }
11408
- };
11409
- const pruned = dryRun ? sweep() : this.withLock(sweep);
11410
- if (!pruned.ok)
11411
- return pruned;
11412
- const tasks = this.listTasks();
11413
- if (!tasks.ok)
11414
- return tasks;
11415
- for (const task of tasks.data) {
11416
- const deduped = dedupeCandidates(task.candidates);
11417
- const removed = task.candidates.length - deduped.length;
11418
- if (removed === 0)
11419
- continue;
11420
- if (task.status === "active") {
11421
- report.candidates.skippedActive.push(task.id);
11422
- continue;
11423
- }
11424
- if (task.status === "closed") {
11425
- report.candidates.skippedClosed.push(task.id);
11426
- continue;
11427
- }
11428
- if (!dryRun) {
11429
- const written = this.mutateTask(task.id, task.revision, (current) => success(current.revision, null, {
11430
- ...current,
11431
- candidates: dedupeCandidates(current.candidates)
11432
- }));
11433
- if (!written.ok) {
11434
- report.candidates.failed.push(task.id);
11435
- continue;
11436
- }
11437
- }
11438
- report.candidates.removed += removed;
11439
- report.candidates.tasks.push(task.id);
11440
12533
  }
11441
- return success(null, null, report);
11442
12534
  }
11443
- findRecovery(target, taskId, digest, workspace) {
11444
- if (!fs6.existsSync(this.recoveryDir))
11445
- return null;
11446
- const workspaceId = workspace?.id ?? this.trustedWorkspaceId();
11447
- if (target === "workspace" && !workspaceId)
11448
- return null;
11449
- const candidates = this.recoveryCandidates();
11450
- if (!candidates.ok)
11451
- return null;
11452
- for (const candidate of candidates.data) {
11453
- if (candidate.target !== target || candidate.digest !== digest)
11454
- continue;
11455
- try {
11456
- if (!fs6.lstatSync(candidate.path).isFile())
11457
- continue;
11458
- const bytes = fs6.readFileSync(candidate.path);
11459
- if (digestBytes(bytes) !== digest)
11460
- continue;
11461
- if (target === "workspace") {
11462
- const parsed = this.parseBytes(bytes, workspaceRecordSchema);
11463
- if (parsed.ok && parsed.data.id === workspaceId && parsed.data.root === this.root)
11464
- return bytes;
11465
- } else {
11466
- const parsed = this.parseBytes(bytes, taskRecordSchema);
11467
- if (parsed.ok && parsed.data.id === taskId && parsed.data.workspaceId === workspace?.id)
11468
- return bytes;
11469
- }
11470
- } catch {}
12535
+ withLock(lockPath, operation) {
12536
+ try {
12537
+ this.initializeStorage();
12538
+ fs8.mkdirSync(path9.dirname(lockPath), { recursive: true });
12539
+ } catch (error) {
12540
+ return failure2("storage_error", `unable to initialize store: ${String(error)}`, {
12541
+ path: this.paths().dir
12542
+ });
11471
12543
  }
11472
- return null;
11473
- }
11474
- fsyncDirectory(directory) {
12544
+ let handle;
11475
12545
  try {
11476
- const fd = fs6.openSync(directory, "r");
11477
- try {
11478
- fs6.fsyncSync(fd);
11479
- } finally {
11480
- fs6.closeSync(fd);
11481
- }
12546
+ handle = this.acquire(lockPath, this.lockOptions(lockPath));
11482
12547
  } catch (error) {
11483
- if (process.platform !== "win32")
11484
- throw error;
12548
+ return this.lockFailure(lockPath, error);
11485
12549
  }
11486
- }
11487
- trustedWorkspaceId() {
11488
- if (!fs6.existsSync(this.tasksDir))
11489
- return null;
11490
- const ids = new Set;
12550
+ hold(lockPath);
12551
+ let result;
11491
12552
  try {
11492
- for (const name of fs6.readdirSync(this.tasksDir)) {
11493
- if (!name.endsWith(".json") || !validId(name.slice(0, -5)))
11494
- return null;
11495
- const parsed = this.parseBytes(fs6.readFileSync(path7.join(this.tasksDir, name)), taskRecordSchema);
11496
- if (!parsed.ok || parsed.data.id !== name.slice(0, -5))
11497
- return null;
11498
- ids.add(parsed.data.workspaceId);
12553
+ if (!handle.verifyStillHeld())
12554
+ result = failure2("recovery_required", "metadata lock was compromised", { path: lockPath });
12555
+ else {
12556
+ try {
12557
+ result = operation();
12558
+ } catch (error) {
12559
+ result = failure2("storage_error", `mutation failed: ${String(error)}`);
12560
+ }
11499
12561
  }
11500
- } catch {
11501
- return null;
12562
+ } catch (error) {
12563
+ result = this.lockFailure(lockPath, error);
12564
+ }
12565
+ drop(lockPath);
12566
+ try {
12567
+ retryTransient(() => handle.release());
12568
+ } catch (error) {
12569
+ const released = this.lockFailure(lockPath, error);
12570
+ const releaseError = released.ok ? "metadata lock release failed" : released.error;
12571
+ result = result.ok ? released : failure2("recovery_required", `${result.error}; ${releaseError}`, result.details);
11502
12572
  }
11503
- return ids.size === 1 ? [...ids][0] : null;
12573
+ return result;
11504
12574
  }
11505
- snapshotBytes(file) {
12575
+ lockDirWritable(lockPath) {
12576
+ const probe = `${lockPath}.${process.pid}.${randomUUID3()}.probe`;
11506
12577
  try {
11507
- return retryTransient(() => fs6.readFileSync(file));
12578
+ fs8.closeSync(fs8.openSync(probe, "wx", 384));
11508
12579
  } catch {
11509
- return null;
12580
+ return false;
11510
12581
  }
12582
+ try {
12583
+ fs8.rmSync(probe, { force: true });
12584
+ } catch {}
12585
+ return true;
11511
12586
  }
11512
- readRecord(file, schema) {
12587
+ lockHolderPresent(lockPath) {
11513
12588
  try {
11514
- const bytes = retryTransient(() => fs6.readFileSync(file, "utf8"));
11515
- return { exists: true, result: this.parseBytes(bytes, schema) };
12589
+ fs8.lstatSync(lockPath);
12590
+ return true;
11516
12591
  } catch (error) {
11517
- if (error?.code === "ENOENT")
11518
- return { exists: false, result: success(null, null, null) };
11519
- return {
11520
- exists: true,
11521
- result: failure2("recovery_required", `snapshot cannot be read: ${String(error)}`, {
11522
- path: file
11523
- })
11524
- };
12592
+ return error?.code !== "ENOENT";
11525
12593
  }
11526
12594
  }
11527
- parseBytes(bytes, schema, mode = "read") {
11528
- let value;
11529
- try {
11530
- value = JSON.parse(typeof bytes === "string" ? bytes : bytes.toString("utf8"));
11531
- } catch {
11532
- return failure2("recovery_required", "snapshot JSON is corrupt");
12595
+ lockFailure(lockPath, error) {
12596
+ const value = error;
12597
+ const code = typeof value?.code === "string" ? value.code : "";
12598
+ if (isTransientWindowsError(error) && !this.lockHolderPresent(lockPath) && !this.lockDirWritable(lockPath))
12599
+ return failure2("storage_error", `cannot create the workit lock (${code}); no other Workit call holds it`, {
12600
+ path: lockPath,
12601
+ guidance: `Check permissions and the read-only attribute on ${path9.dirname(lockPath)}.`
12602
+ });
12603
+ if (code === "EEXIST" || code === "file_lock_timeout" || code === "file_lock_stale" || isTransientWindowsError(error)) {
12604
+ let lock = null;
12605
+ try {
12606
+ lock = parseMetadataLockOrNull(fs8.readFileSync(lockPath, "utf8"));
12607
+ } catch {}
12608
+ if (lock?.externalAction)
12609
+ return failure2("writer_conflict", "workspace is reserved by a managed external action", {
12610
+ outcome: "not_started",
12611
+ path: lockPath
12612
+ });
12613
+ return failure2("busy", `workit lock is held by another Workit call${lock ? ` (pid ${lock.pid} on ${lock.host})` : ""}; retry shortly`, {
12614
+ outcome: "not_started",
12615
+ path: lockPath,
12616
+ guidance: "Retry the same call. If it stays busy, run `workit doctor --fix-lock` to clear a lock left by a dead process."
12617
+ });
11533
12618
  }
11534
- if (isObject2(value) && "schemaVersion" in value && value.schemaVersion !== SCHEMA_VERSION)
11535
- return failure2("unsupported_version", "unsupported snapshot schema version");
11536
- const parsed = parseStoredRecord(schema, value);
11537
- if (parsed.success && (mode === "read" || parsed.stripped.length === 0))
11538
- return success(null, null, parsed.data);
11539
- const writerVersion = isObject2(value) && isObject2(value.runtime) ? value.runtime.updatedWith : null;
11540
- const upgrade = `upgrade Workit before ${parsed.success ? "recovering" : "mutating"} this checkout`;
11541
- if (parsed.success)
11542
- return failure2("recovery_required", `snapshot carries fields this Workit cannot preserve (${parsed.stripped.join(", ")}); ${upgrade}`);
11543
- if (parsed.critical.length > 0)
11544
- return failure2("recovery_required", `snapshot requires fields this Workit cannot read (${parsed.critical.join(", ")}); ${upgrade}`);
11545
- if (typeof writerVersion === "string" && isNewerVersion(writerVersion, runtimeVersion()))
11546
- return failure2("recovery_required", `snapshot was written by workit ${writerVersion}; upgrade Workit before mutating this checkout`);
11547
- return failure2("recovery_required", "snapshot does not satisfy its schema");
11548
- }
11549
- conflict(expected, actual) {
11550
- return failure2("revision_conflict", "snapshot revision does not match; omit expectedRevision to use the current record", {
11551
- expectedRevision: expected,
11552
- actualRevision: actual
11553
- });
11554
- }
11555
- workspaceConflict(expected, actual) {
11556
- return failure2("revision_conflict", "workspace revision does not match; omit expectedWorkspaceRevision to use the current record", { expectedWorkspaceRevision: expected, actualWorkspaceRevision: actual });
11557
- }
11558
- taskPath(taskId) {
11559
- return path7.join(this.tasksDir, `${taskId}.json`);
11560
- }
11561
- get workitDir() {
11562
- return path7.join(this.root, ".workit");
11563
- }
11564
- get tasksDir() {
11565
- return path7.join(this.workitDir, "tasks");
11566
- }
11567
- get recoveryDir() {
11568
- return path7.join(this.workitDir, "recovery");
11569
- }
11570
- get workspacePath() {
11571
- return path7.join(this.workitDir, "workspace.json");
11572
- }
11573
- get indexPath() {
11574
- return path7.join(this.workitDir, "index.json");
11575
- }
11576
- get lockPath() {
11577
- return path7.join(this.workitDir, "metadata.lock");
11578
- }
11579
- get gitignorePath() {
11580
- return path7.join(this.workitDir, ".gitignore");
12619
+ const recovery = code === "metadata_lock_invalid" || code === "not-file" || /metadata lock|file lock|reclaim/i.test(String(value?.message ?? error));
12620
+ return failure2(recovery ? "recovery_required" : "storage_error", `lock operation failed: ${String(error)}`, { path: lockPath });
11581
12621
  }
11582
12622
  }
11583
12623
 
@@ -11654,7 +12694,7 @@ var operationAutoClass = (operation) => typeof operation === "string" ? OPERATIO
11654
12694
  var digestOf = (configDirPath) => {
11655
12695
  try {
11656
12696
  const raw = readFileSync4(`${configDirPath}/workspaces.json`, "utf8");
11657
- return createHash5("sha256").update(raw).digest("hex");
12697
+ return createHash7("sha256").update(raw).digest("hex");
11658
12698
  } catch {
11659
12699
  return null;
11660
12700
  }
@@ -11698,7 +12738,7 @@ var targetOf = (root, descriptor) => {
11698
12738
  const cwd = parsed?.payload?.cwd;
11699
12739
  if (cwd === undefined)
11700
12740
  return root;
11701
- if (typeof cwd !== "string" || !path8.isAbsolute(cwd))
12741
+ if (typeof cwd !== "string" || !path10.isAbsolute(cwd))
11702
12742
  return null;
11703
12743
  return realpathSync3(cwd);
11704
12744
  } catch {
@@ -11923,11 +12963,11 @@ function applicableDecision(task, purpose, binding, checkoutRoot) {
11923
12963
  return [];
11924
12964
  return task.decisions.filter((entry) => entry.provenance.kind !== "imported" && decisionMatches(entry, purpose, binding) && (checkoutRoot ? verifyDecisionContentAtRoot(checkoutRoot, entry.data.binding).ok : entry.data.binding.contentRefs.every((reference) => reference.kind !== "file"))).map((entry) => entry.data);
11925
12965
  }
11926
- var digestBytes2 = (bytes) => createHash6("sha256").update(bytes).digest("hex");
12966
+ var digestBytes = (bytes) => createHash8("sha256").update(bytes).digest("hex");
11927
12967
  var verifyDecisionContentAtRoot = (checkoutRoot, binding) => {
11928
12968
  let root;
11929
12969
  try {
11930
- root = fs7.realpathSync(checkoutRoot);
12970
+ root = fs9.realpathSync(checkoutRoot);
11931
12971
  } catch {
11932
12972
  return failure2("permission_denied", "checkout root is unavailable");
11933
12973
  }
@@ -11936,23 +12976,23 @@ var verifyDecisionContentAtRoot = (checkoutRoot, binding) => {
11936
12976
  continue;
11937
12977
  if (!reference.digest)
11938
12978
  return failure2("invalid_input", "document references require a byte digest");
11939
- const target = path9.resolve(root, reference.path);
11940
- if (target !== root && !target.startsWith(`${root}${path9.sep}`))
12979
+ const target = path11.resolve(root, reference.path);
12980
+ if (target !== root && !target.startsWith(`${root}${path11.sep}`))
11941
12981
  return failure2("invalid_input", "document reference escapes checkout; cite it as an external file:// reference instead", {
11942
12982
  fields: [
11943
12983
  { path: "contentRefs.path", reason: `${reference.path} is outside the checkout` }
11944
12984
  ]
11945
12985
  });
11946
12986
  try {
11947
- const relative = path9.relative(root, target);
12987
+ const relative = path11.relative(root, target);
11948
12988
  let current = root;
11949
- for (const segment of relative.split(path9.sep)) {
12989
+ for (const segment of relative.split(path11.sep)) {
11950
12990
  if (!segment)
11951
12991
  continue;
11952
- current = path9.join(current, segment);
11953
- const stat = fs7.lstatSync(current);
11954
- const real = fs7.realpathSync(current);
11955
- if (real !== root && !real.startsWith(`${root}${path9.sep}`))
12992
+ current = path11.join(current, segment);
12993
+ const stat = fs9.lstatSync(current);
12994
+ const real = fs9.realpathSync(current);
12995
+ if (real !== root && !real.startsWith(`${root}${path11.sep}`))
11956
12996
  return failure2("invalid_input", "document reference escapes checkout; cite it as an external file:// reference instead", {
11957
12997
  fields: [
11958
12998
  {
@@ -11968,7 +13008,7 @@ var verifyDecisionContentAtRoot = (checkoutRoot, binding) => {
11968
13008
  if (current !== target && !stat.isDirectory())
11969
13009
  return failure2("invalid_input", "document reference ancestor is not a directory");
11970
13010
  }
11971
- if (digestBytes2(fs7.readFileSync(target)) !== reference.digest)
13011
+ if (digestBytes(fs9.readFileSync(target)) !== reference.digest)
11972
13012
  return failure2("permission_denied", "approved document bytes have changed");
11973
13013
  } catch {
11974
13014
  return failure2("invalid_input", "approved document is unavailable");
@@ -12332,9 +13372,9 @@ var bindingCovers = scopeCovers;
12332
13372
  var verifyDecisionContent = verifyContentRefs;
12333
13373
 
12334
13374
  // packages/workit-core/src/check-config.ts
12335
- import { spawnSync as spawnSync3 } from "node:child_process";
12336
- import fs8 from "node:fs";
12337
- import path10 from "node:path";
13375
+ import { spawnSync as spawnSync4 } from "node:child_process";
13376
+ import fs10 from "node:fs";
13377
+ import path12 from "node:path";
12338
13378
  var SCRIPT_CHECKS = ["test", "lint", "typecheck", "check"];
12339
13379
  var CHECKS_FILE = "workit.checks.json";
12340
13380
  var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -12376,7 +13416,7 @@ function splitCommand(command) {
12376
13416
  return argv.length ? argv : new Error("empty command");
12377
13417
  }
12378
13418
  var git = (cwd, args) => {
12379
- const run = spawnSync3("git", args, {
13419
+ const run = spawnSync4("git", args, {
12380
13420
  cwd,
12381
13421
  encoding: "utf8",
12382
13422
  timeout: 20000,
@@ -12387,10 +13427,10 @@ var git = (cwd, args) => {
12387
13427
  return value || null;
12388
13428
  };
12389
13429
  function checkRoot(cwd) {
12390
- return path10.resolve(git(cwd, ["rev-parse", "--show-toplevel"]) ?? cwd);
13430
+ return path12.resolve(git(cwd, ["rev-parse", "--show-toplevel"]) ?? cwd);
12391
13431
  }
12392
13432
  var packageManager = (root) => {
12393
- const has = (file) => fs8.existsSync(path10.join(root, file));
13433
+ const has = (file) => fs10.existsSync(path12.join(root, file));
12394
13434
  if (has("bun.lock") || has("bun.lockb"))
12395
13435
  return "bun";
12396
13436
  if (has("pnpm-lock.yaml"))
@@ -12410,7 +13450,7 @@ var CHECK_NAME = /^[a-z0-9][a-z0-9:._-]{0,63}$/u;
12410
13450
  function detectedChecks(root) {
12411
13451
  const read = (file) => {
12412
13452
  try {
12413
- return fs8.readFileSync(path10.join(root, file), "utf8");
13453
+ return fs10.readFileSync(path12.join(root, file), "utf8");
12414
13454
  } catch {
12415
13455
  return null;
12416
13456
  }
@@ -12449,7 +13489,7 @@ function detectedChecks(root) {
12449
13489
  var GATES = ["testing", "verification"];
12450
13490
  function loadCheckConfig(cwd) {
12451
13491
  const root = checkRoot(cwd);
12452
- const file = path10.join(root, CHECKS_FILE);
13492
+ const file = path12.join(root, CHECKS_FILE);
12453
13493
  const none = (error = null) => ({
12454
13494
  source: error ? CHECKS_FILE : "none",
12455
13495
  root,
@@ -12459,7 +13499,7 @@ function loadCheckConfig(cwd) {
12459
13499
  });
12460
13500
  let text = null;
12461
13501
  try {
12462
- text = fs8.readFileSync(file, "utf8");
13502
+ text = fs10.readFileSync(file, "utf8");
12463
13503
  } catch (error) {
12464
13504
  if (error.code !== "ENOENT")
12465
13505
  return none(`cannot read ${CHECKS_FILE}: ${error.message}`);
@@ -12522,10 +13562,10 @@ function workitAvailable(env = process.env) {
12522
13562
  return workitOnPath;
12523
13563
  const windows = process.platform === "win32";
12524
13564
  const exts = windows ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
12525
- const dirs = (env.PATH ?? env.Path ?? "").split(path10.delimiter).filter(Boolean);
13565
+ const dirs = (env.PATH ?? env.Path ?? "").split(path12.delimiter).filter(Boolean);
12526
13566
  workitOnPath = dirs.some((dir) => exts.some((ext) => {
12527
13567
  try {
12528
- return fs8.statSync(path10.join(dir, `workit${ext}`)).isFile();
13568
+ return fs10.statSync(path12.join(dir, `workit${ext}`)).isFile();
12529
13569
  } catch {
12530
13570
  return false;
12531
13571
  }
@@ -12537,15 +13577,15 @@ function checkCommand(args, version, onPath = workitAvailable()) {
12537
13577
  }
12538
13578
 
12539
13579
  // packages/workit-core/src/git/rev.ts
12540
- import { spawnSync as spawnSync4 } from "node:child_process";
12541
- import { createHash as createHash7 } from "node:crypto";
12542
- import fs9 from "node:fs";
13580
+ import { spawnSync as spawnSync5 } from "node:child_process";
13581
+ import { createHash as createHash9 } from "node:crypto";
13582
+ import fs11 from "node:fs";
12543
13583
  import os2 from "node:os";
12544
- import path11 from "node:path";
13584
+ import path13 from "node:path";
12545
13585
  var MAX_BUFFER = 256 * 1024 * 1024;
12546
13586
  var GIT_TIMEOUTS = { local: 20000, worktree: 120000, network: 15000 };
12547
13587
  var git2 = (cwd, args, options = {}) => {
12548
- const result = spawnSync4("git", args, {
13588
+ const result = spawnSync5("git", args, {
12549
13589
  cwd,
12550
13590
  encoding: "utf8",
12551
13591
  env: options.env ?? process.env,
@@ -12575,10 +13615,10 @@ function headSha(cwd) {
12575
13615
  var emptyTree = (cwd) => line(git2(cwd, ["mktree"], { input: "" }));
12576
13616
  var MAX_UNTRACKED_BYTES = 5 * 1024 * 1024;
12577
13617
  var seedIndex = (from, to) => {
12578
- fs9.copyFileSync(from, to);
12579
- const { atimeNs, mtimeNs } = fs9.statSync(from, { bigint: true });
13618
+ fs11.copyFileSync(from, to);
13619
+ const { atimeNs, mtimeNs } = fs11.statSync(from, { bigint: true });
12580
13620
  const seconds = (ns) => (Number(ns / 1000n) - 1) / 1e6;
12581
- fs9.utimesSync(to, seconds(atimeNs), seconds(mtimeNs));
13621
+ fs11.utimesSync(to, seconds(atimeNs), seconds(mtimeNs));
12582
13622
  };
12583
13623
  function worktreeTree(cwd, options = {}) {
12584
13624
  const top = line(git2(cwd, ["rev-parse", "--show-toplevel"]));
@@ -12587,10 +13627,10 @@ function worktreeTree(cwd, options = {}) {
12587
13627
  return null;
12588
13628
  const cap = options.maxUntrackedBytes ?? MAX_UNTRACKED_BYTES;
12589
13629
  const timeoutMs = options.timeoutMs ?? GIT_TIMEOUTS.worktree;
12590
- const scratch = fs9.mkdtempSync(path11.join(os2.tmpdir(), "workit-tree-"));
13630
+ const scratch = fs11.mkdtempSync(path13.join(os2.tmpdir(), "workit-tree-"));
12591
13631
  try {
12592
- const tempIndex = path11.join(scratch, "index");
12593
- if (fs9.existsSync(indexPath))
13632
+ const tempIndex = path13.join(scratch, "index");
13633
+ if (fs11.existsSync(indexPath))
12594
13634
  seedIndex(indexPath, tempIndex);
12595
13635
  const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
12596
13636
  const untracked = git2(top, ["ls-files", "--others", "--exclude-standard", "-z"], {
@@ -12602,7 +13642,7 @@ function worktreeTree(cwd, options = {}) {
12602
13642
  const skipped = [];
12603
13643
  for (const file of untracked.stdout.split("\x00").filter(Boolean)) {
12604
13644
  try {
12605
- const stat = fs9.statSync(path11.join(top, file));
13645
+ const stat = fs11.statSync(path13.join(top, file));
12606
13646
  if (stat.isFile() && stat.size > cap)
12607
13647
  skipped.push({ path: file, size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) });
12608
13648
  } catch {}
@@ -12619,10 +13659,10 @@ function worktreeTree(cwd, options = {}) {
12619
13659
  if (!tree)
12620
13660
  return null;
12621
13661
  const base = line(git2(top, ["rev-parse", "--verify", "-q", "HEAD^{tree}"])) ?? emptyTree(top);
12622
- const key = skipped.length ? `sha256:${createHash7("sha256").update(JSON.stringify({ tree, skipped })).digest("hex")}` : tree;
13662
+ const key = skipped.length ? `sha256:${createHash9("sha256").update(JSON.stringify({ tree, skipped })).digest("hex")}` : tree;
12623
13663
  return { tree, key, dirty: tree !== base || skipped.length > 0, skipped };
12624
13664
  } finally {
12625
- fs9.rmSync(scratch, { recursive: true, force: true });
13665
+ fs11.rmSync(scratch, { recursive: true, force: true });
12626
13666
  }
12627
13667
  }
12628
13668
  function worktreeSignal(cwd, options = {}) {
@@ -12642,16 +13682,16 @@ function worktreeSignal(cwd, options = {}) {
12642
13682
  });
12643
13683
  if (!status.ok)
12644
13684
  return null;
12645
- const hash = createHash7("sha256");
13685
+ const hash = createHash9("sha256");
12646
13686
  hash.update(`${headSha(top) ?? "unborn"}\x00`);
12647
13687
  for (const entry of status.stdout.split("\x00")) {
12648
13688
  if (entry.length < 4)
12649
13689
  continue;
12650
13690
  const file = entry.slice(3);
12651
- const absolute = path11.join(top, file);
13691
+ const absolute = path13.join(top, file);
12652
13692
  let stat = "-";
12653
13693
  try {
12654
- const info = fs9.lstatSync(absolute, { bigint: true });
13694
+ const info = fs11.lstatSync(absolute, { bigint: true });
12655
13695
  stat = `${info.mode.toString(8)}:${info.size}:${info.mtimeNs}`;
12656
13696
  if (info.isDirectory())
12657
13697
  stat += `:${headSha(absolute) ?? "-"}`;
@@ -12662,15 +13702,15 @@ function worktreeSignal(cwd, options = {}) {
12662
13702
  }
12663
13703
 
12664
13704
  // packages/workit-core/src/core/task-evaluation.ts
12665
- var digestBytes3 = (value) => createHash8("sha256").update(value).digest("hex");
12666
- var inside = (root, candidate) => candidate === root || candidate.startsWith(`${root}${path12.sep}`);
12667
- var relative = (root, candidate) => path12.relative(root, candidate).split(path12.sep).join("/") || ".";
13705
+ var digestBytes2 = (value) => createHash10("sha256").update(value).digest("hex");
13706
+ var inside = (root, candidate) => candidate === root || candidate.startsWith(`${root}${path14.sep}`);
13707
+ var relative = (root, candidate) => path14.relative(root, candidate).split(path14.sep).join("/") || ".";
12668
13708
  var canonicalPath = (value) => {
12669
13709
  if (typeof value !== "string" || !value)
12670
13710
  return null;
12671
13711
  if (value.includes("\\") || value.split("/").includes(".."))
12672
13712
  return null;
12673
- const normalized = path12.posix.normalize(value);
13713
+ const normalized = path14.posix.normalize(value);
12674
13714
  if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/"))
12675
13715
  return null;
12676
13716
  return normalized === "." ? "." : normalized.replace(/\/$/, "");
@@ -12706,7 +13746,7 @@ var compareCodeUnits2 = (left, right) => {
12706
13746
  return left.length - right.length;
12707
13747
  };
12708
13748
  var gitPaths = (root) => {
12709
- const result = spawnSync5("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
13749
+ const result = spawnSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
12710
13750
  cwd: root,
12711
13751
  encoding: "buffer"
12712
13752
  });
@@ -12718,21 +13758,21 @@ var gitPaths = (root) => {
12718
13758
  git: !/not a git repository/i.test(stderr)
12719
13759
  };
12720
13760
  }
12721
- const paths = result.stdout ? result.stdout.toString("utf8").split("\x00").filter(Boolean).map((item) => item.split(path12.sep).join("/")) : [];
12722
- const stagedDeleted = spawnSync5("git", ["diff", "--cached", "--name-only", "--diff-filter=D", "-z"], { cwd: root, encoding: "buffer" });
13761
+ const paths = result.stdout ? result.stdout.toString("utf8").split("\x00").filter(Boolean).map((item) => item.split(path14.sep).join("/")) : [];
13762
+ const stagedDeleted = spawnSync6("git", ["diff", "--cached", "--name-only", "--diff-filter=D", "-z"], { cwd: root, encoding: "buffer" });
12723
13763
  if (stagedDeleted.status !== 0) {
12724
13764
  const stderr = stagedDeleted.stderr?.toString("utf8") ?? "";
12725
13765
  if (!/not a git repository/i.test(stderr))
12726
13766
  return { paths, uncertain: true, git: true };
12727
13767
  } else if (stagedDeleted.stdout) {
12728
- paths.push(...stagedDeleted.stdout.toString("utf8").split("\x00").filter(Boolean).map((item) => item.split(path12.sep).join("/")));
13768
+ paths.push(...stagedDeleted.stdout.toString("utf8").split("\x00").filter(Boolean).map((item) => item.split(path14.sep).join("/")));
12729
13769
  }
12730
13770
  return { paths: [...new Set(paths)], uncertain: false, git: true };
12731
13771
  };
12732
13772
  var walk = (root, directory, output) => {
12733
13773
  let names;
12734
13774
  try {
12735
- names = fs10.readdirSync(directory);
13775
+ names = fs12.readdirSync(directory);
12736
13776
  } catch {
12737
13777
  return true;
12738
13778
  }
@@ -12740,10 +13780,10 @@ var walk = (root, directory, output) => {
12740
13780
  for (const name of names) {
12741
13781
  if (name === ".git" || name === ".workit")
12742
13782
  continue;
12743
- const target = path12.join(directory, name);
13783
+ const target = path14.join(directory, name);
12744
13784
  let stat;
12745
13785
  try {
12746
- stat = fs10.lstatSync(target);
13786
+ stat = fs12.lstatSync(target);
12747
13787
  } catch {
12748
13788
  uncertain = true;
12749
13789
  continue;
@@ -12758,14 +13798,14 @@ var walk = (root, directory, output) => {
12758
13798
  var scopeRoots = (root, scope) => {
12759
13799
  const roots = [];
12760
13800
  for (const item of scope.paths.length ? scope.paths : ["."]) {
12761
- const target = path12.resolve(root, item);
13801
+ const target = path14.resolve(root, item);
12762
13802
  if (!inside(root, target))
12763
13803
  return failure2("invalid_input", `candidate scope escapes checkout: ${item} — keep one task per repository and coordinate a separate linked task in the other checkout`, { fields: [{ path: "scope.paths", reason: `${item} is outside the checkout` }] });
12764
13804
  let ancestor = target;
12765
- while (!fs10.existsSync(ancestor) && ancestor !== root)
12766
- ancestor = path12.dirname(ancestor);
13805
+ while (!fs12.existsSync(ancestor) && ancestor !== root)
13806
+ ancestor = path14.dirname(ancestor);
12767
13807
  try {
12768
- if (!inside(root, fs10.realpathSync(ancestor)))
13808
+ if (!inside(root, fs12.realpathSync(ancestor)))
12769
13809
  return failure2("invalid_input", `candidate scope escapes checkout through a symlink: ${item} — keep one task per repository and coordinate a separate linked task in the other checkout`, { fields: [{ path: "scope.paths", reason: `${item} resolves outside the checkout` }] });
12770
13810
  } catch {
12771
13811
  return failure2("invalid_input", `candidate scope cannot be inspected: ${item}`);
@@ -12777,7 +13817,7 @@ var scopeRoots = (root, scope) => {
12777
13817
  function captureCandidate(root, scope, environment = []) {
12778
13818
  let checkout;
12779
13819
  try {
12780
- checkout = fs10.realpathSync(root);
13820
+ checkout = fs12.realpathSync(root);
12781
13821
  } catch {
12782
13822
  return failure2("invalid_input", "candidate root does not exist");
12783
13823
  }
@@ -12795,11 +13835,11 @@ function captureCandidate(root, scope, environment = []) {
12795
13835
  if (item === ".git" || item === ".workit")
12796
13836
  continue;
12797
13837
  names.add(item);
12798
- if (!fs10.existsSync(target))
13838
+ if (!fs12.existsSync(target))
12799
13839
  continue;
12800
13840
  let stat;
12801
13841
  try {
12802
- stat = fs10.lstatSync(target);
13842
+ stat = fs12.lstatSync(target);
12803
13843
  } catch {
12804
13844
  uncertain = true;
12805
13845
  continue;
@@ -12815,17 +13855,17 @@ function captureCandidate(root, scope, environment = []) {
12815
13855
  for (const item of [...names].toSorted()) {
12816
13856
  if (!scopeMatches(item, normalizedScope))
12817
13857
  continue;
12818
- const target = path12.join(checkout, item);
13858
+ const target = path14.join(checkout, item);
12819
13859
  try {
12820
- const stat = fs10.lstatSync(target);
13860
+ const stat = fs12.lstatSync(target);
12821
13861
  if (stat.isSymbolicLink()) {
12822
- const link = fs10.readlinkSync(target);
12823
- files.push({ path: item, kind: "symlink", digest: digestBytes3(link), executable: null });
13862
+ const link = fs12.readlinkSync(target);
13863
+ files.push({ path: item, kind: "symlink", digest: digestBytes2(link), executable: null });
12824
13864
  } else if (stat.isFile()) {
12825
13865
  files.push({
12826
13866
  path: item,
12827
13867
  kind: "file",
12828
- digest: digestBytes3(fs10.readFileSync(target)),
13868
+ digest: digestBytes2(fs12.readFileSync(target)),
12829
13869
  executable: (stat.mode & 73) !== 0
12830
13870
  });
12831
13871
  } else if (!stat.isDirectory()) {
@@ -12850,7 +13890,7 @@ function captureCandidate(root, scope, environment = []) {
12850
13890
  const values = supplied.toSorted((left, right) => compareCodeUnits2(left.name, right.name)).map(({ name, value }) => ({ name, value, refs: [] }));
12851
13891
  if (values.some(({ value }) => value === null))
12852
13892
  completeness = "uncertain";
12853
- const headResult = spawnSync5("git", ["rev-parse", "HEAD"], { cwd: checkout, encoding: "utf8" });
13893
+ const headResult = spawnSync6("git", ["rev-parse", "HEAD"], { cwd: checkout, encoding: "utf8" });
12854
13894
  if (headResult.status !== 0 && !/not a git repository/i.test(headResult.stderr ?? ""))
12855
13895
  completeness = "uncertain";
12856
13896
  const head = headResult.status === 0 ? headResult.stdout.trim() : null;
@@ -13863,6 +14903,38 @@ var applyWorkerLifecycle = (input) => {
13863
14903
  return updated ? success(changed.data.task.revision, changed.data.workspace.revision, updated) : failure2("recovery_required", "worker disappeared during lifecycle observation");
13864
14904
  };
13865
14905
  var refsWithinScope = (refs, scope) => refs.every((ref) => ref.kind !== "file" || bindingCovers(scope, { description: "", paths: [ref.path], exclusions: [] }));
14906
+ var TASK_SCOPED_ACTIONS = new Set([
14907
+ "task.inspect",
14908
+ "task.revise",
14909
+ "task.progress",
14910
+ "task.pause",
14911
+ "task.resume",
14912
+ "task.close",
14913
+ "policy.assess",
14914
+ "policy.preview",
14915
+ "policy.explain",
14916
+ "evidence.record",
14917
+ "finding.record",
14918
+ "finding.resolve",
14919
+ "decision.record",
14920
+ "decision.revoke",
14921
+ "worker.assign",
14922
+ "worker.report",
14923
+ "worker.cancel",
14924
+ "writer.acquire",
14925
+ "writer.release",
14926
+ "state.export"
14927
+ ]);
14928
+ var CREATING_ACTIONS = new Set([
14929
+ "task.revise",
14930
+ "task.progress",
14931
+ "policy.assess",
14932
+ "evidence.record",
14933
+ "finding.record",
14934
+ "decision.record",
14935
+ "worker.assign",
14936
+ "writer.acquire"
14937
+ ]);
13866
14938
  var REVISION_RETRY_ATTEMPTS = 8;
13867
14939
  var retryPause = new Int32Array(new SharedArrayBuffer(4));
13868
14940
  var filledRevisions = (request) => {
@@ -14114,7 +15186,9 @@ class WorkitCore {
14114
15186
  }
14115
15187
  contextRootError() {
14116
15188
  try {
14117
- if (realpathSync6(this.context.root) !== this.store.root)
15189
+ realpathSync6(this.context.root);
15190
+ const root = checkoutRootOf(this.context.root);
15191
+ if (root !== this.store.root && !sameDirectoryIdentity(root, this.store.root))
14118
15192
  return failure2("invalid_input", "operation context root does not match the task store root");
14119
15193
  } catch {
14120
15194
  return failure2("invalid_input", "operation context root cannot be resolved");
@@ -14136,6 +15210,30 @@ class WorkitCore {
14136
15210
  retryOmittedRevisions(request, run) {
14137
15211
  return retryFilledRevisions(filledRevisions(request), run);
14138
15212
  }
15213
+ implicitTask(family, request) {
15214
+ if (typeof request !== "object" || request === null || Array.isArray(request))
15215
+ return success(null, null, request);
15216
+ const value = request;
15217
+ if (value.taskId !== undefined || typeof value.action !== "string")
15218
+ return success(null, null, request);
15219
+ const action = `${family}.${value.action}`;
15220
+ if (!TASK_SCOPED_ACTIONS.has(action))
15221
+ return success(null, null, request);
15222
+ const root = this.contextRootError();
15223
+ if (!root.ok)
15224
+ return root;
15225
+ const create = CREATING_ACTIONS.has(action) && (this.context.workerId ?? null) === null;
15226
+ const found = this.store.implicitTask({
15227
+ provenance: provenance(this.context),
15228
+ create,
15229
+ now: trustedNow(this.context)
15230
+ });
15231
+ if (!found.ok)
15232
+ return found;
15233
+ if (!found.data)
15234
+ return failure2("not_found", 'no task for this branch yet; pass taskId, or record something first (`workit task start "<objective>"`, a note, a check) to create it');
15235
+ return success(null, null, { ...request, taskId: found.data.task.id });
15236
+ }
14139
15237
  helperEntry(task, requireSession = false, requireActive = false) {
14140
15238
  const workerId = this.context.workerId ?? null;
14141
15239
  if (workerId === null)
@@ -14167,7 +15265,11 @@ class WorkitCore {
14167
15265
  return null;
14168
15266
  }
14169
15267
  state(request) {
14170
- return request?.action === "import" ? this.retryOmittedRevisions(request, () => this.stateOnce(request)) : this.stateOnce(request);
15268
+ const resolved = this.implicitTask("state", request);
15269
+ if (!resolved.ok)
15270
+ return resolved;
15271
+ const value = resolved.data;
15272
+ return value?.action === "import" ? this.retryOmittedRevisions(value, () => this.stateOnce(value)) : this.stateOnce(value);
14171
15273
  }
14172
15274
  stateOnce(request) {
14173
15275
  const root = this.contextRootError();
@@ -14234,44 +15336,14 @@ class WorkitCore {
14234
15336
  return imported;
14235
15337
  return this.summary(imported.data);
14236
15338
  }
14237
- if (!this.context.nativeRecovery)
14238
- return failure2("permission_denied", "native recovery authority is unavailable");
14239
- const processEvidence = (lock, writer) => this.context.nativeRecovery({
14240
- lock,
14241
- writer,
14242
- reason: input.reason,
14243
- authorityRefs: input.authorityRefs
14244
- });
14245
- if (input.target === "workspace")
14246
- return this.store.recoverWorkspace({
14247
- expectedBytes: input.expectedBytes,
14248
- snapshotDigest: input.snapshotDigest,
14249
- reason: input.reason,
14250
- authorityRefs: input.authorityRefs,
14251
- expectedWorkspaceRevision: input.expectedWorkspaceRevision,
14252
- processEvidence
14253
- });
14254
- const workspace = this.store.readWorkspace();
14255
- if (!workspace.ok)
14256
- return workspace;
14257
- if (!workspace.data)
14258
- return failure2("not_found", "workspace not found");
14259
- this.fillRevisions(input, undefined, workspace.data);
14260
- if (input.target === "workspace" && input.taskId !== undefined)
14261
- return failure2("invalid_input", "workspace recovery takes no task ID");
14262
- if (input.target === "task" && input.taskId === undefined)
14263
- return failure2("invalid_input", "task recovery requires a task ID");
14264
- return this.store.recoverTask(input.taskId, {
14265
- expectedBytes: input.expectedBytes,
14266
- snapshotDigest: input.snapshotDigest,
14267
- reason: input.reason,
14268
- authorityRefs: input.authorityRefs,
14269
- expectedWorkspaceRevision: input.expectedWorkspaceRevision,
14270
- processEvidence
14271
- });
15339
+ return failure2("invalid_input", "unknown state action");
14272
15340
  }
14273
15341
  task(request) {
14274
- return this.retryOmittedRevisions(request, () => this.taskOnce(request));
15342
+ const resolved = this.implicitTask("task", request);
15343
+ if (!resolved.ok)
15344
+ return resolved;
15345
+ const value = resolved.data;
15346
+ return this.retryOmittedRevisions(value, () => this.taskOnce(value));
14275
15347
  }
14276
15348
  taskOnce(request) {
14277
15349
  const root = this.contextRootError();
@@ -14373,7 +15445,11 @@ class WorkitCore {
14373
15445
  }
14374
15446
  }
14375
15447
  policy(request) {
14376
- return this.retryOmittedRevisions(request, () => this.policyOnce(request));
15448
+ const resolved = this.implicitTask("policy", request);
15449
+ if (!resolved.ok)
15450
+ return resolved;
15451
+ const value = resolved.data;
15452
+ return this.retryOmittedRevisions(value, () => this.policyOnce(value));
14377
15453
  }
14378
15454
  policyOnce(request) {
14379
15455
  const root = this.contextRootError();
@@ -14422,7 +15498,11 @@ class WorkitCore {
14422
15498
  return success(changed.data.revision, null, changed.data.policy);
14423
15499
  }
14424
15500
  evidence(request) {
14425
- return this.retryOmittedRevisions(request, () => this.evidenceOnce(request));
15501
+ const resolved = this.implicitTask("evidence", request);
15502
+ if (!resolved.ok)
15503
+ return resolved;
15504
+ const value = resolved.data;
15505
+ return this.retryOmittedRevisions(value, () => this.evidenceOnce(value));
14426
15506
  }
14427
15507
  evidenceOnce(request) {
14428
15508
  const root = this.contextRootError();
@@ -14563,13 +15643,16 @@ class WorkitCore {
14563
15643
  return success(changed.data.revision, null, changed.data.evidence.at(-1));
14564
15644
  }
14565
15645
  decision(request) {
14566
- return this.recordDecision(request);
15646
+ const resolved = this.implicitTask("decision", request);
15647
+ return resolved.ok ? this.recordDecision(resolved.data) : resolved;
14567
15648
  }
14568
15649
  observeDecision(request, observation) {
14569
- return this.recordDecision(request, observation, true);
15650
+ const resolved = this.implicitTask("decision", request);
15651
+ return resolved.ok ? this.recordDecision(resolved.data, observation, true) : resolved;
14570
15652
  }
14571
15653
  observeStandingDecision(request) {
14572
- return this.recordDecision(request, undefined, true, true);
15654
+ const resolved = this.implicitTask("decision", request);
15655
+ return resolved.ok ? this.recordDecision(resolved.data, undefined, true, true) : resolved;
14573
15656
  }
14574
15657
  commitTask(taskId, expected, filled, recheck, update) {
14575
15658
  let revision = expected;
@@ -14731,7 +15814,11 @@ class WorkitCore {
14731
15814
  return entry ? success(changed.data.revision, null, entry) : failure2("recovery_required", "decision disappeared");
14732
15815
  }
14733
15816
  finding(request) {
14734
- return this.retryOmittedRevisions(request, () => this.findingOnce(request));
15817
+ const resolved = this.implicitTask("finding", request);
15818
+ if (!resolved.ok)
15819
+ return resolved;
15820
+ const value = resolved.data;
15821
+ return this.retryOmittedRevisions(value, () => this.findingOnce(value));
14735
15822
  }
14736
15823
  findingOnce(request) {
14737
15824
  const root = this.contextRootError();
@@ -14857,7 +15944,11 @@ class WorkitCore {
14857
15944
  return entry ? success(changed.data.revision, null, entry) : failure2("recovery_required", "finding disappeared");
14858
15945
  }
14859
15946
  worker(request) {
14860
- return this.retryOmittedRevisions(request, () => this.workerOnce(request));
15947
+ const resolved = this.implicitTask("worker", request);
15948
+ if (!resolved.ok)
15949
+ return resolved;
15950
+ const value = resolved.data;
15951
+ return this.retryOmittedRevisions(value, () => this.workerOnce(value));
14861
15952
  }
14862
15953
  workerOnce(request) {
14863
15954
  const root = this.contextRootError();
@@ -15207,7 +16298,11 @@ class WorkitCore {
15207
16298
  return applied;
15208
16299
  }
15209
16300
  writer(request) {
15210
- return this.retryOmittedRevisions(request, () => this.writerOnce(request));
16301
+ const resolved = this.implicitTask("writer", request);
16302
+ if (!resolved.ok)
16303
+ return resolved;
16304
+ const value = resolved.data;
16305
+ return this.retryOmittedRevisions(value, () => this.writerOnce(value));
15211
16306
  }
15212
16307
  writerOnce(request) {
15213
16308
  const root = this.contextRootError();
@@ -15817,6 +16912,8 @@ class WorkitCore {
15817
16912
  }
15818
16913
 
15819
16914
  // packages/workit-core/src/hooks/context.ts
16915
+ var MIGRATION_PENDING_NOTE = "workit migration pending — run `workit task status`";
16916
+ var migrationPending = (result) => !result.ok && result.code === "needs_input" && (result.error ?? "").startsWith(MIGRATION_PENDING);
15820
16917
  var boundTo = (entry, session) => entry.sessions.some((item) => item.host === session.host && item.handle === session.handle);
15821
16918
  var newestFirst = (left, right) => right.updatedAt.localeCompare(left.updatedAt);
15822
16919
  var sessionTaskEntry = (entries, session) => entries.filter((entry) => entry.status !== "closed" && boundTo(entry, session)).toSorted(newestFirst)[0] ?? null;
@@ -15827,6 +16924,15 @@ var currentTaskEntry = (entries, session, selection) => {
15827
16924
  const active = entries.filter((entry) => entry.status === "active");
15828
16925
  return active.length === 1 ? active[0] : null;
15829
16926
  };
16927
+ var implicitTaskEntry = (store, entries, session) => {
16928
+ const open = entries.filter((entry) => entry.key !== null && entry.status !== "closed" && !entry.sessions.some((item) => item.host !== "workit_cli" && !(item.host === session.host && item.handle === session.handle)));
16929
+ if (open.length === 0)
16930
+ return null;
16931
+ const key = store.currentKey();
16932
+ if (!key.ok)
16933
+ return null;
16934
+ return open.filter((entry) => entry.key === key.data.key).toSorted(newestFirst)[0] ?? null;
16935
+ };
15830
16936
  var unfinishedTaskOffer = (entries, session, excludeTaskId = null) => {
15831
16937
  const tasks = unboundOpenTaskEntries(entries.filter((entry) => entry.id !== excludeTaskId), session);
15832
16938
  if (tasks.length === 0)
@@ -15858,8 +16964,8 @@ var cache = new Map;
15858
16964
  function sessionCompactContext(store, session, context, selection = "session-bound") {
15859
16965
  const listed = store.listTaskIndex();
15860
16966
  if (!listed.ok)
15861
- return null;
15862
- const entry = currentTaskEntry(listed.data, session, selection);
16967
+ return migrationPending(listed) ? MIGRATION_PENDING_NOTE : null;
16968
+ const entry = currentTaskEntry(listed.data, session, selection) ?? implicitTaskEntry(store, listed.data, session);
15863
16969
  return entry ? entryCompactContext(store, entry, session, context) : null;
15864
16970
  }
15865
16971
  function entryCompactContext(store, entry, session, context) {
@@ -15884,7 +16990,7 @@ function entryCompactContext(store, entry, session, context) {
15884
16990
  return null;
15885
16991
  const freshness = { mode: "signal", current: () => recentSignal(store.root) };
15886
16992
  const observed = task.data.evidence.some(isObservedCheck);
15887
- const files = task.data.decisions.flatMap((decision) => decision.data.binding.contentRefs.flatMap((ref) => ref.kind === "file" ? [path13.resolve(store.root, ref.path)] : []));
16993
+ const files = task.data.decisions.flatMap((decision) => decision.data.binding.contentRefs.flatMap((ref) => ref.kind === "file" ? [path15.resolve(store.root, ref.path)] : []));
15888
16994
  const observedFiles = filesKey(files);
15889
16995
  const compact = new WorkitCore(store, context).compactContext(entry.id, freshness);
15890
16996
  if (!compact.ok)
@@ -15917,9 +17023,15 @@ var sessionContextText = (input, descriptor, options) => {
15917
17023
  try {
15918
17024
  const store = new TaskStore(input.cwd);
15919
17025
  const listed = store.listTaskIndex();
17026
+ if (migrationPending(listed))
17027
+ return `<workit-contract>
17028
+ ${invariantBootstrap()}
17029
+ ${MIGRATION_PENDING_NOTE}${options.addendum ? `
17030
+ ${options.addendum}` : ""}
17031
+ </workit-contract>`;
15920
17032
  if (!listed.ok)
15921
17033
  throw new Error(listed.error);
15922
- const entry = currentTaskEntry(listed.data, session, descriptor.context.task);
17034
+ const entry = currentTaskEntry(listed.data, session, descriptor.context.task) ?? implicitTaskEntry(store, listed.data, session);
15923
17035
  const text = entry ? entryCompactContext(store, entry, session, hookOperationContext(input, descriptor)) : null;
15924
17036
  if (text)
15925
17037
  compact = `
@@ -15939,6 +17051,8 @@ ${options.addendum}` : ""}
15939
17051
  var turnContextText = (input, descriptor) => {
15940
17052
  try {
15941
17053
  const text = sessionCompactContext(new TaskStore(input.cwd), { host: input.host, handle: input.session.id }, hookOperationContext(input, descriptor), descriptor.context.task);
17054
+ if (text === MIGRATION_PENDING_NOTE)
17055
+ return text;
15942
17056
  return text ? `<workit-task-context>${text}</workit-task-context>` : null;
15943
17057
  } catch {
15944
17058
  return null;
@@ -15970,7 +17084,16 @@ var usable2 = (value) => value === "native" || value === "partial";
15970
17084
  var NONE2 = { kind: "none" };
15971
17085
  var offered = new Set;
15972
17086
  var CLAUDE_WORKTREE_IMPLEMENTER = "workit:implementer";
15973
- var subagentStartText = (host, descriptor, event) => host === "claude_code" && event.agentType === CLAUDE_WORKTREE_IMPLEMENTER ? `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.` : `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided; writer delegation is unavailable.`;
17087
+ var CLAUDE_JUDGES = new Set(["workit:verifier", "workit:reviewer"]);
17088
+ var subagentSession = (session, agentId) => [session, agentId].filter((part) => Boolean(part)).join(":").replace(/[^A-Za-z0-9_.:@/+-]/g, "-").slice(0, 128);
17089
+ var subagentStartText = (host, session, descriptor, event) => {
17090
+ if (host === "claude_code" && event.agentType === CLAUDE_WORKTREE_IMPLEMENTER)
17091
+ 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.`;
17092
+ const readOnly = `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided; writer delegation is unavailable.`;
17093
+ if (host === "claude_code" && event.agentType && CLAUDE_JUDGES.has(event.agentType) && event.agentId)
17094
+ 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.`;
17095
+ return readOnly;
17096
+ };
15974
17097
  function handleHook(input, deps) {
15975
17098
  const { descriptor } = deps;
15976
17099
  const event = input.event;
@@ -15996,7 +17119,10 @@ function handleHook(input, deps) {
15996
17119
  case "shell.pre":
15997
17120
  return usable2(descriptor.shellPolicy.deny) ? shellPolicy(input.cwd, event.command) : NONE2;
15998
17121
  case "subagent.start":
15999
- return { kind: "context", text: subagentStartText(input.host, descriptor, event) };
17122
+ return {
17123
+ kind: "context",
17124
+ text: subagentStartText(input.host, input.session.id ?? null, descriptor, event)
17125
+ };
16000
17126
  case "compact.pre":
16001
17127
  return {
16002
17128
  kind: "notice",
@@ -16041,12 +17167,12 @@ var dispatchHook = (adapter, raw, env = process.env) => {
16041
17167
  };
16042
17168
  // packages/workit-core/src/hooks/hosts/fields.ts
16043
17169
  import { existsSync as existsSync5, realpathSync as realpathSync7, statSync as statSync2 } from "node:fs";
16044
- import path14 from "node:path";
17170
+ import path16 from "node:path";
16045
17171
  var isRecord3 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
16046
17172
  var nonEmpty2 = (value) => typeof value === "string" && value.trim() !== "";
16047
17173
  var optionalText = (value) => typeof value === "string" ? value : null;
16048
17174
  var existingDirectory = (value) => {
16049
- if (!nonEmpty2(value) || !path14.isAbsolute(value) || !existsSync5(value))
17175
+ if (!nonEmpty2(value) || !path16.isAbsolute(value) || !existsSync5(value))
16050
17176
  return null;
16051
17177
  try {
16052
17178
  return statSync2(value).isDirectory() ? realpathSync7(value) : null;
@@ -16550,11 +17676,11 @@ var codexAdapter = {
16550
17676
  };
16551
17677
  },
16552
17678
  render: render2,
16553
- addendum: (input) => `<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>`
17679
+ addendum: (input) => `<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>`
16554
17680
  };
16555
17681
 
16556
17682
  // packages/workit-core/src/hooks/hosts/cursor.ts
16557
- import path15 from "node:path";
17683
+ import path17 from "node:path";
16558
17684
  var undocumented2 = { support: "undocumented", native: null };
16559
17685
  var none = { support: "none", native: null };
16560
17686
  var CURSOR_DESCRIPTOR = {
@@ -16737,7 +17863,7 @@ var protocolEvent2 = (input) => {
16737
17863
  return { kind: "compact.pre", trigger: "auto" };
16738
17864
  }
16739
17865
  };
16740
- var within = (root, dir) => dir === root || dir.startsWith(root.endsWith(path15.sep) ? root : `${root}${path15.sep}`);
17866
+ var within = (root, dir) => dir === root || dir.startsWith(root.endsWith(path17.sep) ? root : `${root}${path17.sep}`);
16741
17867
  var hookCwd = (input) => {
16742
17868
  const cwd = existingDirectory(input.cwd);
16743
17869
  if (cwd && input.hook_event_name === "beforeShellExecution")