@astrosheep/keiyaku 2.9.8 → 2.9.10

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 (230) hide show
  1. package/README.md +50 -182
  2. package/build/.tsbuildinfo +1 -1
  3. package/build/agents/harness/control-types.js +18 -1
  4. package/build/agents/harness/index.js +2 -0
  5. package/build/agents/harness/outcome-error.js +81 -0
  6. package/build/agents/harness/projection.js +10 -7
  7. package/build/agents/harness/router.js +5 -1
  8. package/build/agents/providers/claude-agent-sdk/adapter.js +18 -4
  9. package/build/agents/providers/claude-agent-sdk/registration.js +11 -1
  10. package/build/agents/providers/claude-agent-sdk/session.js +19 -5
  11. package/build/agents/providers/codex-app-server/adapter.js +11 -6
  12. package/build/agents/providers/codex-app-server/events.js +67 -6
  13. package/build/agents/providers/codex-app-server/registration.js +11 -1
  14. package/build/agents/providers/opencode-sdk/adapter.js +8 -1
  15. package/build/agents/providers/opencode-sdk/events.js +24 -2
  16. package/build/agents/providers/opencode-sdk/registration.js +11 -1
  17. package/build/agents/providers/pi/checkpoint.js +9 -1
  18. package/build/agents/providers/pi/events.js +2 -0
  19. package/build/agents/providers/pi/registration.js +14 -1
  20. package/build/cli/commands/akuma/akuma/handler.js +1 -1
  21. package/build/cli/commands/akuma/akuma/meta.js +3 -3
  22. package/build/cli/commands/akuma/catalog.js +2 -2
  23. package/build/cli/commands/akuma/{view → show}/handler.js +5 -5
  24. package/build/cli/commands/akuma/{view → show}/meta.js +3 -3
  25. package/build/cli/commands/akuma.js +1 -1
  26. package/build/cli/commands/contract/amend/handler.js +5 -4
  27. package/build/cli/commands/contract/arc/handler.js +5 -4
  28. package/build/cli/commands/contract/audit/handler.js +2 -2
  29. package/build/cli/commands/contract/bind/handler.js +12 -3
  30. package/build/cli/commands/contract/bind/meta.js +45 -15
  31. package/build/cli/commands/contract/forfeit/handler.js +9 -5
  32. package/build/cli/commands/contract/log/handler.js +2 -2
  33. package/build/cli/commands/contract/petition/handler.js +34 -7
  34. package/build/cli/commands/contract/renew/handler.js +7 -7
  35. package/build/cli/commands/contract/renew/meta.js +3 -3
  36. package/build/cli/commands/metadata.js +10 -6
  37. package/build/cli/commands/projection/call/meta.js +1 -1
  38. package/build/cli/commands/projection/catalog.js +2 -0
  39. package/build/cli/commands/projection/history/handler.js +21 -0
  40. package/build/cli/commands/projection/history/meta.js +9 -0
  41. package/build/cli/commands/projection/shared.js +3 -4
  42. package/build/cli/commands/projection/status/handler.js +3 -3
  43. package/build/cli/commands/projection/wait/handler.js +20 -11
  44. package/build/cli/commands/task/add/handler.js +14 -5
  45. package/build/cli/commands/task/add/meta.js +2 -2
  46. package/build/cli/commands/task/catalog.js +4 -0
  47. package/build/cli/commands/task/done/handler.js +1 -4
  48. package/build/cli/commands/task/done/meta.js +1 -1
  49. package/build/cli/commands/task/drop/meta.js +1 -1
  50. package/build/cli/commands/task/hold/handler.js +3 -0
  51. package/build/cli/commands/task/hold/meta.js +8 -0
  52. package/build/cli/commands/task/log/handler.js +1 -1
  53. package/build/cli/commands/task/log/meta.js +2 -2
  54. package/build/cli/commands/task/resume/handler.js +3 -0
  55. package/build/cli/commands/task/resume/meta.js +8 -0
  56. package/build/cli/commands/task/shared.js +39 -14
  57. package/build/cli/commands/task/show/handler.js +1 -1
  58. package/build/cli/commands/task/show/meta.js +1 -1
  59. package/build/cli/commands/task/start/meta.js +1 -1
  60. package/build/cli/commands/task/stop/handler.js +1 -4
  61. package/build/cli/commands/task/stop/meta.js +1 -1
  62. package/build/cli/commands/task/update/handler.js +11 -3
  63. package/build/cli/commands/task/update/meta.js +3 -3
  64. package/build/cli/completion.js +3 -2
  65. package/build/cli/flags.js +51 -14
  66. package/build/cli/help.js +4 -1
  67. package/build/cli/index.js +40 -25
  68. package/build/cli/parse-flags.js +40 -7
  69. package/build/cli/parse-metadata.js +5 -2
  70. package/build/cli/parse-selectors.js +2 -3
  71. package/build/cli/parse.js +40 -6
  72. package/build/cli/projection-address.js +12 -10
  73. package/build/cli/render/amendment-warning.js +8 -0
  74. package/build/cli/render/arc.js +6 -2
  75. package/build/cli/render/audit.js +400 -81
  76. package/build/cli/render/kanshi.js +242 -79
  77. package/build/cli/render/lifecycle.js +56 -0
  78. package/build/cli/render/misc.js +24 -28
  79. package/build/cli/render/petition.js +5 -0
  80. package/build/cli/render/projection-activity.js +65 -29
  81. package/build/cli/render/projection-history.js +64 -0
  82. package/build/cli/render/shared.js +23 -9
  83. package/build/cli/render/status.js +13 -35
  84. package/build/cli/render/task.js +51 -26
  85. package/build/cli/render/terminal-failure.js +3 -1
  86. package/build/cli/render/wait.js +45 -69
  87. package/build/cli/subagent-guard.js +1 -1
  88. package/build/cli/types.js +1 -1
  89. package/build/config/env-keys.js +3 -1
  90. package/build/config/env.js +24 -1
  91. package/build/config/settings/schema.js +3 -1
  92. package/build/core/address-carrier.js +89 -0
  93. package/build/core/addressing.js +255 -196
  94. package/build/core/amend.js +115 -38
  95. package/build/core/amendment-criteria.js +17 -0
  96. package/build/core/amendment-warning.js +10 -0
  97. package/build/core/arc.js +184 -83
  98. package/build/core/audit/candidate.js +110 -9
  99. package/build/core/audit/coordinates.js +120 -65
  100. package/build/core/audit/evidence.js +19 -23
  101. package/build/core/audit/facade.js +34 -11
  102. package/build/core/audit/report.js +5 -5
  103. package/build/core/bind-reconciliation.js +390 -0
  104. package/build/core/bind-workspace.js +12 -20
  105. package/build/core/bind.js +242 -273
  106. package/build/core/call/call.js +1 -0
  107. package/build/core/call/context.js +46 -71
  108. package/build/core/call/prompt.js +6 -3
  109. package/build/core/context.js +49 -35
  110. package/build/core/contract-carrier-runtime.js +408 -0
  111. package/build/core/contract-carrier.js +270 -0
  112. package/build/core/contract-view.js +33 -27
  113. package/build/core/contract.js +5 -1
  114. package/build/core/derived-replay.js +207 -0
  115. package/build/core/draft.js +125 -57
  116. package/build/core/forfeit.js +113 -56
  117. package/build/core/identifier-slug.js +63 -13
  118. package/build/core/ids.js +4 -4
  119. package/build/core/lifecycle-history.js +35 -0
  120. package/build/core/lifecycle-recovery.js +117 -0
  121. package/build/core/lifecycle-runner.js +377 -0
  122. package/build/core/log.js +37 -11
  123. package/build/core/outcome-base.js +181 -0
  124. package/build/core/process-group.js +139 -0
  125. package/build/core/projection/generation/database.js +82 -7
  126. package/build/core/projection/generation/model.js +9 -36
  127. package/build/core/projection/generation/projection-generation-continuation.js +10 -22
  128. package/build/core/projection/generation/projection-generation-execution.js +13 -6
  129. package/build/core/projection/index.js +5 -4
  130. package/build/core/projection/projection-activity.js +29 -8
  131. package/build/core/projection/projection-alias.js +23 -10
  132. package/build/core/projection/projection-core.js +21 -7
  133. package/build/core/projection/projection-execution-observer.js +91 -39
  134. package/build/core/projection/projection-history.js +474 -0
  135. package/build/core/projection/projection-runner-lock.js +12 -0
  136. package/build/core/projection/projection-status-observation.js +365 -0
  137. package/build/core/projection/projection-status.js +35 -337
  138. package/build/core/projection/projection-terminal-failure.js +13 -8
  139. package/build/core/projection/projection-wait.js +7 -2
  140. package/build/core/projection/tell/launch-store.js +244 -0
  141. package/build/core/projection/tell/model.js +3 -3
  142. package/build/core/projection/tell/store.js +70 -118
  143. package/build/core/registry.js +22 -87
  144. package/build/core/renew-build.js +424 -0
  145. package/build/core/renew-plan.js +239 -0
  146. package/build/core/renew-rewrite.js +77 -0
  147. package/build/core/renew.js +295 -520
  148. package/build/core/repository-ledger/accepted-fold-read.js +19 -0
  149. package/build/core/repository-ledger/accepted-tail.js +116 -0
  150. package/build/core/repository-ledger/admission.js +38 -0
  151. package/build/core/repository-ledger/authoritative-transaction.js +1 -0
  152. package/build/core/repository-ledger/claim-evidence.js +147 -0
  153. package/build/core/repository-ledger/codec.js +297 -0
  154. package/build/core/repository-ledger/current-state-store.js +853 -0
  155. package/build/core/repository-ledger/fold-repository.js +375 -0
  156. package/build/core/repository-ledger/fold.js +248 -0
  157. package/build/core/repository-ledger/git-object-store.js +243 -0
  158. package/build/core/repository-ledger/history-read.js +15 -0
  159. package/build/core/repository-ledger/incremental-read.js +153 -0
  160. package/build/core/repository-ledger/index.js +15 -0
  161. package/build/core/repository-ledger/inventory-plan.js +75 -0
  162. package/build/core/repository-ledger/inventory.js +310 -0
  163. package/build/core/repository-ledger/publication-admission.js +125 -0
  164. package/build/core/repository-ledger/publication.js +353 -0
  165. package/build/core/repository-ledger/read-model.js +469 -0
  166. package/build/core/repository-ledger/transaction-membership.js +78 -0
  167. package/build/core/repository-ledger/traversal.js +404 -0
  168. package/build/core/repository-ledger/write-transaction.js +339 -0
  169. package/build/core/seal.js +168 -161
  170. package/build/core/settlement/claim-delivery.js +42 -215
  171. package/build/core/settlement/claim.js +235 -153
  172. package/build/core/settlement/index.js +4 -4
  173. package/build/core/settlement/petition-claim-gates.js +52 -33
  174. package/build/core/settlement/petition-forfeit.js +12 -5
  175. package/build/core/settlement/petition-head-guard.js +8 -5
  176. package/build/core/settlement/petition-preview.js +68 -56
  177. package/build/core/settlement/petition-window.js +50 -0
  178. package/build/core/settlement/petition.js +252 -131
  179. package/build/core/settlement/queue-read-model.js +72 -95
  180. package/build/core/settlement/queue-seat-allocation.js +35 -126
  181. package/build/core/settlement/queue.js +1 -1
  182. package/build/core/settlement/settlement.js +172 -80
  183. package/build/core/settlement/verdict.js +112 -66
  184. package/build/core/settlement/verification-coordination.js +305 -0
  185. package/build/core/settlement/verification-supervisor.js +275 -0
  186. package/build/core/settlement/verification.js +398 -31
  187. package/build/core/status/board.js +125 -127
  188. package/build/core/status/lifecycle.js +7 -80
  189. package/build/core/structured-query.js +23 -0
  190. package/build/core/target-ref.js +1 -1
  191. package/build/core/task/board.js +59 -24
  192. package/build/core/task/commands.js +181 -33
  193. package/build/core/task/coordinate.js +56 -0
  194. package/build/core/task/document.js +25 -9
  195. package/build/core/task/index.js +7 -4
  196. package/build/core/task/query.js +17 -27
  197. package/build/core/task/settlement-git.js +164 -179
  198. package/build/core/task/settlement-policy.js +15 -11
  199. package/build/core/task/source-board.js +31 -13
  200. package/build/core/task/task-bind-preparation.js +193 -0
  201. package/build/core/task/task-contract.js +102 -193
  202. package/build/core/task/task-file-transaction.js +171 -0
  203. package/build/core/task/task-git-runtime.js +76 -14
  204. package/build/core/task/task-git-store.js +68 -169
  205. package/build/core/task/task-store-repository.js +13 -6
  206. package/build/core/task/task-worktree.js +6 -33
  207. package/build/core/task/task.js +1 -1
  208. package/build/core/task/tree.js +3 -3
  209. package/build/core/task/validation.js +4 -3
  210. package/build/core/transcripts.js +93 -1
  211. package/build/core/worktree-bootstrap.js +44 -36
  212. package/build/core/worktree-path.js +210 -77
  213. package/build/flow-error.js +7 -7
  214. package/build/generated/version.js +2 -2
  215. package/build/git/core.js +76 -13
  216. package/build/git/refs.js +168 -46
  217. package/build/git/streaming-batch.js +73 -13
  218. package/package.json +1 -1
  219. package/skills/keiyaku/SKILL.md +1 -1
  220. package/skills/keiyaku-akuma/SKILL.md +3 -3
  221. package/skills/keiyaku-task/SKILL.md +4 -2
  222. package/skills/keiyaku-workflow/SKILL.md +88 -203
  223. package/build/core/entry.js +0 -305
  224. package/build/core/ledger-batch.js +0 -194
  225. package/build/core/ledger.js +0 -84
  226. package/build/core/projection/leash.js +0 -87
  227. package/build/core/ref-log.js +0 -119
  228. package/build/core/renew-session.js +0 -128
  229. package/build/core/status/ledger-batch.js +0 -1
  230. package/build/core/status/reconciliation.js +0 -137
@@ -1,30 +1,92 @@
1
- import { listContractIds } from "../ledger.js";
2
- import { contractBoundByEngineWorktree, stableRepoRoot } from "../worktree-path.js";
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { KEIYAKU_FILE } from "../../keiyaku.js";
4
+ import { readCurrentContractLifecycle, readRepositoryLifecycle } from "../repository-ledger/read-model.js";
5
+ import { repositoryDeliveryRef } from "../repository-ledger/codec.js";
6
+ import { contractIdentityFromWorktreeWitness, stableRepoRoot } from "../worktree-path.js";
3
7
  import { inspectTaskLock, locateTaskBoard, openTaskStore, openTaskStoreAt, } from "./task-git-store.js";
4
8
  import { taskRepositoryFromStore } from "./task-store-repository.js";
5
9
  import { withTaskWorktreePolicy } from "./task-worktree.js";
6
10
  import { inspectTaskDoctor } from "./task-doctor.js";
7
- export async function taskCommandContractId(cwd) {
8
- const board = await locateTaskBoard(cwd);
11
+ import { taskNamespaceFromScope } from "./coordinate.js";
12
+ import { FlowError } from "../../flow-error.js";
13
+ import { runGitProcess } from "../../git/core.js";
14
+ function taskNamespaceFromBind(bind) {
15
+ const namespaces = bind.data.scope.flatMap((term) => taskNamespaceFromScope(term) ?? []);
16
+ if (namespaces.length !== 1)
17
+ throw new FlowError("INTERNAL_STATE", `contract ${bind.contract} must have exactly one bind task namespace`);
18
+ return namespaces[0];
19
+ }
20
+ async function taskCommandContext(cwd, signal) {
21
+ const board = await locateTaskBoard(cwd, (repository, args, input) => runGitProcess(repository, args, { input, signal }));
9
22
  if (board.mode !== "git")
10
- return undefined;
11
- return contractBoundByEngineWorktree(cwd, board.root);
23
+ return { board };
24
+ if (board.surfaceRoot === board.root)
25
+ return { board };
26
+ const identity = await contractIdentityFromWorktreeWitness(cwd, board.root, signal);
27
+ if (!identity) {
28
+ try {
29
+ await fs.access(path.join(board.surfaceRoot, KEIYAKU_FILE));
30
+ throw new FlowError("INVALID_TARGET", "contract task namespace is unavailable");
31
+ }
32
+ catch (error) {
33
+ if (error instanceof FlowError)
34
+ throw error;
35
+ if (error.code !== "ENOENT")
36
+ throw error;
37
+ }
38
+ return { board };
39
+ }
40
+ const outcome = await readCurrentContractLifecycle(board.root, identity.contractId, { signal });
41
+ if (outcome.status !== "healthy" || outcome.model.resources.worktree === null) {
42
+ throw new FlowError("INVALID_TARGET", `contract ${identity.contractId} task namespace is unavailable`);
43
+ }
44
+ const expectedPath = await fs.realpath(path.join(board.root, ".keiyaku", "wt", outcome.model.resources.worktree.place));
45
+ if (identity.branch !== repositoryDeliveryRef(identity.contractId)
46
+ || identity.branch !== outcome.model.resources.deliveryRef
47
+ || identity.path !== expectedPath) {
48
+ throw new FlowError("INVALID_TARGET", `contract ${identity.contractId} task namespace is unavailable`);
49
+ }
50
+ const bind = outcome.model.history.find((row) => row.entry.kind === "bind")?.entry;
51
+ if (!bind || bind.kind !== "bind")
52
+ throw new FlowError("INTERNAL_STATE", `contract ${identity.contractId} has no accepted bind`);
53
+ const namespace = taskNamespaceFromBind(bind);
54
+ return { board, contractId: identity.contractId, namespace };
12
55
  }
13
- export async function openTaskCommandRepository(cwd) {
14
- const board = await locateTaskBoard(cwd);
15
- const store = openTaskStoreAt(board);
56
+ export async function taskCommandContractId(cwd) {
57
+ return (await taskCommandContext(cwd)).contractId;
58
+ }
59
+ export async function openTaskCommandRepository(cwd, signal) {
60
+ const context = await taskCommandContext(cwd, signal);
61
+ const board = context.board;
62
+ const store = openTaskStoreAt(board, { signal });
16
63
  const repository = taskRepositoryFromStore(store);
17
- if (board.mode !== "git")
64
+ if (!context.contractId || !context.namespace)
18
65
  return repository;
19
- const contractId = await contractBoundByEngineWorktree(cwd, board.root);
20
- return contractId ? withTaskWorktreePolicy(repository, contractId) : repository;
66
+ return withTaskWorktreePolicy(repository, context.contractId, context.namespace);
67
+ }
68
+ export async function openTaskMutationContext(cwd, signal) {
69
+ const context = await taskCommandContext(cwd, signal);
70
+ const store = openTaskStoreAt(context.board, { signal });
71
+ const repository = taskRepositoryFromStore(store);
72
+ return {
73
+ repository: context.contractId && context.namespace
74
+ ? withTaskWorktreePolicy(repository, context.contractId, context.namespace)
75
+ : repository,
76
+ namespace: context.namespace ?? null,
77
+ };
78
+ }
79
+ export async function taskCommandNamespace(cwd) {
80
+ const context = await taskCommandContext(cwd);
81
+ return context.namespace ?? null;
21
82
  }
22
83
  export async function taskContractIds(cwd) {
23
- return new Set(await listContractIds(await stableRepoRoot(cwd)));
84
+ const read = await readRepositoryLifecycle(await stableRepoRoot(cwd));
85
+ return new Set(read.contracts.map((model) => model.contract));
24
86
  }
25
87
  export async function readTaskDoctor(cwd) {
26
88
  const store = await openTaskStore(cwd);
27
- const repository = taskRepositoryFromStore(store);
89
+ const repository = await openTaskCommandRepository(cwd);
28
90
  const gitMode = store.mode === "git";
29
91
  const lockHeld = (await inspectTaskLock(cwd)).state === "held";
30
92
  return inspectTaskDoctor(store, repository, {
@@ -1,15 +1,15 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import * as fs from "node:fs";
3
2
  import * as path from "node:path";
4
3
  import { DatabaseSync } from "node:sqlite";
5
4
  import { setTimeout as delay } from "node:timers/promises";
6
5
  import { FlowError } from "../../flow-error.js";
7
- import { syncDirectorySync } from "../../fs/durability.js";
8
6
  import { normalizeFilesystemCoordinate } from "../../fs/path-coordinate.js";
9
7
  import { isGitExecutableUnavailable, NOT_GIT_REPOSITORY_PATTERNS, runGitProcess } from "../../git/core.js";
10
8
  import { gitCommonRootPath, normalizeGitPath } from "../../git/path-coordinate.js";
11
9
  import { inspectCurrentBranch, publishTaskMutationCommit } from "./settlement-git.js";
12
- import { isTaskId } from "./validation.js";
10
+ import { createTaskFileTransaction, generationFor, sameSources, taskBoardGeneration, validateTaskMutationWrites, } from "./task-file-transaction.js";
11
+ export { assertTaskBoardGeneration, atomicReplaceTaskFile } from "./task-file-transaction.js";
12
+ import { isTaskCoordinate, parseTaskCoordinate, taskCoordinateFromSourcePath } from "./coordinate.js";
13
13
  const SQLITE_BUSY = 5;
14
14
  const LOCK_FILE = "task-lock.sqlite";
15
15
  const GIT_LOCK_FILE = "keiyaku-task-lock.sqlite";
@@ -99,15 +99,16 @@ export async function locateTaskBoard(cwd, runGitProbe = defaultRunGit) {
99
99
  return {
100
100
  mode: "git",
101
101
  root: taskBoardRootFromGitDir(gitDir),
102
+ surfaceRoot: normalizedCheckoutRoot,
102
103
  gitDir,
103
104
  };
104
105
  }
105
106
  function taskDirectory(board) {
106
- return path.join(board.root, ".keiyaku", "tasks");
107
+ return path.join(board.mode === "git" ? board.surfaceRoot : board.root, ".keiyaku", "tasks");
107
108
  }
108
109
  const TASK_HISTORY_FORMAT = "%aI%x00%an%x00%s";
109
- function taskHistoryPath(taskId) {
110
- return `.keiyaku/tasks/${taskId}.md`;
110
+ function taskHistoryPath(coordinate) {
111
+ return `.keiyaku/tasks/${coordinate}.md`;
111
112
  }
112
113
  function parseTaskHistory(output) {
113
114
  const fields = output.split("\0");
@@ -129,7 +130,7 @@ function parseTaskHistory(output) {
129
130
  return entries;
130
131
  }
131
132
  function assertKeiyakuDirectory(board) {
132
- const keiyakuDirectory = path.join(board.root, ".keiyaku");
133
+ const keiyakuDirectory = path.join(board.mode === "git" ? board.surfaceRoot : board.root, ".keiyaku");
133
134
  const stat = lstatIfPresent(keiyakuDirectory);
134
135
  if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) {
135
136
  throw new FlowError("INTERNAL_STATE", `.keiyaku is not a regular directory: ${keiyakuDirectory}`);
@@ -224,23 +225,21 @@ export async function inspectTaskLock(cwd) {
224
225
  closeQuietly(database);
225
226
  }
226
227
  }
227
- /** Read the current hub branch's task-specific Git history without acquiring the task lock. */
228
+ /** Read the current checkout branch's task-specific Git history without acquiring the task lock. */
228
229
  export async function readTaskHistory(cwd, taskId, deps = {}) {
229
- if (!isTaskId(taskId)) {
230
- throw new FlowError("INVALID_TARGET", `task history requires a canonical task id: ${taskId}`);
231
- }
230
+ const coordinate = parseTaskCoordinate(taskId, "task history coordinate").coordinate;
232
231
  const board = await locateTaskBoard(cwd, deps.runGit);
233
232
  if (board.mode !== "git") {
234
233
  throw new FlowError("NOT_GIT_REPO", "task history is available only in a Git repository");
235
234
  }
236
- const result = await (deps.runGit ?? defaultRunGit)(board.root, [
235
+ const result = await (deps.runGit ?? defaultRunGit)(board.surfaceRoot, [
237
236
  "log",
238
237
  "-z",
239
238
  "--topo-order",
240
239
  "--reverse",
241
240
  `--format=${TASK_HISTORY_FORMAT}`,
242
241
  "--",
243
- taskHistoryPath(taskId),
242
+ taskHistoryPath(coordinate),
244
243
  ]);
245
244
  if (result.error) {
246
245
  throw new FlowError("INTERNAL_STATE", `cannot read task history: ${result.error.message}`, { cause: result.error });
@@ -251,34 +250,6 @@ export async function readTaskHistory(cwd, taskId, deps = {}) {
251
250
  }
252
251
  return parseTaskHistory(result.stdout);
253
252
  }
254
- export function atomicReplaceTaskFile(filePath, text, _deps) {
255
- const directory = path.dirname(filePath);
256
- fs.mkdirSync(directory, { recursive: true });
257
- const temporary = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomBytes(12).toString("hex")}.tmp`);
258
- let descriptor;
259
- try {
260
- descriptor = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
261
- fs.writeFileSync(descriptor, text, { encoding: "utf8" });
262
- fs.fsyncSync(descriptor);
263
- fs.closeSync(descriptor);
264
- descriptor = undefined;
265
- fs.renameSync(temporary, filePath);
266
- syncDirectorySync(directory);
267
- }
268
- catch (error) {
269
- if (descriptor !== undefined) {
270
- try {
271
- fs.closeSync(descriptor);
272
- }
273
- catch { /* preserve original failure */ }
274
- }
275
- try {
276
- fs.unlinkSync(temporary);
277
- }
278
- catch { /* absent after rename or already cleaned */ }
279
- throw error;
280
- }
281
- }
282
253
  function lstatIfPresent(target) {
283
254
  try {
284
255
  return fs.lstatSync(target);
@@ -296,81 +267,29 @@ async function readTaskSources(directory) {
296
267
  if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
297
268
  throw new FlowError("INTERNAL_STATE", `task directory is not a regular directory: ${directory}`);
298
269
  }
299
- const names = fs.readdirSync(directory)
300
- .filter((name) => name.endsWith(".md"))
301
- .sort((left, right) => Buffer.from(left).compare(Buffer.from(right)));
302
- return names.map((name) => {
303
- const sourcePath = path.join(directory, name);
304
- const sourceStat = fs.lstatSync(sourcePath);
305
- if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
306
- throw new FlowError("INTERNAL_STATE", `task source is not a regular file: ${sourcePath}`);
307
- }
308
- return {
309
- path: sourcePath,
310
- text: fs.readFileSync(sourcePath, "utf8"),
311
- };
312
- });
313
- }
314
- function validatePlan(plan) {
315
- validateWrites(plan.writes);
316
- }
317
- function validateWrites(writes) {
318
- if (writes.length === 0) {
319
- throw new FlowError("INTERNAL_STATE", "task mutation must contain at least one write");
320
- }
321
- const targets = new Set();
322
- for (const write of writes) {
323
- const target = `${write.id}.md`;
324
- if (!isTaskId(write.id)) {
325
- throw new FlowError("INTERNAL_STATE", `invalid task mutation id: ${write.id}`);
326
- }
327
- if (targets.has(target))
328
- throw new FlowError("INTERNAL_STATE", `duplicate task mutation target: ${target}`);
329
- targets.add(target);
330
- }
331
- }
332
- function mutationTarget(write) {
333
- return `${write.id}.md`;
334
- }
335
- function capturePreviousFiles(directory, writes) {
336
- return writes.map((write) => {
337
- const destination = path.join(directory, mutationTarget(write));
338
- const stat = lstatIfPresent(destination);
339
- if (!stat)
340
- return { path: destination };
341
- if (stat.isSymbolicLink() || !stat.isFile()) {
342
- throw new FlowError("INTERNAL_STATE", `task destination is not a regular file: ${destination}`);
270
+ const sources = [];
271
+ const visit = (current, depth) => {
272
+ for (const name of fs.readdirSync(current).sort((left, right) => Buffer.from(left).compare(Buffer.from(right)))) {
273
+ const sourcePath = path.join(current, name);
274
+ const sourceStat = fs.lstatSync(sourcePath);
275
+ if (sourceStat.isSymbolicLink())
276
+ throw new FlowError("INTERNAL_STATE", `task source is a symbolic link: ${sourcePath}`);
277
+ if (sourceStat.isDirectory()) {
278
+ if (depth !== 0 || !isTaskCoordinate(name)) {
279
+ throw new FlowError("INTERNAL_STATE", `task namespace must be one canonical directory level: ${sourcePath}`);
280
+ }
281
+ visit(sourcePath, depth + 1);
282
+ }
283
+ else if (sourceStat.isFile() && name.endsWith(".md")) {
284
+ sources.push({ path: sourcePath, text: fs.readFileSync(sourcePath, "utf8") });
285
+ }
286
+ else {
287
+ throw new FlowError("INTERNAL_STATE", `task source is not a Markdown file: ${sourcePath}`);
288
+ }
343
289
  }
344
- return { path: destination, text: fs.readFileSync(destination, "utf8") };
345
- });
346
- }
347
- function sameSources(left, right) {
348
- return left.length === right.length && left.every((source, index) => {
349
- const candidate = right[index];
350
- return candidate !== undefined && source.path === candidate.path && source.text === candidate.text;
351
- });
352
- }
353
- function removeTaskFile(filePath) {
354
- try {
355
- fs.unlinkSync(filePath);
356
- syncDirectorySync(path.dirname(filePath));
357
- }
358
- catch (error) {
359
- if (error.code !== "ENOENT")
360
- throw error;
361
- }
362
- }
363
- function restoreFiles(previous, deps) {
364
- for (const entry of previous) {
365
- if (entry.text === undefined)
366
- removeTaskFile(entry.path);
367
- else
368
- (deps.atomicReplace ?? atomicReplaceTaskFile)(entry.path, entry.text);
369
- }
370
- }
371
- function writeTaskFile(directory, write, deps) {
372
- const destination = path.join(directory, mutationTarget(write));
373
- (deps.atomicReplace ?? atomicReplaceTaskFile)(destination, write.text);
290
+ };
291
+ visit(directory, 0);
292
+ return sources;
374
293
  }
375
294
  function releaseTaskLock(database, deps) {
376
295
  try {
@@ -389,12 +308,6 @@ function releaseTaskLock(database, deps) {
389
308
  closeQuietly(database);
390
309
  }
391
310
  }
392
- function assertTaskDirectorySafety(directory) {
393
- const stat = lstatIfPresent(directory);
394
- if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) {
395
- throw new FlowError("INTERNAL_STATE", `task directory is not a regular directory: ${directory}`);
396
- }
397
- }
398
311
  export function openTaskStoreAt(board, deps = {}) {
399
312
  const directory = taskDirectory(board);
400
313
  return {
@@ -407,23 +320,24 @@ export function openTaskStoreAt(board, deps = {}) {
407
320
  return withLockHeldTaskBoard(board.root, async (transaction) => {
408
321
  const previousSources = transaction.sources;
409
322
  const plan = await apply(transaction.sources);
410
- validatePlan(plan);
323
+ validateTaskMutationWrites(plan.writes);
411
324
  let subject;
412
325
  if (transaction.location.mode === "git") {
413
326
  subject = plan.subject?.trim();
414
327
  if (!subject) {
415
328
  throw new FlowError("INTERNAL_STATE", "git task mutation requires a micro-commit subject");
416
329
  }
417
- await inspectCurrentBranch(transaction.location.root);
330
+ await inspectCurrentBranch(transaction.location.surfaceRoot, deps.signal);
418
331
  }
419
332
  await transaction.replace(plan.writes);
420
333
  if (transaction.location.mode === "git") {
421
334
  try {
422
335
  await (deps.publishMutation ?? publishTaskMutationCommit)({
423
- cwd: transaction.location.root,
336
+ cwd: transaction.location.surfaceRoot,
424
337
  gitDir: transaction.location.gitDir,
425
338
  writes: plan.writes,
426
339
  subject: subject,
340
+ signal: deps.signal,
427
341
  });
428
342
  }
429
343
  catch (error) {
@@ -446,55 +360,40 @@ export async function withLockHeldTaskBoard(cwd, apply, deps = {}, locatedBoard)
446
360
  const database = await acquireTaskLockWithAdmission(taskLockPath(board), deps);
447
361
  try {
448
362
  let sources = await readTaskSources(directory);
449
- const transaction = {
363
+ const transaction = createTaskFileTransaction({
450
364
  location: board,
451
- get sources() {
452
- return sources;
453
- },
454
- async replace(writes) {
455
- validateWrites(writes);
456
- assertKeiyakuDirectory(board);
457
- assertTaskDirectorySafety(directory);
458
- const currentSources = await readTaskSources(directory);
459
- if (!sameSources(sources, currentSources)) {
460
- throw new FlowError("CONCURRENT_MODIFICATION", "task sources changed while applying the mutation");
461
- }
462
- assertKeiyakuDirectory(board);
463
- assertTaskDirectorySafety(directory);
464
- const previous = capturePreviousFiles(directory, writes);
465
- let applied = 0;
466
- try {
467
- for (const write of writes) {
468
- writeTaskFile(directory, write, deps);
469
- applied += 1;
470
- }
471
- }
472
- catch (error) {
473
- try {
474
- restoreFiles(previous.slice(0, applied + 1), deps);
475
- }
476
- catch { /* the original replacement failure remains authoritative */ }
477
- throw error;
478
- }
479
- sources = await readTaskSources(directory);
480
- },
481
- async restore(writes, previousSources) {
482
- validateWrites(writes);
483
- const currentSources = await readTaskSources(directory);
484
- if (!sameSources(sources, currentSources)) {
485
- throw new FlowError("CONCURRENT_MODIFICATION", "task sources changed while rolling back a failed publication");
486
- }
487
- const previousById = new Map(previousSources.map((source) => [path.basename(source.path, ".md"), source.text]));
488
- restoreFiles(writes.map((write) => ({
489
- path: path.join(directory, mutationTarget(write)),
490
- text: previousById.get(write.id),
491
- })), deps);
492
- sources = await readTaskSources(directory);
493
- },
494
- };
365
+ directory,
366
+ getSources: () => sources,
367
+ setSources: (next) => { sources = next; },
368
+ readSources: () => readTaskSources(directory),
369
+ assertBoardSafety: () => assertKeiyakuDirectory(board),
370
+ deps,
371
+ });
495
372
  return await apply(transaction);
496
373
  }
497
374
  finally {
498
375
  releaseTaskLock(database, deps);
499
376
  }
500
377
  }
378
+ export async function readTaskBoardGeneration(cwd, signal) {
379
+ return withLockHeldTaskBoard(cwd, taskBoardGeneration, { signal });
380
+ }
381
+ /** Read-only stable observation for preflight paths that must not create the task lock database. */
382
+ export async function observeTaskBoardSnapshot(cwd, deps = {}) {
383
+ const location = await locateTaskBoard(cwd, deps.runGit);
384
+ const directory = taskDirectory(location);
385
+ const first = await readTaskSources(directory);
386
+ const generation = generationFor(location, first);
387
+ const second = await readTaskSources(directory);
388
+ if (!sameSources(first, second)) {
389
+ throw new FlowError("CONCURRENT_MODIFICATION", "task sources changed during read-only observation");
390
+ }
391
+ return { generation, sources: second };
392
+ }
393
+ export async function readTaskNamespaces(cwd) {
394
+ const snapshot = await observeTaskBoardSnapshot(cwd);
395
+ return new Set(snapshot.sources.flatMap((source) => {
396
+ const namespace = taskCoordinateFromSourcePath(source.path).namespace;
397
+ return namespace === null ? [] : [namespace];
398
+ }));
399
+ }
@@ -1,15 +1,20 @@
1
1
  import { requireHealthy } from "./board.js";
2
- import { parseTaskDocument, serializeTaskDocument } from "./document.js";
2
+ import { serializeTaskDocument } from "./document.js";
3
3
  import { buildTaskSourceBoard } from "./source-board.js";
4
+ import { formatTaskCoordinate, taskCoordinateFromSourcePath } from "./coordinate.js";
4
5
  export function taskBoardFromSourceBoard(sourceBoard) {
5
6
  const tasks = sourceBoard.tasks.map((task) => ({
6
7
  id: task.id,
8
+ namespace: task.namespace,
9
+ coordinate: task.coordinate,
7
10
  title: task.title,
8
11
  state: task.state,
9
12
  pri: task.pri,
10
13
  needs: [...task.needs],
11
14
  parent: task.parent,
12
15
  from: [...task.from],
16
+ supersedes: [...task.supersedes],
17
+ relatesTo: [...task.relatesTo],
13
18
  createdAt: task.createdAt,
14
19
  ...(task.updatedAt === undefined ? {} : { updatedAt: task.updatedAt }),
15
20
  creator: task.creator,
@@ -19,6 +24,8 @@ export function taskBoardFromSourceBoard(sourceBoard) {
19
24
  blocks: [...task.blocks],
20
25
  blocked: task.blocked,
21
26
  releasedNeeds: [...task.releasedNeeds],
27
+ supersededBy: [...task.supersededBy],
28
+ relatedTasks: [...task.relatedTasks],
22
29
  }));
23
30
  return {
24
31
  tasks,
@@ -28,21 +35,21 @@ export function taskBoardFromSourceBoard(sourceBoard) {
28
35
  ...(disease.id === undefined ? {} : { id: disease.id }),
29
36
  ...(disease.relatedIds === undefined ? {} : { relatedIds: [...disease.relatedIds] }),
30
37
  })),
31
- byId: new Map(tasks.map((task) => [task.id, task])),
38
+ byId: new Map(tasks.map((task) => [task.coordinate, task])),
32
39
  };
33
40
  }
34
41
  export function prepareTaskDocumentReplacements(sources, replacements) {
35
42
  const serialized = replacements.map((document) => ({
36
- id: document.id,
43
+ id: document.coordinate ?? formatTaskCoordinate(document.namespace ?? null, document.id),
37
44
  text: serializeTaskDocument(document),
38
45
  }));
39
46
  const replacementIds = new Set(serialized.map((replacement) => replacement.id));
40
47
  const paths = new Map();
41
48
  const retained = sources.filter((source) => {
42
49
  try {
43
- const id = parseTaskDocument(source).id;
44
- paths.set(id, source.path);
45
- return !replacementIds.has(id);
50
+ const sourceCoordinate = taskCoordinateFromSourcePath(source.path).coordinate;
51
+ paths.set(sourceCoordinate, source.path);
52
+ return !replacementIds.has(sourceCoordinate);
46
53
  }
47
54
  catch {
48
55
  // Malformed sources remain quarantined and visible to the resulting board.
@@ -1,40 +1,13 @@
1
1
  import { FlowError } from "../../flow-error.js";
2
- function documentFacts(task) {
3
- return JSON.stringify({
4
- id: task.id,
5
- title: task.title,
6
- state: task.state,
7
- pri: task.pri,
8
- needs: [...task.needs],
9
- parent: task.parent,
10
- from: [...task.from],
11
- createdAt: task.createdAt,
12
- creator: task.creator,
13
- ...(task.startedAt === undefined ? {} : { startedAt: task.startedAt }),
14
- ...(task.contractId === undefined ? {} : { contractId: task.contractId }),
15
- body: task.body,
16
- });
17
- }
18
- function isNeedsAppend(prior, next) {
19
- return next.needs.length > prior.needs.length
20
- && prior.needs.every((need, index) => next.needs[index] === need)
21
- && new Set(next.needs).size === next.needs.length
22
- && documentFacts(prior) === documentFacts({ ...next, needs: prior.needs });
23
- }
24
- export function validateTaskWorktreeMutation(before, replacements, after, contractId) {
25
- const existing = new Map(before.tasks.map((task) => [task.id, task]));
26
- const bound = before.tasks.filter((task) => task.contractId === contractId);
2
+ import { formatTaskCoordinate } from "./coordinate.js";
3
+ export function validateTaskWorktreeMutation(_before, replacements, _after, contractId, namespace) {
27
4
  for (const replacement of replacements) {
28
- const prior = existing.get(replacement.id);
29
- if (!prior)
30
- continue;
31
- const next = after.byId.get(replacement.id);
32
- if (bound.length === 1 && prior.id === bound[0].id && next && isNeedsAppend(prior, next))
5
+ if (replacement.namespace === namespace)
33
6
  continue;
34
- throw new FlowError("UNSUPPORTED_POLICY", `task ${replacement.id} is not yours; this checkout is bound to contract ${contractId}`);
7
+ throw new FlowError("UNSUPPORTED_POLICY", `task ${replacement.coordinate ?? formatTaskCoordinate(replacement.namespace ?? null, replacement.id)} is outside contract ${contractId} namespace ${namespace}`);
35
8
  }
36
9
  }
37
- export function withTaskWorktreePolicy(repository, contractId) {
10
+ export function withTaskWorktreePolicy(repository, contractId, namespace) {
38
11
  return {
39
12
  read: repository.read.bind(repository),
40
13
  mutate: async (apply) => repository.mutate(async (before) => {
@@ -43,7 +16,7 @@ export function withTaskWorktreePolicy(repository, contractId) {
43
16
  replacements: mutation.replacements,
44
17
  ...(mutation.subject === undefined ? {} : { subject: mutation.subject }),
45
18
  select(after) {
46
- validateTaskWorktreeMutation(before, mutation.replacements, after, contractId);
19
+ validateTaskWorktreeMutation(before, mutation.replacements, after, contractId, namespace);
47
20
  return mutation.select(after);
48
21
  },
49
22
  };
@@ -4,4 +4,4 @@ export { parseTaskDocument, serializeTaskDocument, } from "./document.js";
4
4
  export { resolveTaskReadId, } from "./board.js";
5
5
  export { buildTaskBoard } from "./source-board.js";
6
6
  export { buildTaskTree } from "./tree.js";
7
- export { readTaskBoard, createTask, updateTask, startTask, stopTask, doneTask, dropTask, selectSoleInProgress, rollbackOrphanTaskPointers, } from "./commands.js";
7
+ export { readTaskBoard, createTask, updateTask, updateTaskWithReceipt, startTask, stopTask, holdTask, resumeTask, doneTask, dropTask, selectSoleInProgress, rollbackOrphanTaskPointers, } from "./commands.js";
@@ -8,7 +8,7 @@ function compareBoardOrder(left, right) {
8
8
  return leftTerminal - rightTerminal
9
9
  || left.pri - right.pri
10
10
  || compareText(left.createdAt, right.createdAt)
11
- || compareText(left.id, right.id);
11
+ || compareText(left.coordinate, right.coordinate);
12
12
  }
13
13
  function diagnosticOrder(left, right) {
14
14
  return compareText(left.code, right.code)
@@ -25,10 +25,10 @@ export function buildTaskTree(board, rootId) {
25
25
  for (const children of childrenByParent.values())
26
26
  children.sort(compareBoardOrder);
27
27
  const visit = (parentId, ancestors) => (childrenByParent.get(parentId) ?? [])
28
- .filter((child) => !ancestors.has(child.id))
28
+ .filter((child) => !ancestors.has(child.coordinate))
29
29
  .map((child) => ({
30
30
  task: child,
31
- children: visit(child.id, new Set([...ancestors, child.id])),
31
+ children: visit(child.coordinate, new Set([...ancestors, child.coordinate])),
32
32
  }));
33
33
  return {
34
34
  descendants: visit(rootId, new Set([rootId])),
@@ -1,6 +1,7 @@
1
1
  import { FlowError } from "../../flow-error.js";
2
2
  import { isNormalizedIdentifierSlug } from "../identifier-slug.js";
3
- export const TASK_STATES = new Set(["open", "in_progress", "done", "drop"]);
3
+ import { isTaskCoordinate } from "./coordinate.js";
4
+ export const TASK_STATES = new Set(["open", "in_progress", "on_hold", "done", "drop"]);
4
5
  const CANONICAL_ISO_UTC_MS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
5
6
  export function invalid(message) {
6
7
  throw new FlowError("INVALID_TARGET", message);
@@ -13,8 +14,8 @@ export function nonblank(field, value) {
13
14
  }
14
15
  export function fullId(field, value) {
15
16
  const id = nonblank(field, value);
16
- if (!isTaskId(id))
17
- invalid(`task field '${field}' must be a canonical full task id`);
17
+ if (!isTaskCoordinate(id))
18
+ invalid(`task field '${field}' must be a canonical full task coordinate`);
18
19
  return id;
19
20
  }
20
21
  export function isTaskId(value) {