@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,5 +1,5 @@
1
1
  import { FlowError } from "../../flow-error.js";
2
- import { isTaskId } from "./validation.js";
2
+ import { formatTaskCoordinate, parseTaskCoordinate } from "./coordinate.js";
3
3
  function compareText(left, right) {
4
4
  return left < right ? -1 : left > right ? 1 : 0;
5
5
  }
@@ -14,7 +14,16 @@ function taskSort(left, right) {
14
14
  return leftTerminal - rightTerminal
15
15
  || left.pri - right.pri
16
16
  || compareText(left.createdAt, right.createdAt)
17
- || compareText(left.id, right.id);
17
+ || compareText(left.coordinate ?? left.id, right.coordinate ?? right.id);
18
+ }
19
+ function normalizeDocument(task) {
20
+ const namespace = task.namespace ?? null;
21
+ const coordinate = task.coordinate ?? formatTaskCoordinate(namespace, task.id);
22
+ const parsed = parseTaskCoordinate(coordinate);
23
+ if (parsed.namespace !== namespace || parsed.localId !== task.id) {
24
+ throw new FlowError("INVALID_TARGET", `task coordinate ${coordinate} does not match its namespace and local id`);
25
+ }
26
+ return { ...task, namespace, coordinate };
18
27
  }
19
28
  function cycleComponents(tasks) {
20
29
  let nextIndex = 0;
@@ -59,6 +68,9 @@ function cycleComponents(tasks) {
59
68
  visit(id);
60
69
  return cycles;
61
70
  }
71
+ function supersedesCycleComponents(tasks) {
72
+ return cycleComponents(new Map([...tasks].map(([id, task]) => [id, { ...task, needs: task.supersedes }])));
73
+ }
62
74
  function parentCycleComponents(tasks) {
63
75
  const cycles = [];
64
76
  const visited = new Set();
@@ -85,21 +97,23 @@ function parentCycleComponents(tasks) {
85
97
  }
86
98
  /** Derive the complete storage-neutral board from domain records. */
87
99
  export function buildTaskBoardFromDocuments(documents) {
100
+ documents = documents.map(normalizeDocument);
88
101
  const diseases = [];
89
102
  const counts = new Map();
90
103
  for (const task of documents)
91
- counts.set(task.id, (counts.get(task.id) ?? 0) + 1);
104
+ counts.set(task.coordinate, (counts.get(task.coordinate) ?? 0) + 1);
92
105
  const candidates = new Map();
93
106
  for (const task of documents) {
94
- if (counts.get(task.id) > 1) {
107
+ const coordinate = task.coordinate;
108
+ if (counts.get(coordinate) > 1) {
95
109
  diseases.push({
96
110
  code: "duplicate_id",
97
- message: `duplicate authoritative task id ${task.id}`,
98
- id: task.id,
111
+ message: `duplicate authoritative task coordinate ${coordinate}`,
112
+ id: coordinate,
99
113
  });
100
114
  }
101
115
  else {
102
- candidates.set(task.id, task);
116
+ candidates.set(coordinate, task);
103
117
  }
104
118
  }
105
119
  for (const component of cycleComponents(candidates)) {
@@ -114,6 +128,18 @@ export function buildTaskBoardFromDocuments(documents) {
114
128
  candidates.delete(id);
115
129
  }
116
130
  }
131
+ for (const component of supersedesCycleComponents(candidates)) {
132
+ const relatedIds = [...component].sort();
133
+ for (const id of relatedIds) {
134
+ diseases.push({
135
+ code: "supersedes_cycle",
136
+ message: `task ${id} participates in a supersedes cycle`,
137
+ id,
138
+ relatedIds,
139
+ });
140
+ candidates.delete(id);
141
+ }
142
+ }
117
143
  for (const component of parentCycleComponents(candidates)) {
118
144
  const relatedIds = [...component].sort();
119
145
  for (const id of relatedIds) {
@@ -129,7 +155,7 @@ export function buildTaskBoardFromDocuments(documents) {
129
155
  while (changed) {
130
156
  changed = false;
131
157
  for (const [id, task] of [...candidates].sort(([a], [b]) => compareText(a, b))) {
132
- const missing = [...task.needs, ...(task.parent ? [task.parent] : []), ...task.from]
158
+ const missing = [...task.needs, ...(task.parent ? [task.parent] : []), ...task.from, ...task.supersedes, ...task.relatesTo]
133
159
  .filter((related) => !candidates.has(related));
134
160
  if (missing.length === 0)
135
161
  continue;
@@ -145,65 +171,74 @@ export function buildTaskBoardFromDocuments(documents) {
145
171
  }
146
172
  }
147
173
  const blocks = new Map();
174
+ const supersededBy = new Map();
175
+ const relatedTasks = new Map();
148
176
  for (const task of candidates.values()) {
149
177
  for (const need of task.needs)
150
- blocks.set(need, [...(blocks.get(need) ?? []), task.id]);
178
+ blocks.set(need, [...(blocks.get(need) ?? []), task.coordinate]);
179
+ for (const superseded of task.supersedes)
180
+ supersededBy.set(superseded, [...(supersededBy.get(superseded) ?? []), task.coordinate]);
181
+ for (const related of task.relatesTo)
182
+ relatedTasks.set(related, [...(relatedTasks.get(related) ?? []), task.coordinate]);
151
183
  }
152
184
  const tasks = [...candidates.values()].sort(taskSort).map((task) => {
153
185
  const blockingNeeds = task.needs.filter((id) => {
154
186
  const state = candidates.get(id).state;
155
- return state === "open" || state === "in_progress";
187
+ return state === "open" || state === "in_progress" || state === "on_hold";
156
188
  });
157
189
  return {
158
190
  id: task.id,
191
+ namespace: task.namespace ?? null,
192
+ coordinate: task.coordinate,
159
193
  title: task.title,
160
194
  state: task.state,
161
195
  pri: task.pri,
162
196
  needs: [...task.needs],
163
197
  parent: task.parent,
164
198
  from: [...task.from],
199
+ supersedes: [...task.supersedes],
200
+ relatesTo: [...task.relatesTo],
165
201
  createdAt: task.createdAt,
166
202
  ...(task.updatedAt === undefined ? {} : { updatedAt: task.updatedAt }),
167
203
  creator: task.creator,
168
204
  ...(task.startedAt === undefined ? {} : { startedAt: task.startedAt }),
169
205
  ...(task.contractId === undefined ? {} : { contractId: task.contractId }),
170
206
  body: task.body,
171
- blocks: [...(blocks.get(task.id) ?? [])].sort(),
207
+ blocks: [...(blocks.get(task.coordinate) ?? [])].sort(),
172
208
  blocked: task.state === "open" && blockingNeeds.length > 0,
173
209
  releasedNeeds: task.needs.filter((id) => candidates.get(id).state === "drop").sort(),
210
+ supersededBy: [...(supersededBy.get(task.coordinate) ?? [])].sort(),
211
+ relatedTasks: [...new Set([...task.relatesTo, ...(relatedTasks.get(task.coordinate) ?? [])])].sort(),
174
212
  };
175
213
  });
176
214
  return {
177
215
  tasks,
178
216
  diseases: diseases.sort(diseaseSort),
179
- byId: new Map(tasks.map((task) => [task.id, task])),
217
+ byId: new Map(tasks.map((task) => [task.coordinate, task])),
180
218
  };
181
219
  }
182
220
  export function resolveTaskReadId(board, input) {
183
221
  return resolveTaskId(board, input);
184
222
  }
185
223
  export function resolveTaskId(board, input) {
186
- if (!isTaskId(input)) {
187
- throw new FlowError("INVALID_TARGET", `invalid task id '${input}'`);
188
- }
189
- if (board.byId.has(input))
190
- return input;
224
+ const requested = parseTaskCoordinate(input);
225
+ if (board.byId.has(requested.coordinate))
226
+ return requested.coordinate;
191
227
  const matches = board.tasks
192
- .map((task) => task.id)
193
- .filter((id) => id.startsWith(input))
228
+ .filter((task) => task.namespace === requested.namespace && task.id.startsWith(requested.localId))
229
+ .map((task) => task.coordinate)
194
230
  .sort();
195
231
  if (matches.length === 0)
196
232
  throw new FlowError("INVALID_TARGET", `task '${input}' not found`);
197
233
  if (matches.length > 1) {
198
- throw Object.assign(new FlowError("CONTRACT_AMBIGUOUS", `task prefix '${input}' is ambiguous: ${matches.join(", ")}`), { candidates: matches });
234
+ throw Object.assign(new FlowError("TASK_AMBIGUOUS", `task prefix '${input}' is ambiguous: ${matches.join(", ")}`, {
235
+ facts: { kind: "task_ambiguous", candidates: matches },
236
+ }), { candidates: matches });
199
237
  }
200
238
  return matches[0];
201
239
  }
202
240
  export function requireCanonicalTaskId(input) {
203
- if (!isTaskId(input)) {
204
- throw new FlowError("INVALID_TARGET", "task mutations require a canonical full task id");
205
- }
206
- return input;
241
+ return parseTaskCoordinate(input, "task mutation coordinate").coordinate;
207
242
  }
208
243
  export function requireHealthy(board) {
209
244
  if (board.diseases.length > 0) {
@@ -1,20 +1,26 @@
1
1
  import { FlowError } from "../../flow-error.js";
2
- import { IdentifierSlugError, normalizeIdentifierSlug, SUFFIXABLE_IDENTIFIER_SLUG_MAX_UTF8_BYTES, } from "../identifier-slug.js";
2
+ import { IdentifierSlugError, taskIdentifierSlug, } from "../identifier-slug.js";
3
+ import { markdownSourceSections } from "../scope.js";
3
4
  import { requireCanonicalTaskId, requireHealthy, resolveTaskId, } from "./board.js";
4
5
  import { dateTimestamp, invalid, nonblank, } from "./validation.js";
5
6
  import { advancedTaskUpdatedAt } from "./model.js";
7
+ import { formatTaskCoordinate, parseTaskCoordinate } from "./coordinate.js";
6
8
  export async function readTaskBoard(repository) {
7
9
  return repository.read();
8
10
  }
9
11
  function documentFromView(view) {
10
12
  return {
11
13
  id: view.id,
14
+ namespace: view.namespace,
15
+ coordinate: view.coordinate,
12
16
  title: view.title,
13
17
  state: view.state,
14
18
  pri: view.pri,
15
19
  needs: [...view.needs],
16
20
  parent: view.parent,
17
21
  from: [...view.from],
22
+ supersedes: [...view.supersedes],
23
+ relatesTo: [...view.relatesTo],
18
24
  createdAt: view.createdAt,
19
25
  ...(view.updatedAt === undefined ? {} : { updatedAt: view.updatedAt }),
20
26
  creator: view.creator,
@@ -23,14 +29,18 @@ function documentFromView(view) {
23
29
  body: view.body,
24
30
  };
25
31
  }
32
+ function documentCoordinate(document) {
33
+ return document.coordinate ?? formatTaskCoordinate(document.namespace ?? null, document.id);
34
+ }
26
35
  function replaceDocument(document, subject) {
27
36
  return {
28
37
  replacements: [document],
29
38
  subject,
30
39
  select(resulting) {
31
- const value = resulting.byId.get(document.id);
40
+ const coordinate = documentCoordinate(document);
41
+ const value = resulting.byId.get(coordinate);
32
42
  if (!value)
33
- throw new FlowError("INTERNAL_STATE", `mutated task ${document.id} disappeared from the board`);
43
+ throw new FlowError("INTERNAL_STATE", `mutated task ${coordinate} disappeared from the board`);
34
44
  return value;
35
45
  },
36
46
  };
@@ -44,24 +54,45 @@ function requireExisting(board, id) {
44
54
  function resolveMutationIds(board, ids) {
45
55
  return ids.map((id) => resolveTaskId(board, id));
46
56
  }
57
+ function validateRelations(board, id, supersedes, relatesTo) {
58
+ if (supersedes.includes(id) || relatesTo.includes(id))
59
+ invalid(`task ${id} relations must not reference itself`);
60
+ const seen = new Set();
61
+ const reachesSource = (current) => {
62
+ if (current === id)
63
+ return true;
64
+ if (seen.has(current))
65
+ return false;
66
+ seen.add(current);
67
+ return (board.byId.get(current)?.supersedes ?? []).some(reachesSource);
68
+ };
69
+ for (const target of supersedes) {
70
+ seen.clear();
71
+ if (reachesSource(target))
72
+ invalid(`task ${id} would create a supersedes cycle`);
73
+ }
74
+ }
75
+ function updateRelations(board, document, field, additions, removals) {
76
+ const added = resolveMutationIds(board, additions ?? []);
77
+ if (new Set(added).size !== added.length)
78
+ invalid(`task ${field} edges must be unique`);
79
+ const removed = new Set(resolveMutationIds(board, removals ?? []));
80
+ const retained = (document[field] ?? []).filter((value) => !removed.has(value));
81
+ if (added.some((value) => retained.includes(value)))
82
+ invalid(`task ${field} edges must be unique`);
83
+ document[field] = [...retained, ...added];
84
+ }
47
85
  export async function createTask(repository, input, deps) {
48
86
  nonblank("title", input.title);
49
87
  nonblank("creator", input.creator);
50
- let baseId;
51
- try {
52
- baseId = normalizeIdentifierSlug(input.title, SUFFIXABLE_IDENTIFIER_SLUG_MAX_UTF8_BYTES);
53
- }
54
- catch (error) {
55
- if (error instanceof IdentifierSlugError) {
56
- throw new FlowError("INVALID_TARGET", `task title ${error.message}`, { cause: error });
57
- }
58
- throw error;
59
- }
60
88
  const pri = input.pri ?? 2;
61
89
  if (!Number.isInteger(pri) || pri < 0 || pri > 3)
62
90
  invalid("task priority must be an integer from 0 through 3");
63
91
  return repository.mutate((board) => {
64
92
  requireHealthy(board);
93
+ const namespace = input.namespace ?? null;
94
+ if (namespace !== null)
95
+ parseTaskCoordinate(`${namespace}/task`, "task namespace");
65
96
  const needs = resolveMutationIds(board, input.needs ?? []);
66
97
  if (new Set(needs).size !== needs.length)
67
98
  invalid("task needs must be unique");
@@ -69,34 +100,64 @@ export async function createTask(repository, input, deps) {
69
100
  const from = resolveMutationIds(board, input.from ?? []);
70
101
  if (new Set(from).size !== from.length)
71
102
  invalid("task from edges must be unique");
72
- let id = baseId;
73
- for (let ordinal = 2; board.byId.has(id); ordinal += 1) {
103
+ const supersedes = resolveMutationIds(board, input.supersedes ?? []);
104
+ if (new Set(supersedes).size !== supersedes.length)
105
+ invalid("task supersedes edges must be unique");
106
+ const relatesTo = resolveMutationIds(board, input.relatesTo ?? []);
107
+ if (new Set(relatesTo).size !== relatesTo.length)
108
+ invalid("task relatesTo edges must be unique");
109
+ let ordinal = 1;
110
+ let id;
111
+ for (;;) {
74
112
  if (!Number.isSafeInteger(ordinal)) {
75
- throw new FlowError("INTERNAL_STATE", `task id collision ordinal exceeded the safe integer range for ${baseId}`);
113
+ throw new FlowError("INTERNAL_STATE", "task id collision ordinal exceeded the safe integer range");
114
+ }
115
+ try {
116
+ id = taskIdentifierSlug(input.title, ordinal === 1 ? "" : `-${ordinal}`);
76
117
  }
77
- id = `${baseId}-${ordinal}`;
118
+ catch (error) {
119
+ if (error instanceof IdentifierSlugError) {
120
+ throw new FlowError("INVALID_TARGET", `task title ${error.message}`, { cause: error });
121
+ }
122
+ throw error;
123
+ }
124
+ if (!board.byId.has(formatTaskCoordinate(namespace, id)))
125
+ break;
126
+ ordinal += 1;
78
127
  }
128
+ const coordinate = formatTaskCoordinate(namespace, id);
129
+ validateRelations(board, coordinate, supersedes, relatesTo);
79
130
  const now = deps.now();
80
131
  const document = {
81
132
  id,
133
+ namespace,
134
+ coordinate,
82
135
  title: input.title,
83
136
  state: "open",
84
137
  pri,
85
138
  needs,
86
139
  parent,
87
140
  from,
141
+ supersedes,
142
+ relatesTo,
88
143
  createdAt: dateTimestamp("createdAt", now),
89
144
  updatedAt: dateTimestamp("updatedAt", now),
90
145
  creator: input.creator,
91
146
  body: input.body ?? "",
92
147
  };
93
- return replaceDocument(document, `Add task ${id}`);
148
+ return replaceDocument(document, `Add task ${coordinate}`);
94
149
  });
95
150
  }
96
151
  export async function updateTask(repository, fullIdValue, patch, now = new Date()) {
152
+ return (await updateTaskWithReceipt(repository, fullIdValue, patch, now)).task;
153
+ }
154
+ export async function updateTaskWithReceipt(repository, fullIdValue, patch, now = new Date()) {
97
155
  const hasChange = Object.values(patch).some((value) => value !== undefined);
98
156
  if (!hasChange)
99
157
  throw new FlowError("EMPTY_PARAM", "task update requires at least one change");
158
+ if (patch.body !== undefined && patch.appendBody !== undefined) {
159
+ throw new FlowError("EMPTY_PARAM", "task update cannot combine --body and --append");
160
+ }
100
161
  return repository.mutate((board) => {
101
162
  requireHealthy(board);
102
163
  const id = resolveTaskId(board, fullIdValue);
@@ -106,6 +167,8 @@ export async function updateTask(repository, fullIdValue, patch, now = new Date(
106
167
  document.title = nonblank("title", patch.title);
107
168
  if (patch.body !== undefined)
108
169
  document.body = patch.body;
170
+ if (patch.appendBody !== undefined)
171
+ document.body = appendBody(document.body, patch.appendBody);
109
172
  if (patch.pri !== undefined) {
110
173
  if (!Number.isInteger(patch.pri) || patch.pri < 0 || patch.pri > 3)
111
174
  invalid("task priority must be an integer from 0 through 3");
@@ -119,18 +182,82 @@ export async function updateTask(repository, fullIdValue, patch, now = new Date(
119
182
  if (patch.from !== undefined) {
120
183
  document.from = [...new Set([...document.from, ...resolveMutationIds(board, patch.from)])];
121
184
  }
185
+ updateRelations(board, document, "supersedes", patch.addSupersedes, patch.dropSupersedes);
186
+ updateRelations(board, document, "relatesTo", patch.addRelatesTo, patch.dropRelatesTo);
187
+ validateRelations(board, document.coordinate, document.supersedes, document.relatesTo);
122
188
  if (JSON.stringify(document) === JSON.stringify(documentFromView(current))) {
123
189
  throw new FlowError("EMPTY_PARAM", "task update requires at least one change");
124
190
  }
125
191
  document.updatedAt = advancedTaskUpdatedAt(current, now);
126
- return replaceDocument(document, `Update task ${id}`);
192
+ const bodyChange = patch.body === undefined ? undefined : describeBodyChange(current.body, document.body);
193
+ return {
194
+ replacements: [document],
195
+ subject: `Update task ${id}`,
196
+ select(resulting) {
197
+ const coordinate = documentCoordinate(document);
198
+ const task = resulting.byId.get(coordinate);
199
+ if (!task)
200
+ throw new FlowError("INTERNAL_STATE", `mutated task ${coordinate} disappeared from the board`);
201
+ return { task, ...(bodyChange === undefined ? {} : { bodyChange }) };
202
+ },
203
+ };
127
204
  });
128
205
  }
206
+ function appendBody(body, addition) {
207
+ if (!body || !addition || body.endsWith("\n\n") || addition.startsWith("\n"))
208
+ return body + addition;
209
+ return `${body}${body.endsWith("\n") ? "\n" : "\n\n"}${addition}`;
210
+ }
211
+ function markdownLevelTwoSections(body) {
212
+ return markdownSourceSections(body)
213
+ .filter((section) => section.level === 2)
214
+ .map((section) => section.title.trim());
215
+ }
216
+ function bodyLines(body) {
217
+ return body.match(/[^\n]*\n|[^\n]+$/g) ?? [];
218
+ }
219
+ function lineDiffstat(before, after) {
220
+ const oldLines = bodyLines(before);
221
+ const newLines = bodyLines(after);
222
+ const lcs = Array(newLines.length + 1).fill(0);
223
+ for (const oldLine of oldLines) {
224
+ let previous = 0;
225
+ for (let index = 1; index <= newLines.length; index += 1) {
226
+ const saved = lcs[index];
227
+ lcs[index] = oldLine === newLines[index - 1]
228
+ ? previous + 1
229
+ : Math.max(lcs[index], lcs[index - 1]);
230
+ previous = saved;
231
+ }
232
+ }
233
+ const unchanged = lcs[newLines.length];
234
+ return { additions: newLines.length - unchanged, deletions: oldLines.length - unchanged };
235
+ }
236
+ function describeBodyChange(before, after) {
237
+ const remaining = new Map();
238
+ for (const section of markdownLevelTwoSections(after)) {
239
+ remaining.set(section, (remaining.get(section) ?? 0) + 1);
240
+ }
241
+ const oldBytes = Buffer.byteLength(before, "utf8");
242
+ const newBytes = Buffer.byteLength(after, "utf8");
243
+ return {
244
+ oldBytes,
245
+ newBytes,
246
+ removedSections: markdownLevelTwoSections(before).filter((section) => {
247
+ const count = remaining.get(section) ?? 0;
248
+ if (count === 0)
249
+ return true;
250
+ remaining.set(section, count - 1);
251
+ return false;
252
+ }),
253
+ diffstat: lineDiffstat(before, after),
254
+ };
255
+ }
129
256
  async function transition(repository, fullIdValue, apply, subject, now) {
130
257
  return repository.mutate((board) => {
131
258
  requireHealthy(board);
132
259
  const id = fullIdValue === undefined
133
- ? selectSoleInProgress(board).id
260
+ ? selectSoleInProgress(board).coordinate
134
261
  : resolveTaskId(board, fullIdValue);
135
262
  const current = requireExisting(board, id);
136
263
  const document = documentFromView(current);
@@ -141,17 +268,18 @@ async function transition(repository, fullIdValue, apply, subject, now) {
141
268
  }
142
269
  function rejectBound(document, action = "state") {
143
270
  if (document.contractId !== undefined) {
271
+ const coordinate = documentCoordinate(document);
144
272
  if (action === "drop") {
145
- throw new FlowError("UNSUPPORTED_POLICY", `task ${document.id} is bound to contract ${document.contractId}; forfeit the contract before dropping this task`);
273
+ throw new FlowError("UNSUPPORTED_POLICY", `task ${coordinate} is bound to contract ${document.contractId}; forfeit the contract before dropping this task`);
146
274
  }
147
- throw new FlowError("UNSUPPORTED_POLICY", `task ${document.id} is bound to contract ${document.contractId}; its state follows the contract`);
275
+ throw new FlowError("UNSUPPORTED_POLICY", `task ${coordinate} is bound to contract ${document.contractId}; its state follows the contract`);
148
276
  }
149
277
  }
150
278
  export async function startTask(repository, fullId, now) {
151
279
  return transition(repository, fullId, (document) => {
152
280
  rejectBound(document);
153
281
  if (document.state !== "open")
154
- invalid(`task ${document.id} is ${document.state}, not open`);
282
+ invalid(`task ${documentCoordinate(document)} is ${document.state}, not open`);
155
283
  document.state = "in_progress";
156
284
  document.startedAt = dateTimestamp("startedAt", now);
157
285
  }, (id) => `Start task ${id}`, now);
@@ -160,16 +288,34 @@ export async function stopTask(repository, fullId, now = new Date()) {
160
288
  return transition(repository, fullId, (document) => {
161
289
  rejectBound(document);
162
290
  if (document.state !== "in_progress")
163
- invalid(`task ${document.id} is ${document.state}, not in_progress`);
291
+ invalid(`task ${documentCoordinate(document)} is ${document.state}, not in_progress`);
164
292
  document.state = "open";
165
293
  delete document.startedAt;
166
294
  }, (id) => `Stop task ${id}`, now);
167
295
  }
168
- export async function doneTask(repository, fullId, now = new Date()) {
296
+ export async function holdTask(repository, fullId, now = new Date()) {
169
297
  return transition(repository, fullId, (document) => {
170
298
  rejectBound(document);
171
299
  if (document.state !== "open" && document.state !== "in_progress") {
172
- invalid(`task ${document.id} is ${document.state}, not open or in_progress`);
300
+ invalid(`task ${documentCoordinate(document)} is ${document.state}, not open or in_progress`);
301
+ }
302
+ document.state = "on_hold";
303
+ delete document.startedAt;
304
+ }, (id) => `Hold task ${id}`, now);
305
+ }
306
+ export async function resumeTask(repository, fullId, now = new Date()) {
307
+ return transition(repository, fullId, (document) => {
308
+ rejectBound(document);
309
+ if (document.state !== "on_hold")
310
+ invalid(`task ${documentCoordinate(document)} is ${document.state}, not on_hold`);
311
+ document.state = "open";
312
+ }, (id) => `Resume task ${id}`, now);
313
+ }
314
+ export async function doneTask(repository, fullId, now = new Date()) {
315
+ return transition(repository, fullId, (document) => {
316
+ rejectBound(document);
317
+ if (document.state !== "open" && document.state !== "in_progress" && document.state !== "on_hold") {
318
+ invalid(`task ${documentCoordinate(document)} is ${document.state}, not open, in_progress, or on_hold`);
173
319
  }
174
320
  document.state = "done";
175
321
  delete document.startedAt;
@@ -178,8 +324,8 @@ export async function doneTask(repository, fullId, now = new Date()) {
178
324
  export async function dropTask(repository, fullId, now = new Date()) {
179
325
  return transition(repository, fullId, (document) => {
180
326
  rejectBound(document, "drop");
181
- if (document.state !== "open" && document.state !== "in_progress") {
182
- invalid(`task ${document.id} is ${document.state}, not open or in_progress`);
327
+ if (document.state !== "open" && document.state !== "in_progress" && document.state !== "on_hold") {
328
+ invalid(`task ${documentCoordinate(document)} is ${document.state}, not open, in_progress, or on_hold`);
183
329
  }
184
330
  document.state = "drop";
185
331
  delete document.startedAt;
@@ -189,11 +335,13 @@ export function selectSoleInProgress(board) {
189
335
  const candidates = board.tasks.filter((task) => task.state === "in_progress");
190
336
  if (candidates.length === 1)
191
337
  return candidates[0];
192
- const ids = candidates.map((task) => task.id).sort();
193
- const code = candidates.length === 0 ? "INVALID_TARGET" : "CONTRACT_AMBIGUOUS";
338
+ const ids = candidates.map((task) => task.coordinate).sort();
339
+ const code = candidates.length === 0 ? "INVALID_TARGET" : "TASK_AMBIGUOUS";
194
340
  throw Object.assign(new FlowError(code, candidates.length === 0
195
341
  ? "no task in progress"
196
- : "more than one task in progress; pass an ID"), { candidates: ids, candidateTasks: candidates });
342
+ : "more than one task in progress; pass a coordinate", candidates.length === 0 ? undefined : {
343
+ facts: { kind: "task_ambiguous", candidates: ids },
344
+ }), { candidates: ids, candidateTasks: candidates });
197
345
  }
198
346
  /** Reopen task pointers whose contract authority has disappeared. The caller
199
347
  * supplies the ledger fact; this operation itself remains storage-neutral. */
@@ -201,7 +349,7 @@ export async function rollbackOrphanTaskPointers(repository, contractIds, now =
201
349
  const before = await repository.read();
202
350
  const orphans = before.tasks
203
351
  .filter((task) => task.contractId !== undefined && !contractIds.has(task.contractId))
204
- .map((task) => ({ taskId: task.id, contractId: task.contractId }))
352
+ .map((task) => ({ taskId: task.coordinate, contractId: task.contractId }))
205
353
  .sort((a, b) => a.taskId.localeCompare(b.taskId));
206
354
  if (orphans.length === 0)
207
355
  return [];
@@ -220,7 +368,7 @@ export async function rollbackOrphanTaskPointers(repository, contractIds, now =
220
368
  if (replacements.length === 0)
221
369
  throw new FlowError("CONCURRENT_MODIFICATION", "task pointers changed while doctor was reconciling them");
222
370
  const subject = replacements.length === 1
223
- ? `Clear orphan contract pointer from task ${replacements[0].id}`
371
+ ? `Clear orphan contract pointer from task ${documentCoordinate(replacements[0])}`
224
372
  : `Clear orphan contract pointers from ${replacements.length} tasks`;
225
373
  return { replacements, subject, select: () => undefined };
226
374
  });
@@ -0,0 +1,56 @@
1
+ import { FlowError } from "../../flow-error.js";
2
+ import { isNormalizedIdentifierSlug } from "../identifier-slug.js";
3
+ import { taskIdentifierSlug } from "../identifier-slug.js";
4
+ export function formatTaskCoordinate(namespace, localId) {
5
+ return namespace === null ? localId : `${namespace}/${localId}`;
6
+ }
7
+ export function parseTaskCoordinate(value, field = "task coordinate") {
8
+ const parts = value.split("/");
9
+ if (parts.length < 1 || parts.length > 2) {
10
+ throw new FlowError("INVALID_TARGET", `${field} must be <local-id> or <namespace>/<local-id>`);
11
+ }
12
+ const namespace = parts.length === 1 ? null : parts[0];
13
+ const localId = parts.at(-1);
14
+ if (!isNormalizedIdentifierSlug(localId) || (namespace !== null && !isNormalizedIdentifierSlug(namespace))) {
15
+ throw new FlowError("INVALID_TARGET", `${field} must be <local-id> or <namespace>/<local-id>`);
16
+ }
17
+ return { namespace, localId, coordinate: formatTaskCoordinate(namespace, localId) };
18
+ }
19
+ export function isTaskCoordinate(value) {
20
+ try {
21
+ parseTaskCoordinate(value);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ export function taskCoordinateFromSourcePath(sourcePath) {
29
+ const normalized = sourcePath.replaceAll("\\", "/");
30
+ const marker = "/.keiyaku/tasks/";
31
+ const markerIndex = normalized.lastIndexOf(marker);
32
+ const relative = markerIndex < 0 ? normalized : normalized.slice(markerIndex + marker.length);
33
+ if (!relative.endsWith(".md")) {
34
+ throw new FlowError("INVALID_TARGET", `task source must be a Markdown file below .keiyaku/tasks: ${sourcePath}`);
35
+ }
36
+ return parseTaskCoordinate(relative.slice(0, -3), "task source coordinate");
37
+ }
38
+ export const TASK_NAMESPACE_SCOPE_PREFIX = ".keiyaku/tasks/";
39
+ export function taskNamespaceScope(namespace) {
40
+ parseTaskCoordinate(`${namespace}/task`, "task namespace");
41
+ return `${TASK_NAMESPACE_SCOPE_PREFIX}${namespace}/**`;
42
+ }
43
+ export function taskNamespaceFromScope(term) {
44
+ const match = /^\.keiyaku\/tasks\/([^/]+)\/\*\*$/.exec(term);
45
+ if (!match || !isNormalizedIdentifierSlug(match[1]))
46
+ return undefined;
47
+ return match[1];
48
+ }
49
+ export function allocateTaskNamespace(title, occupied) {
50
+ for (let ordinal = 1; Number.isSafeInteger(ordinal); ordinal += 1) {
51
+ const candidate = taskIdentifierSlug(title, ordinal === 1 ? "" : `-${ordinal}`);
52
+ if (!occupied.has(candidate))
53
+ return candidate;
54
+ }
55
+ throw new FlowError("INTERNAL_STATE", "task namespace collision ordinal exceeded the safe integer range");
56
+ }