@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.
- package/README.md +324 -117
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/agents/design-explorer.md +14 -18
- package/agents/diagnosis-worker.md +14 -14
- package/agents/implementation-worker.md +16 -17
- package/config/workflow-task.example.json +7 -0
- package/docs/remote-access.md +60 -0
- package/docs/reuse-and-distribution.md +44 -0
- package/docs/service-management.md +108 -0
- package/licenses/bundled-web.txt +6245 -0
- package/licenses/pi-web-ui-MIT.txt +21 -0
- package/licenses/pi-web-ui-reuse.md +22 -0
- package/package.json +92 -20
- package/src/index.ts +21 -1802
- package/src/native-build.ts +6 -0
- package/src/native-client.ts +103 -0
- package/src/native-export.ts +58 -0
- package/src/native-history.ts +359 -0
- package/src/operator-cli.mjs +453 -1937
- package/src/plan-proposal.ts +87 -0
- package/src/sdk-worker.mjs +549 -0
- package/src/service/contracts/active.ts +82 -0
- package/src/service/contracts/budget.ts +148 -0
- package/src/service/contracts/build.ts +67 -0
- package/src/service/contracts/capture.ts +48 -0
- package/src/service/contracts/core.ts +33 -0
- package/src/service/contracts/current.ts +261 -0
- package/src/service/contracts/domain.ts +68 -0
- package/src/service/contracts/history.ts +2245 -0
- package/src/service/contracts/models.ts +139 -0
- package/src/service/coordination.ts +862 -0
- package/src/service/private-files.ts +204 -0
- package/src/service-cli.mjs +5592 -0
- package/src/service-client.ts +747 -0
- package/src/service-contract.ts +245 -0
- package/src/service-host.mjs +22382 -0
- package/src/service-http-client.ts +425 -0
- package/src/service-wire.ts +372 -0
- package/src/task-shell.ts +1045 -0
- package/src/task-store.ts +479 -0
- package/src/task-types.ts +184 -0
- package/src/verification-diagnostics.ts +29 -72
- package/src/worker-capture.mjs +1197 -0
- package/src/worker-public.mjs +1753 -0
- package/src/worker-redaction.mjs +608 -0
- package/src/worker-tools.mjs +202 -0
- package/web/dist/assets/KaTeX_AMS-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic.woff +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Italic.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic.woff +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-Italic.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Script-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/web/dist/assets/cadence.css +2 -0
- package/web/dist/assets/cadence.js +7 -0
- package/web/dist/assets/markdown.js +296 -0
- package/web/dist/assets/react.js +9 -0
- package/web/dist/assets/rolldown-runtime.js +1 -0
- package/web/dist/assets/timeline-client.js +41 -0
- package/web/dist/assets/vis-timeline.css +1274 -0
- package/web/dist/assets/vis-timeline.js +32 -0
- package/web/dist/index.html +18 -0
- package/web/dist/manifest.json +505 -0
- package/config/plan-draft.example.json +0 -125
- package/config/plan-draft.multiple-tasks.example.json +0 -375
- package/config/plan-draft.quick.example.json +0 -78
- package/prompts/abel-design.md +0 -382
- package/prompts/abel-diagnose.md +0 -71
- package/prompts/abel-implement.md +0 -284
- package/prompts/abel-init.md +0 -52
- package/src/activation.ts +0 -44
- package/src/activity-contracts.ts +0 -82
- package/src/agent-registry.ts +0 -53
- package/src/apply-transaction.ts +0 -1437
- package/src/artifact-store.ts +0 -213
- package/src/build-windows-job.mjs +0 -95
- package/src/candidate-artifact.ts +0 -143
- package/src/candidate-context.ts +0 -289
- package/src/candidate-patch.ts +0 -271
- package/src/candidate-workspace.ts +0 -80
- package/src/canonical.ts +0 -16
- package/src/change-contract-schema.ts +0 -191
- package/src/change-contract.ts +0 -324
- package/src/change-verification.ts +0 -1617
- package/src/contracts.ts +0 -2466
- package/src/control-contracts.ts +0 -382
- package/src/delivery-compiler.ts +0 -1805
- package/src/delivery-revision.ts +0 -42
- package/src/design-control.ts +0 -1299
- package/src/design-diagnostics.ts +0 -181
- package/src/design-journal.ts +0 -1773
- package/src/directory-sync.ts +0 -36
- package/src/durable-contracts.ts +0 -227
- package/src/durable-workflow.ts +0 -822
- package/src/evidence-draft.ts +0 -122
- package/src/execution-profile.ts +0 -64
- package/src/execution-retention.ts +0 -77
- package/src/file-snapshot.ts +0 -163
- package/src/implement-continuation.ts +0 -559
- package/src/implement-graph.ts +0 -690
- package/src/implement-plan.ts +0 -72
- package/src/install-config.d.mts +0 -6
- package/src/isolation-backend.ts +0 -353
- package/src/model-source.ts +0 -22
- package/src/openspec-cli.ts +0 -494
- package/src/operator-cli-entry.mjs +0 -24
- package/src/operator-tools.ts +0 -309
- package/src/package-candidate-process.ts +0 -206
- package/src/package-candidate.ts +0 -24
- package/src/package-delivery.ts +0 -486
- package/src/package-state.ts +0 -320
- package/src/package-verification.ts +0 -635
- package/src/package-workflow.ts +0 -305
- package/src/packet-runtime.ts +0 -459
- package/src/phase-execution.ts +0 -2163
- package/src/pi-adapter.ts +0 -19
- package/src/plan-draft-summary.ts +0 -527
- package/src/plan-draft.ts +0 -502
- package/src/remove-path.ts +0 -32
- package/src/run-state.ts +0 -420
- package/src/run-store.ts +0 -1179
- package/src/safe-path.ts +0 -77
- package/src/scoped-grep-worker.mjs +0 -23
- package/src/scoped-tools.ts +0 -775
- package/src/single-task-draft.ts +0 -142
- package/src/sqlite-schema.ts +0 -171
- package/src/state-root.ts +0 -215
- package/src/storage-schema.ts +0 -360
- package/src/subagent-activity.ts +0 -1417
- package/src/subagent-process.ts +0 -476
- package/src/task-ledger.ts +0 -1338
- package/src/transport-budget.ts +0 -50
- package/src/verification-capability.ts +0 -1191
- package/src/verification-environment-io.ts +0 -389
- package/src/verification-environment.ts +0 -162
- package/src/verification-identity.ts +0 -77
- package/src/verification-scan.ts +0 -64
- package/src/windows-job-backend.ts +0 -419
- package/src/windows-job.c +0 -149
- package/src/workflow-engine.ts +0 -28
- package/src/workflow-policy.ts +0 -1670
- package/src/workflow-recovery-policy.ts +0 -97
- package/src/workflow-scheduling.ts +0 -105
- package/src/workflow-state-machine.ts +0 -4341
- package/src/workflow-status.ts +0 -576
- package/src/workspace-io-entry.mjs +0 -76
- package/src/workspace-io-worker.mjs +0 -1312
- package/src/workspace-io.ts +0 -89
- 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
|
+
}
|