@abelxiaoxing/cadence 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,282 @@
1
+ // Scoped read-only filesystem tools: read, grep, find, ls. Repository-relative
2
+ // paths only, no mutation, no process execution, bounded output and scan sizes.
3
+ import {
4
+ lstatSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ type Stats,
9
+ statSync,
10
+ } from "node:fs";
11
+ import { basename, join, relative, resolve } from "node:path";
12
+
13
+ export const TOOL_LIMITS = {
14
+ maxReadBytes: 50 * 1024,
15
+ maxReadLines: 2000,
16
+ maxGrepPattern: 1024,
17
+ maxGrepFiles: 2000,
18
+ maxGrepMillis: 5000,
19
+ maxEntries: 20000,
20
+ } as const;
21
+
22
+ export type Observation =
23
+ | { kind: "file"; path: string }
24
+ | { kind: "dir"; path: string };
25
+
26
+ export interface ScopedToolDef {
27
+ name: string;
28
+ description: string;
29
+ execute(params: Record<string, unknown>): Promise<Record<string, unknown>>;
30
+ }
31
+
32
+ interface Options {
33
+ roots: string[];
34
+ observer?: (obs: Observation) => void;
35
+ }
36
+
37
+ type PathResult =
38
+ | { ok: true; abs: string; rel: string }
39
+ | { ok: false; error: string };
40
+
41
+ function resolveScoped(roots: string[], input: unknown): PathResult {
42
+ if (typeof input !== "string" || input.length === 0) {
43
+ return { ok: false, error: "missing path" };
44
+ }
45
+ if (input.includes("\u0000"))
46
+ return { ok: false, error: "NUL byte rejected" };
47
+ if (input.includes("\\")) return { ok: false, error: "backslash rejected" };
48
+ if (input.startsWith("/") || input.startsWith("~")) {
49
+ return { ok: false, error: "absolute path rejected" };
50
+ }
51
+ if (input.split("/").includes("..")) {
52
+ return { ok: false, error: "parent traversal rejected" };
53
+ }
54
+ if (input.startsWith("./") || input.includes("//") || input.endsWith("/.")) {
55
+ return { ok: false, error: "noncanonical path rejected" };
56
+ }
57
+ for (const root of roots) {
58
+ const abs = resolve(root, input);
59
+ const inside =
60
+ abs === root || abs.startsWith(root.endsWith("/") ? root : `${root}/`);
61
+ if (!inside) continue;
62
+ // Hidden directories are rejected unless the root itself is the hidden scope.
63
+ const hiddenRoot =
64
+ basename(realpathSync(root)) === ".git" ||
65
+ basename(realpathSync(root)) === "node_modules";
66
+ for (const segment of input.split("/").slice(0, -1)) {
67
+ if ((segment === ".git" || segment === "node_modules") && !hiddenRoot) {
68
+ return { ok: false, error: "hidden path rejected" };
69
+ }
70
+ }
71
+ // Reject symlinks on any existing component and verify realpath containment.
72
+ let current = root;
73
+ for (const segment of input.split("/")) {
74
+ current = join(current, segment);
75
+ let st: Stats | undefined;
76
+ try {
77
+ st = lstatSync(current);
78
+ } catch {
79
+ break; // nonexistent leaf is fine for absent markers
80
+ }
81
+ if (st.isSymbolicLink()) return { ok: false, error: "symlink rejected" };
82
+ }
83
+ if (existsSync(current)) {
84
+ const real = realpathSync(current);
85
+ const realRoot = realpathSync(root);
86
+ if (real !== realRoot && !real.startsWith(`${realRoot}/`)) {
87
+ return { ok: false, error: "path escapes the approved root" };
88
+ }
89
+ }
90
+ return { ok: true, abs: abs, rel: relative(root, abs) || "." };
91
+ }
92
+ return { ok: false, error: "path is outside every approved root" };
93
+ }
94
+
95
+ function existsSync(p: string): boolean {
96
+ try {
97
+ statSync(p);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ function utf8Text(abs: string): string | null {
105
+ const buf = readFileSync(abs);
106
+ try {
107
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(buf);
108
+ return text;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ export function createScopedTools(opts: Options): ScopedToolDef[] {
115
+ const roots = opts.roots.map((r) => resolve(r));
116
+ const observe = opts.observer ?? (() => {});
117
+
118
+ const readTool: ScopedToolDef = {
119
+ name: "read",
120
+ description:
121
+ "Read a regular UTF-8 text file within the approved scope (max 2,000 lines / 50 KiB).",
122
+ async execute(params) {
123
+ const resolved = resolveScoped(roots, params.path);
124
+ if (!resolved.ok) return { ok: false, error: resolved.error };
125
+ let st: Stats | undefined;
126
+ try {
127
+ st = statSync(resolved.abs);
128
+ } catch {
129
+ return { ok: false, error: "file does not exist" };
130
+ }
131
+ if (!st.isFile()) return { ok: false, error: "not a regular file" };
132
+ const text = utf8Text(resolved.abs);
133
+ if (text === null) return { ok: false, error: "not valid UTF-8 text" };
134
+ observe({ kind: "file", path: resolved.rel });
135
+ const bytes = Buffer.byteLength(text, "utf8");
136
+ const lines = text.split("\n").length;
137
+ const truncated =
138
+ bytes > TOOL_LIMITS.maxReadBytes || lines > TOOL_LIMITS.maxReadLines;
139
+ let content = text;
140
+ if (bytes > TOOL_LIMITS.maxReadBytes) {
141
+ content = Buffer.from(text, "utf8")
142
+ .subarray(0, TOOL_LIMITS.maxReadBytes)
143
+ .toString("utf8");
144
+ }
145
+ if (content.split("\n").length > TOOL_LIMITS.maxReadLines) {
146
+ content = content
147
+ .split("\n")
148
+ .slice(0, TOOL_LIMITS.maxReadLines)
149
+ .join("\n");
150
+ }
151
+ return { ok: true, content, truncated };
152
+ },
153
+ };
154
+
155
+ const grepTool: ScopedToolDef = {
156
+ name: "grep",
157
+ description:
158
+ "Search regular text files with a bounded JavaScript regular expression (max 1,024 chars, 2,000 files).",
159
+ async execute(params) {
160
+ const pattern = params.pattern;
161
+ if (typeof pattern !== "string" || pattern.length === 0) {
162
+ return { ok: false, error: "missing pattern" };
163
+ }
164
+ if (pattern.length > TOOL_LIMITS.maxGrepPattern) {
165
+ return {
166
+ ok: false,
167
+ error: `pattern exceeds ${TOOL_LIMITS.maxGrepPattern} characters`,
168
+ };
169
+ }
170
+ const resolved = resolveScoped(roots, params.path ?? ".");
171
+ if (!resolved.ok) return { ok: false, error: resolved.error };
172
+ let regex: RegExp;
173
+ try {
174
+ regex = new RegExp(pattern, "u");
175
+ } catch {
176
+ return { ok: false, error: "invalid regular expression" };
177
+ }
178
+ const start = performance.now();
179
+ const matches: { path: string; line: number; text: string }[] = [];
180
+ let scanned = 0;
181
+ const files = collectFiles(resolved.abs, [], TOOL_LIMITS.maxEntries);
182
+ for (const file of files) {
183
+ if (scanned >= TOOL_LIMITS.maxGrepFiles) break;
184
+ if (performance.now() - start > TOOL_LIMITS.maxGrepMillis) break;
185
+ scanned++;
186
+ const text = utf8Text(file);
187
+ if (text === null) continue;
188
+ observe({ kind: "file", path: relative(roots[0], file) });
189
+ const lines = text.split("\n");
190
+ for (let i = 0; i < lines.length; i++) {
191
+ regex.lastIndex = 0;
192
+ if (regex.test(lines[i])) {
193
+ matches.push({
194
+ path: relative(roots[0], file),
195
+ line: i + 1,
196
+ text: lines[i].slice(0, 500),
197
+ });
198
+ }
199
+ }
200
+ }
201
+ return { ok: true, matches };
202
+ },
203
+ };
204
+
205
+ const lsTool: ScopedToolDef = {
206
+ name: "ls",
207
+ description:
208
+ "List a directory within the approved scope (max 20,000 entries, stable code-unit order).",
209
+ async execute(params) {
210
+ const resolved = resolveScoped(roots, params.path ?? ".");
211
+ if (!resolved.ok) return { ok: false, error: resolved.error };
212
+ let st: Stats | undefined;
213
+ try {
214
+ st = statSync(resolved.abs);
215
+ } catch {
216
+ return { ok: false, error: "directory does not exist" };
217
+ }
218
+ if (!st.isDirectory()) return { ok: false, error: "not a directory" };
219
+ observe({ kind: "dir", path: resolved.rel });
220
+ const entries = readdirSync(resolved.abs, { withFileTypes: true })
221
+ .filter((e) => !isHidden(e.name, roots))
222
+ .slice(0, TOOL_LIMITS.maxEntries)
223
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
224
+ return {
225
+ ok: true,
226
+ entries: entries.map((e) => ({
227
+ name: e.name,
228
+ type: e.isDirectory() ? "dir" : "file",
229
+ })),
230
+ };
231
+ },
232
+ };
233
+
234
+ const findTool: ScopedToolDef = {
235
+ name: "find",
236
+ description:
237
+ "Recursively list paths within the approved scope (max 20,000 entries, stable code-unit order).",
238
+ async execute(params) {
239
+ const resolved = resolveScoped(roots, params.path ?? ".");
240
+ if (!resolved.ok) return { ok: false, error: resolved.error };
241
+ let st: Stats | undefined;
242
+ try {
243
+ st = statSync(resolved.abs);
244
+ } catch {
245
+ return { ok: false, error: "directory does not exist" };
246
+ }
247
+ if (!st.isDirectory()) return { ok: false, error: "not a directory" };
248
+ observe({ kind: "dir", path: resolved.rel });
249
+ const out = collectFiles(resolved.abs, [], TOOL_LIMITS.maxEntries);
250
+ return { ok: true, entries: out.slice(0, TOOL_LIMITS.maxEntries).sort() };
251
+ },
252
+ };
253
+
254
+ function collectFiles(dir: string, out: string[], limit: number): string[] {
255
+ if (out.length >= limit) return out;
256
+ const entries = readdirSync(dir, { withFileTypes: true });
257
+ for (const e of entries) {
258
+ if (out.length >= limit) break;
259
+ if (isHidden(e.name, roots)) continue;
260
+ const full = join(dir, e.name);
261
+ if (e.isDirectory()) {
262
+ collectFiles(full, out, limit);
263
+ } else if (e.isFile()) {
264
+ out.push(full);
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+
270
+ function isHidden(name: string, scopes: string[]): boolean {
271
+ if (name !== ".git" && name !== "node_modules") return false;
272
+ // Explicit scope: the approved root itself is the hidden directory.
273
+ for (const scope of scopes) {
274
+ if (basename(realpathSync(scope)) === name) return false;
275
+ }
276
+ return true;
277
+ }
278
+
279
+ return [readTool, grepTool, lsTool, findTool];
280
+ }
281
+
282
+ export { resolveScoped };
@@ -0,0 +1,135 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import {
4
+ type DiffResult,
5
+ type EvidenceResult,
6
+ validateDiffResult,
7
+ validateEvidenceResult,
8
+ } from "./contracts.ts";
9
+
10
+ const evidenceSchema = Type.Object({
11
+ id: Type.String(),
12
+ role: Type.String(),
13
+ kind: Type.Literal("evidence"),
14
+ conclusions: Type.Array(Type.String()),
15
+ citations: Type.Array(
16
+ Type.Object({ path: Type.String(), lines: Type.String() }),
17
+ ),
18
+ constraints: Type.Array(Type.String()),
19
+ dependencies: Type.Array(Type.String()),
20
+ risks: Type.Array(Type.String()),
21
+ blockingQuestions: Type.Array(Type.String()),
22
+ hints: Type.Object({
23
+ writeSet: Type.Array(Type.String()),
24
+ verification: Type.String(),
25
+ agentsImpact: Type.String(),
26
+ }),
27
+ });
28
+
29
+ const diffSchema = Type.Object({
30
+ id: Type.String(),
31
+ role: Type.String(),
32
+ kind: Type.Literal("diff"),
33
+ taskId: Type.String(),
34
+ phase: Type.String(),
35
+ summary: Type.String(),
36
+ diff: Type.String(),
37
+ expectedVerification: Type.String(),
38
+ risks: Type.Array(Type.String()),
39
+ nextStep: Type.String(),
40
+ contractCompliant: Type.Literal(true),
41
+ });
42
+
43
+ export type FinalCategory =
44
+ | "no-final-assistant"
45
+ | "text-only"
46
+ | "mixed"
47
+ | "multiple-submit"
48
+ | "single-submit-only";
49
+
50
+ export interface IdentityOutcome {
51
+ request: boolean;
52
+ role: boolean;
53
+ task: boolean;
54
+ phase: boolean;
55
+ }
56
+
57
+ export interface SubmitClassification {
58
+ finalCategory: FinalCategory;
59
+ attempts: number;
60
+ schema: "valid" | "invalid";
61
+ identity: IdentityOutcome;
62
+ }
63
+
64
+ export function createSubmitTool(input: {
65
+ requestId: string;
66
+ role: string;
67
+ phase: string;
68
+ output: "evidence" | "diff";
69
+ }) {
70
+ let submitted: EvidenceResult | DiffResult | undefined;
71
+ let attempts = 0;
72
+ let schema: "valid" | "invalid" = "invalid";
73
+ const identity: IdentityOutcome = {
74
+ request: true,
75
+ role: true,
76
+ task: true,
77
+ phase: true,
78
+ };
79
+
80
+ const tool = defineTool({
81
+ name: "abel_submit_result",
82
+ label: "Submit Abel Result",
83
+ description:
84
+ "Submit the one final structured Abel evidence or complete unified-diff result.",
85
+ executionMode: "sequential",
86
+ parameters: input.output === "evidence" ? evidenceSchema : diffSchema,
87
+ async execute(_toolCallId, params) {
88
+ attempts++;
89
+ const value = params as unknown as Record<string, unknown>;
90
+ if (value === null || typeof value !== "object") {
91
+ identity.request = false;
92
+ identity.role = false;
93
+ identity.task = false;
94
+ identity.phase = false;
95
+ schema = "invalid";
96
+ throw new Error("submitted result is not an object");
97
+ }
98
+ if (value.id !== input.requestId) identity.request = false;
99
+ if (value.role !== input.role) identity.role = false;
100
+ if (input.output === "diff") {
101
+ if (value.taskId !== input.requestId) identity.task = false;
102
+ if (value.phase !== input.phase) identity.phase = false;
103
+ }
104
+ const validation =
105
+ input.output === "evidence"
106
+ ? validateEvidenceResult(value)
107
+ : validateDiffResult(value);
108
+ schema = validation.ok ? "valid" : "invalid";
109
+ const matches =
110
+ identity.request && identity.role && identity.task && identity.phase;
111
+ if (!validation.ok || !matches) {
112
+ throw new Error(
113
+ validation.reason ?? "submitted result identity does not match",
114
+ );
115
+ }
116
+ if (submitted) throw new Error("duplicate structural submission");
117
+ submitted = structuredClone(value) as unknown as
118
+ | EvidenceResult
119
+ | DiffResult;
120
+ return {
121
+ content: [{ type: "text" as const, text: "Abel result accepted." }],
122
+ details: { accepted: true },
123
+ terminate: true,
124
+ };
125
+ },
126
+ });
127
+
128
+ return {
129
+ tool,
130
+ getResult: () => submitted,
131
+ getAttempts: () => attempts,
132
+ getSchema: () => schema,
133
+ getIdentity: () => ({ ...identity }),
134
+ };
135
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,119 @@
1
+ // Process-local logical Worker registry for private orchestration. A Worker
2
+ // is pinned by the first accepted phase: logical ID, stage, role, the
3
+ // provider/model/API/thinking identity, and the approved non-snapshot
4
+ // contract. Later accepted Red/Green/optional Refactor phases receive fresh
5
+ // snapshots and fresh phase-local auth for the SAME pinned identity. A failed
6
+ // phase may be mechanically redispatched exactly once when its canonical
7
+ // request is identical apart from refreshed snapshot hashes; a second failure
8
+ // blocks that branch. No credential, transcript, or retry ledger is retained.
9
+
10
+ import type { RequestEnvelope } from "./contracts.ts";
11
+
12
+ export interface WorkerContract {
13
+ stage: string;
14
+ role: string;
15
+ id: string;
16
+ objective: string;
17
+ roots: string[];
18
+ agents: string;
19
+ contractText: string;
20
+ read: string[];
21
+ write: string[];
22
+ conflicts: string[];
23
+ resources: string[];
24
+ verificationLock?: string;
25
+ output: string;
26
+ }
27
+
28
+ export interface LogicalWorker {
29
+ identity: string;
30
+ contract: WorkerContract;
31
+ redispatchUsed: boolean;
32
+ }
33
+
34
+ // Pins the semantic scope of a logical Worker, excluding the phase label so a
35
+ // later Red/Green/Refactor phase is recognized as the same Worker.
36
+ export function contractOf(envelope: RequestEnvelope): WorkerContract {
37
+ return {
38
+ stage: envelope.stage,
39
+ role: envelope.role,
40
+ id: envelope.id,
41
+ objective: envelope.objective,
42
+ roots: [...envelope.roots],
43
+ agents: envelope.context.agents,
44
+ contractText: envelope.context.contract,
45
+ read: [...envelope.declared.read],
46
+ write: [...envelope.declared.write],
47
+ conflicts: [...envelope.declared.conflicts],
48
+ resources: [...envelope.declared.resources],
49
+ verificationLock: envelope.declared.verificationLock,
50
+ output: envelope.output,
51
+ };
52
+ }
53
+
54
+ export function sameContract(a: WorkerContract, b: WorkerContract): boolean {
55
+ if (
56
+ a.stage !== b.stage ||
57
+ a.role !== b.role ||
58
+ a.id !== b.id ||
59
+ a.objective !== b.objective ||
60
+ a.agents !== b.agents ||
61
+ a.contractText !== b.contractText ||
62
+ a.output !== b.output ||
63
+ a.verificationLock !== b.verificationLock
64
+ )
65
+ return false;
66
+ return (
67
+ arrayEqual(a.roots, b.roots) &&
68
+ arrayEqual(a.read, b.read) &&
69
+ arrayEqual(a.write, b.write) &&
70
+ arrayEqual(a.conflicts, b.conflicts) &&
71
+ arrayEqual(a.resources, b.resources)
72
+ );
73
+ }
74
+
75
+ function arrayEqual(a: string[], b: string[]): boolean {
76
+ return a.length === b.length && a.every((value, index) => value === b[index]);
77
+ }
78
+
79
+ // Stable provider/model/API/thinking identity fingerprint for a phase. Each
80
+ // child attempt re-derives it while the phase-local auth boundary is called
81
+ // anew, so the identity stays pinned across fresh phases.
82
+ export function workerIdentity(model: {
83
+ provider?: unknown;
84
+ baseUrl?: unknown;
85
+ id?: unknown;
86
+ name?: unknown;
87
+ }): string {
88
+ const parts: string[] = [`p:${String(model.provider ?? "")}`];
89
+ if (model.baseUrl !== undefined) parts.push(`u:${String(model.baseUrl)}`);
90
+ if (typeof model.id === "string") parts.push(`id:${model.id}`);
91
+ if (typeof model.name === "string") parts.push(`n:${model.name}`);
92
+ return parts.join("|");
93
+ }
94
+
95
+ export class WorkerRegistry {
96
+ private readonly workers = new Map<string, LogicalWorker>();
97
+
98
+ has(id: string): boolean {
99
+ return this.workers.has(id);
100
+ }
101
+
102
+ get(id: string): LogicalWorker | undefined {
103
+ return this.workers.get(id);
104
+ }
105
+
106
+ pin(contract: WorkerContract, identity: string): LogicalWorker {
107
+ const worker: LogicalWorker = {
108
+ identity,
109
+ contract,
110
+ redispatchUsed: false,
111
+ };
112
+ this.workers.set(contract.id, worker);
113
+ return worker;
114
+ }
115
+
116
+ clear(): void {
117
+ this.workers.clear();
118
+ }
119
+ }