@abelxiaoxing/cadence 1.8.4 → 2.0.1

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 (202) hide show
  1. package/README.md +324 -117
  2. package/THIRD_PARTY_NOTICES.md +15 -0
  3. package/agents/design-explorer.md +14 -18
  4. package/agents/diagnosis-worker.md +14 -14
  5. package/agents/implementation-worker.md +16 -17
  6. package/config/workflow-task.example.json +7 -0
  7. package/docs/remote-access.md +60 -0
  8. package/docs/reuse-and-distribution.md +44 -0
  9. package/docs/service-management.md +108 -0
  10. package/licenses/bundled-web.txt +6245 -0
  11. package/licenses/pi-web-ui-MIT.txt +21 -0
  12. package/licenses/pi-web-ui-reuse.md +22 -0
  13. package/package.json +92 -20
  14. package/src/index.ts +21 -1802
  15. package/src/native-build.ts +6 -0
  16. package/src/native-client.ts +103 -0
  17. package/src/native-export.ts +58 -0
  18. package/src/native-history.ts +359 -0
  19. package/src/operator-cli.mjs +453 -1937
  20. package/src/plan-proposal.ts +87 -0
  21. package/src/sdk-worker.mjs +549 -0
  22. package/src/service/contracts/active.ts +82 -0
  23. package/src/service/contracts/budget.ts +148 -0
  24. package/src/service/contracts/build.ts +67 -0
  25. package/src/service/contracts/capture.ts +48 -0
  26. package/src/service/contracts/core.ts +33 -0
  27. package/src/service/contracts/current.ts +261 -0
  28. package/src/service/contracts/domain.ts +68 -0
  29. package/src/service/contracts/history.ts +2245 -0
  30. package/src/service/contracts/models.ts +139 -0
  31. package/src/service/coordination.ts +862 -0
  32. package/src/service/private-files.ts +204 -0
  33. package/src/service-cli.mjs +5592 -0
  34. package/src/service-client.ts +747 -0
  35. package/src/service-contract.ts +245 -0
  36. package/src/service-host.mjs +22382 -0
  37. package/src/service-http-client.ts +425 -0
  38. package/src/service-wire.ts +372 -0
  39. package/src/task-shell.ts +1045 -0
  40. package/src/task-store.ts +479 -0
  41. package/src/task-types.ts +184 -0
  42. package/src/verification-diagnostics.ts +29 -72
  43. package/src/worker-capture.mjs +1197 -0
  44. package/src/worker-public.mjs +1753 -0
  45. package/src/worker-redaction.mjs +608 -0
  46. package/src/worker-tools.mjs +202 -0
  47. package/web/dist/assets/KaTeX_AMS-Regular.ttf +0 -0
  48. package/web/dist/assets/KaTeX_AMS-Regular.woff +0 -0
  49. package/web/dist/assets/KaTeX_AMS-Regular.woff2 +0 -0
  50. package/web/dist/assets/KaTeX_Caligraphic-Bold.ttf +0 -0
  51. package/web/dist/assets/KaTeX_Caligraphic-Bold.woff +0 -0
  52. package/web/dist/assets/KaTeX_Caligraphic-Bold.woff2 +0 -0
  53. package/web/dist/assets/KaTeX_Caligraphic-Regular.ttf +0 -0
  54. package/web/dist/assets/KaTeX_Caligraphic-Regular.woff +0 -0
  55. package/web/dist/assets/KaTeX_Caligraphic-Regular.woff2 +0 -0
  56. package/web/dist/assets/KaTeX_Fraktur-Bold.ttf +0 -0
  57. package/web/dist/assets/KaTeX_Fraktur-Bold.woff +0 -0
  58. package/web/dist/assets/KaTeX_Fraktur-Bold.woff2 +0 -0
  59. package/web/dist/assets/KaTeX_Fraktur-Regular.ttf +0 -0
  60. package/web/dist/assets/KaTeX_Fraktur-Regular.woff +0 -0
  61. package/web/dist/assets/KaTeX_Fraktur-Regular.woff2 +0 -0
  62. package/web/dist/assets/KaTeX_Main-Bold.ttf +0 -0
  63. package/web/dist/assets/KaTeX_Main-Bold.woff +0 -0
  64. package/web/dist/assets/KaTeX_Main-Bold.woff2 +0 -0
  65. package/web/dist/assets/KaTeX_Main-BoldItalic.ttf +0 -0
  66. package/web/dist/assets/KaTeX_Main-BoldItalic.woff +0 -0
  67. package/web/dist/assets/KaTeX_Main-BoldItalic.woff2 +0 -0
  68. package/web/dist/assets/KaTeX_Main-Italic.ttf +0 -0
  69. package/web/dist/assets/KaTeX_Main-Italic.woff +0 -0
  70. package/web/dist/assets/KaTeX_Main-Italic.woff2 +0 -0
  71. package/web/dist/assets/KaTeX_Main-Regular.ttf +0 -0
  72. package/web/dist/assets/KaTeX_Main-Regular.woff +0 -0
  73. package/web/dist/assets/KaTeX_Main-Regular.woff2 +0 -0
  74. package/web/dist/assets/KaTeX_Math-BoldItalic.ttf +0 -0
  75. package/web/dist/assets/KaTeX_Math-BoldItalic.woff +0 -0
  76. package/web/dist/assets/KaTeX_Math-BoldItalic.woff2 +0 -0
  77. package/web/dist/assets/KaTeX_Math-Italic.ttf +0 -0
  78. package/web/dist/assets/KaTeX_Math-Italic.woff +0 -0
  79. package/web/dist/assets/KaTeX_Math-Italic.woff2 +0 -0
  80. package/web/dist/assets/KaTeX_SansSerif-Bold.ttf +0 -0
  81. package/web/dist/assets/KaTeX_SansSerif-Bold.woff +0 -0
  82. package/web/dist/assets/KaTeX_SansSerif-Bold.woff2 +0 -0
  83. package/web/dist/assets/KaTeX_SansSerif-Italic.ttf +0 -0
  84. package/web/dist/assets/KaTeX_SansSerif-Italic.woff +0 -0
  85. package/web/dist/assets/KaTeX_SansSerif-Italic.woff2 +0 -0
  86. package/web/dist/assets/KaTeX_SansSerif-Regular.ttf +0 -0
  87. package/web/dist/assets/KaTeX_SansSerif-Regular.woff +0 -0
  88. package/web/dist/assets/KaTeX_SansSerif-Regular.woff2 +0 -0
  89. package/web/dist/assets/KaTeX_Script-Regular.ttf +0 -0
  90. package/web/dist/assets/KaTeX_Script-Regular.woff +0 -0
  91. package/web/dist/assets/KaTeX_Script-Regular.woff2 +0 -0
  92. package/web/dist/assets/KaTeX_Size1-Regular.ttf +0 -0
  93. package/web/dist/assets/KaTeX_Size1-Regular.woff +0 -0
  94. package/web/dist/assets/KaTeX_Size1-Regular.woff2 +0 -0
  95. package/web/dist/assets/KaTeX_Size2-Regular.ttf +0 -0
  96. package/web/dist/assets/KaTeX_Size2-Regular.woff +0 -0
  97. package/web/dist/assets/KaTeX_Size2-Regular.woff2 +0 -0
  98. package/web/dist/assets/KaTeX_Size3-Regular.ttf +0 -0
  99. package/web/dist/assets/KaTeX_Size3-Regular.woff +0 -0
  100. package/web/dist/assets/KaTeX_Size3-Regular.woff2 +0 -0
  101. package/web/dist/assets/KaTeX_Size4-Regular.ttf +0 -0
  102. package/web/dist/assets/KaTeX_Size4-Regular.woff +0 -0
  103. package/web/dist/assets/KaTeX_Size4-Regular.woff2 +0 -0
  104. package/web/dist/assets/KaTeX_Typewriter-Regular.ttf +0 -0
  105. package/web/dist/assets/KaTeX_Typewriter-Regular.woff +0 -0
  106. package/web/dist/assets/KaTeX_Typewriter-Regular.woff2 +0 -0
  107. package/web/dist/assets/cadence.css +2 -0
  108. package/web/dist/assets/cadence.js +7 -0
  109. package/web/dist/assets/markdown.js +296 -0
  110. package/web/dist/assets/react.js +9 -0
  111. package/web/dist/assets/rolldown-runtime.js +1 -0
  112. package/web/dist/assets/timeline-client.js +41 -0
  113. package/web/dist/assets/vis-timeline.css +1274 -0
  114. package/web/dist/assets/vis-timeline.js +32 -0
  115. package/web/dist/index.html +18 -0
  116. package/web/dist/manifest.json +505 -0
  117. package/config/plan-draft.example.json +0 -125
  118. package/config/plan-draft.multiple-tasks.example.json +0 -375
  119. package/config/plan-draft.quick.example.json +0 -78
  120. package/prompts/abel-design.md +0 -382
  121. package/prompts/abel-diagnose.md +0 -71
  122. package/prompts/abel-implement.md +0 -284
  123. package/prompts/abel-init.md +0 -52
  124. package/src/activation.ts +0 -44
  125. package/src/activity-contracts.ts +0 -82
  126. package/src/agent-registry.ts +0 -53
  127. package/src/apply-transaction.ts +0 -1437
  128. package/src/artifact-store.ts +0 -213
  129. package/src/build-windows-job.mjs +0 -95
  130. package/src/candidate-artifact.ts +0 -143
  131. package/src/candidate-context.ts +0 -289
  132. package/src/candidate-patch.ts +0 -271
  133. package/src/candidate-workspace.ts +0 -80
  134. package/src/canonical.ts +0 -16
  135. package/src/change-contract-schema.ts +0 -191
  136. package/src/change-contract.ts +0 -324
  137. package/src/change-verification.ts +0 -1617
  138. package/src/contracts.ts +0 -2466
  139. package/src/control-contracts.ts +0 -382
  140. package/src/delivery-compiler.ts +0 -1805
  141. package/src/delivery-revision.ts +0 -42
  142. package/src/design-control.ts +0 -1299
  143. package/src/design-diagnostics.ts +0 -181
  144. package/src/design-journal.ts +0 -1773
  145. package/src/directory-sync.ts +0 -36
  146. package/src/durable-contracts.ts +0 -227
  147. package/src/durable-workflow.ts +0 -822
  148. package/src/evidence-draft.ts +0 -122
  149. package/src/execution-profile.ts +0 -64
  150. package/src/execution-retention.ts +0 -77
  151. package/src/file-snapshot.ts +0 -163
  152. package/src/implement-continuation.ts +0 -559
  153. package/src/implement-graph.ts +0 -690
  154. package/src/implement-plan.ts +0 -72
  155. package/src/install-config.d.mts +0 -6
  156. package/src/isolation-backend.ts +0 -353
  157. package/src/model-source.ts +0 -22
  158. package/src/openspec-cli.ts +0 -494
  159. package/src/operator-cli-entry.mjs +0 -24
  160. package/src/operator-tools.ts +0 -309
  161. package/src/package-candidate-process.ts +0 -206
  162. package/src/package-candidate.ts +0 -24
  163. package/src/package-delivery.ts +0 -486
  164. package/src/package-state.ts +0 -320
  165. package/src/package-verification.ts +0 -635
  166. package/src/package-workflow.ts +0 -305
  167. package/src/packet-runtime.ts +0 -459
  168. package/src/phase-execution.ts +0 -2163
  169. package/src/pi-adapter.ts +0 -19
  170. package/src/plan-draft-summary.ts +0 -527
  171. package/src/plan-draft.ts +0 -502
  172. package/src/remove-path.ts +0 -32
  173. package/src/run-state.ts +0 -420
  174. package/src/run-store.ts +0 -1179
  175. package/src/safe-path.ts +0 -77
  176. package/src/scoped-grep-worker.mjs +0 -23
  177. package/src/scoped-tools.ts +0 -775
  178. package/src/single-task-draft.ts +0 -142
  179. package/src/sqlite-schema.ts +0 -171
  180. package/src/state-root.ts +0 -215
  181. package/src/storage-schema.ts +0 -360
  182. package/src/subagent-activity.ts +0 -1417
  183. package/src/subagent-process.ts +0 -476
  184. package/src/task-ledger.ts +0 -1338
  185. package/src/transport-budget.ts +0 -50
  186. package/src/verification-capability.ts +0 -1191
  187. package/src/verification-environment-io.ts +0 -389
  188. package/src/verification-environment.ts +0 -162
  189. package/src/verification-identity.ts +0 -77
  190. package/src/verification-scan.ts +0 -64
  191. package/src/windows-job-backend.ts +0 -419
  192. package/src/windows-job.c +0 -149
  193. package/src/workflow-engine.ts +0 -28
  194. package/src/workflow-policy.ts +0 -1670
  195. package/src/workflow-recovery-policy.ts +0 -97
  196. package/src/workflow-scheduling.ts +0 -105
  197. package/src/workflow-state-machine.ts +0 -4341
  198. package/src/workflow-status.ts +0 -576
  199. package/src/workspace-io-entry.mjs +0 -76
  200. package/src/workspace-io-worker.mjs +0 -1312
  201. package/src/workspace-io.ts +0 -89
  202. package/src/workspace-store.ts +0 -634
@@ -0,0 +1,479 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import {
4
+ closeSync,
5
+ constants,
6
+ fstatSync,
7
+ fsyncSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ openSync,
11
+ readdirSync,
12
+ readSync,
13
+ realpathSync,
14
+ renameSync,
15
+ type Stats,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { homedir, hostname } from "node:os";
20
+ import {
21
+ dirname,
22
+ isAbsolute,
23
+ join,
24
+ parse,
25
+ relative,
26
+ resolve,
27
+ sep,
28
+ } from "node:path";
29
+ import {
30
+ decodeCheck,
31
+ decodeTask,
32
+ MAX_RECORD_BYTES,
33
+ object,
34
+ type TaskRecord,
35
+ validId,
36
+ } from "./task-types.ts";
37
+ import { diagnosticText } from "./verification-diagnostics.ts";
38
+
39
+ function parseRecord(data: Buffer): unknown {
40
+ try {
41
+ return JSON.parse(data.toString("utf8"));
42
+ } catch {
43
+ throw new Error("Corrupt JSON state; preserved without reset");
44
+ }
45
+ }
46
+ function absent(error: unknown): boolean {
47
+ return object(error) && error.code === "ENOENT";
48
+ }
49
+ /** Reject links in every existing ancestor. Missing observation never creates paths.
50
+ * The readonly hook inspects each existing directory synchronously, including
51
+ * the root, before visiting its child. It must not await, mutate or retain state
52
+ * as authority for a later call/effect. Legacy/default traversal is unchanged.
53
+ */
54
+ export function safeDirectory(path: string, create?: boolean): boolean;
55
+ export function safeDirectory(
56
+ path: string,
57
+ create: false,
58
+ inspectExisting: (path: string, stat: Stats) => void,
59
+ ): boolean;
60
+ export function safeDirectory(
61
+ path: string,
62
+ create = false,
63
+ inspectExisting?: (path: string, stat: Stats) => void,
64
+ ): boolean {
65
+ const full = resolve(path);
66
+ const root = parse(full).root;
67
+ if (inspectExisting) {
68
+ const st = lstatSync(root);
69
+ if (!st.isDirectory() || st.isSymbolicLink())
70
+ throw new Error("Unsafe directory");
71
+ inspectExisting(root, st);
72
+ }
73
+ let at = root;
74
+ for (const part of full.slice(root.length).split(sep).filter(Boolean)) {
75
+ at = join(at, part);
76
+ let existing: Stats | undefined;
77
+ try {
78
+ const st = lstatSync(at);
79
+ if (!st.isDirectory()) throw new Error("Unsafe directory");
80
+ const checked = lstatSync(at);
81
+ if (
82
+ checked.isSymbolicLink() ||
83
+ (inspectExisting && !checked.isDirectory())
84
+ )
85
+ throw new Error("Unsafe directory");
86
+ // Inspect the latest observation, not an earlier stat whose mode/uid
87
+ // may already have changed. No extra IO or cross-call authority cache.
88
+ existing = checked;
89
+ } catch (error) {
90
+ if (!absent(error)) throw error;
91
+ if (!create) return false;
92
+ try {
93
+ mkdirSync(at, { mode: 0o700 });
94
+ } catch (mkdirError) {
95
+ if (!object(mkdirError) || mkdirError.code !== "EEXIST")
96
+ throw mkdirError;
97
+ }
98
+ if (!lstatSync(at).isDirectory() || lstatSync(at).isSymbolicLink())
99
+ throw new Error("Unsafe directory");
100
+ }
101
+ // Outside the ENOENT handler: an inspection refusal is never absence and
102
+ // must not authorize creation, even if its error happens to carry ENOENT.
103
+ if (existing) inspectExisting?.(at, existing);
104
+ }
105
+ return true;
106
+ }
107
+ export function readBounded(path: string, max: number): Buffer | undefined {
108
+ if (!safeDirectory(dirname(path))) return undefined;
109
+ let fd: number;
110
+ try {
111
+ const st = lstatSync(path);
112
+ if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1 || st.size > max)
113
+ throw new Error("Unsafe or oversized file");
114
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
115
+ } catch (error) {
116
+ if (absent(error)) return undefined;
117
+ throw error;
118
+ }
119
+ try {
120
+ const st = fstatSync(fd);
121
+ if (!st.isFile() || st.nlink !== 1 || st.size > max)
122
+ throw new Error("Unsafe or oversized file");
123
+ // Read at most max+1 even if another editor grows the file after stat.
124
+ const buffer = Buffer.alloc(max + 1);
125
+ let size = 0;
126
+ while (size < buffer.length) {
127
+ const n = readSync(fd, buffer, size, buffer.length - size, null);
128
+ if (!n) break;
129
+ size += n;
130
+ }
131
+ if (size > max) throw new Error("Oversized file");
132
+ return buffer.subarray(0, size);
133
+ } finally {
134
+ closeSync(fd);
135
+ }
136
+ }
137
+
138
+ /** Git observations must describe the physical cwd, not inherited alternate index/worktree overrides. */
139
+ export function gitEnvironment(): NodeJS.ProcessEnv {
140
+ return {
141
+ ...Object.fromEntries(
142
+ Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_")),
143
+ ),
144
+ GIT_OPTIONAL_LOCKS: "0",
145
+ LC_ALL: "C",
146
+ };
147
+ }
148
+ export function resolveWorkspace(cwd: string): { root: string; cwd: string } {
149
+ const actual = realpathSync(cwd);
150
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
151
+ cwd: actual,
152
+ env: gitEnvironment(),
153
+ encoding: "utf8",
154
+ timeout: 3000,
155
+ maxBuffer: 8192,
156
+ windowsHide: true,
157
+ });
158
+ // Remove only Git's record terminator, not valid whitespace in a path.
159
+ const topLevel = result.stdout?.replace(
160
+ process.platform === "win32" ? /\r?\n$/ : /\n$/,
161
+ "",
162
+ );
163
+ if (result.status === 0 && topLevel)
164
+ return { root: realpathSync(topLevel), cwd: actual };
165
+ if (result.error && !object(result.error))
166
+ throw new Error("Cannot resolve workspace");
167
+ if (result.error && object(result.error) && result.error.code !== "ENOENT")
168
+ throw new Error("Workspace lookup failed");
169
+ // A missing Git executable or a confirmed non-repository is an honest non-Git workspace.
170
+ if (!result.error && !result.stderr.includes("not a git repository"))
171
+ throw new Error("Git workspace lookup refused");
172
+ return { root: actual, cwd: actual };
173
+ }
174
+ export function defaultStateHome(): string {
175
+ const candidate =
176
+ process.platform === "win32"
177
+ ? process.env.LOCALAPPDATA
178
+ : process.env.XDG_STATE_HOME;
179
+ if (candidate && !isAbsolute(candidate))
180
+ throw new Error("State home must be absolute");
181
+ return (
182
+ candidate ??
183
+ join(
184
+ homedir(),
185
+ process.platform === "win32" ? "AppData/Local" : ".local/state",
186
+ )
187
+ );
188
+ }
189
+ /** Canonical bytes written by save; identity is optimistic record concurrency, not consent. */
190
+ function recordText(record: TaskRecord): string {
191
+ return `${JSON.stringify(record, null, 2)}\n`;
192
+ }
193
+ function contentIdentity(text: string | Buffer): string {
194
+ return createHash("sha256").update(text).digest("hex");
195
+ }
196
+ export function savedRecordIdentity(record: TaskRecord): string {
197
+ return contentIdentity(recordText(record));
198
+ }
199
+ /** Resolve ambient aliases, including a missing tail, without creating anything. */
200
+ function canonicalAmbientRoot(path: string): string {
201
+ const full = resolve(path);
202
+ try {
203
+ return realpathSync(full);
204
+ } catch (error) {
205
+ if (!absent(error)) throw error;
206
+ // A dangling alias is not a missing ambient directory.
207
+ try {
208
+ lstatSync(full);
209
+ throw new Error("Dangling ambient root");
210
+ } catch (statError) {
211
+ if (!absent(statError)) throw statError;
212
+ }
213
+ const parent = dirname(full);
214
+ if (parent === full) throw error;
215
+ return join(canonicalAmbientRoot(parent), full.slice(parent.length));
216
+ }
217
+ }
218
+ export interface CheckoutLock {
219
+ schema: "cadence-assistant-lock/1";
220
+ token: string;
221
+ pid: number;
222
+ host: string;
223
+ runId: string;
224
+ root: string;
225
+ }
226
+ export class TaskStore {
227
+ readonly root: string;
228
+ readonly tasks: string;
229
+ readonly privateRoot: string;
230
+ constructor(root: string, stateHome = defaultStateHome()) {
231
+ this.root = realpathSync(root);
232
+ this.tasks = join(this.root, ".cadence", "assistant", "tasks");
233
+ this.privateRoot = join(
234
+ canonicalAmbientRoot(stateHome),
235
+ "cadence-assistant-v1",
236
+ createHash("sha256").update(this.root).digest("hex"),
237
+ );
238
+ const within = relative(this.root, resolve(this.privateRoot));
239
+ if (
240
+ !within ||
241
+ (!within.startsWith(`..${sep}`) && within !== ".." && !isAbsolute(within))
242
+ )
243
+ throw new Error("Private state home must be outside the working copy");
244
+ }
245
+ get lockPath(): string {
246
+ return join(this.privateRoot, "checkout.lock");
247
+ }
248
+ private inspectPrivate(): void {
249
+ const namespace = dirname(this.privateRoot);
250
+ for (const path of [namespace, this.privateRoot]) {
251
+ if (!safeDirectory(path)) continue;
252
+ const st = lstatSync(path);
253
+ if (
254
+ process.platform !== "win32" &&
255
+ ((st.mode & 0o077) !== 0 ||
256
+ (process.getuid && st.uid !== process.getuid()))
257
+ )
258
+ throw new Error("Private state permissions refused");
259
+ }
260
+ }
261
+ private inspectPrivateFile(path: string, max: number): void {
262
+ this.inspectPrivate();
263
+ if (!safeDirectory(this.privateRoot)) return;
264
+ try {
265
+ const st = lstatSync(path);
266
+ if (
267
+ !st.isFile() ||
268
+ st.isSymbolicLink() ||
269
+ st.nlink !== 1 ||
270
+ st.size > max ||
271
+ (process.platform !== "win32" &&
272
+ ((st.mode & 0o077) !== 0 ||
273
+ (process.getuid && st.uid !== process.getuid())))
274
+ )
275
+ throw new Error("Unsafe private state file");
276
+ } catch (error) {
277
+ if (!absent(error)) throw error;
278
+ }
279
+ }
280
+ inspectLock(): CheckoutLock | undefined {
281
+ this.inspectPrivateFile(this.lockPath, 8192);
282
+ const data = readBounded(this.lockPath, 8192);
283
+ if (!data) return undefined;
284
+ const lock: unknown = parseRecord(data);
285
+ if (
286
+ !object(lock) ||
287
+ lock.schema !== "cadence-assistant-lock/1" ||
288
+ lock.root !== this.root ||
289
+ typeof lock.token !== "string" ||
290
+ !validId(lock.token) ||
291
+ typeof lock.runId !== "string" ||
292
+ !validId(lock.runId) ||
293
+ typeof lock.host !== "string" ||
294
+ !Number.isInteger(lock.pid) ||
295
+ Number(lock.pid) < 1
296
+ )
297
+ throw new Error("Unknown or corrupt coordination lock");
298
+ return lock as unknown as CheckoutLock;
299
+ }
300
+ acquire(): CheckoutLock {
301
+ this.inspectPrivate();
302
+ safeDirectory(this.privateRoot, true);
303
+ const lock: CheckoutLock = {
304
+ schema: "cadence-assistant-lock/1",
305
+ token: randomUUID(),
306
+ runId: randomUUID(),
307
+ pid: process.pid,
308
+ host: hostname(),
309
+ root: this.root,
310
+ };
311
+ let fd: number;
312
+ try {
313
+ fd = openSync(this.lockPath, "wx", 0o600);
314
+ } catch {
315
+ throw new Error(
316
+ "Checkout busy or unresolved lock; inspect and explicitly recover only a dead owner",
317
+ );
318
+ }
319
+ try {
320
+ writeFileSync(fd, JSON.stringify(lock));
321
+ fsyncSync(fd);
322
+ } finally {
323
+ closeSync(fd);
324
+ }
325
+ return lock;
326
+ }
327
+ assertOwner(lock: CheckoutLock): void {
328
+ if (JSON.stringify(this.inspectLock()) !== JSON.stringify(lock))
329
+ throw new Error("Coordination lock changed");
330
+ }
331
+ release(lock: CheckoutLock): void {
332
+ if (lock.pid !== process.pid || lock.host !== hostname())
333
+ throw new Error("Only the owning process may release a lock");
334
+ this.assertOwner(lock);
335
+ unlinkSync(this.lockPath);
336
+ }
337
+ recover(expected: CheckoutLock, acknowledgeEscapedProcesses: boolean): void {
338
+ if (!acknowledgeEscapedProcesses || expected.host !== hostname())
339
+ throw new Error("Recovery requires informed local-owner confirmation");
340
+ this.assertOwner(expected);
341
+ try {
342
+ process.kill(expected.pid, 0);
343
+ } catch (error) {
344
+ if (!object(error) || error.code !== "ESRCH")
345
+ throw new Error("Owner status unknown");
346
+ this.assertOwner(expected);
347
+ unlinkSync(this.lockPath);
348
+ return;
349
+ }
350
+ throw new Error("Owner is live; stop it first");
351
+ }
352
+ load(id: string): TaskRecord | undefined {
353
+ return this.loadSnapshot(id)?.record;
354
+ }
355
+ loadSnapshot(
356
+ id: string,
357
+ ): { record: TaskRecord; identity: string } | undefined {
358
+ if (!validId(id)) throw new Error("Invalid task id");
359
+ const directory = join(this.tasks, id);
360
+ if (
361
+ safeDirectory(directory) &&
362
+ readdirSync(directory).some((name) => name !== "task.json")
363
+ )
364
+ throw new Error(
365
+ "Unknown task directory content; preserved without reset",
366
+ );
367
+ const data = readBounded(
368
+ join(this.tasks, id, "task.json"),
369
+ MAX_RECORD_BYTES,
370
+ );
371
+ if (!data) return undefined;
372
+ const record = decodeTask(parseRecord(data));
373
+ if (record.id !== id) throw new Error("Task identity mismatch");
374
+ return { record, identity: contentIdentity(data) };
375
+ }
376
+ list(): TaskRecord[] {
377
+ if (!safeDirectory(this.tasks)) return [];
378
+ const entries = readdirSync(this.tasks);
379
+ if (entries.length > 1000) throw new Error("Task listing limit exceeded");
380
+ return entries.map((id) => {
381
+ const task = this.load(id);
382
+ if (!task) throw new Error("Unknown task directory");
383
+ return task;
384
+ });
385
+ }
386
+ save(
387
+ record: TaskRecord,
388
+ expectedRevision: number,
389
+ held?: CheckoutLock,
390
+ expectedIdentity?: string,
391
+ ): TaskRecord {
392
+ const next = decodeTask(record);
393
+ if (next.revision !== expectedRevision + 1)
394
+ throw new Error("Revision conflict");
395
+ const text = recordText(next);
396
+ if (Buffer.byteLength(text) > MAX_RECORD_BYTES)
397
+ throw new Error("Task record limit");
398
+ const lock = held ?? this.acquire();
399
+ if (lock.pid !== process.pid || lock.host !== hostname())
400
+ throw new Error("Not the lock owner");
401
+ try {
402
+ this.assertOwner(lock);
403
+ const current = this.loadSnapshot(next.id);
404
+ if ((current?.record.revision ?? 0) !== expectedRevision)
405
+ throw new Error("Revision conflict");
406
+ if (current?.identity !== expectedIdentity)
407
+ throw new Error("Task content changed; reopen it");
408
+ const dir = join(this.tasks, next.id);
409
+ safeDirectory(dir, true);
410
+ // Unknown sibling state is not overwritten or cleaned.
411
+ if (!current && readdirSync(dir).length)
412
+ throw new Error("Unknown task directory");
413
+ const temporary = join(dir, `task.${randomUUID()}.tmp`);
414
+ const fd = openSync(temporary, "wx", 0o600);
415
+ try {
416
+ writeFileSync(fd, text);
417
+ fsyncSync(fd);
418
+ } finally {
419
+ closeSync(fd);
420
+ }
421
+ this.assertOwner(lock);
422
+ // Check again after preparing the replacement. External editors do not share our lock;
423
+ // this detects observed conflicts, not an atomic filesystem CAS against hostile writers.
424
+ const latest = readBounded(join(dir, "task.json"), MAX_RECORD_BYTES);
425
+ if ((latest ? contentIdentity(latest) : undefined) !== expectedIdentity)
426
+ throw new Error(
427
+ "Task content changed; replacement preserved without overwrite",
428
+ );
429
+ renameSync(temporary, join(dir, "task.json"));
430
+ return next;
431
+ } finally {
432
+ if (!held) this.release(lock);
433
+ }
434
+ }
435
+ checks() {
436
+ const data = readBounded(
437
+ join(this.root, ".cadence", "assistant", "config.json"),
438
+ 32768,
439
+ );
440
+ if (!data) return [];
441
+ const value: unknown = parseRecord(data);
442
+ if (
443
+ !object(value) ||
444
+ value.schema !== TASK_CONFIG_SCHEMA ||
445
+ !Array.isArray(value.checks) ||
446
+ value.checks.length > 32 ||
447
+ Object.keys(value).some((key) => !["schema", "checks"].includes(key))
448
+ )
449
+ throw new Error("Unknown check configuration");
450
+ const checks = value.checks.map(decodeCheck);
451
+ if (
452
+ new Set(checks.map((check) => check.name)).size !== checks.length ||
453
+ checks.some((check) => check.name === "Manual command")
454
+ )
455
+ throw new Error("Duplicate or reserved check name");
456
+ return checks;
457
+ }
458
+ readLog(name: string): string {
459
+ if (!/^[a-f0-9-]{36}\.log$/.test(name))
460
+ throw new Error("Invalid diagnostic name");
461
+ this.inspectPrivateFile(join(this.privateRoot, name), 65536);
462
+ const data = readBounded(join(this.privateRoot, name), 65536);
463
+ return data
464
+ ? diagnosticText(data.toString("utf8"), 65536)
465
+ : "Private diagnostic unavailable";
466
+ }
467
+ writeLog(lock: CheckoutLock, output: string): string {
468
+ if (lock.pid !== process.pid || lock.host !== hostname())
469
+ throw new Error("Not the lock owner");
470
+ this.assertOwner(lock);
471
+ const name = `${randomUUID()}.log`;
472
+ writeFileSync(join(this.privateRoot, name), diagnosticText(output, 65536), {
473
+ flag: "wx",
474
+ mode: 0o600,
475
+ });
476
+ return name;
477
+ }
478
+ }
479
+ export const TASK_CONFIG_SCHEMA = "cadence-assistant-checks/1";
@@ -0,0 +1,184 @@
1
+ /** Plain workflow context, not executable approvals or certified evidence. */
2
+ export const TASK_SCHEMA = "cadence-workflow-assistant/1";
3
+ export const MAX_RECORD_BYTES = 128 * 1024;
4
+ export type TaskKind = "code" | "research" | "diagnosis";
5
+ export type TaskPhase =
6
+ | "planning"
7
+ | "running"
8
+ | "review"
9
+ | "interrupted"
10
+ | "failed"
11
+ | "done";
12
+ export interface CheckDefinition {
13
+ name: string;
14
+ command: string;
15
+ timeoutMs: number;
16
+ }
17
+ export interface CheckRecord extends CheckDefinition {
18
+ cwd: string;
19
+ exitCode: number | null;
20
+ durationMs: number;
21
+ status: "passed" | "failed" | "canceled" | "stale";
22
+ fingerprint: string;
23
+ log?: string;
24
+ }
25
+ export interface TaskRecord {
26
+ schema: typeof TASK_SCHEMA;
27
+ id: string;
28
+ revision: number;
29
+ kind: TaskKind;
30
+ goal: string;
31
+ plan: string;
32
+ cwd: string;
33
+ phase: TaskPhase;
34
+ result: string;
35
+ baseline: string;
36
+ checks: CheckRecord[];
37
+ review?: {
38
+ fingerprint: string;
39
+ outcome: "reviewed" | "reviewed-with-issues";
40
+ };
41
+ activity: { at: string; text: string }[];
42
+ }
43
+ export interface ChangeView {
44
+ fingerprint: string;
45
+ text: string;
46
+ complete: boolean;
47
+ git: boolean;
48
+ }
49
+ export interface RunResult {
50
+ outcome: "completed" | "failed" | "canceled" | "timeout" | "unknown";
51
+ closed: boolean;
52
+ exitCode: number | null;
53
+ signal: string | null;
54
+ durationMs: number;
55
+ text: string;
56
+ output: string;
57
+ truncated: boolean;
58
+ }
59
+ export function object(value: unknown): value is Record<string, unknown> {
60
+ return value !== null && typeof value === "object" && !Array.isArray(value);
61
+ }
62
+ export function boundedString(value: unknown, max: number): value is string {
63
+ return (
64
+ typeof value === "string" &&
65
+ Buffer.byteLength(value) <= max &&
66
+ !value.includes("\0")
67
+ );
68
+ }
69
+ export function validId(value: string): boolean {
70
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
71
+ value,
72
+ );
73
+ }
74
+ export function decodeCheck(value: unknown): CheckDefinition {
75
+ if (
76
+ !object(value) ||
77
+ !boundedString(value.name, 128) ||
78
+ !value.name.trim() ||
79
+ !boundedString(value.command, 4096) ||
80
+ !value.command.trim() ||
81
+ !Number.isInteger(value.timeoutMs) ||
82
+ Number(value.timeoutMs) < 100 ||
83
+ Number(value.timeoutMs) > 1_200_000
84
+ )
85
+ throw new Error("Invalid check definition");
86
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: reject invisible terminal controls in executable confirmations
87
+ const controls = /[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u;
88
+ if (
89
+ controls.test(value.command) ||
90
+ controls.test(value.name) ||
91
+ /[\n\t]/u.test(value.name)
92
+ )
93
+ throw new Error(
94
+ "Check commands/names must be visibly representable in the terminal",
95
+ );
96
+ return {
97
+ name: value.name,
98
+ command: value.command,
99
+ timeoutMs: Number(value.timeoutMs),
100
+ };
101
+ }
102
+ export function decodeTask(value: unknown): TaskRecord {
103
+ if (
104
+ !object(value) ||
105
+ value.schema !== TASK_SCHEMA ||
106
+ typeof value.id !== "string" ||
107
+ !validId(value.id) ||
108
+ !Number.isSafeInteger(value.revision) ||
109
+ Number(value.revision) < 1 ||
110
+ typeof value.kind !== "string" ||
111
+ !["code", "research", "diagnosis"].includes(value.kind) ||
112
+ typeof value.phase !== "string" ||
113
+ ![
114
+ "planning",
115
+ "running",
116
+ "review",
117
+ "interrupted",
118
+ "failed",
119
+ "done",
120
+ ].includes(value.phase) ||
121
+ !boundedString(value.goal, 8192) ||
122
+ !value.goal.trim() ||
123
+ !boundedString(value.plan, 32768) ||
124
+ !boundedString(value.cwd, 4096) ||
125
+ !boundedString(value.result, 16384) ||
126
+ !boundedString(value.baseline, 16384) ||
127
+ !Array.isArray(value.checks) ||
128
+ value.checks.length > 32 ||
129
+ !Array.isArray(value.activity) ||
130
+ value.activity.length > 64
131
+ )
132
+ throw new Error("Unknown or corrupt task record");
133
+ for (const check of value.checks) {
134
+ decodeCheck(check);
135
+ if (
136
+ !object(check) ||
137
+ !boundedString(check.cwd, 4096) ||
138
+ !(check.exitCode === null || Number.isInteger(check.exitCode)) ||
139
+ typeof check.durationMs !== "number" ||
140
+ !Number.isFinite(check.durationMs) ||
141
+ check.durationMs < 0 ||
142
+ typeof check.status !== "string" ||
143
+ !["passed", "failed", "canceled", "stale"].includes(check.status) ||
144
+ !boundedString(check.fingerprint, 128) ||
145
+ (check.log !== undefined &&
146
+ (typeof check.log !== "string" || !/^[a-f0-9-]+\.log$/.test(check.log)))
147
+ )
148
+ throw new Error("Corrupt check record");
149
+ }
150
+ for (const entry of value.activity) {
151
+ if (
152
+ !object(entry) ||
153
+ !boundedString(entry.at, 64) ||
154
+ !boundedString(entry.text, 512)
155
+ )
156
+ throw new Error("Corrupt activity");
157
+ }
158
+ if (
159
+ value.review !== undefined &&
160
+ (!object(value.review) ||
161
+ !boundedString(value.review.fingerprint, 128) ||
162
+ typeof value.review.outcome !== "string" ||
163
+ !["reviewed", "reviewed-with-issues"].includes(value.review.outcome))
164
+ )
165
+ throw new Error("Corrupt review");
166
+ const allowed = new Set([
167
+ "schema",
168
+ "id",
169
+ "revision",
170
+ "kind",
171
+ "goal",
172
+ "plan",
173
+ "cwd",
174
+ "phase",
175
+ "result",
176
+ "baseline",
177
+ "checks",
178
+ "review",
179
+ "activity",
180
+ ]);
181
+ if (Object.keys(value).some((key) => !allowed.has(key)))
182
+ throw new Error("Unknown task fields");
183
+ return structuredClone(value) as unknown as TaskRecord;
184
+ }